"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 && (
{otherAccounts.length === 0 ? (
- Keine weiteren Accounts
) : (
otherAccounts.map((acc) => {
const id = acc.$id || acc.id;
const label = getAccountDisplayName(acc) || id;
return (
-
);
})
)}
)}
);
}
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 (
);
}
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) */}
{/* 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 */}
)}
);
};