feat: Integrate account_metrics collection with monthly refresh calendar

- Add account_metrics collection schema script
- Implement accountMetricsService with upsertAccountMetric, fetchAccountMetricsForMonth, calculateSalesBucket
- Extend accountsService with getLastSuccessfulAccountMetric
- Update AccountsPage to track daily metrics and display in calendar
- Calculate sales difference from last successful refresh
- Display refresh status and sales buckets in monthly calendar view
- Remove account_refresh_events dependency (use account_metrics only)
This commit is contained in:
KNSONWS
2026-01-27 20:36:47 +01:00
parent 75aef1941e
commit a1201d572e
11 changed files with 2641 additions and 49 deletions

View File

@@ -8,8 +8,8 @@ import ColourfulText from "@/components/ui/colourful-text";
import { PlaceholdersAndVanishInput } from "@/components/ui/placeholders-and-vanish-input";
import { MultiStepLoader } from "@/components/ui/multi-step-loader";
import { IPhoneNotification } from "@/components/ui/iphone-notification";
import { parseEbayAccount } from "@/services/ebayParserService";
import { createManagedAccount, fetchManagedAccounts } from "@/services/accountsService";
import { parseEbayAccount, parseViaExtensionExtended } from "@/services/ebayParserService";
import { createManagedAccount, fetchManagedAccounts, determineRefreshStatus } from "@/services/accountsService";
import { account, databases, databaseId, usersCollectionId } from "@/lib/appwrite";
import { DottedGlowBackground } from "@/components/ui/dotted-glow-background";
@@ -618,13 +618,20 @@ export const OnboardingGate = ({ userName, onStart, loading, error, initialPhase
setPhase("loading");
try {
// Parse eBay account
// Parse eBay account (erweitert mit Feedback, About, Store)
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:449',message:'handleAccountSubmit: before parseEbayAccount',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:449',message:'handleAccountSubmit: before parseViaExtensionExtended',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
const accountData = await parseEbayAccount(url);
let accountData;
try {
accountData = await parseViaExtensionExtended(url);
} catch (extendedError) {
// Fallback zu normalem Parsing wenn erweiterte Funktion fehlschlägt
console.warn("Extended parsing failed, falling back to normal parsing:", extendedError);
accountData = await parseEbayAccount(url);
}
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:452',message:'handleAccountSubmit: parseEbayAccount success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:452',message:'handleAccountSubmit: parseViaExtensionExtended success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
// Validate that sellerId was extracted successfully
@@ -676,6 +683,21 @@ export const OnboardingGate = ({ userName, onStart, loading, error, initialPhase
account_url: url,
account_status: accountData.status || "active",
account_sells: accountData.stats?.itemsSold ?? null, // Setze account_sells wenn verfügbar
// Neue erweiterte Felder
account_response_time_hours: accountData.responseTimeHours ?? null,
account_followers: accountData.followers ?? null,
account_feedback_total: accountData.feedbackTotal ?? null,
account_feedback_12m_positive: accountData.feedback12mPositive ?? null,
account_feedback_12m_neutral: accountData.feedback12mNeutral ?? null,
account_feedback_12m_negative: accountData.feedback12mNegative ?? null,
account_last_refresh_at: new Date().toISOString(),
account_last_refresh_status: accountData.partialResults ?
determineRefreshStatus(accountData.partialResults, {
followers: accountData.followers,
feedbackTotal: accountData.feedbackTotal,
feedback12mPositive: accountData.feedback12mPositive,
responseTimeHours: accountData.responseTimeHours
}) : null,
});
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:494',message:'handleAccountSubmit: createManagedAccount success',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H3'})}).catch(()=>{});

View File

@@ -0,0 +1,51 @@
.animated-gradient-text {
position: relative;
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
border-radius: 1.25rem;
font-weight: 500;
backdrop-filter: blur(10px);
transition: box-shadow 0.5s ease-out;
overflow: hidden;
}
.animated-gradient-text.with-border {
padding: 0.35rem 0.75rem;
}
.gradient-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
z-index: 0;
pointer-events: none;
}
.gradient-overlay::before {
content: '';
position: absolute;
left: 0;
top: 0;
border-radius: inherit;
width: calc(100% - 2px);
height: calc(100% - 2px);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background-color: #060010;
z-index: -1;
}
.text-content {
display: inline-block;
position: relative;
z-index: 2;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}

View File

@@ -0,0 +1,100 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
import './GradientText.css';
export default function GradientText({
children,
className = '',
colors = ['#5227FF', '#FF9FFC', '#B19EEF'],
animationSpeed = 8,
showBorder = false,
direction = 'horizontal',
pauseOnHover = false,
yoyo = true,
style = {}
}) {
const [isPaused, setIsPaused] = useState(false);
const progress = useMotionValue(0);
const elapsedRef = useRef(0);
const lastTimeRef = useRef(null);
const animationDuration = animationSpeed * 1000;
useAnimationFrame(time => {
if (isPaused) {
lastTimeRef.current = null;
return;
}
if (lastTimeRef.current === null) {
lastTimeRef.current = time;
return;
}
const deltaTime = time - lastTimeRef.current;
lastTimeRef.current = time;
elapsedRef.current += deltaTime;
if (yoyo) {
const fullCycle = animationDuration * 2;
const cycleTime = elapsedRef.current % fullCycle;
if (cycleTime < animationDuration) {
progress.set((cycleTime / animationDuration) * 100);
} else {
progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100);
}
} else {
// Continuously increase position for seamless looping
progress.set((elapsedRef.current / animationDuration) * 100);
}
});
useEffect(() => {
elapsedRef.current = 0;
progress.set(0);
}, [animationSpeed, progress, yoyo]);
const backgroundPosition = useTransform(progress, p => {
if (direction === 'horizontal') {
return `${p}% 50%`;
} else if (direction === 'vertical') {
return `50% ${p}%`;
} else {
// For diagonal, move only horizontally to avoid interference patterns
return `${p}% 50%`;
}
});
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsPaused(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsPaused(false);
}, [pauseOnHover]);
const gradientAngle =
direction === 'horizontal' ? 'to right' : direction === 'vertical' ? 'to bottom' : 'to bottom right';
// Duplicate first color at the end for seamless looping
const gradientColors = [...colors, colors[0]].join(', ');
const gradientStyle = {
backgroundImage: `linear-gradient(${gradientAngle}, ${gradientColors})`,
backgroundSize: direction === 'horizontal' ? '300% 100%' : direction === 'vertical' ? '100% 300%' : '300% 300%',
backgroundRepeat: 'repeat'
};
return (
<motion.div
className={`animated-gradient-text ${showBorder ? 'with-border' : ''} ${className}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showBorder && <motion.div className="gradient-overlay" style={{ ...gradientStyle, backgroundPosition }} />}
<motion.div className="text-content" style={{ ...gradientStyle, backgroundPosition, ...style }}>
{children}
</motion.div>
</motion.div>
);
}

View File

@@ -5,8 +5,6 @@ import {
IconX,
IconRefresh,
IconChevronDown,
IconChartBar,
IconSettings,
} from "@tabler/icons-react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "../lib/utils";
@@ -16,10 +14,12 @@ import {
getActiveAccountId,
getAccountDisplayName,
} from "../services/accountService";
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService";
import { getAuthUser } from "../lib/appwrite";
import { parseEbayAccount } from "../services/ebayParserService";
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount, determineRefreshStatus, calculateDataFreshness, getLastSuccessfulAccountMetric } from "../services/accountsService";
import { upsertAccountMetric, fetchAccountMetricsForMonth } from "../services/accountMetricsService";
import { getAuthUser, databases, databaseId } from "../lib/appwrite";
import { parseEbayAccount, parseViaExtensionExtended } from "../services/ebayParserService";
import { BentoGrid, BentoGridItem } from "../components/ui/bento-grid";
import GradientText from "../components/ui/GradientText";
function AccountNameCard({
name,
@@ -96,18 +96,28 @@ function AccountNameCard({
href={url}
target="_blank"
rel="noopener noreferrer"
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)] hover:underline"
style={{ fontSize: `${fontSize}px` }}
className="max-w-full shrink-0 whitespace-nowrap hover:underline"
>
{name}
<GradientText
colors={['#5227FF', '#FF9FFC', '#B19EEF']}
animationSpeed={5}
showBorder={false}
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight"
style={{ fontSize: `${fontSize}px` }}
>
{name}
</GradientText>
</a>
) : (
<span
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)]"
<GradientText
colors={['#5227FF', '#FF9FFC', '#B19EEF']}
animationSpeed={5}
showBorder={false}
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight"
style={{ fontSize: `${fontSize}px` }}
>
{name}
</span>
</GradientText>
)}
</div>
{platformAccountId != null && platformAccountId !== "" && (
@@ -232,6 +242,374 @@ function RangCard({ rank, className }) {
);
}
function RefreshActivityCard({ monthMetrics = new Map(), className }) {
// monthMetrics: Map von date (yyyy-mm-dd) -> metric document
const today = new Date();
const currentMonth = today.getMonth();
const currentYear = today.getFullYear();
const currentDate = today.getDate();
// Erstelle Kalenderstruktur für aktuellen Monat
const getMonthCalendar = () => {
const firstDay = new Date(currentYear, currentMonth, 1);
const lastDay = new Date(currentYear, currentMonth + 1, 0);
const daysInMonth = lastDay.getDate();
const startDayOfWeek = firstDay.getDay(); // 0 = Sonntag, 1 = Montag, etc.
// Wochen beginnen mit Montag (1) statt Sonntag (0)
const adjustedStartDay = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
const calendar = [];
let currentWeek = [];
// Leere Felder für Tage vor Monatsbeginn
for (let i = 0; i < adjustedStartDay; i++) {
currentWeek.push(null);
}
// Tage des Monats
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(currentYear, currentMonth, day);
currentWeek.push(date);
if (currentWeek.length === 7) {
calendar.push(currentWeek);
currentWeek = [];
}
}
// Leere Felder für restliche Woche
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(null);
}
calendar.push(currentWeek);
}
return calendar;
};
const getDayData = (date) => {
if (!date) return { refreshStatus: null, salesBucket: null };
const dateStr = date.toISOString().split('T')[0];
const metric = monthMetrics.get(dateStr);
if (!metric) {
return {
refreshStatus: 'not-refreshed',
salesBucket: null
};
}
// Bestimme refreshStatus aus metric
let refreshStatus = 'not-refreshed';
if (metric.account_metrics_refresh_status === 'failed') {
refreshStatus = 'failed';
} else if (metric.account_metrics_refreshed === true && metric.account_metrics_refresh_status === 'success') {
refreshStatus = 'refreshed';
}
return {
refreshStatus: refreshStatus,
salesBucket: metric.account_metrics_sales_bucket || null
};
};
const getStatusColor = (status, isToday) => {
if (isToday) {
// Aktueller Tag: dezente Umrandung
switch (status) {
case 'refreshed':
return 'bg-green-500/30 border-2 border-green-600 dark:bg-green-500/20 dark:border-green-500';
case 'failed':
return 'bg-red-500/20 border-2 border-red-600 dark:bg-red-500/10 dark:border-red-500';
default:
return 'bg-neutral-100 border-2 border-neutral-400 dark:bg-neutral-800 dark:border-neutral-500';
}
}
switch (status) {
case 'refreshed':
return 'bg-green-500/30 border border-green-500/50 dark:bg-green-500/20 dark:border-green-500/40';
case 'failed':
return 'bg-red-500/20 border border-red-500/30 dark:bg-red-500/10 dark:border-red-500/20';
default:
return 'bg-neutral-100 border border-neutral-200 dark:bg-neutral-800 dark:border-neutral-700';
}
};
const calendar = getMonthCalendar();
const isTodayDate = (date) => {
if (!date) return false;
return date.getDate() === currentDate &&
date.getMonth() === currentMonth &&
date.getFullYear() === currentYear;
};
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
<div className="flex h-full flex-col">
{/* Titel oben links */}
<div className="mb-2 flex items-baseline gap-2">
<div className="text-xs font-medium text-[var(--muted)]">Refresh Activity</div>
</div>
{/* Wochenraster: 7 Spalten x 5 Zeilen */}
<div className="grid h-full grid-cols-7 grid-rows-5 gap-0.5 flex-1">
{calendar.map((week, weekIndex) => (
<React.Fragment key={weekIndex}>
{week.map((date, dayIndex) => {
if (!date) {
return (
<div
key={`empty-${weekIndex}-${dayIndex}`}
className="border border-transparent"
/>
);
}
const dayData = getDayData(date);
const isToday = isTodayDate(date);
const dayNumber = date.getDate();
return (
<div
key={`${weekIndex}-${dayIndex}`}
className={cn(
"relative flex items-center justify-center rounded-sm min-h-[20px]",
getStatusColor(dayData.refreshStatus, isToday),
"transition-colors"
)}
title={`${date.toLocaleDateString('de-DE')}: ${dayData.refreshStatus === 'not-refreshed' ? 'Not refreshed' : dayData.refreshStatus === 'refreshed' ? 'Refreshed' : 'Refresh failed'}${dayData.salesBucket ? `, Sales: ${dayData.salesBucket}` : ''}`}
>
{/* Tag-Nummer (klein, oben links) */}
<div className="absolute top-0.5 left-0.5 text-[8px] text-[var(--muted)] leading-none">
{dayNumber}
</div>
{/* Sales-Bucket (zentriert) */}
{dayData.salesBucket && (
<div className="text-[10px] font-semibold text-[var(--text)] leading-none">
{dayData.salesBucket}
</div>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
</div>
);
}
function AccountRefreshCard({
onRefresh,
isRefreshing,
lastRefreshDate,
streak,
dataFreshness = 'Aging',
className
}) {
const getLastRefreshText = () => {
if (!lastRefreshDate) return "Not refreshed today";
const today = new Date();
const refreshDate = new Date(lastRefreshDate);
const diffTime = today - refreshDate;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Last refresh: Today";
if (diffDays === 1) return "Last refresh: Yesterday";
if (diffDays < 7) return `Last refresh: ${diffDays} days ago`;
return `Last refresh: ${refreshDate.toLocaleDateString('de-DE')}`;
};
const getFreshnessLabel = () => {
switch (dataFreshness) {
case 'Fresh': return 'Fresh';
case 'Outdated': return 'Outdated';
default: return 'Aging';
}
};
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
{/* Grid: 5 Zeilen x 5 Spalten */}
<div className="grid h-full grid-cols-5 grid-rows-5 gap-0">
{/* Zeile 1: Kontext & Bedeutung */}
{/* Bereich A (Zeile 1, Spalten 1-5) */}
<div className="col-span-5 row-span-1 flex items-center justify-between">
<div className="text-xs font-medium text-[var(--muted)]">Account Refresh</div>
<div className="text-[10px] text-[var(--muted)]">manual</div>
</div>
{/* Zeile 2-3: Zentrale Aktion (Ritualkern) */}
{/* Bereich B (Zeilen 2-3, Spalten 1-5) */}
<div className="col-span-5 row-span-2 flex items-center justify-center">
<button
onClick={onRefresh}
disabled={isRefreshing}
className={cn(
"w-full h-full rounded-xl border transition-all active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed",
"border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]",
"flex items-center justify-center gap-2 font-medium text-sm"
)}
title="Account aktualisieren"
>
<IconRefresh className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
Refresh Today
</button>
</div>
{/* Zeile 4: Tages- & Streak-Status */}
{/* Bereich C (Zeile 4, Spalten 1-5) */}
<div className="col-span-5 row-span-1 flex items-center justify-between text-xs text-[var(--muted)]">
<span>{getLastRefreshText()}</span>
{streak != null && streak > 0 && (
<span>Streak: {streak} {streak === 1 ? 'day' : 'days'}</span>
)}
</div>
{/* Zeile 5: Datenqualitäts-Hinweis */}
{/* Bereich D (Zeile 5, Spalten 1-5) */}
<div className="col-span-5 row-span-1 flex items-center">
<div className="text-[10px] text-[var(--muted)]">
Data freshness: {getFreshnessLabel()}
</div>
</div>
</div>
</div>
);
}
function SalesCard({ sales, follower, responseTime, positiveReviews, neutralReviews, negativeReviews, totalReviews, className }) {
// Berechne Anteile für Balkendiagramm
// WICHTIG: null = nicht verfügbar, 0 = echter Wert
const total = (positiveReviews ?? 0) + (neutralReviews ?? 0) + (negativeReviews ?? 0);
const positivePercent = total > 0 ? ((positiveReviews ?? 0) / total) * 100 : 0;
const neutralPercent = total > 0 ? ((neutralReviews ?? 0) / total) * 100 : 0;
const negativePercent = total > 0 ? ((negativeReviews ?? 0) / total) * 100 : 0;
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
{/* Grid: 5 Zeilen x 10 Spalten */}
<div className="grid h-full grid-cols-10 grid-rows-5 gap-0">
{/* Zeile 1-2: Hero-Zone (40%) */}
{/* Block A: Sales (Spalten 1-4, Zeilen 1-2) */}
<div className="col-span-4 row-span-2 flex flex-col justify-center">
<div className="text-4xl font-bold text-[var(--text)] md:text-5xl">
{sales != null ? new Intl.NumberFormat("de-DE").format(sales) : ""}
</div>
<div className="mt-1 text-xs font-medium text-[var(--muted)]">Sales (gesamt)</div>
</div>
{/* Block B: Follower (Spalten 5-7, Zeilen 1-2) */}
<div className="col-span-3 row-span-2 flex flex-col justify-center">
<div className="text-2xl font-bold text-[var(--text)] md:text-3xl">
{follower != null ? new Intl.NumberFormat("de-DE").format(follower) : ""}
</div>
<div className="mt-1 text-xs font-medium text-[var(--muted)]">Follower</div>
</div>
{/* Block C: Antwortzeit (Spalten 8-10, Zeile 1) */}
<div className="col-span-3 row-span-1 flex flex-col justify-center">
<div className="text-xs font-medium text-[var(--muted)]">
Antwortzeit {responseTime ?? "—"}
</div>
</div>
{/* Zeile 2, Spalten 8-10: leer */}
<div className="col-span-3 row-span-1" />
{/* Zeile 3-4: Bewertungen (40%) */}
{/* Block D: Bewertungszusammenfassung (Spalten 1-10, Zeilen 3-4) */}
<div className="col-span-10 row-span-2 flex flex-col justify-center space-y-2">
<div className="text-xs font-medium text-[var(--muted)]">Bewertungen der letzten 12 Monate</div>
{/* Balkendiagramm */}
<div className="flex h-8 w-full items-center gap-0.5 overflow-hidden rounded border border-neutral-200 dark:border-neutral-700">
{positivePercent > 0 && (
<div
className="flex h-full items-center justify-center bg-green-500/20 text-[10px] font-medium text-green-700 dark:bg-green-500/30 dark:text-green-400"
style={{ width: `${positivePercent}%` }}
title={`Positiv: ${positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}`}
>
{positivePercent > 15 && (
<span className="truncate px-1">
{positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}
</span>
)}
</div>
)}
{neutralPercent > 0 && (
<div
className="flex h-full items-center justify-center bg-gray-400/20 text-[10px] font-medium text-gray-700 dark:bg-gray-400/30 dark:text-gray-400"
style={{ width: `${neutralPercent}%` }}
title={`Neutral: ${neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}`}
>
{neutralPercent > 15 && (
<span className="truncate px-1">
{neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}
</span>
)}
</div>
)}
{negativePercent > 0 && (
<div
className="flex h-full items-center justify-center bg-red-500/20 text-[10px] font-medium text-red-700 dark:bg-red-500/30 dark:text-red-400"
style={{ width: `${negativePercent}%` }}
title={`Negativ: ${negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}`}
>
{negativePercent > 15 && (
<span className="truncate px-1">
{negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}
</span>
)}
</div>
)}
</div>
{/* Zahlen unter dem Balken (falls Platz) */}
{positivePercent <= 15 && neutralPercent <= 15 && negativePercent <= 15 && (
<div className="flex gap-4 text-[10px] text-[var(--muted)]">
<span>Positiv: {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}</span>
<span>Neutral: {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}</span>
<span>Negativ: {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}</span>
</div>
)}
</div>
{/* Zeile 5: Meta-Informationen (20%) */}
{/* Block E: Gesamtbewertungen (Spalten 1-10, Zeile 5) */}
<div className="col-span-10 row-span-1 flex items-center">
<div className="text-xs text-[var(--muted)]">
Gesamtbewertungen: {totalReviews != null ? new Intl.NumberFormat("de-DE").format(totalReviews) : ""}
</div>
</div>
</div>
</div>
);
}
export const AccountsPage = () => {
const { navigate } = useHashRoute();
const [accounts, setAccounts] = useState([]);
@@ -253,6 +631,9 @@ export const AccountsPage = () => {
// Nur ein Account wird angezeigt; Wechsel über Dropdown
const [displayedAccountId, setDisplayedAccountId] = useState(null);
// Monats-Metriken für Kalender (Map: date -> metric)
const [monthMetrics, setMonthMetrics] = useState(new Map());
// Form-Felder (nur noch URL)
const [formData, setFormData] = useState({
account_url: "",
@@ -278,6 +659,15 @@ export const AccountsPage = () => {
}
}, [accounts]);
// Lade Monats-Metriken wenn displayedAccountId sich ändert
useEffect(() => {
if (displayedAccountId) {
loadMonthMetrics(displayedAccountId);
} else {
setMonthMetrics(new Map());
}
}, [displayedAccountId]);
async function loadAccounts() {
setLoading(true);
try {
@@ -297,6 +687,25 @@ export const AccountsPage = () => {
}
}
async function loadMonthMetrics(accountId) {
if (!accountId) {
setMonthMetrics(new Map());
return;
}
try {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1; // 1-12
const metrics = await fetchAccountMetricsForMonth(accountId, year, month);
setMonthMetrics(metrics);
} catch (e) {
console.error("Fehler beim Laden der Monats-Metriken:", e);
setMonthMetrics(new Map());
}
}
const handleDisplayedAccountChange = (accountId) => {
setDisplayedAccountId(accountId);
setActiveAccountId(accountId);
@@ -315,8 +724,16 @@ export const AccountsPage = () => {
setRefreshingAccountId(accountId);
try {
// URL erneut parsen
const parsedData = await parseEbayAccount(accountUrl);
// URL erweitert parsen (mit Feedback, About, Store)
const parsedData = await parseViaExtensionExtended(accountUrl);
// Refresh-Status bestimmen basierend auf Partial Results
const refreshStatus = determineRefreshStatus(parsedData.partialResults, {
followers: parsedData.followers,
feedbackTotal: parsedData.feedbackTotal,
feedback12mPositive: parsedData.feedback12mPositive,
responseTimeHours: parsedData.responseTimeHours
});
// Account in DB aktualisieren
// WICHTIG: Nur Felder setzen, die nicht leer sind und sich geändert haben
@@ -337,6 +754,18 @@ export const AccountsPage = () => {
updatePayload.account_shop_name = parsedData.shopName || null;
updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
// Neue erweiterte Felder
updatePayload.account_response_time_hours = parsedData.responseTimeHours ?? null;
updatePayload.account_followers = parsedData.followers ?? null;
updatePayload.account_feedback_total = parsedData.feedbackTotal ?? null;
updatePayload.account_feedback_12m_positive = parsedData.feedback12mPositive ?? null;
updatePayload.account_feedback_12m_neutral = parsedData.feedback12mNeutral ?? null;
updatePayload.account_feedback_12m_negative = parsedData.feedback12mNegative ?? null;
// Refresh-Metadaten
updatePayload.account_last_refresh_at = new Date().toISOString();
updatePayload.account_last_refresh_status = refreshStatus;
// account_status wird weggelassen (wie beim Erstellen)
// Grund: Schema-Konflikt - Enum-Feld akzeptiert weder String noch Array im Update
// TODO: Schema in Appwrite prüfen und korrigieren, dann account_status wieder hinzufügen
@@ -344,7 +773,76 @@ export const AccountsPage = () => {
// Setze account_updated_at auf aktuelle Zeit
updatePayload.account_updated_at = new Date().toISOString();
await updateManagedAccount(accountId, updatePayload);
const updatedAccount = await updateManagedAccount(accountId, updatePayload);
// Berechne Sales-Differenz und speichere in account_metrics
try {
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd
// Lade letzte erfolgreiche Metrik für Sales-Differenz-Berechnung
const lastMetric = await getLastSuccessfulAccountMetric(accountId);
const currentSalesTotal = updatedAccount.account_sells;
// Berechne Sales-Differenz:
// Da account_metrics_last_sales_total nicht erlaubt ist, verwenden wir einen Workaround:
// - account_metrics_sales_count speichert den absoluten account_sells Wert
// - Beim nächsten Refresh berechnen wir die Differenz: currentSalesTotal - lastMetric.sales_count
// - Die Differenz wird in account_metrics_sales_bucket gespeichert (als Bucket-String)
// - Für die Anzeige im Kalender verwenden wir den Bucket-String
let salesDifference = null;
if (lastMetric && lastMetric.account_metrics_sales_count !== null && currentSalesTotal !== null) {
// Letzte Metrik existiert: Berechne Differenz
// lastMetric.account_metrics_sales_count ist der absolute Wert vom letzten Refresh
const lastAbsoluteValue = lastMetric.account_metrics_sales_count;
salesDifference = currentSalesTotal - lastAbsoluteValue;
// Stelle sicher, dass Differenz nicht negativ ist (falls account_sells zurückgesetzt wurde)
if (salesDifference < 0) {
salesDifference = null;
}
}
// Speichere absoluten Wert in sales_count (für nächsten Refresh)
// Die Differenz wird in sales_bucket gespeichert (berechnet via calculateSalesBucket)
const salesCountToStore = currentSalesTotal;
// Upsert account_metrics für heute
// WICHTIG: Da account_metrics_last_sales_total nicht erlaubt ist, speichern wir:
// - account_metrics_sales_count: absoluten account_sells Wert (für nächsten Refresh)
// - account_metrics_sales_bucket: Bucket basierend auf der Differenz (wenn berechenbar)
//
// Beim nächsten Refresh: Differenz = currentSalesTotal - lastMetric.sales_count
// Berechne Bucket aus Differenz (falls berechenbar)
const { calculateSalesBucket } = await import("../services/accountMetricsService");
const bucket = salesDifference !== null ? calculateSalesBucket(salesDifference) : null;
// Erstelle/Update Metrik mit absolutem Wert
const metricDoc = await upsertAccountMetric(accountId, todayStr, {
refreshed: true,
refreshStatus: refreshStatus === "success" ? "success" : "failed",
refreshedAt: new Date().toISOString(),
salesCount: currentSalesTotal // Absoluter Wert für nächsten Refresh
});
// Update sales_bucket separat (da upsertAccountMetric Bucket aus salesCount berechnet)
if (bucket !== null && metricDoc) {
await databases.updateDocument(
databaseId,
"account_metrics",
metricDoc.$id,
{ account_metrics_sales_bucket: bucket }
);
}
// Lade Monats-Metriken neu
await loadMonthMetrics(accountId);
} catch (metricsError) {
// Nicht kritisch, nur loggen
console.warn("Fehler beim Erstellen der Account-Metrik:", metricsError);
}
// Accounts-Liste neu laden (in-place Update)
await loadAccounts();
@@ -362,6 +860,25 @@ export const AccountsPage = () => {
setRefreshToast({ show: true, message: errorMessage, type: "error" });
setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000);
// Auch bei Fehler: Metrik für heute speichern (mit failed Status)
try {
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd
await upsertAccountMetric(accountId, todayStr, {
refreshed: false,
refreshStatus: "failed",
refreshedAt: null,
salesCount: null
});
// Lade Monats-Metriken neu
await loadMonthMetrics(accountId);
} catch (metricsError) {
// Nicht kritisch, nur loggen
console.warn("Fehler beim Erstellen der Account-Metrik (failed):", metricsError);
}
} finally {
setRefreshingAccountId(null);
}
@@ -473,10 +990,6 @@ export const AccountsPage = () => {
const displayedAccount =
accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null;
const BentoHeader = () => (
<div className="flex flex-1 w-full h-full min-h-[4rem] rounded-xl border border-neutral-100 dark:border-white/[0.2] bg-neutral-50 dark:bg-neutral-800" />
);
return (
<div className="flex flex-1">
<div className="flex h-full w-full flex-1 flex-col gap-4 rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900">
@@ -557,33 +1070,26 @@ export const AccountsPage = () => {
className="md:col-span-1"
/>
<RangCard rank={undefined} className="md:col-span-1" />
<BentoGridItem
title="Sales"
description={sales}
header={<BentoHeader />}
icon={<IconChartBar className="h-4 w-4 text-neutral-500" />}
<SalesCard
sales={account.account_sells ?? null}
follower={account.account_followers ?? null}
responseTime={account.account_response_time_hours ? `< ${account.account_response_time_hours}h` : null}
positiveReviews={account.account_feedback_12m_positive ?? null}
neutralReviews={account.account_feedback_12m_neutral ?? null}
negativeReviews={account.account_feedback_12m_negative ?? null}
totalReviews={account.account_feedback_total ?? null}
className="md:col-span-2"
/>
<BentoGridItem
title="Actions"
description={
<div className="flex flex-wrap items-center gap-2 mt-1">
<button
onClick={() => handleRefreshAccount(account)}
disabled={isRefreshing}
className={cn(
"inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs transition-all active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed",
"border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
)}
title="Account aktualisieren"
>
<IconRefresh className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
Refresh
</button>
</div>
}
header={<BentoHeader />}
icon={<IconSettings className="h-4 w-4 text-neutral-500" />}
<AccountRefreshCard
onRefresh={() => handleRefreshAccount(account)}
isRefreshing={isRefreshing}
lastRefreshDate={account.account_last_refresh_at || null}
streak={account.account_refresh_streak || null}
dataFreshness={calculateDataFreshness(account.account_last_refresh_at) || 'Aging'}
/>
<RefreshActivityCard
monthMetrics={monthMetrics}
className="md:col-span-3"
/>
</BentoGrid>
);

View File

@@ -0,0 +1,219 @@
/**
* Account Metrics Service
* CRUD-Operationen für account_metrics Collection
* Verwaltet tägliche Account-Metriken (Refresh-Status, Sales-Daten)
*/
import { databases, databaseId } from "../lib/appwrite";
import { ID, Query } from "appwrite";
const accountMetricsCollectionId = "account_metrics";
/**
* Berechnet Sales-Bucket aus Sales-Count
* @param {number|null} salesCount - Sales-Count oder null
* @returns {string|null} Bucket ("1+", "5+", "10+", "50+") oder null
*/
export function calculateSalesBucket(salesCount) {
if (salesCount === null || salesCount === undefined || salesCount === 0) {
return null;
}
if (salesCount >= 1 && salesCount <= 4) {
return "1+";
}
if (salesCount >= 5 && salesCount <= 9) {
return "5+";
}
if (salesCount >= 10 && salesCount <= 49) {
return "10+";
}
if (salesCount >= 50) {
return "50+";
}
return null;
}
/**
* Formatiert Datum zu "yyyy-mm-dd" String
* @param {Date|string} date - Date-Objekt oder ISO-String
* @returns {string} Formatierte Datum-String
*/
function formatDate(date) {
if (typeof date === 'string') {
// Wenn bereits String, stelle sicher dass es yyyy-mm-dd Format ist
const d = new Date(date);
return d.toISOString().split('T')[0];
}
return date.toISOString().split('T')[0];
}
/**
* Formatiert Datum zu "yyyy-mm" String
* @param {Date|string} date - Date-Objekt oder ISO-String
* @returns {string} Formatierte Monat-String
*/
function formatMonth(date) {
const dateStr = formatDate(date);
return dateStr.substring(0, 7); // "yyyy-mm"
}
/**
* Erstellt oder aktualisiert eine Account-Metrik für einen Tag
* @param {string} accountId - ID des Accounts
* @param {string|Date} date - Datum (yyyy-mm-dd String oder Date-Objekt)
* @param {Object} data - Metrik-Daten
* @param {boolean} data.refreshed - Refresh-Status
* @param {string} data.refreshStatus - "success" | "failed"
* @param {number|null} [data.salesCount] - Sales-Differenz
* @returns {Promise<Object>} Erstelltes oder aktualisiertes Metrik-Dokument
*/
export async function upsertAccountMetric(accountId, date, data) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
const dateStr = formatDate(date);
const monthStr = formatMonth(date);
// Berechne Sales-Bucket aus salesCount
const salesBucket = calculateSalesBucket(data.salesCount);
// Prüfe ob Metrik für diesen Tag bereits existiert
try {
const existing = await databases.listDocuments(
databaseId,
accountMetricsCollectionId,
[
Query.equal("account_metrics_account_id", accountId),
Query.equal("account_metrics_date", dateStr),
Query.limit(1)
]
);
const payload = {
account_metrics_account_id: accountId,
account_metrics_date: dateStr,
account_metrics_month: monthStr,
account_metrics_refreshed: data.refreshed,
account_metrics_refresh_status: data.refreshStatus,
account_metrics_refreshed_at: data.refreshedAt || null,
account_metrics_sales_count: data.salesCount ?? null,
account_metrics_sales_bucket: salesBucket
};
// Speichere lastSalesTotal in rawSnapshot (falls vorhanden)
// Da rawSnapshot nicht in der erlaubten Liste ist, speichern wir es nicht
// Stattdessen: lastSalesTotal wird beim nächsten Refresh aus dem Account-Dokument geholt
let result;
if (existing.documents.length > 0) {
// Update existing document
const existingDoc = existing.documents[0];
result = await databases.updateDocument(
databaseId,
accountMetricsCollectionId,
existingDoc.$id,
payload
);
} else {
// Create new document
result = await databases.createDocument(
databaseId,
accountMetricsCollectionId,
ID.unique(),
payload
);
}
return result;
} catch (e) {
console.error("Fehler beim Upsert der Account-Metrik:", e);
throw e;
}
}
/**
* Lädt alle Account-Metriken für einen Monat
* @param {string} accountId - ID des Accounts
* @param {number} year - Jahr (z.B. 2026)
* @param {number} month - Monat (1-12)
* @returns {Promise<Map<string, Object>>} Map von date (yyyy-mm-dd) -> Metrik-Dokument
*/
export async function fetchAccountMetricsForMonth(accountId, year, month) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
// Formatiere Monat zu "yyyy-mm"
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
try {
const response = await databases.listDocuments(
databaseId,
accountMetricsCollectionId,
[
Query.equal("account_metrics_account_id", accountId),
Query.equal("account_metrics_month", monthStr),
Query.orderAsc("account_metrics_date")
]
);
// Konvertiere Array zu Map: date -> metric
const metricsMap = new Map();
for (const doc of response.documents) {
const date = doc.account_metrics_date;
if (date) {
metricsMap.set(date, doc);
}
}
return metricsMap;
} catch (e) {
// Wenn Collection nicht existiert, gib leere Map zurück
if (e.code === 404 || e.type === 'collection_not_found') {
console.warn("account_metrics Collection existiert noch nicht. Bitte Schema erstellen.");
return new Map();
}
console.error("Fehler beim Laden der Account-Metriken:", e);
throw e;
}
}
/**
* Lädt die letzte erfolgreiche Account-Metrik
* @param {string} accountId - ID des Accounts
* @returns {Promise<Object|null>} Letzte erfolgreiche Metrik oder null
*/
export async function getLastSuccessfulAccountMetric(accountId) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
try {
const response = await databases.listDocuments(
databaseId,
accountMetricsCollectionId,
[
Query.equal("account_metrics_account_id", accountId),
Query.equal("account_metrics_refresh_status", "success"),
Query.orderDesc("account_metrics_date"),
Query.limit(1)
]
);
if (response.documents.length > 0) {
return response.documents[0];
}
return null;
} catch (e) {
// Wenn Collection nicht existiert, gib null zurück
if (e.code === 404 || e.type === 'collection_not_found') {
return null;
}
console.error("Fehler beim Laden der letzten erfolgreichen Metrik:", e);
throw e;
}
}

View File

@@ -6,6 +6,7 @@
import { databases, databaseId, accountsCollectionId } from "../lib/appwrite";
import { ID, Query } from "appwrite";
import { getLastSuccessfulAccountMetric as getLastSuccessfulAccountMetricFromService } from "./accountMetricsService";
/**
* Lädt ein einzelnes Account nach ID
@@ -201,6 +202,15 @@ export async function createManagedAccount(authUserId, accountData) {
account_shop_name: accountData.account_shop_name || null,
account_url: accountData.account_url || null,
account_sells: accountData.account_sells ?? null, // Setze account_sells wenn verfügbar
// Neue Felder
account_response_time_hours: accountData.account_response_time_hours ?? null,
account_followers: accountData.account_followers ?? null,
account_feedback_total: accountData.account_feedback_total ?? null,
account_feedback_12m_positive: accountData.account_feedback_12m_positive ?? null,
account_feedback_12m_neutral: accountData.account_feedback_12m_neutral ?? null,
account_feedback_12m_negative: accountData.account_feedback_12m_negative ?? null,
account_last_refresh_at: accountData.account_last_refresh_at ?? null,
account_last_refresh_status: accountData.account_last_refresh_status ?? null,
};
// account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen
@@ -323,3 +333,73 @@ export async function deleteManagedAccount(accountId) {
throw e;
}
}
/**
* Berechnet Data Freshness zur Laufzeit basierend auf account_last_refresh_at
* @param {string|null} lastRefreshAt - ISO 8601 Datum-String oder null
* @returns {string|null} "fresh" | "aging" | "outdated" | null
*/
export function calculateDataFreshness(lastRefreshAt) {
if (!lastRefreshAt) {
return null;
}
try {
const refreshDate = new Date(lastRefreshAt);
const now = new Date();
const diffMs = now - refreshDate;
const diffDays = diffMs / (1000 * 60 * 60 * 24);
if (diffDays < 1) {
return "fresh";
} else if (diffDays <= 3) {
return "aging";
} else {
return "outdated";
}
} catch (e) {
console.warn("Error calculating data freshness:", e);
return null;
}
}
/**
* Bestimmt Refresh-Status basierend auf Partial Results
* Kernfelder: followers + feedbackTotal + (feedback12mPositive ODER responseTimeHours)
* @param {object} partialResults - Partial Results Objekt mit ok/error pro Sub-Scan
* @param {object} data - Extrahierte Daten (responseTimeHours, followers, feedbackTotal, feedback12mPositive)
* @returns {string} "success" | "partial" | "failed"
*/
export function determineRefreshStatus(partialResults, data) {
if (!partialResults || !data) {
return "failed";
}
// Kernfelder prüfen
const hasFollowers = data.followers !== null && data.followers !== undefined;
const hasFeedbackTotal = data.feedbackTotal !== null && data.feedbackTotal !== undefined;
const hasFeedback12m = data.feedback12mPositive !== null && data.feedback12mPositive !== undefined;
const hasResponseTime = data.responseTimeHours !== null && data.responseTimeHours !== undefined;
const hasThirdField = hasFeedback12m || hasResponseTime;
const coreFieldsCount = (hasFollowers ? 1 : 0) + (hasFeedbackTotal ? 1 : 0) + (hasThirdField ? 1 : 0);
if (coreFieldsCount >= 2) {
return "success";
} else if (coreFieldsCount === 1) {
return "partial";
} else {
return "failed";
}
}
/**
* Lädt die letzte erfolgreiche Account-Metrik
* Wrapper um accountMetricsService.getLastSuccessfulAccountMetric()
* @param {string} accountId - ID des Accounts
* @returns {Promise<Object|null>} Letzte erfolgreiche Metrik oder null
*/
export async function getLastSuccessfulAccountMetric(accountId) {
return await getLastSuccessfulAccountMetricFromService(accountId);
}

View File

@@ -521,6 +521,101 @@ async function parseViaStub(url) {
};
}
/**
* Parst eine eBay-URL erweitert und extrahiert Account-Daten inkl. Feedback, Response Time, Followers
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL
* @returns {Promise<{sellerId, shopName, market, status, stats, responseTimeHours, followers, feedbackTotal, feedback12mPositive, feedback12mNeutral, feedback12mNegative, partialResults}>}
* @throws {Error} - Wenn URL ungültig ist oder keine eBay-URL
*/
export async function parseViaExtensionExtended(url) {
// Validierung
if (!url || typeof url !== 'string') {
throw new Error("Invalid URL");
}
// Methode 1: chrome.runtime.sendMessage (externally_connectable)
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
try {
const extensionId = await getExtensionId();
if (!extensionId) {
throw new Error("Extension ID not available");
}
return new Promise((resolve, reject) => {
const message = {
action: "PARSE_ACCOUNT_EXTENDED",
url: url
};
const sendMessageCallback = (response) => {
if (chrome.runtime.lastError) {
const errorMsg = chrome.runtime.lastError.message || "Extension communication error";
reject(new Error(errorMsg));
return;
}
if (response && response.ok && response.data) {
resolve(response.data);
} else {
reject(new Error(response?.error || "Extension parsing failed"));
}
};
chrome.runtime.sendMessage(extensionId, message, sendMessageCallback);
// Timeout nach 90s (erhöht von 60s für 4 Tabs + Store-Suche)
setTimeout(() => {
reject(new Error("Extension timeout"));
}, 90000);
});
} catch (error) {
if (error.message && !error.message.includes("Extension")) {
throw error;
}
// Weiter zu Methode 2
}
}
// Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden)
if (typeof window !== 'undefined' && window.__EBAY_EXTENSION__ === true) {
return new Promise((resolve, reject) => {
const messageId = `parse_extended_${Date.now()}_${Math.random()}`;
const responseHandler = (event) => {
if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) {
return;
}
window.removeEventListener('message', responseHandler);
if (event.data?.ok && event.data?.data) {
resolve(event.data.data);
} else {
reject(new Error(event.data?.error || "Extension parsing failed"));
}
};
window.addEventListener('message', responseHandler);
window.postMessage({
source: 'eship-webapp',
action: 'PARSE_ACCOUNT_EXTENDED',
url: url,
messageId: messageId
}, '*');
setTimeout(() => {
window.removeEventListener('message', responseHandler);
reject(new Error("Extension timeout"));
}, 60000);
});
}
// Keine Extension verfügbar
throw new Error("Extension not available");
}
/**
* Parst eine eBay-URL und extrahiert Account-Daten (Facade)
* Versucht zuerst Extension-Pfad, fällt zurück auf Stub-Implementierung