Files
eship/Server/src/pages/AccountsPage.jsx
Basilosaurusrex a29086173f login fix
2026-03-02 11:59:29 +01:00

1081 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<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
ref={containerRef}
className="relative flex w-full shrink-0 flex-col items-start justify-start overflow-hidden text-left"
>
<span
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 whitespace-nowrap font-bold leading-tight text-[var(--text)]"
aria-hidden
>
{name}
</span>
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="max-w-full shrink-0 whitespace-nowrap hover:underline"
>
<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>
) : (
<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>
)}
</div>
{platformAccountId != null && platformAccountId !== "" && (
<div className="mt-1 shrink-0 text-sm text-[var(--muted)]">
ID: {platformAccountId}
</div>
)}
<div ref={listRef} className="relative mt-2 flex shrink-0 flex-col gap-1">
<label className="text-xs font-medium text-[var(--muted)]">Account wechseln</label>
<button
type="button"
onClick={() => setListOpen((o) => !o)}
className="flex w-full items-center justify-between rounded-xl border border-[var(--line)] bg-white/5 px-3 py-2 text-left text-sm text-[var(--text)] outline-none transition-colors hover:border-[rgba(106,166,255,0.4)] focus:border-[rgba(106,166,255,0.5)] dark:bg-neutral-800"
>
<span>Account wählen</span>
<IconChevronDown className={cn("h-4 w-4 shrink-0 transition-transform", listOpen && "rotate-180")} />
</button>
{listOpen && (
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-xl border border-[var(--line)] bg-white shadow-lg dark:bg-neutral-800">
{otherAccounts.length === 0 ? (
<li className="px-3 py-2 text-xs text-[var(--muted)]">Keine weiteren Accounts</li>
) : (
otherAccounts.map((acc) => {
const id = acc.$id || acc.id;
const label = getAccountDisplayName(acc) || id;
return (
<li key={id}>
<button
type="button"
onClick={() => {
onSelectAccount(id);
setListOpen(false);
}}
className="flex w-full items-center px-3 py-2 text-left text-sm text-[var(--text)] transition-colors hover:bg-white/10 dark:hover:bg-neutral-700"
>
{label}
</button>
</li>
);
})
)}
</ul>
)}
</div>
</div>
);
}
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 (
<div
className={cn(
"row-span-1 relative flex h-full min-h-[12rem] items-center justify-center overflow-hidden 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
)}
>
{cfg ? (
<img
src={src || cfg.fallback}
alt=""
onError={onError}
className="max-h-full w-full object-contain"
/>
) : (
<span className="text-xs text-[var(--muted)]">{platform || ""}</span>
)}
{market != null && String(market).trim() !== "" && (
<div className="absolute bottom-2 right-2 font-bold text-[var(--text)]">
{String(market).trim().toUpperCase()}
</div>
)}
</div>
);
}
function RangCard({ rank, className }) {
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col items-center justify-center 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="text-center">
<div className="text-xs font-medium uppercase tracking-wide text-[var(--muted)]">Rang</div>
<div className="mt-1 text-2xl font-bold text-[var(--text)]">{rank ?? ""}</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={() => {
// #region agent log
fetch('http://127.0.0.1:7243/ingest/2cdae91e-9f0b-48c7-8e02-a970375bdaff',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:'debug-session',runId:'run1',hypothesisId:'H1',location:'AccountsPage.jsx:297',message:'AccountRefreshCard click',data:{isRefreshing,hasOnRefresh:!!onRefresh},timestamp:Date.now()})}).catch(()=>{});
// #endregion
if (onRefresh) {
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([]);
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 (
<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">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="m-0 text-2xl font-semibold tracking-wide text-[var(--text)]">
Accounts
</h1>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
Verwalte deine Plattform-Accounts
</p>
</div>
<button
onClick={handleAddAccount}
className="flex items-center gap-2 rounded-xl border border-[var(--line)] bg-white/3 px-4 py-2.5 text-xs font-medium text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
>
<IconPlus className="h-4 w-4" />
Add Account
</button>
</div>
{/* Toast Notification */}
<AnimatePresence>
{refreshToast.show && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
"fixed top-4 right-4 z-50 rounded-lg px-4 py-3 text-sm shadow-lg",
refreshToast.type === "success"
? "bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
: "bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"
)}
>
{refreshToast.message}
</motion.div>
)}
</AnimatePresence>
{/* Bento Grid nur ein Account */}
<div className="flex flex-1 flex-col gap-8 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
Loading accounts...
</div>
) : !displayedAccount ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
No accounts yet. Add one above.
</div>
) : (
(() => {
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 (
<BentoGrid key={accountId} className="max-w-4xl mx-auto md:auto-rows-[18rem]">
<AccountNameCard
name={name}
url={url}
platformAccountId={account.account_platform_account_id}
accounts={accounts}
displayedAccountId={displayedAccountId}
onSelectAccount={handleDisplayedAccountChange}
className="md:col-span-1"
/>
<PlatformLogoCard
platform={account.account_platform}
market={account.account_platform_market}
className="md:col-span-1"
/>
<RangCard rank={undefined} className="md:col-span-1" />
<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"
/>
<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'}
/>
</BentoGrid>
);
})()
)}
</div>
{/* Add Account Form Modal */}
<AnimatePresence>
{showAddForm && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleCloseForm}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => 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 */}
<button
onClick={handleCloseForm}
className="absolute right-4 top-4 rounded-lg p-1.5 text-[var(--muted)] transition-colors hover:bg-neutral-100 hover:text-[var(--text)] dark:hover:bg-neutral-700"
>
<IconX className="h-5 w-5" />
</button>
{/* Form Header */}
<h2 className="mb-4 text-xl font-semibold text-[var(--text)]">
Add Account
</h2>
{/* Error/Success Messages */}
{parsingError && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{parsingError}
</div>
)}
{formError && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{formError}
</div>
)}
{formSuccess && (
<div className="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
{formSuccess}
</div>
)}
{/* Form */}
<form onSubmit={handleFormSubmit} className="space-y-4">
{/* Phase 1: URL Input */}
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--text)]">
eBay Account URL <span className="text-red-500">*</span>
</label>
<input
type="url"
value={formData.account_url}
onChange={(e) => 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"
/>
<p className="mt-1.5 text-xs text-[var(--muted)]">
Füge den Link zum eBay-Verkäuferprofil oder Shop ein. Wir lesen die Account-Daten automatisch aus.
</p>
</div>
{/* Phase 2: Preview (wenn parsedData vorhanden) */}
{parsedData && (
<div className="space-y-4 rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-700 dark:bg-neutral-800/50">
<h3 className="text-sm font-semibold text-[var(--text)]">
Account-Informationen (automatisch erkannt)
</h3>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Market (Auto)
</label>
<input
type="text"
value={parsedData.market}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Automatisch erkannter Marktplatz (z.B. DE oder US).
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
eBay Seller ID (Auto)
</label>
<input
type="text"
value={parsedData.sellerId}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Eindeutige Verkäufer-ID von eBay. Wird für Abgleich und Duplikat-Erkennung verwendet.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Shop Name (Auto)
</label>
<input
type="text"
value={parsedData.shopName}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Öffentlich sichtbarer Name des Shops auf eBay.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Sales (Auto)
</label>
<input
type="text"
value={parsedData.stats?.itemsSold ? new Intl.NumberFormat("de-DE").format(parsedData.stats.itemsSold) : "-"}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Gesamtzahl der verkauften Artikel.
</p>
</div>
</div>
)}
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={handleCloseForm}
disabled={parsing || formLoading}
className="rounded-lg border border-[var(--line)] bg-white px-4 py-2 text-sm font-medium text-[var(--text)] transition-colors hover:bg-neutral-50 disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Cancel
</button>
{parsedData ? (
<button
type="submit"
disabled={formLoading}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{formLoading ? "Speichern..." : "Account speichern"}
</button>
) : (
<button
type="submit"
disabled={parsing || formLoading || !formData.account_url?.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{parsing ? "Lese Accountdaten aus..." : "Account hinzufügen"}
</button>
)}
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};