1081 lines
44 KiB
JavaScript
1081 lines
44 KiB
JavaScript
"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>
|
||
);
|
||
};
|