"use client"; import React, { useState, useEffect } from "react"; import { IconPlus, IconX, IconRefresh, IconChevronDown, } from "@tabler/icons-react"; import { motion, AnimatePresence } from "motion/react"; import { cn } from "../lib/utils"; import { useHashRoute } from "../lib/routing"; import { setActiveAccountId, getActiveAccountId, getAccountDisplayName, } from "../services/accountService"; import { fetchManagedAccounts, createManagedAccount, updateManagedAccount, determineRefreshStatus, calculateDataFreshness, getLastSuccessfulAccountMetric } from "../services/accountsService"; import { upsertAccountMetric } 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, url, platformAccountId, accounts, displayedAccountId, onSelectAccount, className, }) { const containerRef = React.useRef(null); const measureRef = React.useRef(null); const listRef = React.useRef(null); const [fontSize, setFontSize] = React.useState(48); const [listOpen, setListOpen] = React.useState(false); const otherAccounts = React.useMemo( () => accounts.filter((acc) => (acc.$id || acc.id) !== displayedAccountId), [accounts, displayedAccountId] ); React.useEffect(() => { if (!listOpen) return; const handleClickOutside = (e) => { if (listRef.current && !listRef.current.contains(e.target)) setListOpen(false); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [listOpen]); React.useEffect(() => { const cont = containerRef.current; const meas = measureRef.current; if (!cont || !meas || !name) return; const fit = () => { const w = cont.clientWidth; if (w <= 0) return; let fs = 48; meas.style.fontSize = `${fs}px`; while (meas.scrollWidth > w && fs > 12) { fs -= 2; meas.style.fontSize = `${fs}px`; } setFontSize(fs); }; fit(); const ro = new ResizeObserver(fit); ro.observe(cont); return () => ro.disconnect(); }, [name]); return (
{name} {url ? ( {name} ) : ( {name} )}
{platformAccountId != null && platformAccountId !== "" && (
ID: {platformAccountId}
)}
{listOpen && ( )}
); } const PLATFORM_LOGOS = { ebay: { local: "/assets/platforms/ebay.png", fallback: "https://upload.wikimedia.org/wikipedia/commons/1/1b/EBay_logo.svg", }, amazon: { local: "/assets/platforms/amazon.png", fallback: "https://upload.wikimedia.org/wikipedia/commons/a/a9/Amazon_logo.svg", }, }; function PlatformLogoCard({ platform, market, className }) { const key = (platform || "").toLowerCase(); const cfg = PLATFORM_LOGOS[key]; const [src, setSrc] = React.useState(cfg ? cfg.local : null); const [usedFallback, setUsedFallback] = React.useState(false); React.useEffect(() => { const c = PLATFORM_LOGOS[key]; if (!c) { setSrc(null); setUsedFallback(true); return; } setSrc(c.local); setUsedFallback(false); }, [key]); const onError = React.useCallback(() => { if (usedFallback || !cfg) return; setSrc(cfg.fallback); setUsedFallback(true); }, [cfg, usedFallback]); return (
{cfg ? ( ) : ( {platform || "–"} )} {market != null && String(market).trim() !== "" && (
{String(market).trim().toUpperCase()}
)}
); } function RangCard({ rank, className }) { return (
Rang
{rank ?? "–"}
); } 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 (
{/* Grid: 5 Zeilen x 5 Spalten */}
{/* Zeile 1: Kontext & Bedeutung */} {/* Bereich A (Zeile 1, Spalten 1-5) */}
Account Refresh
manual
{/* Zeile 2-3: Zentrale Aktion (Ritualkern) */} {/* Bereich B (Zeilen 2-3, Spalten 1-5) */}
{/* Zeile 4: Tages- & Streak-Status */} {/* Bereich C (Zeile 4, Spalten 1-5) */}
{getLastRefreshText()} {streak != null && streak > 0 && ( Streak: {streak} {streak === 1 ? 'day' : 'days'} )}
{/* Zeile 5: Datenqualitäts-Hinweis */} {/* Bereich D (Zeile 5, Spalten 1-5) */}
Data freshness: {getFreshnessLabel()}
); } 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 (
{/* Grid: 5 Zeilen x 10 Spalten */}
{/* Zeile 1-2: Hero-Zone (40%) */} {/* Block A: Sales (Spalten 1-4, Zeilen 1-2) */}
{sales != null ? new Intl.NumberFormat("de-DE").format(sales) : "–"}
Sales (gesamt)
{/* Block B: Follower (Spalten 5-7, Zeilen 1-2) */}
{follower != null ? new Intl.NumberFormat("de-DE").format(follower) : "–"}
Follower
{/* Block C: Antwortzeit (Spalten 8-10, Zeile 1) */}
Antwortzeit {responseTime ?? "—"}
{/* Zeile 2, Spalten 8-10: leer */}
{/* Zeile 3-4: Bewertungen (40%) */} {/* Block D: Bewertungszusammenfassung (Spalten 1-10, Zeilen 3-4) */}
Bewertungen der letzten 12 Monate
{/* Balkendiagramm */}
{positivePercent > 0 && (
{positivePercent > 15 && ( {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"} )}
)} {neutralPercent > 0 && (
{neutralPercent > 15 && ( {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"} )}
)} {negativePercent > 0 && (
{negativePercent > 15 && ( {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"} )}
)}
{/* Zahlen unter dem Balken (falls Platz) */} {positivePercent <= 15 && neutralPercent <= 15 && negativePercent <= 15 && (
Positiv: {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"} Neutral: {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"} Negativ: {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}
)}
{/* Zeile 5: Meta-Informationen (20%) */} {/* Block E: Gesamtbewertungen (Spalten 1-10, Zeile 5) */}
Gesamtbewertungen: {totalReviews != null ? new Intl.NumberFormat("de-DE").format(totalReviews) : "–"}
); } export const AccountsPage = () => { const { navigate } = useHashRoute(); const [accounts, setAccounts] = useState([]); const [loading, setLoading] = useState(true); const [showAddForm, setShowAddForm] = useState(false); const [formError, setFormError] = useState(""); const [formSuccess, setFormSuccess] = useState(""); const [formLoading, setFormLoading] = useState(false); // Parse-State für Zwei-Phasen-Flow const [parsedData, setParsedData] = useState(null); const [parsing, setParsing] = useState(false); const [parsingError, setParsingError] = useState(""); // Refresh-State pro Account const [refreshingAccountId, setRefreshingAccountId] = useState(null); const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" }); // Nur ein Account wird angezeigt; Wechsel über Dropdown const [displayedAccountId, setDisplayedAccountId] = useState(null); // Form-Felder (nur noch URL) const [formData, setFormData] = useState({ account_url: "", }); // Accounts laden useEffect(() => { loadAccounts(); }, []); // displayedAccountId setzen sobald Accounts geladen (aktiv oder erster) useEffect(() => { if (accounts.length === 0) { setDisplayedAccountId(null); return; } const active = getActiveAccountId(); const hasActive = accounts.some((a) => (a.$id || a.id) === active); if (hasActive) { setDisplayedAccountId(active); } else { setDisplayedAccountId(accounts[0].$id || accounts[0].id); } }, [accounts]); async function loadAccounts() { setLoading(true); try { const authUser = await getAuthUser(); if (!authUser) { setAccounts([]); return; } const loadedAccounts = await fetchManagedAccounts(authUser.$id); setAccounts(loadedAccounts); } catch (e) { console.error("Fehler beim Laden der Accounts:", e); setAccounts([]); } finally { setLoading(false); } } const handleDisplayedAccountChange = (accountId) => { setDisplayedAccountId(accountId); setActiveAccountId(accountId); }; const handleRefreshAccount = async (account) => { const accountId = account.$id || account.id; const accountUrl = account.account_url; if (!accountUrl) { setRefreshToast({ show: true, message: "Account hat keine URL zum Aktualisieren.", type: "error" }); setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000); return; } setRefreshingAccountId(accountId); try { // 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 // Leere account_platform_account_id würde Unique-Index-Konflikte verursachen const updatePayload = {}; // Nur market setzen, wenn nicht leer if (parsedData.market && parsedData.market.trim()) { updatePayload.account_platform_market = parsedData.market; } // Nur sellerId setzen, wenn nicht leer (verhindert Unique-Index-Konflikte mit leerem String) if (parsedData.sellerId && parsedData.sellerId.trim()) { updatePayload.account_platform_account_id = parsedData.sellerId; } // Shop-Name kann auch leer sein (optional) 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 // Setze account_updated_at auf aktuelle Zeit updatePayload.account_updated_at = new Date().toISOString(); 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(); // Success-Toast setRefreshToast({ show: true, message: "Account aktualisiert", type: "success" }); setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000); } catch (e) { console.error("Fehler beim Aktualisieren des Accounts:", e); let errorMessage = "Update fehlgeschlagen"; if (e.message?.includes("Parsing") || e.message?.includes("URL")) { errorMessage = "Parsing fehlgeschlagen"; } 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); } }; const handleAddAccount = () => { setShowAddForm(true); setFormError(""); setFormSuccess(""); }; const handleCloseForm = () => { setShowAddForm(false); setFormError(""); setFormSuccess(""); setParsedData(null); setParsingError(""); // Form zurücksetzen setFormData({ account_url: "", }); }; const handleFormChange = (field, value) => { setFormData((prev) => ({ ...prev, [field]: value })); // Clear errors beim Eingeben if (formError) setFormError(""); if (parsingError) setParsingError(""); }; const handleParseUrl = async () => { const url = formData.account_url?.trim(); if (!url) { setParsingError("Bitte gib eine eBay-URL ein."); return; } setParsing(true); setParsingError(""); setFormError(""); try { const result = await parseEbayAccount(url); setParsedData(result); setParsingError(""); } catch (e) { setParsingError(e.message || "Bitte gib eine gültige eBay-URL ein."); setParsedData(null); } finally { setParsing(false); } }; const handleFormSubmit = async (e) => { e.preventDefault(); // Wenn noch nicht geparst, zuerst parsen if (!parsedData) { await handleParseUrl(); return; } // Save-Phase: Account in DB speichern setFormError(""); setFormSuccess(""); setFormLoading(true); try { const authUser = await getAuthUser(); if (!authUser) { setFormError("Nicht eingeloggt. Bitte neu anmelden."); return; } // Payload aus parsedData zusammenstellen const accountSellsValue = parsedData.stats?.itemsSold ?? null; const newAccount = await createManagedAccount(authUser.$id, { account_url: formData.account_url.trim(), account_platform_account_id: parsedData.sellerId, account_platform_market: parsedData.market, account_shop_name: parsedData.shopName, account_sells: accountSellsValue, // account_status wird nicht mehr gesendet (optional, Schema-Problem in Appwrite) }); // Erfolg: Liste refreshen await loadAccounts(); // Wenn dies das erste Account ist, setze es als aktiv if (accounts.length === 0) { setActiveAccountId(newAccount.$id); } setFormSuccess("Account erfolgreich erstellt!"); setTimeout(() => { handleCloseForm(); }, 1500); } catch (e) { // Fehlerbehandlung const errorMessage = e.message || "Fehler beim Erstellen des Accounts. Bitte versuche es erneut."; setFormError(errorMessage); } finally { setFormLoading(false); } }; const displayedAccount = accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null; return (
{/* Header */}

Accounts

Verwalte deine Plattform-Accounts

{/* Toast Notification */} {refreshToast.show && ( {refreshToast.message} )} {/* Bento Grid – nur ein Account */}
{loading ? (
Loading accounts...
) : !displayedAccount ? (
No accounts yet. Add one above.
) : ( (() => { const account = displayedAccount; const accountId = account.$id || account.id; const isRefreshing = refreshingAccountId === accountId; const name = getAccountDisplayName(account) || "–"; const url = account.account_url; const sales = account.account_sells != null ? new Intl.NumberFormat("de-DE").format(account.account_sells) : "–"; return ( 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'} /> ); })() )}
{/* Add Account Form Modal */} {showAddForm && ( e.stopPropagation()} className="relative w-full max-w-2xl rounded-2xl border border-[var(--line)] bg-white p-6 shadow-xl dark:bg-neutral-800" > {/* Close Button */} {/* Form Header */}

Add Account

{/* Error/Success Messages */} {parsingError && (
{parsingError}
)} {formError && (
{formError}
)} {formSuccess && (
{formSuccess}
)} {/* Form */}
{/* Phase 1: URL Input */}
handleFormChange("account_url", e.target.value)} placeholder="https://www.ebay.de/usr/..." required disabled={parsing || formLoading} className="w-full rounded-lg border border-[var(--line)] bg-white px-3 py-2 text-sm text-[var(--text)] outline-none transition-colors focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-neutral-900" />

Füge den Link zum eBay-Verkäuferprofil oder Shop ein. Wir lesen die Account-Daten automatisch aus.

{/* Phase 2: Preview (wenn parsedData vorhanden) */} {parsedData && (

Account-Informationen (automatisch erkannt)

Automatisch erkannter Marktplatz (z.B. DE oder US).

Eindeutige Verkäufer-ID von eBay. Wird für Abgleich und Duplikat-Erkennung verwendet.

Öffentlich sichtbarer Name des Shops auf eBay.

Gesamtzahl der verkauften Artikel.

)} {/* Form Actions */}
{parsedData ? ( ) : ( )}
)}
); };