Convert Server from submodule to normal files

This commit is contained in:
2026-01-18 17:50:49 +01:00
parent 86d2191a25
commit 0012a10249
52 changed files with 11975 additions and 1 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules/
Server/node_modules/
Server/backend/node_modules/
# Build outputs
dist/
build/
*.log
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Cursor
.cursor/debug.log

1
Server

Submodule Server deleted from 6100df3f83

3
Server/appwrite.json Normal file
View File

@@ -0,0 +1,3 @@
{
"projectId": "696b82bb0036d2e547ad"
}

View File

@@ -0,0 +1,15 @@
{
"name": "eship-backend",
"version": "0.1.0",
"type": "module",
"main": "server.js",
"scripts": {
"dev": "node server.js",
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"node-appwrite": "^14.0.0",
"dotenv": "^16.3.1"
}
}

54
Server/backend/server.js Normal file
View File

@@ -0,0 +1,54 @@
import express from "express";
import { Client, Account, Databases } from "node-appwrite";
import dotenv from "dotenv";
dotenv.config();
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
function makeUserClient(jwt) {
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setJWT(jwt);
return client;
}
function makeAdminClient() {
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
return client;
}
app.post("/api/action", async (req, res) => {
try {
const auth = req.headers.authorization || "";
const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!jwt) return res.status(401).json({ ok: false, error: "missing token" });
// 1) user token validieren
const userClient = makeUserClient(jwt);
const account = new Account(userClient);
const user = await account.get(); // wirft Fehler, wenn JWT ungueltig/abgelaufen
// 2) privilegierte Aktion nur serverseitig mit Admin Key
const adminClient = makeAdminClient();
const db = new Databases(adminClient);
// Beispiel: lies etwas, das nur du lesen darfst
// const data = await db.listDocuments("dbId", "collectionId");
return res.json({ ok: true, userId: user.$id, info: "action allowed" });
} catch (e) {
return res.status(401).json({ ok: false, error: "unauthorized" });
}
});
app.listen(PORT, () => {
console.log(`Backend server running on port ${PORT}`);
});

13
Server/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EShip - Login</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5634
Server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
Server/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "react-starter-kit-for-appwrite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@appwrite.io/pink-icons": "^1.0.0",
"@gsap/react": "^2.1.2",
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/vite": "^4.0.14",
"appwrite": "^21.2.1",
"clsx": "^2.1.1",
"gsap": "^3.14.2",
"motion": "^12.26.2",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"styled-components": "^6.3.8",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.14"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"prettier": "3.5.3",
"vite": "^6.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

376
Server/src/App.jsx Normal file
View File

@@ -0,0 +1,376 @@
"use client";
import React, { useEffect, useState } from "react";
import { Sidebar, SidebarBody, SidebarLink } from "./components/sidebar";
import {
IconArrowLeft,
IconBrandTabler,
IconSettings,
IconUserBolt,
IconShoppingBag,
} from "@tabler/icons-react";
import { motion } from "motion/react";
import { cn } from "./lib/utils";
import { BackgroundRippleEffect } from "./components/layout/BackgroundRippleEffect";
import { Dashboard } from "./components/dashboard/Dashboard";
import { AccountsPage } from "./pages/AccountsPage";
import LogoutButton from "./components/ui/LogoutButton";
import { OnboardingGate } from "./components/onboarding/OnboardingGate";
import { SidebarHeader } from "./components/sidebar/SidebarHeader";
import { useHashRoute } from "./lib/routing";
import { account, databases, databaseId, usersCollectionId } from "./lib/appwrite";
export default function App() {
const { route, navigate } = useHashRoute();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState({ loading: true, authed: false, error: "" });
const [sidebarOpen, setSidebarOpen] = useState(false);
// Gate-Management State
const [authUser, setAuthUser] = useState(null);
const [hasUserDoc, setHasUserDoc] = useState(false);
const [checkingUserDoc, setCheckingUserDoc] = useState(false);
const [onboardingLoading, setOnboardingLoading] = useState(false);
const [onboardingError, setOnboardingError] = useState("");
const showGate = !hasUserDoc && !checkingUserDoc && authUser !== null;
async function checkUserDocument(userId) {
if (!databases || !databaseId || !usersCollectionId) {
return false;
}
try {
await databases.getDocument(databaseId, usersCollectionId, userId);
return true; // Dokument existiert
} catch (e) {
// 404 bedeutet, dass das Dokument nicht existiert - das ist OK
if (e.code === 404 || e.type === 'document_not_found') {
return false; // Dokument existiert nicht
}
// Andere Fehler: Loggen, aber nicht als kritischen Fehler behandeln
console.warn('Fehler beim Prüfen des User-Dokuments:', e.message || e);
return false; // Im Zweifel als "nicht vorhanden" behandeln
}
}
async function refreshAuth() {
setStatus((s) => ({ ...s, loading: true, error: "" }));
setCheckingUserDoc(true);
setOnboardingError("");
try {
const user = await account.get(); // wirft Fehler, wenn keine Session
setAuthUser(user);
setStatus({ loading: false, authed: true, error: "" });
// Prüfe, ob User-Dokument existiert
const userDocExists = await checkUserDocument(user.$id);
setHasUserDoc(userDocExists);
await handoffJwtToExtension();
} catch (e) {
setStatus({ loading: false, authed: false, error: "" });
setAuthUser(null);
setHasUserDoc(false);
} finally {
setCheckingUserDoc(false);
}
}
async function login(e) {
e.preventDefault();
setStatus((s) => ({ ...s, loading: true, error: "" }));
setCheckingUserDoc(true);
setOnboardingError("");
try {
await account.createEmailPasswordSession(email, password);
const user = await account.get();
setAuthUser(user);
setStatus({ loading: false, authed: true, error: "" });
// Prüfe, ob User-Dokument existiert
const userDocExists = await checkUserDocument(user.$id);
setHasUserDoc(userDocExists);
await handoffJwtToExtension();
} catch (e) {
setStatus({ loading: false, authed: false, error: "Login fehlgeschlagen" });
setAuthUser(null);
setHasUserDoc(false);
} finally {
setCheckingUserDoc(false);
}
}
async function handleOnboardingStart() {
if (!authUser || !databases || !databaseId || !usersCollectionId) {
setOnboardingError("Fehler: Konfiguration unvollständig");
return;
}
setOnboardingLoading(true);
setOnboardingError("");
try {
await databases.createDocument(
databaseId,
usersCollectionId,
authUser.$id, // Document-ID = Auth-User-ID
{
user_name: authUser.name || "User"
}
);
// Erfolg: User-Dokument erstellt
setHasUserDoc(true);
setOnboardingError("");
} catch (e) {
// 409 Conflict bedeutet, dass das Dokument bereits existiert
// Das ist ok, da wir idempotent sein wollen
if (e.code === 409 || e.type === 'document_already_exists') {
setHasUserDoc(true);
setOnboardingError("");
} else if (e.code === 401 || e.type === 'general_unauthorized_scope') {
// 401 Unauthorized: Permissions nicht richtig gesetzt
setOnboardingError(
"Berechtigung verweigert. Bitte prüfe in Appwrite, ob die users Collection die richtigen Permissions hat. " +
"Siehe setup/USERS_COLLECTION_SETUP.md für Details."
);
} else {
// Andere Fehler anzeigen
setOnboardingError(e.message || "Fehler beim Erstellen des Profils. Bitte versuche es erneut.");
}
} finally {
setOnboardingLoading(false);
}
}
async function logout() {
setStatus((s) => ({ ...s, loading: true, error: "" }));
try {
await account.deleteSession("current");
} catch {}
setStatus({ loading: false, authed: false, error: "" });
setAuthUser(null);
setHasUserDoc(false);
setOnboardingError("");
// Extension informieren: Token weg
sendToExtension({ type: "AUTH_CLEARED" });
}
async function handoffJwtToExtension() {
// JWT ist userbezogen und kann serverseitig validiert werden
const jwt = await account.createJWT();
sendToExtension({ type: "AUTH_JWT", jwt: jwt.jwt });
}
function sendToExtension(payload) {
// Sende Nachricht über window.postMessage (keine Extension ID nötig)
// Das Content Script der Extension lauscht darauf
window.postMessage(
{
source: "eship-webapp",
...payload,
},
"*"
);
}
useEffect(() => {
refreshAuth();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const links = [
{
label: "Dashboard",
href: "#/",
icon: (
<IconBrandTabler className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
navigate("/");
},
},
{
label: "Accounts",
href: "#/accounts",
icon: (
<IconShoppingBag className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
navigate("/accounts");
},
},
{
label: "Profile",
href: "#",
icon: (
<IconUserBolt className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
},
{
label: "Settings",
href: "#",
icon: (
<IconSettings className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
},
{
label: "Logout",
href: "#",
icon: (
<IconArrowLeft className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
logout();
},
},
];
// Rendere Content basierend auf Route
const renderContent = () => {
if (route === "/accounts") {
return <AccountsPage />;
}
// Default: Dashboard
return <Dashboard />;
};
return (
<div style={styles.page}>
{!status.authed && (
<div style={styles.lockOverlay}>
<div style={styles.card}>
<div style={styles.title}>Login</div>
<div style={styles.sub}>Appwrite Session erforderlich</div>
<form onSubmit={login} style={styles.form}>
<input
style={styles.input}
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
<input
style={styles.input}
placeholder="Passwort"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<button style={styles.button} disabled={status.loading}>
{status.loading ? "..." : "Anmelden"}
</button>
{status.error && <div style={styles.error}>{status.error}</div>}
</form>
<div style={styles.hint}>
Nach Login wird der Sperrbildschirm entfernt und die Extension erhaelt ein JWT.
</div>
</div>
</div>
)}
{status.authed && (
<>
{showGate && (
<OnboardingGate
userName={authUser?.name || "User"}
onStart={handleOnboardingStart}
loading={onboardingLoading}
error={onboardingError}
/>
)}
<div style={{ display: showGate ? "none" : "block" }}>
<BackgroundRippleEffect />
<div
className={cn(
"flex w-full flex-1 flex-col overflow-hidden rounded-md border border-neutral-200 bg-gray-100 md:flex-row dark:border-neutral-700 dark:bg-neutral-800",
"h-screen relative z-10"
)}>
<Sidebar open={sidebarOpen} setOpen={setSidebarOpen} animate={true}>
<SidebarBody className="justify-between gap-10">
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
<SidebarHeader />
<div className="mt-8 flex flex-col gap-2">
{links.map((link, idx) => (
<SidebarLink key={idx} link={link} />
))}
</div>
</div>
<div>
<LogoutButton onClick={(e) => {
e.preventDefault();
logout();
}} />
</div>
</SidebarBody>
</Sidebar>
{renderContent()}
</div>
</div>
</>
)}
</div>
);
}
const styles = {
page: {
minHeight: "100vh",
background: "#0b0f19",
color: "#e7aaf0",
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
},
lockOverlay: {
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.70)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 18,
zIndex: 9999,
},
card: {
width: "100%",
maxWidth: 420,
background: "rgba(255,255,255,0.07)",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 18,
padding: 20,
backdropFilter: "blur(10px)",
},
title: { fontSize: 20, fontWeight: 800 },
sub: { marginTop: 6, opacity: 0.8, fontSize: 13 },
form: { marginTop: 14, display: "grid", gap: 10 },
input: {
height: 42,
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.16)",
outline: "none",
padding: "0 12px",
background: "rgba(0,0,0,0.20)",
color: "#e7aaf0",
},
button: {
height: 42,
borderRadius: 12,
border: "0",
cursor: "pointer",
fontWeight: 800,
background: "#e7aaf0",
color: "#0b0f19",
},
error: { marginTop: 6, color: "#ffb3b3", fontSize: 13 },
hint: { marginTop: 12, opacity: 0.75, fontSize: 12, lineHeight: 1.4 },
};

View File

@@ -0,0 +1,146 @@
import React, { useState, useEffect } from "react";
import { OverviewSection } from "./sections/OverviewSection";
import { AccountsSection } from "./sections/AccountsSection";
import { ProductsSection } from "./sections/ProductsSection";
import { InsightsSection } from "./sections/InsightsSection";
import { useScrollSnap } from "./hooks/useScrollSnap";
import { getAuthUser } from "../../lib/appwrite";
import { fetchManagedAccounts } from "../../services/accountsService";
import { resolveActiveAccount } from "../../services/accountService";
import { useHashRoute } from "../../lib/routing";
export const Dashboard = () => {
const { navigate } = useHashRoute();
const [productFilters, setProductFilters] = useState({});
const [activeAccountId, setActiveAccountId] = useState(null);
const [hasAccounts, setHasAccounts] = useState(false);
const [loadingAccounts, setLoadingAccounts] = useState(true);
const sectionIds = ["s1", "s2", "s3", "s4"];
const { scrollToSection } = useScrollSnap(sectionIds);
// Accounts laden und aktiven Account auflösen beim Mount und nach Updates
useEffect(() => {
async function loadAccountsAndResolveActive() {
setLoadingAccounts(true);
try {
const authUser = await getAuthUser();
if (!authUser) {
setHasAccounts(false);
setActiveAccountId(null);
return;
}
const accounts = await fetchManagedAccounts(authUser.$id);
setHasAccounts(accounts.length > 0);
const activeAccount = resolveActiveAccount(accounts);
if (activeAccount) {
const accountId = activeAccount.$id || activeAccount.id;
setActiveAccountId(accountId);
} else {
setActiveAccountId(null);
}
} catch (e) {
console.error("Fehler beim Laden der Accounts:", e);
setHasAccounts(false);
setActiveAccountId(null);
} finally {
setLoadingAccounts(false);
}
}
loadAccountsAndResolveActive();
// Listen for accounts update events (e.g., after onboarding)
const handleAccountsUpdated = () => {
loadAccountsAndResolveActive();
};
window.addEventListener('accountsUpdated', handleAccountsUpdated);
return () => {
window.removeEventListener('accountsUpdated', handleAccountsUpdated);
};
}, []);
// Event-Listener für Account-Wechsel
useEffect(() => {
function handleActiveAccountChanged(event) {
const { accountId } = event.detail;
setActiveAccountId(accountId);
// Dashboard-Daten würden hier neu geladen werden (später, wenn Products-Service existiert)
}
window.addEventListener("activeAccountChanged", handleActiveAccountChanged);
return () => {
window.removeEventListener("activeAccountChanged", handleActiveAccountChanged);
};
}, []);
const handleJumpToSection = (sectionId) => {
scrollToSection(sectionId);
};
const handleFilterProducts = (filters) => {
setProductFilters(filters);
};
// Empty-State wenn kein Account vorhanden
if (!loadingAccounts && !hasAccounts) {
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">
<div className="flex flex-col items-center justify-center py-20 text-center">
<h2 className="mb-2 text-xl font-semibold text-[var(--text)]">
No account connected
</h2>
<p className="mb-6 text-sm text-[var(--muted)]">
Du musst zuerst einen Account hinzufügen, um das Dashboard zu nutzen.
</p>
<button
onClick={() => navigate("/accounts")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-4 py-2.5 text-sm 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]"
>
Add Account
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-1">
<div className="flex h-full w-full flex-1 flex-col gap-2 rounded-2xl border border-neutral-200 bg-white p-2 md:p-4 dark:border-neutral-700 dark:bg-neutral-900">
<div
className="h-full w-full overflow-y-scroll hide-scrollbar"
style={{
scrollSnapType: "y mandatory",
scrollBehavior: "smooth",
}}
>
<div className="min-h-screen">
<OverviewSection
onJumpToSection={handleJumpToSection}
activeAccountId={activeAccountId}
/>
<AccountsSection
onJumpToSection={handleJumpToSection}
activeAccountId={activeAccountId}
/>
<ProductsSection
onJumpToSection={handleJumpToSection}
activeAccountId={activeAccountId}
/>
<InsightsSection
onJumpToSection={handleJumpToSection}
onFilterProducts={handleFilterProducts}
activeAccountId={activeAccountId}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { useState, useMemo } from "react";
export const usePagination = (items, itemsPerPage = 8) => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(items.length / itemsPerPage));
const currentPageItems = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return items.slice(start, start + itemsPerPage);
}, [items, currentPage, itemsPerPage]);
const nextPage = () => {
setCurrentPage(prev => Math.min(prev + 1, totalPages));
};
const prevPage = () => {
setCurrentPage(prev => Math.max(prev - 1, 1));
};
const goToPage = (page) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
// Reset to page 1 when items change
const resetPage = () => {
setCurrentPage(1);
};
return {
currentPage,
totalPages,
currentPageItems,
nextPage,
prevPage,
goToPage,
resetPage,
setCurrentPage
};
};

View File

@@ -0,0 +1,41 @@
import { useState, useEffect, useRef } from "react";
export const useScrollSnap = (sectionIds) => {
const [activeSection, setActiveSection] = useState(sectionIds[0] || "");
const containerRef = useRef(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const sections = sectionIds.map(id => document.getElementById(id)).filter(Boolean);
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) {
setActiveSection(visible.target.id);
}
},
{ threshold: [0.55, 0.7] }
);
sections.forEach(section => observer.observe(section));
return () => {
sections.forEach(section => observer.unobserve(section));
};
}, [sectionIds]);
const scrollToSection = (sectionId) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
return { activeSection, scrollToSection, containerRef };
};

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect, useMemo } from "react";
import { DataTable } from "../ui/DataTable";
import { Pagination } from "../ui/Pagination";
import { Filters } from "../ui/Filters";
import { usePagination } from "../hooks/usePagination";
import { cn } from "../../../lib/utils";
import { fetchManagedAccounts } from "../../../services/accountsService";
import { getActiveAccountId, setActiveAccountId, getAccountDisplayName } from "../../../services/accountService";
import { getAuthUser } from "../../../lib/appwrite";
export const AccountsSection = ({ onJumpToSection, activeAccountId }) => {
const [platformFilter, setPlatformFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [accounts, setAccounts] = useState([]);
// Lade Accounts beim Mount
useEffect(() => {
async function loadAccounts() {
setLoading(true);
setError(null);
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);
setError(e.message || "Fehler beim Laden der Accounts");
} finally {
setLoading(false);
}
}
loadAccounts();
}, []);
const filteredAccounts = useMemo(() => {
return accounts.filter((a) => {
if (platformFilter !== "all" && a.account_platform !== platformFilter) return false;
return true;
});
}, [accounts, platformFilter]);
const { currentPage, totalPages, currentPageItems, nextPage, prevPage, resetPage } =
usePagination(filteredAccounts, 8);
const filters = [
{
id: "platform",
type: "select",
value: platformFilter,
options: [
{ value: "all", label: "Platform: all" },
{ value: "amazon", label: "Platform: amazon" },
{ value: "ebay", label: "Platform: ebay" },
],
},
];
const handleFilterChange = (id, value) => {
if (id === "platform") {
setPlatformFilter(value);
}
resetPage();
};
const handleQuickSwitch = (accountId) => {
setActiveAccountId(accountId);
// Event wird automatisch durch setActiveAccountId getriggert
};
const currentActiveId = getActiveAccountId();
return (
<section
id="s2"
className="flex min-h-screen w-auto flex-col gap-[18px] rounded-2xl px-4 py-4"
style={{ scrollSnapAlign: "start", scrollSnapStop: "normal", color: "var(--text)" }}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2.5">
<h1 className="m-0 text-lg font-medium tracking-wide text-[var(--text)]">Accounts</h1>
<span className="rounded-full border border-[var(--line)] bg-white/3 px-2.5 py-1.5 text-xs text-[var(--muted)]">
snap page
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<button
onClick={() => onJumpToSection("s1")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Overview
</button>
<button
onClick={() => onJumpToSection("s2")}
className={cn(
"rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2.5 text-xs 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]"
)}
>
Accounts
</button>
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Products
</button>
<button
onClick={() => onJumpToSection("s4")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Insights
</button>
</div>
</div>
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="m-0 text-xl tracking-wide text-[var(--text)]">Accounts</h2>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
Managed vs scanned accounts, with quick drill-down.
</p>
</div>
<Filters filters={filters} onChange={handleFilterChange} />
{loading && (
<span className="rounded-full border border-[var(--line)] bg-white/3 px-2.5 py-1.5 text-xs text-[var(--muted)]">
loading...
</span>
)}
</div>
<div className="grid grid-cols-2 gap-3.5 max-[1100px]:grid-cols-1">
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<div className="mb-4 px-0 pb-0 pt-0">
<p className="mb-2 text-xs text-[var(--muted)]">Managed accounts</p>
</div>
<div className="px-0 pb-4 pt-0">
{loading ? (
<div className="py-8 text-center text-xs text-[var(--muted)]">Loading...</div>
) : error ? (
<div className="py-8 text-center text-xs text-red-400">{error}</div>
) : accounts.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--muted)]">No accounts yet</div>
) : (
<>
<DataTable
columns={["Shop", "Platform", "Market", "Action"]}
data={currentPageItems}
renderCell={(col, row) => {
if (col === "Action") {
const accountId = row.$id || row.id;
const isActive = accountId === currentActiveId;
return (
<button
onClick={() => handleQuickSwitch(accountId)}
className={cn(
"rounded-xl border px-3 py-2 text-xs text-[var(--text)] transition-all active:translate-y-[1px]",
isActive
? "border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
: "border-[var(--line)] bg-white/3 hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
)}
>
{isActive ? "Active" : "Switch"}
</button>
);
}
if (col === "Shop") {
return getAccountDisplayName(row);
}
if (col === "Platform") {
return row.account_platform || "-";
}
if (col === "Market") {
return row.account_platform_market || "-";
}
return row[col.toLowerCase()] || "-";
}}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPrev={prevPage}
onNext={nextPage}
info={`Page ${currentPage} / ${totalPages} - ${filteredAccounts.length} accounts`}
/>
</>
)}
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">Account management</p>
<div className="mb-4 text-xs text-[var(--muted)]">
{currentActiveId
? `Active account: ${getAccountDisplayName(accounts.find((a) => (a.$id || a.id) === currentActiveId)) || currentActiveId}`
: "No account selected. Use 'Switch' to activate an account."}
</div>
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3">
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2 text-xs 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]"
>
View Products
</button>
<div className="text-xs text-[var(--muted)]">{filteredAccounts.length} accounts</div>
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect } from "react";
import { InsightCard } from "../ui/InsightCard";
import { cn } from "../../../lib/utils";
import { getInsights } from "../../../services/dashboardService";
export const InsightsSection = ({ onJumpToSection, onFilterProducts, activeAccountId }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [insights, setInsights] = useState(null);
// Lade Insights wenn activeAccountId sich ändert
useEffect(() => {
if (!activeAccountId) {
setInsights(null);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
getInsights(activeAccountId)
.then((data) => {
setInsights(data);
})
.catch((e) => {
console.error("Fehler beim Laden der Insights:", e);
setError(e.message || "Fehler beim Laden der Daten");
})
.finally(() => {
setLoading(false);
});
}, [activeAccountId]);
return (
<section
id="s4"
className="flex min-h-screen w-auto flex-col gap-[18px] rounded-2xl px-4 py-4"
style={{
color: "var(--text)",
scrollSnapAlign: "start",
scrollSnapStop: "normal"
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2.5">
<h1 className="m-0 text-lg font-medium tracking-wide text-[var(--text)]">Insights</h1>
{loading && (
<span className="rounded-full border border-[var(--line)] bg-white/3 px-2.5 py-1.5 text-xs text-[var(--muted)]">
loading...
</span>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<button
onClick={() => onJumpToSection("s1")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Overview
</button>
<button
onClick={() => onJumpToSection("s2")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Accounts
</button>
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Products
</button>
<button
onClick={() => onJumpToSection("s4")}
className={cn(
"rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2.5 text-xs 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]"
)}
>
Insights
</button>
</div>
</div>
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="m-0 text-xl tracking-wide text-[var(--text)]">Winning / Intelligence</h2>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
Preset insights that jump back into Products.
</p>
</div>
</div>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
Fehler: {error}
</div>
)}
{!activeAccountId && !loading && (
<div className="rounded-xl border border-[var(--line)] bg-white/3 p-8 text-center">
<p className="text-sm text-[var(--muted)]">No account selected</p>
</div>
)}
{loading && (
<div className="grid grid-cols-2 gap-3.5 max-[1100px]:grid-cols-1">
{[1, 2].map((i) => (
<div key={i} className="h-48 animate-pulse rounded-[18px] border border-[var(--line)] bg-white/2" />
))}
</div>
)}
{activeAccountId && !loading && !error && insights && (
<>
<div className="grid grid-cols-2 gap-3.5 max-[1100px]:grid-cols-1">
<InsightCard
title="Trending products"
value={`${insights.trending.length}`}
subtitle={`Recent products (last ${insights.trending.length})`}
items={insights.trending.slice(0, 5).map((item) => ({
label: item.label,
value: item.value,
}))}
actionButton={{
element: (
<button
onClick={() => {
if (onFilterProducts) {
onFilterProducts({});
}
onJumpToSection("s3");
}}
className={cn(
"rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2 text-xs 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]"
)}
>
Open in Products
</button>
),
info: "View all",
}}
/>
<InsightCard
title="High price spread"
value={insights.priceSpread.length > 0 ? `${insights.priceSpread.length} groups` : "0"}
subtitle="Price variations for similar items"
items={insights.priceSpread.slice(0, 5).map((item) => ({
label: item.label,
value: item.value,
}))}
actionButton={{
element: (
<button
onClick={() => {
if (onFilterProducts) {
onFilterProducts({ search: insights.priceSpread[0]?.title });
}
onJumpToSection("s3");
}}
disabled={insights.priceSpread.length === 0}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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] disabled:cursor-not-allowed disabled:opacity-45"
>
Open Products
</button>
),
info: insights.priceSpread.length > 0 ? "Top spread" : "No data",
}}
/>
</div>
<div className="grid grid-cols-2 gap-3.5 max-[1100px]:grid-cols-1">
<InsightCard
title="Category share"
value={`${insights.categoryShare.length} categories`}
subtitle="Top categories by product count"
items={insights.categoryShare.map((item) => ({
label: item.label,
value: item.value,
}))}
actionButton={{
element: (
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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]"
>
View Products
</button>
),
info: "Top 5",
}}
/>
<InsightCard
title="Active products"
value={insights.trending.filter((t) => t.product?.product_status === "active").length}
subtitle="Currently active from recent products"
items={insights.trending
.filter((t) => t.product?.product_status === "active")
.slice(0, 5)
.map((item) => ({
label: item.label,
value: item.value,
}))}
actionButton={{
element: (
<button
onClick={() => {
if (onFilterProducts) {
onFilterProducts({ status: "active" });
}
onJumpToSection("s3");
}}
className={cn(
"rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2 text-xs 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]"
)}
>
Open Active
</button>
),
info: "Filter: active",
}}
/>
</div>
</>
)}
{activeAccountId && !loading && !error && insights &&
insights.trending.length === 0 &&
insights.priceSpread.length === 0 &&
insights.categoryShare.length === 0 && (
<div className="rounded-xl border border-[var(--line)] bg-white/3 p-8 text-center">
<p className="text-sm text-[var(--muted)]">No insights available yet</p>
</div>
)}
</section>
);
};

View File

@@ -0,0 +1,211 @@
import React, { useState, useEffect } from "react";
import { KPICard } from "../ui/KPICard";
import { cn } from "../../../lib/utils";
import { getOverviewKPIs } from "../../../services/dashboardService";
export const OverviewSection = ({ onJumpToSection, activeAccountId }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [kpis, setKpis] = useState(null);
const [newestProducts, setNewestProducts] = useState([]);
// Lade KPIs wenn activeAccountId sich ändert
useEffect(() => {
if (!activeAccountId) {
setKpis(null);
setNewestProducts([]);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
getOverviewKPIs(activeAccountId)
.then((data) => {
setKpis(data);
setNewestProducts(data.newestProducts || []);
})
.catch((e) => {
console.error("Fehler beim Laden der KPIs:", e);
setError(e.message || "Fehler beim Laden der Daten");
})
.finally(() => {
setLoading(false);
});
}, [activeAccountId]);
return (
<section
id="s1"
className="flex min-h-screen w-auto flex-col gap-[18px] rounded-2xl px-4 py-4"
style={{
scrollSnapAlign: "start",
scrollSnapStop: "normal",
color: "var(--text)",
background: "transparent"
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2.5">
<h1 className="m-0 text-lg font-medium tracking-wide text-[var(--text)]">Dashboard</h1>
{loading && (
<span className="rounded-full border border-[var(--line)] bg-white/3 px-2.5 py-1.5 text-xs text-[var(--muted)]">
loading...
</span>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<button
onClick={() => onJumpToSection("s2")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Go to Accounts
</button>
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Go to Products
</button>
<button
onClick={() => onJumpToSection("s4")}
className={cn(
"rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2.5 text-xs 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]"
)}
>
Go to Insights
</button>
</div>
</div>
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="m-0 text-xl tracking-wide text-[var(--text)]">Overview</h2>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
System health and high-level metrics.
</p>
</div>
</div>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
Fehler: {error}
</div>
)}
{!activeAccountId && !loading && (
<div className="rounded-xl border border-[var(--line)] bg-white/3 p-8 text-center">
<p className="text-sm text-[var(--muted)]">No account selected</p>
</div>
)}
{activeAccountId && loading && (
<div className="grid grid-cols-6 gap-3.5 max-[1100px]:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-24 animate-pulse rounded-[18px] border border-[var(--line)] bg-white/2" />
))}
</div>
)}
{activeAccountId && !loading && !error && kpis && (
<>
<div className="grid grid-cols-6 gap-3.5 max-[1100px]:grid-cols-3">
<KPICard
title="Products (total)"
value={kpis.totalProducts}
sub={`Active: ${kpis.activeProducts} | Ended: ${kpis.endedProducts}`}
/>
<KPICard
title="Active Products"
value={kpis.activeProducts}
sub="Currently active"
/>
<KPICard
title="Ended Products"
value={kpis.endedProducts}
sub="No longer active"
/>
<KPICard
title="Avg price"
value={kpis.avgPrice ? `EUR ${kpis.avgPrice.toFixed(2)}` : "N/A"}
sub="Across active products"
/>
<KPICard
title="Newest"
value={newestProducts.length}
sub="Latest products"
/>
<KPICard
title="Status"
value={kpis.totalProducts > 0 ? "Active" : "Empty"}
sub={kpis.totalProducts > 0 ? "Account has products" : "No products yet"}
/>
</div>
</>
)}
{activeAccountId && !loading && !error && kpis && kpis.totalProducts === 0 && (
<div className="rounded-xl border border-[var(--line)] bg-white/3 p-8 text-center">
<p className="text-sm text-[var(--muted)]">No products yet for this account.</p>
</div>
)}
<div className="grid grid-cols-2 gap-3.5 max-[1100px]:grid-cols-1">
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">Product overview</p>
<div className="mb-3 text-xs text-[var(--muted)]">
{loading ? "Loading..." : kpis ? `${kpis.totalProducts} total products` : "No data"}
</div>
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3">
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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]"
>
Explore products
</button>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">Newest items</p>
{loading ? (
<div className="mb-3 text-xs text-[var(--muted)]">Loading...</div>
) : newestProducts.length > 0 ? (
<div className="mb-3 text-xs text-[var(--muted)] whitespace-pre-line">
{newestProducts.map((p) => `- ${p.product_title || p.$id}${p.product_price ? ` EUR ${p.product_price}` : ""}`).join("\n")}
</div>
) : (
<div className="mb-3 text-xs text-[var(--muted)]">No products yet</div>
)}
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3">
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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]"
>
View products
</button>
<div className="text-xs text-[var(--muted)]">Top 5</div>
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,394 @@
import React, { useState, useEffect } from "react";
import { DataTable } from "../ui/DataTable";
import { Pagination } from "../ui/Pagination";
import { Filters } from "../ui/Filters";
import { cn } from "../../../lib/utils";
import { getProductsPage, getProductPreview } from "../../../services/dashboardService";
import { scanProductsForAccount } from "../../../services/productsService";
import { AnimatePresence, motion } from "motion/react";
export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
const [statusFilter, setStatusFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize] = useState(8);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [products, setProducts] = useState([]);
const [total, setTotal] = useState(0);
const [selectedProduct, setSelectedProduct] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanToast, setScanToast] = useState({ show: false, message: "", type: "success" });
// Lade Products wenn activeAccountId oder Filter sich ändern
useEffect(() => {
loadProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeAccountId, page, pageSize, statusFilter, searchQuery]);
const totalPages = Math.ceil(total / pageSize);
const nextPage = () => {
if (page < totalPages) setPage(page + 1);
};
const prevPage = () => {
if (page > 1) setPage(page - 1);
};
const filters = [
{
id: "status",
type: "select",
value: statusFilter,
options: [
{ value: "all", label: "Status: all" },
{ value: "active", label: "Active" },
{ value: "ended", label: "Ended" },
{ value: "unknown", label: "Unknown" },
],
},
{
id: "search",
type: "input",
value: searchQuery,
placeholder: "Search title",
},
];
const handleFilterChange = (id, value) => {
if (id === "status") {
setStatusFilter(value);
setPage(1); // Reset to first page
} else if (id === "search") {
setSearchQuery(value);
setPage(1); // Reset to first page
}
};
const handleClearFilters = () => {
setStatusFilter("all");
setSearchQuery("");
setPage(1);
};
const handleOpenProduct = async (productId) => {
setPreviewLoading(true);
setSelectedProduct(null);
try {
const preview = await getProductPreview(productId);
setSelectedProduct(preview);
} catch (e) {
console.error("Fehler beim Laden des Product Previews:", e);
setError(e.message || "Fehler beim Laden des Previews");
} finally {
setPreviewLoading(false);
}
};
const loadProducts = async () => {
if (!activeAccountId) {
setProducts([]);
setTotal(0);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const data = await getProductsPage(activeAccountId, {
page,
pageSize,
filters: {
status: statusFilter !== "all" ? statusFilter : undefined,
search: searchQuery.trim() || undefined,
},
});
setProducts(data.items);
setTotal(data.total);
} catch (e) {
console.error("Fehler beim Laden der Products:", e);
setError(e.message || "Fehler beim Laden der Daten");
} finally {
setLoading(false);
}
};
const handleScanProducts = async () => {
if (!activeAccountId || scanning) {
return;
}
setScanning(true);
setError(null);
try {
// Führe Scan aus
const result = await scanProductsForAccount(activeAccountId);
// Refresh Products-Liste
await loadProducts();
// Zeige Erfolgs-Toast
const updated = result.updated ?? 0;
setScanToast({
show: true,
message: `Scan abgeschlossen: ${result.created} neu, ${updated} aktualisiert`,
type: "success",
});
setTimeout(() => {
setScanToast({ show: false, message: "", type: "success" });
}, 3000);
} catch (e) {
console.error("Fehler beim Scannen der Produkte:", e);
// Logge meta für Dev-Debugging
const meta = e.meta || {};
if (Object.keys(meta).length > 0) {
console.log("[scan meta]", meta);
}
// Prüfe auf no_items_found oder empty_items
const errorMsg = e.message || "Fehler beim Scannen der Produkte";
const isNoItems = errorMsg.includes("no_items_found") || errorMsg.includes("empty_items");
// Zeige Toast mit spezifischer Meldung für 0 Items
const toastMessage = isNoItems
? "0 Produkte gefunden. Bitte pruefe, ob die URL auf den Shop/Artikel-Bereich des Sellers zeigt."
: errorMsg;
setScanToast({
show: true,
message: toastMessage,
type: "error",
});
setTimeout(() => {
setScanToast({ show: false, message: "", type: "success" });
}, 3000);
} finally {
setScanning(false);
}
};
return (
<section
id="s3"
className="flex min-h-screen w-auto flex-col gap-[18px] rounded-2xl px-4 py-4"
style={{ scrollSnapAlign: "start", scrollSnapStop: "normal", color: "var(--text)" }}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2.5">
<h1 className="m-0 text-lg font-medium tracking-wide text-[var(--text)]">Products</h1>
<span className="rounded-full border border-[var(--line)] bg-white/3 px-2.5 py-1.5 text-xs text-[var(--muted)]">
explorer
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<button
onClick={() => onJumpToSection("s1")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Overview
</button>
<button
onClick={() => onJumpToSection("s2")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Accounts
</button>
<button
onClick={() => onJumpToSection("s3")}
className={cn(
"rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-3 py-2.5 text-xs 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]"
)}
>
Products
</button>
<button
onClick={() => onJumpToSection("s4")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Insights
</button>
</div>
</div>
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="m-0 text-xl tracking-wide text-[var(--text)]">Products</h2>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
Filter and page through products. No inner scrolling.
</p>
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Filters filters={filters} onChange={handleFilterChange} />
<button
onClick={handleClearFilters}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2.5 text-xs 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]"
>
Clear
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3.5 max-[1100px]:grid-cols-1">
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<div className="mb-4 px-0 pb-0 pt-0">
<p className="mb-2 text-xs text-[var(--muted)]">Product list</p>
</div>
<div className="px-0 pb-4 pt-0">
{loading ? (
<div className="py-8 text-center text-xs text-[var(--muted)]">Loading...</div>
) : error ? (
<div className="py-8 text-center text-xs text-red-400">{error}</div>
) : total === 0 && activeAccountId ? (
<div className="flex flex-col items-center justify-center py-12 px-4">
<h3 className="mb-2 text-base font-semibold text-[var(--text)]">
Keine Produkte gefunden
</h3>
<p className="mb-6 text-center text-xs text-[var(--muted)]">
Scanne den Account, um Produkte zu importieren.
</p>
<button
onClick={handleScanProducts}
disabled={scanning}
className="rounded-xl border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] px-6 py-3 text-sm 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] disabled:cursor-not-allowed disabled:opacity-50"
>
{scanning ? "Scanne..." : "Produkte scannen"}
</button>
</div>
) : !activeAccountId ? (
<div className="py-8 text-center text-xs text-[var(--muted)]">
No account selected
</div>
) : (
<>
<DataTable
columns={["Title", "Price", "Status", "Category", "Action"]}
data={products}
renderCell={(col, row) => {
if (col === "Action") {
return (
<button
onClick={() => handleOpenProduct(row.$id)}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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]"
>
Open
</button>
);
}
if (col === "Title") {
return row.product_title || row.$id;
}
if (col === "Price") {
return row.product_price ? `EUR ${row.product_price}` : "N/A";
}
if (col === "Status") {
return row.product_status || "unknown";
}
if (col === "Category") {
return row.product_category || "-";
}
return row[col.toLowerCase()] || "-";
}}
/>
<Pagination
currentPage={page}
totalPages={totalPages}
onPrev={prevPage}
onNext={nextPage}
info={`Page ${page} / ${totalPages} - ${total} products`}
/>
</>
)}
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">Product preview</p>
{previewLoading ? (
<div className="mb-4 text-xs text-[var(--muted)]">Loading preview...</div>
) : selectedProduct ? (
<div className="mb-4 space-y-2 text-xs text-[var(--muted)]">
<div>
<strong>Title:</strong> {selectedProduct.product_title || selectedProduct.$id}
</div>
{selectedProduct.product_price && (
<div>
<strong>Price:</strong> EUR {selectedProduct.product_price}
</div>
)}
{selectedProduct.product_status && (
<div>
<strong>Status:</strong> {selectedProduct.product_status}
</div>
)}
{selectedProduct.product_category && (
<div>
<strong>Category:</strong> {selectedProduct.product_category}
</div>
)}
{selectedProduct.details && (
<div className="mt-3 border-t border-[var(--line)] pt-2">
<strong>Details available</strong>
</div>
)}
</div>
) : (
<div className="mb-4 text-xs text-[var(--muted)]">
Click "Open" on a product to preview details.
</div>
)}
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3">
<button
disabled={!selectedProduct}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all disabled:cursor-not-allowed disabled:opacity-45"
>
Compare similar
</button>
{selectedProduct && (
<div className="text-xs text-[var(--muted)]">Preview loaded</div>
)}
</div>
</div>
</div>
</div>
{/* Toast Notification */}
<AnimatePresence>
{scanToast.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",
scanToast.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"
)}
>
{scanToast.message}
</motion.div>
)}
</AnimatePresence>
</section>
);
};

View File

@@ -0,0 +1,36 @@
import React from "react";
export const DataTable = ({ columns, data, renderCell }) => {
return (
<div className="overflow-hidden rounded-[18px]">
<table className="w-full border-separate border-spacing-0">
<thead>
<tr>
{columns.map((col, idx) => (
<th
key={idx}
className="border-b border-[var(--line)] bg-white/2 px-3 py-3 text-left text-xs font-semibold text-[var(--muted)]"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIdx) => (
<tr key={rowIdx}>
{columns.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap border-b border-[var(--line)] px-3 py-3 text-xs text-[var(--text)] last:border-b-0"
>
{renderCell ? renderCell(col, row, rowIdx) : row[col]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
export const Filters = ({ filters, onChange }) => {
return (
<div className="flex flex-wrap items-center gap-2.5">
{filters.map((filter, idx) => {
if (filter.type === "select") {
return (
<select
key={idx}
value={filter.value}
onChange={(e) => onChange(filter.id, e.target.value)}
className="cursor-pointer rounded-xl border border-[var(--line)] bg-transparent px-3 py-2.5 text-xs text-[var(--text)] outline-none transition-colors hover:border-[rgba(106,166,255,0.55)]"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
if (filter.type === "input") {
return (
<input
key={idx}
type="text"
placeholder={filter.placeholder}
value={filter.value}
onChange={(e) => onChange(filter.id, e.target.value)}
className="rounded-xl border border-[var(--line)] bg-transparent px-3 py-2.5 text-xs text-[var(--text)] outline-none transition-colors hover:border-[rgba(106,166,255,0.55)]"
/>
);
}
return null;
})}
</div>
);
};

View File

@@ -0,0 +1,42 @@
import React from "react";
export const InsightCard = ({ title, value, subtitle, items, actionButton }) => {
return (
<div className="relative flex min-h-[180px] flex-col gap-2.5 overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative flex flex-1 flex-col gap-2.5">
<h3 className="m-0 text-sm font-medium text-[var(--text)]">{title}</h3>
{value && <div className="text-[28px] font-medium text-[var(--text)]">{value}</div>}
{subtitle && <div className="text-xs text-[var(--muted)]">{subtitle}</div>}
{items && items.length > 0 && (
<div className="mt-2 flex flex-col gap-2">
{items.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-2.5 rounded-xl border border-[var(--line)] bg-white/2 px-2.5 py-2 text-xs text-[var(--text)]"
>
<span>{item.label}</span>
{item.value && <span className="text-[var(--muted)]">{item.value}</span>}
</div>
))}
</div>
)}
{actionButton && (
<div className="mt-auto flex items-center justify-between border-t border-[var(--line)] pt-3">
<div className="flex items-center gap-2">
{actionButton.element || actionButton}
</div>
{actionButton.info && (
<div className="text-xs text-[var(--muted)]">{actionButton.info}</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import React from "react";
export const KPICard = ({ title, value, sub }) => {
return (
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">{title}</p>
<p className="text-[22px] font-medium text-[var(--text)]">{value}</p>
{sub && <p className="mt-2 text-xs text-[var(--muted)]">{sub}</p>}
</div>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from "react";
export const Pagination = ({ currentPage, totalPages, onPrev, onNext, info }) => {
return (
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3 text-xs text-[var(--muted)]">
<div className="flex items-center gap-2">
{info && <span>{info}</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={currentPage <= 1}
className="rounded-[10px] border border-[var(--line)] bg-white/3 px-[10px] py-2 text-xs text-[var(--text)] transition-colors hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] disabled:cursor-not-allowed disabled:opacity-45"
>
Prev
</button>
<button
onClick={onNext}
disabled={currentPage >= totalPages}
className="rounded-[10px] border border-[var(--line)] bg-white/3 px-[10px] py-2 text-xs text-[var(--text)] transition-colors hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] disabled:cursor-not-allowed disabled:opacity-45"
>
Next
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import React from "react";
export const BackgroundImage = () => {
return (
<div
className="fixed inset-0 z-0 pointer-events-none overflow-hidden"
style={{
backgroundImage: "url('/assets/background.png')",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "right center",
right: "-10%",
width: "60%",
height: "100%",
opacity: 0.4,
}}
/>
);
};

View File

@@ -0,0 +1,120 @@
"use client";
import React, { useMemo, useRef, useState, useEffect } from "react";
import { cn } from "@/lib/utils";
export const BackgroundRippleEffect = ({
cellSize = 56
}) => {
const [clickedCell, setClickedCell] = useState(null);
const [rippleKey, setRippleKey] = useState(0);
const ref = useRef(null);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
// Calculate dynamic grid size to cover full viewport
useEffect(() => {
const updateSize = () => {
setViewportSize({ width: window.innerWidth, height: window.innerHeight });
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
const calculatedCols = viewportSize.width > 0 ? Math.ceil(viewportSize.width / cellSize) + 2 : 27;
const calculatedRows = viewportSize.height > 0 ? Math.ceil(viewportSize.height / cellSize) + 2 : 8;
return (
<div
ref={ref}
className={cn(
"absolute inset-0 h-full w-full",
"[--cell-border-color:var(--color-neutral-300)] [--cell-fill-color:var(--color-neutral-100)] [--cell-shadow-color:var(--color-neutral-500)]",
"dark:[--cell-border-color:var(--color-neutral-700)] dark:[--cell-fill-color:var(--color-neutral-900)] dark:[--cell-shadow-color:var(--color-neutral-800)]"
)}>
<div className="relative h-full w-full overflow-hidden">
<div
className="pointer-events-none absolute inset-0 z-[2] h-full w-full overflow-hidden" />
<DivGrid
key={`base-${rippleKey}`}
className="absolute inset-0"
rows={calculatedRows}
cols={calculatedCols}
cellSize={cellSize}
borderColor="var(--cell-border-color)"
fillColor="var(--cell-fill-color)"
clickedCell={clickedCell}
onCellClick={(row, col) => {
setClickedCell({ row, col });
setRippleKey((k) => k + 1);
}}
interactive />
</div>
</div>
);
};
const DivGrid = ({
className,
rows = 7,
cols = 30,
cellSize = 56,
borderColor = "#3f3f46",
fillColor = "rgba(14,165,233,0.3)",
clickedCell = null,
onCellClick = () => {},
interactive = true
}) => {
const cells = useMemo(() => Array.from({ length: rows * cols }, (_, idx) => idx), [rows, cols]);
const gridStyle = {
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
gridTemplateRows: `repeat(${rows}, ${cellSize}px)`,
width: "100vw",
height: "100vh",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
};
return (
<div className={cn("relative z-[3]", className)} style={gridStyle}>
{cells.map((idx) => {
const rowIdx = Math.floor(idx / cols);
const colIdx = idx % cols;
const distance = clickedCell
? Math.hypot(clickedCell.row - rowIdx, clickedCell.col - colIdx)
: 0;
const delay = clickedCell ? Math.max(0, distance * 55) : 0; // ms
const duration = 200 + distance * 80; // ms
const style = clickedCell
? {
"--delay": `${delay}ms`,
"--duration": `${duration}ms`,
}
: {};
return (
<div
key={idx}
className={cn(
"cell relative border-[0.5px] opacity-40 transition-opacity duration-150 will-change-transform hover:opacity-80 dark:shadow-[0px_0px_40px_1px_var(--cell-shadow-color)_inset]",
clickedCell && "animate-cell-ripple [animation-fill-mode:none]",
!interactive && "pointer-events-none"
)}
style={{
backgroundColor: fillColor,
borderColor: borderColor,
...style,
}}
onClick={
interactive ? () => onCellClick?.(rowIdx, colIdx) : undefined
} />
);
})}
</div>
);
};

View File

@@ -0,0 +1,16 @@
import React from "react";
import { motion } from "motion/react";
export const Logo = () => {
return (
<a
href="#"
className="relative z-20 flex items-center space-x-2 py-1 text-sm font-normal text-black dark:text-white">
<img
src="/assets/logos/logo-horizontal.png"
alt="Logo"
className="h-8 w-auto"
/>
</a>
);
};

View File

@@ -0,0 +1,15 @@
import React from "react";
export const LogoSquare = () => {
return (
<a
href="#"
className="relative z-20 flex items-center space-x-2 py-1 text-sm font-normal text-black dark:text-white">
<img
src="/assets/logos/logo-square.png"
alt="Logo Square"
className="h-8 w-8"
/>
</a>
);
};

View File

@@ -0,0 +1,15 @@
import React from "react";
export const LogoVertical = () => {
return (
<a
href="#"
className="relative z-20 flex items-center space-x-2 py-1 text-sm font-normal text-black dark:text-white">
<img
src="/assets/logos/logo-vertical.png"
alt="Logo Vertical"
className="h-8 w-auto"
/>
</a>
);
};

View File

@@ -0,0 +1,477 @@
"use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { BackgroundRippleEffect } from "@/components/layout/BackgroundRippleEffect";
import Shuffle from "@/components/ui/Shuffle";
import ColourfulText from "@/components/ui/colourful-text";
import { PlaceholdersAndVanishInput } from "@/components/ui/placeholders-and-vanish-input";
import { MultiStepLoader } from "@/components/ui/multi-step-loader";
import { IPhoneNotification } from "@/components/ui/iphone-notification";
import { parseEbayAccount } from "@/services/ebayParserService";
import { createManagedAccount, fetchManagedAccounts } from "@/services/accountsService";
import { account, databases, databaseId, usersCollectionId } from "@/lib/appwrite";
export const OnboardingGate = ({ userName, onStart, loading, error }) => {
const [phase, setPhase] = useState("shuffle");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const [showNotification, setShowNotification] = useState(false);
const [notificationMessage, setNotificationMessage] = useState("bitte eine gueltige url verwenden!");
const [skipHovered, setSkipHovered] = useState(false);
const loadingStates = [
{ text: "URL wird verarbeitet..." },
{ text: "Account-Daten werden geladen..." },
{ text: "Account wird erstellt..." },
{ text: "Fast fertig..." },
];
const placeholders = [
"Gib deine eBay-Account-URL ein...",
"z.B. https://www.ebay.de/str/username",
"Paste deine eBay Shop-URL hier...",
];
const handleOverlayClick = async (e) => {
// Only handle clicks on the overlay itself or when in shuffle phase
if (phase === "shuffle") {
// Create user document first (but don't call onStart, as that would hide the gate)
// We need to create it directly to ensure it exists for account creation later
try {
const authUser = await account.get();
if (authUser && databases && databaseId && usersCollectionId) {
try {
await databases.createDocument(
databaseId,
usersCollectionId,
authUser.$id,
{
user_name: authUser.name || "User"
}
);
} catch (docErr) {
// 409 Conflict means document already exists - that's ok
if (docErr.code !== 409 && docErr.type !== 'document_already_exists') {
console.warn("Fehler beim Erstellen des User-Dokuments:", docErr);
}
}
}
} catch (err) {
console.error("Fehler beim Abrufen des Users oder Erstellen des Dokuments:", err);
// Continue anyway - might already exist
}
// Add small delay to let the shuffle exit animation complete
// Exit animation is 0.5s, so we wait a bit longer to see it fully
setTimeout(() => {
setPhase("input");
}, 600); // 600ms delay - slightly longer than exit animation (500ms)
}
};
const handleSkip = async () => {
// Skip onboarding - directly call onStart to proceed to dashboard
// Must await to ensure User document is created before proceeding
if (onStart) {
await onStart();
}
};
// Validate eBay URL
const isValidEbayUrl = (url) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:45',message:'isValidEbayUrl: entry',data:{url,hasUrl:!!url,trimmedUrl:url?.trim()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
if (!url || !url.trim()) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:48',message:'isValidEbayUrl: empty url',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
return false;
}
// Pattern for hostname only (without protocol/path)
const ebayHostnamePattern = /^(www\.)?(ebay\.(de|com|co\.uk|fr|it|es|nl|at|ch|com\.au|ca)|ebay-kleinanzeigen\.de)$/i;
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
const patternMatches = ebayHostnamePattern.test(hostname);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:62',message:'isValidEbayUrl: validation result',data:{url,hostname,patternMatches,pattern:ebayHostnamePattern.toString()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
return patternMatches;
} catch (e) {
// Invalid URL format
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:62',message:'isValidEbayUrl: URL parse error',data:{url,error:e.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
return false;
}
};
const handleAccountSubmit = async (e, url) => {
e.preventDefault();
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:59',message:'handleAccountSubmit: entry',data:{url,hasUrl:!!url,phase},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
if (!url || !url.trim()) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:64',message:'handleAccountSubmit: empty url, showing notification',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
setNotificationMessage("bitte eine gueltige url verwenden!");
setShowNotification(true);
return;
}
// Validate eBay URL
const isValid = isValidEbayUrl(url);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:71',message:'handleAccountSubmit: validation result',data:{url,isValid},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
if (!isValid) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:76',message:'handleAccountSubmit: invalid url, showing notification',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
setNotificationMessage("bitte eine gueltige url verwenden!");
setShowNotification(true);
return;
}
setSubmitting(true);
setSubmitError("");
// Wait 2 seconds for vanish animation, then switch to loading phase
setTimeout(async () => {
// Switch to loading phase first
setPhase("loading");
try {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:88',message:'handleAccountSubmit: before parseEbayAccount',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
// Parse eBay account
const accountData = await parseEbayAccount(url);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:93',message:'handleAccountSubmit: parseEbayAccount success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
// Validate that sellerId was extracted successfully
if (!accountData.sellerId || accountData.sellerId.trim() === "") {
throw new Error("Die eBay-URL konnte nicht verarbeitet werden. Bitte stelle sicher, dass es eine gültige eBay-Shop-URL ist.");
}
// Get current user
const authUser = await account.get();
// Check for duplicate account before creating
const existingAccounts = await fetchManagedAccounts(authUser.$id);
const duplicateAccount = existingAccounts.find(
(acc) =>
acc.account_platform_account_id === accountData.sellerId &&
acc.account_platform_market === accountData.market
);
if (duplicateAccount) {
throw new Error("Dieser Account ist bereits verbunden.");
}
// Create account in database
await createManagedAccount(authUser.$id, {
account_platform_market: accountData.market,
account_platform_account_id: accountData.sellerId,
account_shop_name: accountData.shopName || null,
account_url: url,
account_status: accountData.status || "active",
});
// Account erstellt erfolgreich, rufe onStart auf
onStart && onStart();
// Trigger event to reload Dashboard accounts
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:142',message:'handleAccountSubmit: account created, triggering reload event',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
window.dispatchEvent(new CustomEvent('accountsUpdated'));
} catch (err) {
console.error("Fehler beim Erstellen des Accounts:", err);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:106',message:'handleAccountSubmit: error caught',data:{url,errorMessage:err.message,errorCode:err.code,errorType:err.type,errorStack:err.stack?.substring(0,200)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
// Show notification for URL validation, duplicate, or parsing errors
if (
err.message?.includes("URL") ||
err.message?.includes("ungültig") ||
err.message?.includes("invalid") ||
err.message?.includes("bereits verbunden") ||
err.message?.includes("already")
) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:205',message:'handleAccountSubmit: URL/duplicate error, showing notification',data:{errorMessage:err.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
// Use the actual error message or default message
setNotificationMessage(err.message || "bitte eine gueltige url verwenden!");
setShowNotification(true);
} else {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:175',message:'handleAccountSubmit: non-URL error, setting submitError',data:{errorMessage:err.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
setSubmitError(err.message || "Fehler beim Erstellen des Accounts. Bitte versuche es erneut.");
}
setPhase("input");
setSubmitting(false);
}
}, 2000); // 2 second delay for vanish animation
};
return (
<div
style={{...styles.overlay, cursor: phase === "shuffle" ? "pointer" : "default"}}
onClick={handleOverlayClick}
>
<BackgroundRippleEffect />
{/* Skip Button - Bottom Left */}
<button
onClick={handleSkip}
style={{
...styles.skipButton,
width: skipHovered ? "90px" : "32px",
borderRadius: skipHovered ? "40px" : "50%",
}}
onMouseEnter={() => setSkipHovered(true)}
onMouseLeave={() => setSkipHovered(false)}
>
<div style={{
...styles.skipSign,
width: skipHovered ? "30%" : "100%",
paddingLeft: skipHovered ? "15px" : "0",
}}>
<svg viewBox="0 0 512 512" style={{ width: "12px" }}>
<path fill="white" d="M470.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 256 265.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160zm-352 160l160-160c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L210.7 256 73.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0z" />
</svg>
</div>
<div style={{
...styles.skipText,
opacity: skipHovered ? 1 : 0,
width: skipHovered ? "70%" : "0%",
paddingRight: skipHovered ? "8px" : "0",
}}>Skip</div>
</button>
{/* iPhone-style Notification */}
<IPhoneNotification
show={showNotification}
title="Einstellungen"
message={notificationMessage}
onClose={() => setShowNotification(false)}
duration={4000}
/>
{/* Shuffle and Input Phases - Combined AnimatePresence for smooth transition */}
<AnimatePresence mode="wait">
{phase === "shuffle" && (
<motion.div
key="shuffle"
initial={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={styles.centerContent}
onClick={(e) => {
e.stopPropagation(); // Prevent double-triggering
handleOverlayClick(e);
}}
>
<div style={styles.shuffleContainer}>
<Shuffle
text="Willkommen"
duration={1}
stagger={0.08}
shuffleDirection="down"
loop={true}
loopDelay={2}
triggerOnHover={false}
style={{ letterSpacing: '0.2em' }}
/>
<span style={styles.staticComma}>,</span>
<Shuffle
text={userName}
duration={1}
stagger={0.08}
shuffleDirection="down"
loop={true}
loopDelay={2}
triggerOnHover={false}
style={{ letterSpacing: '0.2em', marginLeft: '0.3em' }}
/>
</div>
</motion.div>
)}
{phase === "input" && (
<motion.div
key="input"
initial={{ opacity: 0, y: -100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -100 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={styles.centerContent}
>
<div style={styles.inputContainer}>
<div style={styles.colourfulTextContainer}>
<span style={styles.textPrefix}>Gib deine </span>
<ColourfulText text="ebay account url" />
<span style={styles.textPrefix}> ein</span>
</div>
<PlaceholdersAndVanishInput
placeholders={placeholders}
onChange={() => {
setSubmitError("");
setShowNotification(false);
setNotificationMessage("bitte eine gueltige url verwenden!"); // Reset to default
}}
onSubmit={handleAccountSubmit}
/>
{(submitError || error) && !showNotification && (
<div style={styles.error}>
{submitError || error}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Loading Phase */}
{phase === "loading" && (
<MultiStepLoader
loadingStates={loadingStates}
loading={true}
duration={2000}
loop={false}
/>
)}
</div>
);
};
const styles = {
overlay: {
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.75)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 18,
zIndex: 9998,
overflow: "hidden",
},
centerContent: {
position: "relative",
zIndex: 10, // Higher than BackgroundRippleEffect (z-[3] = z-index: 3) to ensure input is clickable
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
maxWidth: "800px",
padding: "0 20px",
cursor: "default",
},
shuffleContainer: {
display: "flex",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: "0",
},
staticComma: {
fontSize: "4rem",
fontWeight: 800,
color: "#e7aaf0",
marginLeft: "0.1em",
marginRight: "0.1em",
display: "inline-block",
},
inputContainer: {
width: "100%",
maxWidth: "600px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "24px",
},
colourfulTextContainer: {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
justifyContent: "center",
gap: "4px",
fontSize: "24px",
fontWeight: 600,
color: "#e7aaf0",
marginBottom: "8px",
textAlign: "center",
},
textPrefix: {
color: "#e7aaf0",
},
error: {
marginTop: 12,
color: "#ffb3b3",
fontSize: 13,
textAlign: "center",
padding: "8px 16px",
backgroundColor: "rgba(255, 179, 179, 0.1)",
borderRadius: "8px",
border: "1px solid rgba(255, 179, 179, 0.3)",
},
skipButton: {
position: "fixed",
bottom: 20,
left: 20,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
width: "32px",
height: "32px",
border: "none",
borderRadius: "50%",
cursor: "pointer",
backgroundColor: "rgb(255, 65, 65)",
boxShadow: "2px 2px 10px rgba(0, 0, 0, 0.199)",
transition: "all 0.3s ease",
overflow: "hidden",
zIndex: 10001,
},
skipSign: {
width: "100%",
transition: "all 0.3s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
skipText: {
position: "absolute",
right: "0%",
width: "0%",
opacity: 0,
color: "white",
fontSize: "0.9em",
fontWeight: 600,
transition: "all 0.3s ease",
whiteSpace: "nowrap",
},
};

View File

@@ -0,0 +1,151 @@
"use client";
import { cn } from "../../lib/utils";
import React, { useState, createContext, useContext } from "react";
import { AnimatePresence, motion } from "motion/react";
import { IconMenu2, IconX } from "@tabler/icons-react";
const SidebarContext = createContext(undefined);
export const useSidebar = () => {
const context = useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
};
export const SidebarProvider = ({
children,
open: openProp,
setOpen: setOpenProp,
animate = true
}) => {
const [openState, setOpenState] = useState(false);
const open = openProp !== undefined ? openProp : openState;
const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState;
return (
<SidebarContext.Provider value={{ open, setOpen, animate: animate }}>
{children}
</SidebarContext.Provider>
);
};
export const Sidebar = ({
children,
open,
setOpen,
animate
}) => {
return (
<SidebarProvider open={open} setOpen={setOpen} animate={animate}>
{children}
</SidebarProvider>
);
};
export const SidebarBody = (props) => {
return (
<>
<DesktopSidebar {...props} />
<MobileSidebar {...(props)} />
</>
);
};
export const DesktopSidebar = ({
className,
children,
...props
}) => {
const { open, setOpen, animate } = useSidebar();
return (
<>
<motion.div
className={cn(
"h-full px-4 py-4 hidden md:flex md:flex-col bg-neutral-100 dark:bg-neutral-800 w-[300px] shrink-0 rounded-bl-2xl relative z-10",
className
)}
animate={{
width: animate ? (open ? "300px" : "60px") : "300px",
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
{...props}>
{children}
</motion.div>
</>
);
};
export const MobileSidebar = ({
className,
children,
...props
}) => {
const { open, setOpen } = useSidebar();
return (
<>
<div
className={cn(
"h-10 px-4 py-4 flex flex-row md:hidden items-center justify-between bg-neutral-100 dark:bg-neutral-800 w-full"
)}
{...props}>
<div className="flex justify-end z-20 w-full">
<IconMenu2
className="text-neutral-800 dark:text-neutral-200"
onClick={() => setOpen(!open)} />
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ x: "-100%", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "-100%", opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
className={cn(
"fixed h-full w-full inset-0 bg-white dark:bg-neutral-900 p-10 z-[100] flex flex-col justify-between",
className
)}>
<div
className="absolute right-10 top-10 z-50 text-neutral-800 dark:text-neutral-200"
onClick={() => setOpen(!open)}>
<IconX />
</div>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</>
);
};
export const SidebarLink = ({
link,
className,
...props
}) => {
const { open, animate } = useSidebar();
return (
<a
href={link.href}
onClick={link.onClick}
className={cn("flex items-center justify-start gap-2 group/sidebar py-2", className)}
{...props}>
{link.icon}
<motion.span
animate={{
display: animate ? (open ? "inline-block" : "none") : "inline-block",
opacity: animate ? (open ? 1 : 0) : 1,
}}
className="text-neutral-700 dark:text-neutral-200 text-sm group-hover/sidebar:translate-x-1 transition duration-150 whitespace-pre inline-block !p-0 !m-0">
{link.label}
</motion.span>
</a>
);
};

View File

@@ -0,0 +1,199 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { IconChevronDown } from "@tabler/icons-react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "../../lib/utils";
import { useHashRoute } from "../../lib/routing";
import {
getActiveAccountId,
setActiveAccountId,
resolveActiveAccount,
getAccountDisplayName,
} from "../../services/accountService";
import { fetchManagedAccounts } from "../../services/accountsService";
import { getAuthUser } from "../../lib/appwrite";
export const SidebarHeader = () => {
const { navigate } = useHashRoute();
const [accounts, setAccounts] = useState([]);
const [activeAccount, setActiveAccount] = useState(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [loading, setLoading] = useState(true);
const dropdownRef = useRef(null);
// Accounts laden
useEffect(() => {
async function loadAccounts() {
setLoading(true);
try {
const authUser = await getAuthUser();
if (!authUser) {
setAccounts([]);
setActiveAccount(null);
return;
}
const loadedAccounts = await fetchManagedAccounts(authUser.$id);
setAccounts(loadedAccounts);
const active = resolveActiveAccount(loadedAccounts);
setActiveAccount(active);
} catch (e) {
console.error("Fehler beim Laden der Accounts:", e);
setAccounts([]);
setActiveAccount(null);
} finally {
setLoading(false);
}
}
loadAccounts();
}, []);
// Event-Listener für activeAccountChanged (wenn Account in anderer Komponente gewechselt wird)
useEffect(() => {
function handleActiveAccountChanged(event) {
const { accountId } = event.detail;
// Account aus aktueller Liste finden und als aktiv setzen
const foundAccount = accounts.find(
(acc) => acc.$id === accountId || acc.id === accountId
);
if (foundAccount) {
setActiveAccount(foundAccount);
}
}
window.addEventListener("activeAccountChanged", handleActiveAccountChanged);
return () => {
window.removeEventListener("activeAccountChanged", handleActiveAccountChanged);
};
}, [accounts]);
// Click outside handler für Dropdown
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [dropdownOpen]);
const handleHeaderClick = () => {
if (accounts.length === 0) {
// Keine Accounts: direkt zu /accounts navigieren
navigate("/accounts");
} else {
// Accounts existieren: Dropdown öffnen/schließen
setDropdownOpen(!dropdownOpen);
}
};
const handleAccountSelect = (account) => {
const accountId = account.$id || account.id;
setActiveAccountId(accountId);
setActiveAccount(account);
setDropdownOpen(false);
};
const handleManageAccounts = () => {
setDropdownOpen(false);
navigate("/accounts");
};
const displayText =
accounts.length === 0
? "Add Account"
: activeAccount
? getAccountDisplayName(activeAccount)
: "Add Account";
return (
<div className="relative mb-6" ref={dropdownRef}>
<button
onClick={handleHeaderClick}
className={cn(
"flex w-full items-center gap-3 rounded-xl bg-white/50 px-1.5 py-2.5 transition-all hover:bg-white/80 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80"
)}
>
<img
src="/assets/logos/logo-horizontal.png"
alt="Logo"
className="h-8 w-auto shrink-0 object-contain"
style={{ maxWidth: '120px', marginLeft: '-4px' }}
/>
<span className="flex-1 truncate text-left text-sm font-medium text-neutral-700 dark:text-neutral-200">
{loading ? "Loading..." : displayText}
</span>
{accounts.length > 0 && (
<IconChevronDown
className={cn(
"h-4 w-4 shrink-0 text-neutral-500 transition-transform dark:text-neutral-400",
dropdownOpen && "rotate-180"
)}
/>
)}
</button>
<AnimatePresence>
{dropdownOpen && accounts.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<div className="max-h-64 overflow-y-auto">
{accounts.map((account) => {
const accountId = account.$id || account.id;
const currentActiveId = getActiveAccountId();
const isActive = accountId === currentActiveId;
const displayName = getAccountDisplayName(account);
return (
<button
key={accountId}
onClick={() => handleAccountSelect(account)}
className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700",
isActive && "bg-blue-50 dark:bg-blue-900/20"
)}
>
<span
className={cn(
"flex-1 truncate",
isActive
? "font-medium text-blue-600 dark:text-blue-400"
: "text-neutral-700 dark:text-neutral-200"
)}
>
{displayName}
</span>
{isActive && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-600 dark:bg-blue-400" />
)}
</button>
);
})}
<div className="border-t border-neutral-200 dark:border-neutral-700" />
<button
onClick={handleManageAccounts}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-700"
>
Manage Accounts
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1 @@
export { Sidebar, SidebarBody, SidebarLink, useSidebar } from "./Sidebar.jsx";

View File

@@ -0,0 +1,83 @@
import React from 'react';
import styled from 'styled-components';
const LogoutButton = ({ onClick }) => {
return (
<StyledWrapper>
<button className="Btn" onClick={onClick}>
<div className="sign"><svg viewBox="0 0 512 512"><path d="M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z" /></svg></div>
<div className="text">Logout</div>
</button>
</StyledWrapper>
);
}
const StyledWrapper = styled.div`
.Btn {
display: flex;
align-items: center;
justify-content: flex-start;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
cursor: pointer;
position: relative;
overflow: hidden;
transition-duration: .3s;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.199);
background-color: rgb(255, 65, 65);
}
/* plus sign */
.sign {
width: 100%;
transition-duration: .3s;
display: flex;
align-items: center;
justify-content: center;
}
.sign svg {
width: 12px;
}
.sign svg path {
fill: white;
}
/* text */
.text {
position: absolute;
right: 0%;
width: 0%;
opacity: 0;
color: white;
font-size: 0.9em;
font-weight: 600;
transition-duration: .3s;
}
/* hover effect on button width */
.Btn:hover {
width: 90px;
border-radius: 40px;
transition-duration: .3s;
}
.Btn:hover .sign {
width: 30%;
transition-duration: .3s;
padding-left: 15px;
}
/* hover effect button's text */
.Btn:hover .text {
opacity: 1;
width: 70%;
transition-duration: .3s;
padding-right: 8px;
}
/* button click effect*/
.Btn:active {
transform: translate(2px ,2px);
}`;
export default LogoutButton;

View File

@@ -0,0 +1,392 @@
"use client";
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
const Shuffle = ({
text,
className = '',
style = {},
shuffleDirection = 'right',
duration = 0.35,
maxDelay = 0,
ease = 'power3.out',
threshold = 0.1,
rootMargin = '-100px',
tag = 'p',
textAlign = 'center',
onShuffleComplete,
shuffleTimes = 1,
animationMode = 'evenodd',
loop = false,
loopDelay = 0,
stagger = 0.03,
scrambleCharset = '',
colorFrom,
colorTo,
triggerOnce = true,
respectReducedMotion = true,
triggerOnHover = true
}) => {
const ref = useRef(null);
const [fontsLoaded, setFontsLoaded] = useState(false);
const [ready, setReady] = useState(false);
const splitRef = useRef(null);
const wrappersRef = useRef([]);
const tlRef = useRef(null);
const playingRef = useRef(false);
const hoverHandlerRef = useRef(null);
const userHasFont = useMemo(
() => (style && style.fontFamily) || (className && /font[-[]/i.test(className)),
[style, className]
);
const scrollTriggerStart = useMemo(() => {
const startPct = (1 - threshold) * 100;
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');
const mv = mm ? parseFloat(mm[1]) : 0;
const mu = mm ? mm[2] || 'px' : 'px';
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
return `top ${startPct}%${sign}`;
}, [threshold, rootMargin]);
useEffect(() => {
if ('fonts' in document) {
if (document.fonts.status === 'loaded') setFontsLoaded(true);
else document.fonts.ready.then(() => setFontsLoaded(true));
} else setFontsLoaded(true);
}, []);
useGSAP(
() => {
if (!ref.current || !text || !fontsLoaded) return;
if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
onShuffleComplete?.();
return;
}
const el = ref.current;
let computedFont = '';
if (userHasFont) {
computedFont = style.fontFamily || getComputedStyle(el).fontFamily || '';
} else {
computedFont = `'Press Start 2P', sans-serif`;
}
const start = scrollTriggerStart;
const removeHover = () => {
if (hoverHandlerRef.current && ref.current) {
ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);
hoverHandlerRef.current = null;
}
};
const teardown = () => {
if (tlRef.current) {
tlRef.current.kill();
tlRef.current = null;
}
if (wrappersRef.current.length) {
wrappersRef.current.forEach(wrap => {
const inner = wrap.firstElementChild;
const orig = inner?.querySelector('[data-orig="1"]');
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
});
wrappersRef.current = [];
}
try {
splitRef.current?.revert();
} catch {
/* noop */
}
splitRef.current = null;
playingRef.current = false;
};
const build = () => {
teardown();
splitRef.current = new GSAPSplitText(el, {
type: 'chars',
charsClass: 'shuffle-char',
wordsClass: 'shuffle-word',
linesClass: 'shuffle-line',
smartWrap: true,
reduceWhiteSpace: false
});
const chars = splitRef.current.chars || [];
wrappersRef.current = [];
const rolls = Math.max(1, Math.floor(shuffleTimes));
const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';
chars.forEach(ch => {
const parent = ch.parentElement;
if (!parent) return;
const w = ch.getBoundingClientRect().width;
const h = ch.getBoundingClientRect().height;
if (!w) return;
const wrap = document.createElement('span');
wrap.className = 'inline-block overflow-hidden text-left';
Object.assign(wrap.style, {
width: w + 'px',
height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',
verticalAlign: 'bottom'
});
const inner = document.createElement('span');
inner.className =
'inline-block will-change-transform origin-left transform-gpu ' +
(shuffleDirection === 'up' || shuffleDirection === 'down' ? 'whitespace-normal' : 'whitespace-nowrap');
parent.insertBefore(wrap, ch);
wrap.appendChild(inner);
const firstOrig = ch.cloneNode(true);
firstOrig.className =
'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');
Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
ch.setAttribute('data-orig', '1');
ch.className =
'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');
Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
inner.appendChild(firstOrig);
for (let k = 0; k < rolls; k++) {
const c = ch.cloneNode(true);
if (scrambleCharset) c.textContent = rand(scrambleCharset);
c.className =
'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');
Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
inner.appendChild(c);
}
inner.appendChild(ch);
const steps = rolls + 1;
if (shuffleDirection === 'right' || shuffleDirection === 'down') {
const firstCopy = inner.firstElementChild;
const real = inner.lastElementChild;
if (real) inner.insertBefore(real, inner.firstChild);
if (firstCopy) inner.appendChild(firstCopy);
}
let startX = 0;
let finalX = 0;
let startY = 0;
let finalY = 0;
if (shuffleDirection === 'right') {
startX = -steps * w;
finalX = 0;
} else if (shuffleDirection === 'left') {
startX = 0;
finalX = -steps * w;
} else if (shuffleDirection === 'down') {
startY = -steps * h;
finalY = 0;
} else if (shuffleDirection === 'up') {
startY = 0;
finalY = -steps * h;
}
if (shuffleDirection === 'left' || shuffleDirection === 'right') {
gsap.set(inner, { x: startX, y: 0, force3D: true });
inner.setAttribute('data-start-x', String(startX));
inner.setAttribute('data-final-x', String(finalX));
} else {
gsap.set(inner, { x: 0, y: startY, force3D: true });
inner.setAttribute('data-start-y', String(startY));
inner.setAttribute('data-final-y', String(finalY));
}
if (colorFrom) inner.style.color = colorFrom;
wrappersRef.current.push(wrap);
});
};
const inners = () => wrappersRef.current.map(w => w.firstElementChild);
const randomizeScrambles = () => {
if (!scrambleCharset) return;
wrappersRef.current.forEach(w => {
const strip = w.firstElementChild;
if (!strip) return;
const kids = Array.from(strip.children);
for (let i = 1; i < kids.length - 1; i++) {
kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));
}
});
};
const cleanupToStill = () => {
wrappersRef.current.forEach(w => {
const strip = w.firstElementChild;
if (!strip) return;
const real = strip.querySelector('[data-orig="1"]');
if (!real) return;
strip.replaceChildren(real);
strip.style.transform = 'none';
strip.style.willChange = 'auto';
});
};
const play = () => {
const strips = inners();
if (!strips.length) return;
playingRef.current = true;
const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';
const tl = gsap.timeline({
smoothChildTiming: true,
repeat: loop ? -1 : 0,
repeatDelay: loop ? loopDelay : 0,
onRepeat: () => {
if (scrambleCharset) randomizeScrambles();
if (isVertical) {
gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });
} else {
gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });
}
onShuffleComplete?.();
},
onComplete: () => {
playingRef.current = false;
if (!loop) {
cleanupToStill();
if (colorTo) gsap.set(strips, { color: colorTo });
onShuffleComplete?.();
armHover();
}
}
});
const addTween = (targets, at) => {
const vars = {
duration,
ease,
force3D: true,
stagger: animationMode === 'evenodd' ? stagger : 0
};
if (isVertical) {
vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');
} else {
vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');
}
tl.to(targets, vars, at);
if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);
};
if (animationMode === 'evenodd') {
const odd = strips.filter((_, i) => i % 2 === 1);
const even = strips.filter((_, i) => i % 2 === 0);
const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
const evenStart = odd.length ? oddTotal * 0.7 : 0;
if (odd.length) addTween(odd, 0);
if (even.length) addTween(even, evenStart);
} else {
strips.forEach(strip => {
const d = Math.random() * maxDelay;
const vars = {
duration,
ease,
force3D: true
};
if (isVertical) {
vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');
} else {
vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');
}
tl.to(strip, vars, d);
if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);
});
}
tlRef.current = tl;
};
const armHover = () => {
if (!triggerOnHover || !ref.current) return;
removeHover();
const handler = () => {
if (playingRef.current) return;
build();
if (scrambleCharset) randomizeScrambles();
play();
};
hoverHandlerRef.current = handler;
ref.current.addEventListener('mouseenter', handler);
};
const create = () => {
build();
if (scrambleCharset) randomizeScrambles();
play();
armHover();
setReady(true);
};
const st = ScrollTrigger.create({ trigger: el, start, once: triggerOnce, onEnter: create });
return () => {
st.kill();
removeHover();
teardown();
setReady(false);
};
},
{
dependencies: [
text,
duration,
maxDelay,
ease,
scrollTriggerStart,
fontsLoaded,
shuffleDirection,
shuffleTimes,
animationMode,
loop,
loopDelay,
stagger,
scrambleCharset,
colorFrom,
colorTo,
triggerOnce,
respectReducedMotion,
triggerOnHover,
onShuffleComplete,
userHasFont
],
scope: ref
}
);
const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-[4rem] leading-none';
const classes = useMemo(
() => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),
[baseTw, ready, className]
);
const Tag = tag || 'p';
const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);
return React.createElement(Tag, { ref: ref, className: classes, style: commonStyle }, text);
};
export default Shuffle;

View File

@@ -0,0 +1,52 @@
"use client";
import React from "react";
import { motion } from "motion/react";
export default function ColourfulText({ text }) {
const colors = [
"rgb(131, 179, 32)",
"rgb(47, 195, 106)",
"rgb(42, 169, 210)",
"rgb(4, 112, 202)",
"rgb(107, 10, 255)",
"rgb(183, 0, 218)",
"rgb(218, 0, 171)",
"rgb(230, 64, 92)",
"rgb(232, 98, 63)",
"rgb(249, 129, 47)",
];
const [currentColors, setCurrentColors] = React.useState(colors);
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
const shuffled = [...colors].sort(() => Math.random() - 0.5);
setCurrentColors(shuffled);
setCount((prev) => prev + 1);
}, 5000);
return () => clearInterval(interval);
}, []);
return text.split("").map((char, index) => (
<motion.span
key={`${char}-${count}-${index}`}
initial={{ y: 0 }}
animate={{
color: currentColors[index % currentColors.length],
y: [0, -3, 0],
scale: [1, 1.01, 1],
filter: ["blur(0px)", `blur(5px)`, "blur(0px)"],
opacity: [1, 0.8, 1],
}}
transition={{
duration: 0.5,
delay: index * 0.05,
}}
className="inline-block whitespace-pre font-sans tracking-tight"
>
{char}
</motion.span>
));
}

View File

@@ -0,0 +1,107 @@
"use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
export function IPhoneNotification({ show, title, message, onClose, duration = 3000 }) {
const [isHovered, setIsHovered] = useState(false);
React.useEffect(() => {
if (show && duration > 0) {
const timer = setTimeout(() => {
onClose && onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [show, duration, onClose]);
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: -100, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: -100, x: "-50%" }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={styles.card}
onClick={onClose}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
whileHover={{ scale: 1.05 }}
>
<motion.div
style={{
...styles.img,
background: isHovered
? "linear-gradient(#9198e5, #712020)"
: "linear-gradient(#d7cfcf, #9198e5)"
}}
></motion.div>
<div style={styles.textBox}>
<div style={styles.textContent}>
<p style={styles.h1}>{title}</p>
<span style={styles.span}>now</span>
</div>
<p style={styles.p}>{message}</p>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
const styles = {
card: {
position: "fixed",
top: 20,
left: "50%",
width: "100%",
maxWidth: "290px",
height: "70px",
background: "#27272a", // zinc-800 - same grey as search bar
borderRadius: "20px",
display: "flex",
alignItems: "center",
justifyContent: "left",
backdropFilter: "blur(10px)",
transition: "0.5s ease-in-out",
zIndex: 10000,
cursor: "pointer",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.5)",
border: "1px solid rgba(231, 170, 240, 0.2)",
},
img: {
width: "50px",
height: "50px",
marginLeft: "10px",
borderRadius: "10px",
background: "linear-gradient(#d7cfcf, #9198e5)",
transition: "0.5s ease-in-out",
},
textBox: {
width: "calc(100% - 90px)",
marginLeft: "10px",
color: "#e7aaf0",
fontFamily: "'Poppins', sans-serif",
},
textContent: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
span: {
fontSize: "10px",
opacity: 0.7,
},
h1: {
fontSize: "16px",
fontWeight: "bold",
margin: 0,
},
p: {
fontSize: "12px",
fontWeight: "lighter",
margin: "4px 0 0 0",
opacity: 0.8,
},
};

View File

@@ -0,0 +1,97 @@
"use client";
import { useState, useEffect } from "react";
import { AnimatePresence, motion } from "motion/react";
function LoaderCore({ loadingStates, value }) {
return (
<div className="relative mx-auto mt-40 flex max-w-xl flex-col justify-start">
{loadingStates.map((state, index) => {
const distance = Math.abs(index - value);
const opacity = Math.max(1 - distance * 0.2, 0);
return (
<motion.div
key={index}
initial={{ opacity: 0, y: -(value * 40) }}
animate={{ opacity, y: -(value * 40) }}
transition={{ duration: 0.5 }}
className="mb-4 flex gap-2 text-left"
>
<div>
{index > value ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6 text-gray-400"
>
<path d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={`h-6 w-6 ${index === value ? "text-green-500" : "text-gray-500"}`}
>
<path d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
)}
</div>
<span className={index === value ? "text-green-500" : "text-gray-700 dark:text-gray-300"}>
{state.text}
</span>
</motion.div>
);
})}
</div>
);
}
export function MultiStepLoader({
loadingStates,
loading = false,
duration = 2000,
loop = true,
}) {
const [currentValue, setCurrentValue] = useState(0);
useEffect(() => {
if (!loading) {
setCurrentValue(0);
return;
}
const timer = setTimeout(() => {
setCurrentValue((prev) =>
loop
? prev === loadingStates.length - 1
? 0
: prev + 1
: Math.min(prev + 1, loadingStates.length - 1)
);
}, duration);
return () => clearTimeout(timer);
}, [currentValue, loading, loop, duration, loadingStates.length]);
return (
<AnimatePresence mode="wait">
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-2xl bg-black/50"
>
<div className="relative h-96">
<LoaderCore loadingStates={loadingStates} value={currentValue} />
</div>
<div className="absolute inset-x-0 bottom-0 h-full bg-black dark:bg-black bg-gradient-to-t
[mask-image:radial-gradient(900px_at_center,transparent_30%,black)] z-10" />
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,262 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function PlaceholdersAndVanishInput({
placeholders,
onChange,
onSubmit
}) {
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
const intervalRef = useRef(null);
const startAnimation = () => {
intervalRef.current = setInterval(() => {
setCurrentPlaceholder((prev) => (prev + 1) % placeholders.length);
}, 3000);
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible" && intervalRef.current) {
clearInterval(intervalRef.current); // Clear the interval when the tab is not visible
intervalRef.current = null;
} else if (document.visibilityState === "visible") {
startAnimation(); // Restart the interval when the tab becomes visible
}
};
useEffect(() => {
startAnimation();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [placeholders]);
const canvasRef = useRef(null);
const newDataRef = useRef([]);
const inputRef = useRef(null);
const [value, setValue] = useState("");
const [animating, setAnimating] = useState(false);
const draw = useCallback(() => {
if (!inputRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = 800;
canvas.height = 800;
ctx.clearRect(0, 0, 800, 800);
const computedStyles = getComputedStyle(inputRef.current);
const fontSize = parseFloat(computedStyles.getPropertyValue("font-size"));
ctx.font = `${fontSize * 2}px ${computedStyles.fontFamily}`;
ctx.fillStyle = "#FFF";
ctx.fillText(value, 16, 40);
const imageData = ctx.getImageData(0, 0, 800, 800);
const pixelData = imageData.data;
const newData = [];
for (let t = 0; t < 800; t++) {
let i = 4 * t * 800;
for (let n = 0; n < 800; n++) {
let e = i + 4 * n;
if (
pixelData[e] !== 0 &&
pixelData[e + 1] !== 0 &&
pixelData[e + 2] !== 0
) {
newData.push({
x: n,
y: t,
color: [
pixelData[e],
pixelData[e + 1],
pixelData[e + 2],
pixelData[e + 3],
],
});
}
}
}
newDataRef.current = newData.map(({ x, y, color }) => ({
x,
y,
r: 1,
color: `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})`,
}));
}, [value]);
useEffect(() => {
draw();
}, [value, draw]);
const animate = (start) => {
const animateFrame = (pos = 0) => {
requestAnimationFrame(() => {
const newArr = [];
for (let i = 0; i < newDataRef.current.length; i++) {
const current = newDataRef.current[i];
if (current.x < pos) {
newArr.push(current);
} else {
if (current.r <= 0) {
current.r = 0;
continue;
}
current.x += Math.random() > 0.5 ? 1 : -1;
current.y += Math.random() > 0.5 ? 1 : -1;
current.r -= 0.05 * Math.random();
newArr.push(current);
}
}
newDataRef.current = newArr;
const ctx = canvasRef.current?.getContext("2d");
if (ctx) {
ctx.clearRect(pos, 0, 800, 800);
newDataRef.current.forEach((t) => {
const { x: n, y: i, r: s, color: color } = t;
if (n > pos) {
ctx.beginPath();
ctx.rect(n, i, s, s);
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.stroke();
}
});
}
if (newDataRef.current.length > 0) {
animateFrame(pos - 8);
} else {
setValue("");
setAnimating(false);
}
});
};
animateFrame(start);
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !animating) {
vanishAndSubmit();
}
};
const vanishAndSubmit = () => {
setAnimating(true);
draw();
const value = inputRef.current?.value || "";
if (value && inputRef.current) {
const maxX = newDataRef.current.reduce((prev, current) => (current.x > prev ? current.x : prev), 0);
animate(maxX);
}
};
const handleSubmit = (e) => {
e.preventDefault();
const url = inputRef.current?.value || "";
vanishAndSubmit();
onSubmit && onSubmit(e, url);
};
return (
<form
className={cn(
"w-full relative max-w-xl mx-auto bg-white dark:bg-zinc-800 h-12 rounded-full overflow-hidden shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),_0px_1px_0px_0px_rgba(25,28,33,0.02),_0px_0px_0px_1px_rgba(25,28,33,0.08)] transition duration-200",
value && "bg-gray-50"
)}
onSubmit={handleSubmit}>
<canvas
className={cn(
"absolute pointer-events-none text-base transform scale-50 top-[20%] left-2 sm:left-8 origin-top-left filter invert dark:invert-0 pr-20",
!animating ? "opacity-0" : "opacity-100"
)}
ref={canvasRef} />
<input
onChange={(e) => {
if (!animating) {
setValue(e.target.value);
onChange && onChange(e);
}
}}
onKeyDown={handleKeyDown}
ref={inputRef}
value={value}
type="text"
className={cn(
"w-full relative text-sm sm:text-base z-50 border-none dark:text-white bg-transparent text-black h-full rounded-full focus:outline-none focus:ring-0 pl-4 sm:pl-10 pr-20",
animating && "text-transparent dark:text-transparent"
)} />
<button
disabled={!value}
type="submit"
className="absolute right-2 top-1/2 z-50 -translate-y-1/2 h-8 w-8 rounded-full disabled:bg-gray-100 bg-black dark:bg-zinc-900 dark:disabled:bg-zinc-800 transition duration-200 flex items-center justify-center">
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-300 h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<motion.path
d="M5 12l14 0"
initial={{
strokeDasharray: "50%",
strokeDashoffset: "50%",
}}
animate={{
strokeDashoffset: value ? 0 : "50%",
}}
transition={{
duration: 0.3,
ease: "linear",
}} />
<path d="M13 18l6 -6" />
<path d="M13 6l6 6" />
</motion.svg>
</button>
<div
className="absolute inset-0 flex items-center rounded-full pointer-events-none">
<AnimatePresence mode="wait">
{!value && (
<motion.p
initial={{
y: 5,
opacity: 0,
}}
key={`current-placeholder-${currentPlaceholder}`}
animate={{
y: 0,
opacity: 1,
}}
exit={{
y: -15,
opacity: 0,
}}
transition={{
duration: 0.3,
ease: "linear",
}}
className="dark:text-zinc-500 text-sm sm:text-base font-normal text-neutral-500 pl-4 sm:pl-12 text-left w-[calc(100%-2rem)] truncate">
{placeholders[currentPlaceholder]}
</motion.p>
)}
</AnimatePresence>
</div>
</form>
);
}

43
Server/src/index.css Normal file
View File

@@ -0,0 +1,43 @@
@import "tailwindcss";
:root {
--bg: #0b0d12;
--panel: #121624;
--panel2: #0f1320;
--text: #e8eaf1;
--muted: #9aa3b2;
--line: #22283a;
--accent: #6aa6ff;
--good: #4ade80;
--warn: #fbbf24;
--bad: #f87171;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--radius: 18px;
}
/* Hide scrollbar but keep scrolling functionality */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
@theme inline {
--animate-cell-ripple: cell-ripple var(--duration, 200ms) ease-out none 1
var(--delay, 0ms);
@keyframes cell-ripple {
0% {
opacity: 0.4;
}
50% {
opacity: 0.8;
}
100% {
opacity: 0.4;
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* Zentrales Appwrite Client Setup
* Stellt Client, Account, Databases Instanzen und Helper-Funktionen bereit
*/
import { Client, Account, Databases } from "appwrite";
// Konfiguration aus Environment-Variablen oder Defaults
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || "696b82bb0036d2e547ad";
const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID || "eship-db";
const usersCollectionId = import.meta.env.VITE_APPWRITE_USERS_COLLECTION_ID || "users";
const accountsCollectionId = import.meta.env.VITE_APPWRITE_ACCOUNTS_COLLECTION_ID || "accounts";
// Client initialisieren
const client = new Client()
.setEndpoint(endpoint)
.setProject(projectId);
// Service-Instanzen
const account = new Account(client);
const databases = new Databases(client);
/**
* Gibt den aktuell eingeloggten User zurück
* @returns {Promise<Object|null>} User-Objekt oder null
*/
export async function getAuthUser() {
try {
const user = await account.get();
return user;
} catch (e) {
// Session nicht vorhanden oder abgelaufen
if (e.code === 401 || e.type === 'general_unauthorized_scope') {
return null;
}
console.warn("Fehler beim Abrufen des Users:", e);
return null;
}
}
/**
* Prüft ob eine gültige Session existiert und leitet ggf. zum Login um
* @param {Function} navigate - Navigations-Funktion (z.B. von useHashRoute)
* @param {string} loginRoute - Route zum Login (default: "/")
* @returns {Promise<Object|null>} User-Objekt oder null (wenn nicht eingeloggt)
*/
export async function ensureSessionOrRedirect(navigate, loginRoute = "/") {
const user = await getAuthUser();
if (!user && navigate) {
// Redirect zu Login (aktuell noch kein separater Login-Route, daher "/")
// TODO: Wenn Login-Route existiert, hier verwenden
navigate(loginRoute);
}
return user;
}
// Exports
export { client, account, databases, databaseId, usersCollectionId, accountsCollectionId };

39
Server/src/lib/routing.js Normal file
View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
/**
* Einfaches Hash-basiertes Routing System
* Verwendet URL Hash (#/path) für Navigation ohne Page Reload
*/
export function useHashRoute() {
const [route, setRoute] = useState(() => {
// Initial route aus Hash extrahieren
const hash = window.location.hash.slice(1) || "/";
return hash.startsWith("/") ? hash : `/${hash}`;
});
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1) || "/";
const newRoute = hash.startsWith("/") ? hash : `/${hash}`;
setRoute(newRoute);
};
// Event Listener für Hash-Änderungen
window.addEventListener("hashchange", handleHashChange);
// Initial check
handleHashChange();
return () => {
window.removeEventListener("hashchange", handleHashChange);
};
}, []);
const navigate = (path) => {
const newPath = path.startsWith("/") ? path : `/${path}`;
window.location.hash = newPath;
};
return { route, navigate };
}

6
Server/src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

10
Server/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,656 @@
"use client";
import React, { useState, useEffect } from "react";
import { IconPlus, IconChevronDown, IconX, IconRefresh } from "@tabler/icons-react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "../lib/utils";
import { useHashRoute } from "../lib/routing";
import {
setActiveAccountId,
getAccountDisplayName,
} from "../services/accountService";
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService";
import { getAuthUser } from "../lib/appwrite";
import { parseEbayAccount } from "../services/ebayParserService";
import { DataTable } from "../components/dashboard/ui/DataTable";
export const AccountsPage = () => {
const { navigate } = useHashRoute();
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdvanced, setShowAdvanced] = useState(false);
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" });
// Form-Felder (nur noch URL)
const [formData, setFormData] = useState({
account_url: "",
});
// Accounts laden
useEffect(() => {
loadAccounts();
}, []);
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 handleSelectAccount = (account) => {
const accountId = account.$id || account.id;
setActiveAccountId(accountId);
// Navigiere zurück zum Dashboard
navigate("/");
};
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 erneut parsen
const parsedData = await parseEbayAccount(accountUrl);
// 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 und account_sells können auch leer sein (optional)
updatePayload.account_shop_name = parsedData.shopName || null;
updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'AccountsPage.jsx:93',message:'handleRefreshAccount: update payload',data:{payload:updatePayload},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
// 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
await updateManagedAccount(accountId, updatePayload);
// 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);
} 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;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'AccountsPage.jsx:193',message:'handleFormSubmit: parsedData before save',data:{hasStats:!!parsedData.stats,itemsSold:parsedData.stats?.itemsSold,accountSellsValue},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
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);
}
};
// Spalten für die Tabelle
const columns = [
"Account Name",
"Platform",
"Platform Account ID",
"Market",
"Account URL",
"Status",
"Last Scan",
...(showAdvanced ? ["Owner User ID"] : []),
"Action",
];
const renderCell = (col, row) => {
if (col === "Action") {
const accountId = row.$id || row.id;
const isRefreshing = refreshingAccountId === accountId;
return (
<div className="flex items-center gap-2">
<button
onClick={() => handleRefreshAccount(row)}
disabled={isRefreshing}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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] disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
title="Account aktualisieren"
>
<IconRefresh className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
Refresh
</button>
<button
onClick={() => handleSelectAccount(row)}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs 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]"
>
Select
</button>
</div>
);
}
if (col === "Account Name") {
return getAccountDisplayName(row) || "-";
}
if (col === "Platform") {
return row.account_platform || "-";
}
if (col === "Platform Account ID") {
return row.account_platform_account_id || "-";
}
if (col === "Market") {
return row.account_platform_market || "-";
}
if (col === "Account URL") {
const url = row.account_url;
if (!url) return "-";
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline dark:text-blue-400"
>
{url.length > 40 ? `${url.substring(0, 40)}...` : url}
</a>
);
}
if (col === "Status") {
return row.account_status || "-";
}
if (col === "Last Scan") {
const lastScan = row.account_last_scan_at;
if (!lastScan) return "-";
try {
const date = new Date(lastScan);
return date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch (e) {
return "-";
}
}
if (col === "Owner User ID") {
return row.account_owner_user_id || "-";
}
return "-";
};
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>
{/* Hilfe-Panel */}
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<h2 className="mb-3 text-sm font-semibold text-[var(--text)]">
Account hinzufügen
</h2>
<div className="grid gap-3 text-xs text-[var(--muted)]">
<div>
<span className="font-medium text-[var(--text)]">
eBay Account URL <span className="text-red-500">(Pflichtfeld)</span>
</span>
: Gib einfach die eBay-URL zum Verkäuferprofil oder Shop ein. Alle weiteren Informationen (Market, Seller ID, Shop Name) werden automatisch erkannt.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Market (Auto)
</span>
: Wird automatisch aus der URL extrahiert (z.B. DE, US, UK). Du musst nichts eingeben.
</div>
<div>
<span className="font-medium text-[var(--text)]">
eBay Seller ID (Auto)
</span>
: Wird automatisch erkannt. Dies ist die eindeutige Verkäufer-Kennung von eBay und verhindert Duplikate.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Shop Name (Auto)
</span>
: Öffentlich sichtbarer Name des Shops. Wird automatisch aus der URL/Seite extrahiert.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Status (Auto)
</span>
: Wird automatisch auf "active" gesetzt. Du musst nichts eingeben.
</div>
</div>
<div className="mt-4 rounded-lg border border-[var(--line)] bg-white/2 p-3 text-xs text-[var(--muted)]">
<span className="font-medium text-[var(--text)]">So funktioniert's:</span>{" "}
Gib einfach die eBay-URL ein und klicke auf "Account hinzufügen". Das System liest alle notwendigen Informationen automatisch aus. Du musst keine technischen Felder manuell ausfüllen.
</div>
{/* Advanced Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="mt-4 flex items-center gap-2 text-xs text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<IconChevronDown
className={cn(
"h-4 w-4 transition-transform",
showAdvanced && "rotate-180"
)}
/>
{showAdvanced ? "Weniger anzeigen" : "Erweitert anzeigen"}
</button>
</div>
</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>
{/* Tabelle */}
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
Loading accounts...
</div>
) : (
<DataTable columns={columns} data={accounts} renderCell={renderCell} />
)}
</div>
</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)]">
Artikel verkauft (Auto)
</label>
<input
type="text"
value={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)]">
Automatisch aus dem eBay-Profil gelesen.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Status (Auto)
</label>
<input
type="text"
value={parsedData.status}
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)]">
Interner Status. Normalerweise "active".
</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>
);
};

View File

@@ -0,0 +1,99 @@
/**
* Account Service
* Verwaltet den aktiven Account über localStorage
*/
const ACTIVE_ACCOUNT_ID_KEY = "active_account_id";
/**
* Gibt die ID des aktiven Accounts aus localStorage zurück
* @returns {string|null} Account ID oder null
*/
export function getActiveAccountId() {
try {
return localStorage.getItem(ACTIVE_ACCOUNT_ID_KEY);
} catch (e) {
console.warn("Fehler beim Lesen von localStorage:", e);
return null;
}
}
/**
* Speichert die ID des aktiven Accounts in localStorage
* Dispatch ein CustomEvent für sofortigen Sidebar-Refresh
* @param {string} accountId - Account ID
*/
export function setActiveAccountId(accountId) {
try {
if (accountId) {
localStorage.setItem(ACTIVE_ACCOUNT_ID_KEY, accountId);
} else {
localStorage.removeItem(ACTIVE_ACCOUNT_ID_KEY);
}
// CustomEvent dispatch für Sidebar-Refresh
window.dispatchEvent(
new CustomEvent("activeAccountChanged", { detail: { accountId } })
);
} catch (e) {
console.warn("Fehler beim Schreiben in localStorage:", e);
}
}
/**
* Bestimmt den aktiven Account aus der Liste
* Wenn eine ID in localStorage gespeichert ist und gültig, wird dieser verwendet
* Ansonsten wird der erste Account als Default genommen
* @param {Array} accounts - Liste der Accounts
* @returns {Object|null} Aktiver Account oder null
*/
export function resolveActiveAccount(accounts) {
if (!accounts || accounts.length === 0) {
return null;
}
const storedId = getActiveAccountId();
if (storedId) {
const foundAccount = accounts.find((acc) => acc.$id === storedId || acc.id === storedId);
if (foundAccount) {
return foundAccount;
}
}
// Fallback: Erster Account als Default
const firstAccount = accounts[0];
if (firstAccount) {
const accountId = firstAccount.$id || firstAccount.id;
if (accountId) {
setActiveAccountId(accountId);
}
}
return firstAccount || null;
}
/**
* Gibt den Display-Namen eines Accounts zurück
* Priorität: account_shop_name > account_platform_account_id
* @param {Object} account - Account Objekt
* @returns {string} Display-Name
*/
export function getAccountDisplayName(account) {
if (!account) return "";
if (account.account_shop_name) {
return account.account_shop_name;
}
if (account.account_platform_account_id) {
return account.account_platform_account_id;
}
// Fallback für alte Datenstruktur
if (account.shop) {
return account.shop;
}
return account.id || account.$id || "Unknown Account";
}

View File

@@ -0,0 +1,339 @@
/**
* Accounts Service
* CRUD-Operationen für Accounts aus Appwrite
* Verwaltet nur "Managed Accounts" (account_owner_user_id == authUserId)
*/
import { databases, databaseId, accountsCollectionId } from "../lib/appwrite";
import { ID, Query } from "appwrite";
/**
* Lädt ein einzelnes Account nach ID
* @param {string} accountId - ID des Accounts
* @returns {Promise<Object|null>} Account-Dokument oder null
*/
export async function getAccountById(accountId) {
if (!accountId) {
console.warn("getAccountById: accountId fehlt");
return null;
}
try {
const document = await databases.getDocument(
databaseId,
accountsCollectionId,
accountId
);
return document;
} catch (e) {
// 404 bedeutet, dass das Dokument nicht existiert
if (e.code === 404 || e.type === 'document_not_found') {
return null;
}
console.error("Fehler beim Laden des Accounts:", e);
throw e;
}
}
/**
* Leitet den Market-Code aus einem Account ab
* @param {Object} account - Account-Dokument
* @returns {string} Market-Code (z.B. "DE", "US", "UNKNOWN")
*/
export function deriveMarketFromAccount(account) {
if (!account) {
return "UNKNOWN";
}
// Priority 1: account_platform_market Feld vorhanden
if (account.account_platform_market && account.account_platform_market.trim()) {
return account.account_platform_market.trim().toUpperCase();
}
// Priority 2: Ableitung aus account_url Hostname
if (account.account_url) {
try {
const url = new URL(account.account_url);
const hostname = url.hostname.toLowerCase();
// Mapping von Hostname zu Market-Code
const hostnameToMarket = {
"ebay.de": "DE",
"www.ebay.de": "DE",
"ebay.com": "US",
"www.ebay.com": "US",
"ebay.co.uk": "UK",
"www.ebay.co.uk": "UK",
"ebay.fr": "FR",
"www.ebay.fr": "FR",
"ebay.it": "IT",
"www.ebay.it": "IT",
"ebay.es": "ES",
"www.ebay.es": "ES",
"ebay.ca": "CA",
"www.ebay.ca": "CA",
"ebay.com.au": "AU",
"www.ebay.com.au": "AU",
};
if (hostnameToMarket[hostname]) {
return hostnameToMarket[hostname];
}
// Fallback: Prüfe ob hostname ein bekanntes Pattern enthält
if (hostname.includes("ebay.de")) return "DE";
if (hostname.includes("ebay.com") && !hostname.includes("ebay.com.au")) return "US";
if (hostname.includes("ebay.co.uk")) return "UK";
if (hostname.includes("ebay.fr")) return "FR";
if (hostname.includes("ebay.it")) return "IT";
if (hostname.includes("ebay.es")) return "ES";
if (hostname.includes("ebay.ca")) return "CA";
if (hostname.includes("ebay.com.au")) return "AU";
} catch (e) {
// URL parsing fehlgeschlagen
console.debug("Fehler beim Parsen der Account-URL:", e);
}
}
return "UNKNOWN";
}
/**
* Leitet die Währung aus einem Market-Code ab
* @param {string} market - Market-Code (z.B. "DE", "US", "UK")
* @returns {string} Währungscode (z.B. "EUR", "USD", "GBP")
*/
export function deriveCurrencyFromMarket(market) {
if (!market || market === "UNKNOWN") {
return "EUR"; // Fallback
}
const marketUpper = market.toUpperCase();
// Mapping Market -> Currency
const marketToCurrency = {
// EUR Länder
"DE": "EUR",
"FR": "EUR",
"IT": "EUR",
"ES": "EUR",
"NL": "EUR",
"AT": "EUR",
"BE": "EUR",
"IE": "EUR",
// Andere Währungen
"US": "USD",
"UK": "GBP",
"CA": "CAD",
"AU": "AUD",
};
return marketToCurrency[marketUpper] || "EUR"; // Fallback zu EUR
}
/**
* Lädt alle Managed Accounts für einen User
* @param {string} authUserId - ID des eingeloggten Users
* @returns {Promise<Array>} Array von Account-Dokumenten
*/
export async function fetchManagedAccounts(authUserId) {
if (!authUserId) {
console.warn("fetchManagedAccounts: authUserId fehlt");
return [];
}
try {
const response = await databases.listDocuments(
databaseId,
accountsCollectionId,
[
Query.equal("account_owner_user_id", authUserId),
Query.orderDesc("$createdAt"),
]
);
return response.documents;
} catch (e) {
console.error("Fehler beim Laden der Accounts:", e);
// Wenn Collection nicht existiert oder Berechtigungen fehlen
if (e.code === 404 || e.code === 401) {
return [];
}
throw e;
}
}
/**
* Erstellt ein neues Managed Account
* @param {string} authUserId - ID des eingeloggten Users
* @param {Object} accountData - Account-Daten
* @param {string} accountData.account_platform_market - Required: Market (z.B. "DE", "US")
* @param {string} accountData.account_platform_account_id - Required: Platform Account ID
* @param {string} [accountData.account_shop_name] - Optional: Shop-Name
* @param {string} [accountData.account_url] - Optional: Account URL
* @param {string} [accountData.account_status] - Optional: Status (z.B. "active", "unknown", "disabled")
* @returns {Promise<Object>} Erstelltes Account-Dokument
*/
export async function createManagedAccount(authUserId, accountData) {
if (!authUserId) {
throw new Error("authUserId ist erforderlich");
}
// Validierung der Required-Felder
const { account_platform_market, account_platform_account_id } = accountData;
// account_platform wird IMMER "ebay" gesetzt (eBay-only Tool)
if (!account_platform_market) {
throw new Error("account_platform_market ist erforderlich");
}
if (!account_platform_account_id) {
throw new Error("account_platform_account_id ist erforderlich");
}
// Payload zusammenstellen
const payload = {
account_owner_user_id: authUserId,
account_platform: "ebay", // IMMER "ebay" für über UI erstellte Accounts
account_platform_market,
account_platform_account_id,
account_shop_name: accountData.account_shop_name || null,
account_url: accountData.account_url || null,
account_sells: accountData.account_sells ?? null,
account_managed: true, // Immer true für über die UI erstellte Accounts
};
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:72',message:'createManagedAccount: payload before Appwrite',data:{account_sells:payload.account_sells,accountData_account_sells:accountData.account_sells},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{});
// #endregion
// account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen
// TODO: Schema in Appwrite prüfen und korrigieren (Enum-Feld sollte String akzeptieren, nicht Array)
// Das Feld wird erst wieder hinzugefügt, wenn das Schema korrekt konfiguriert ist
// Document-Level Permissions für den User setzen
const permissions = [
`read("user:${authUserId}")`,
`update("user:${authUserId}")`,
`delete("user:${authUserId}")`,
];
try {
const document = await databases.createDocument(
databaseId,
accountsCollectionId,
ID.unique(),
payload,
permissions
);
return document;
} catch (e) {
// Duplicate-Conflict behandeln (falls Unique-Index auf platform+market+platform_account_id existiert)
if (e.code === 409 || e.message?.includes("duplicate") || e.message?.includes("unique")) {
throw new Error(
"Dieser Account ist bereits verbunden."
);
}
console.error("Fehler beim Erstellen des Accounts:", e);
throw e;
}
}
/**
* Aktualisiert ein Managed Account (optional für v1)
* @param {string} accountId - ID des Accounts
* @param {Object} accountData - Zu aktualisierende Account-Daten
* @returns {Promise<Object>} Aktualisiertes Account-Dokument
*/
export async function updateManagedAccount(accountId, accountData) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
try {
// Entferne undefined/null Werte aus accountData (behalte nur geänderte Felder)
const payload = {};
Object.keys(accountData).forEach((key) => {
if (accountData[key] !== undefined && accountData[key] !== null) {
payload[key] = accountData[key];
}
});
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:133',message:'updateManagedAccount: before updateDocument',data:{accountId,payload,payloadKeys:Object.keys(payload)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
const document = await databases.updateDocument(
databaseId,
accountsCollectionId,
accountId,
payload
);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:147',message:'updateManagedAccount: success',data:{accountId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
return document;
} catch (e) {
console.error("Fehler beim Aktualisieren des Accounts:", e);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:149',message:'updateManagedAccount: error',data:{accountId,errorCode:e.code,errorMessage:e.message,errorType:e.type},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
throw e;
}
}
/**
* Aktualisiert das account_last_scan_at Feld eines Accounts
* @param {string} accountId - ID des Accounts
* @param {string} isoString - ISO 8601 Datum-String (z.B. "2026-01-18T12:34:56.000Z")
* @returns {Promise<Object>} Aktualisiertes Account-Dokument
*/
export async function updateAccountLastScanAt(accountId, isoString) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
if (!isoString) {
throw new Error("isoString ist erforderlich");
}
try {
const document = await databases.updateDocument(
databaseId,
accountsCollectionId,
accountId,
{
account_last_scan_at: isoString,
}
);
return document;
} catch (e) {
console.error("Fehler beim Aktualisieren des account_last_scan_at:", e);
throw e;
}
}
/**
* Löscht ein Managed Account (optional für v1)
* @param {string} accountId - ID des Accounts
* @returns {Promise<void>}
*/
export async function deleteManagedAccount(accountId) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
try {
await databases.deleteDocument(
databaseId,
accountsCollectionId,
accountId
);
} catch (e) {
console.error("Fehler beim Löschen des Accounts:", e);
throw e;
}
}

View File

@@ -0,0 +1,301 @@
/**
* Dashboard Service
* Stellt aggregierte Dashboard-Daten bereit, gescoped nach activeAccountId
* Nutzt productsService und accountsService für Datenabfragen
*/
import { listProductsByAccount, getProductById } from "./productsService";
import { databases, databaseId } from "../lib/appwrite";
import { Query } from "appwrite";
const productDetailsCollectionId =
import.meta.env.VITE_APPWRITE_PRODUCT_DETAILS_COLLECTION_ID || "product_details";
/**
* Lädt Overview KPIs für einen aktiven Account
* @param {string} activeAccountId - ID des aktiven Accounts
* @returns {Promise<{ totalProducts: number, activeProducts: number, endedProducts: number, avgPrice: number | null, newestProducts: Array }>}
*/
export async function getOverviewKPIs(activeAccountId) {
if (!activeAccountId) {
return {
totalProducts: 0,
activeProducts: 0,
endedProducts: 0,
avgPrice: null,
newestProducts: [],
};
}
try {
// Lade bis zu 200 Products für KPI-Berechnung
const products = await listProductsByAccount(activeAccountId, {
limit: 200,
offset: 0,
orderBy: "$createdAt",
orderType: "desc",
});
const totalProducts = products.length;
// Filtere nach Status
const activeProducts = products.filter(
(p) => p.product_status === "active" || p.product_status === "Active"
);
const endedProducts = products.filter(
(p) => p.product_status === "ended" || p.product_status === "Ended"
);
// Berechne Durchschnittspreis für aktive Products
const activeWithPrice = activeProducts.filter(
(p) => p.product_price != null && !isNaN(parseFloat(p.product_price))
);
const avgPrice =
activeWithPrice.length > 0
? activeWithPrice.reduce((sum, p) => sum + parseFloat(p.product_price), 0) /
activeWithPrice.length
: null;
// Neueste 5 Products (bereits nach $createdAt desc sortiert)
const newestProducts = products.slice(0, 5);
return {
totalProducts,
activeProducts: activeProducts.length,
endedProducts: endedProducts.length,
avgPrice: avgPrice ? Math.round(avgPrice * 100) / 100 : null,
newestProducts,
};
} catch (e) {
console.error("Fehler beim Laden der Overview KPIs:", e);
throw e;
}
}
/**
* Lädt eine paginierte Seite von Products für einen aktiven Account
* @param {string} activeAccountId - ID des aktiven Accounts
* @param {Object} options - Pagination und Filter-Optionen
* @param {number} options.page - Seitennummer (1-basiert)
* @param {number} options.pageSize - Anzahl Items pro Seite
* @param {Object} [options.filters] - Filter-Optionen
* @param {string} [options.filters.status] - Status-Filter ("active", "ended", etc.)
* @param {string} [options.filters.search] - Suchbegriff für Titel (client-side)
* @returns {Promise<{ items: Array, total: number, page: number, pageSize: number }>}
*/
export async function getProductsPage(activeAccountId, options = {}) {
if (!activeAccountId) {
return {
items: [],
total: 0,
page: 1,
pageSize: options.pageSize || 25,
};
}
const { page = 1, pageSize = 25, filters = {} } = options;
const offset = (page - 1) * pageSize;
try {
// Lade Products mit Pagination
// Für MVP: Lade mehr als nötig (z.B. pageSize * 3) um client-side Filter zu unterstützen
const fetchLimit = Math.max(pageSize * 3, 100); // Mindestens 100, aber mehr wenn pageSize größer
let products = await listProductsByAccount(activeAccountId, {
limit: fetchLimit,
offset: 0,
orderBy: "$createdAt",
orderType: "desc",
});
// Client-side Filter anwenden (falls Appwrite Query-Filter nicht unterstützt)
if (filters.status && filters.status !== "all") {
products = products.filter(
(p) =>
p.product_status?.toLowerCase() === filters.status.toLowerCase()
);
}
if (filters.search && filters.search.trim()) {
const searchLower = filters.search.trim().toLowerCase();
products = products.filter((p) =>
p.product_title?.toLowerCase().includes(searchLower)
);
}
const total = products.length;
// Pagination auf gefilterten Ergebnissen anwenden
const startIndex = offset;
const endIndex = startIndex + pageSize;
const items = products.slice(startIndex, endIndex);
return {
items,
total,
page,
pageSize,
};
} catch (e) {
console.error("Fehler beim Laden der Products Page:", e);
throw e;
}
}
/**
* Lädt ein Product mit optionalen Details aus product_details Collection
* @param {string} productId - ID des Products
* @returns {Promise<Object>} Product-Objekt mit optionalen Details
*/
export async function getProductPreview(productId) {
if (!productId) {
throw new Error("productId ist erforderlich");
}
try {
// Lade Product
const product = await getProductById(productId);
if (!product) {
return null;
}
// Versuche product_details zu laden (optional, falls vorhanden)
let details = null;
try {
const detailsResponse = await databases.listDocuments(
databaseId,
productDetailsCollectionId,
[Query.equal("product_detail_product_id", productId), Query.limit(1)]
);
if (detailsResponse.documents && detailsResponse.documents.length > 0) {
details = detailsResponse.documents[0];
}
} catch (detailsError) {
// product_details Collection existiert möglicherweise nicht oder hat keine Permissions
// Das ist OK, Details sind optional
console.debug("product_details konnten nicht geladen werden:", detailsError);
}
// Kombiniere Product mit Details
return {
...product,
details,
};
} catch (e) {
console.error("Fehler beim Laden des Product Previews:", e);
throw e;
}
}
/**
* Lädt Insights für einen aktiven Account (regelbasiert, MVP)
* @param {string} activeAccountId - ID des aktiven Accounts
* @returns {Promise<{ trending: Array, priceSpread: Array, categoryShare: Array }>}
*/
export async function getInsights(activeAccountId) {
if (!activeAccountId) {
return {
trending: [],
priceSpread: [],
categoryShare: [],
};
}
try {
// Lade bis zu 500 Products für Insights-Berechnung
const products = await listProductsByAccount(activeAccountId, {
limit: 500,
offset: 0,
orderBy: "$createdAt",
orderType: "desc",
});
// 1. Trending: Neueste 20 Products als "recent"
const trending = products.slice(0, 20).map((p) => ({
product: p,
label: p.product_title || p.$id,
value: p.product_price ? `EUR ${p.product_price}` : "N/A",
}));
// 2. Price Spread: Gruppiere nach normalisiertem Titel (lowercase, remove digits)
// Berechne max-min Spread pro Gruppe, Top 5
const titleGroups = new Map();
products.forEach((p) => {
if (!p.product_title || !p.product_price) return;
// Normalisiere Titel: lowercase, entferne Zahlen
const normalizedTitle = p.product_title
.toLowerCase()
.replace(/\d+/g, "")
.trim();
if (!normalizedTitle) return;
const price = parseFloat(p.product_price);
if (isNaN(price)) return;
if (!titleGroups.has(normalizedTitle)) {
titleGroups.set(normalizedTitle, {
title: normalizedTitle,
prices: [],
originalTitles: new Set(),
});
}
const group = titleGroups.get(normalizedTitle);
group.prices.push(price);
group.originalTitles.add(p.product_title);
});
// Berechne Spread für jede Gruppe
const spreads = Array.from(titleGroups.values())
.map((group) => {
if (group.prices.length < 2) return null; // Mindestens 2 Preise für Spread
const minPrice = Math.min(...group.prices);
const maxPrice = Math.max(...group.prices);
const spread = maxPrice - minPrice;
return {
title: Array.from(group.originalTitles)[0], // Erstes Original-Titel als Label
minPrice,
maxPrice,
spread,
count: group.prices.length,
label: `${Array.from(group.originalTitles)[0]} (${group.prices.length}x)`,
value: `EUR ${minPrice.toFixed(2)} - ${maxPrice.toFixed(2)} (Spread: ${spread.toFixed(2)})`,
};
})
.filter((s) => s !== null)
.sort((a, b) => b.spread - a.spread) // Sortiere nach Spread (höchster zuerst)
.slice(0, 5); // Top 5
// 3. Category Share: Zähle nach product_category, Top 5
const categoryCounts = new Map();
products.forEach((p) => {
const category = p.product_category || "Uncategorized";
categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1);
});
const categoryShare = Array.from(categoryCounts.entries())
.map(([category, count]) => ({
category,
count,
label: category,
value: `${count} items`,
}))
.sort((a, b) => b.count - a.count) // Sortiere nach Count (höchster zuerst)
.slice(0, 5); // Top 5
return {
trending,
priceSpread: spreads,
categoryShare,
};
} catch (e) {
console.error("Fehler beim Laden der Insights:", e);
throw e;
}
}

View File

@@ -0,0 +1,515 @@
/**
* eBay URL Parser Service (Stub)
* Extrahiert Account-Daten aus eBay-URLs
*
* WICHTIG: Dies ist eine Stub-Implementierung ohne echte Network-Calls.
* Später wird diese Funktion durch einen Call zur Browser-Extension ersetzt.
*
* Regeln:
* - Deterministisch: Gleiche URL → gleiche Daten
* - Keine Network-Calls (kein fetch, kein iframe, kein HTML-Parsing)
* - Ersetzbar durch Extension-Call
*/
/**
* Einfacher stabiler Hash für deterministische IDs
* @param {string} str - Input string
* @returns {string} - Hash string
*/
function stableHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36).padStart(10, '0');
}
/**
* Extrahiert Marktplatz aus eBay-URL (Domain)
* @param {string} url - eBay-URL
* @returns {string} - Market Code (z.B. "DE", "US", "UK")
*/
function extractMarketFromUrl(url) {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
// eBay Domain-Patterns
if (hostname.includes('.de') || hostname.includes('ebay.de')) {
return 'DE';
}
if (hostname.includes('.com') || hostname.includes('ebay.com')) {
// Prüfe auf US-spezifische Patterns
if (hostname.includes('ebay.com') && !hostname.includes('.uk')) {
return 'US';
}
}
if (hostname.includes('.uk') || hostname.includes('ebay.co.uk')) {
return 'UK';
}
if (hostname.includes('.fr') || hostname.includes('ebay.fr')) {
return 'FR';
}
if (hostname.includes('.it') || hostname.includes('ebay.it')) {
return 'IT';
}
if (hostname.includes('.es') || hostname.includes('ebay.es')) {
return 'ES';
}
if (hostname.includes('.nl') || hostname.includes('ebay.nl')) {
return 'NL';
}
if (hostname.includes('.at') || hostname.includes('ebay.at')) {
return 'AT';
}
if (hostname.includes('.ch') || hostname.includes('ebay.ch')) {
return 'CH';
}
// Fallback: erster Teil der Domain nach "ebay."
const match = hostname.match(/ebay\.([a-z]{2,3})/);
if (match && match[1]) {
return match[1].toUpperCase();
}
// Default: US
return 'US';
} catch (e) {
return 'US'; // Fallback
}
}
// Extension-ID Cache (gesetzt via postMessage vom Content Script)
let cachedExtensionId = null;
// Listener für Extension-ID vom Content Script
if (typeof window !== 'undefined') {
window.addEventListener('message', (event) => {
if (event.data?.source === 'eship-extension' && event.data?.type === 'EXTENSION_ID' && event.data?.extensionId) {
cachedExtensionId = event.data.extensionId;
}
});
}
/**
* Prüft ob die Browser-Extension verfügbar ist
* @returns {boolean} - true wenn Extension verfügbar
*/
export function isExtensionAvailable() {
if (typeof window === 'undefined') {
return false;
}
// Prüfe ob Extension-ID gecacht ist (via postMessage empfangen)
if (cachedExtensionId) {
return true;
}
// Window flag (set by content script) - das ist das Haupt-Kriterium
// Der Content Script setzt window.__EBAY_EXTENSION__ = true wenn er läuft
const flagValue = window.__EBAY_EXTENSION__;
if (flagValue === true) {
return true;
}
// chrome.runtime.sendMessage allein bedeutet NICHT, dass die Extension verfügbar ist
// (Chrome macht chrome.runtime auch ohne Extension verfügbar, aber ohne Extension-ID können wir sie nicht nutzen)
// Daher: Nur window.__EBAY_EXTENSION__ ist der zuverlässige Indikator
return false;
}
/**
* Holt Extension-ID (für externally_connectable messaging)
* @returns {Promise<string|null>} - Extension-ID oder null
*/
async function getExtensionId() {
try {
// Methode 1: Verwende gecachte Extension-ID (via postMessage vom Content Script empfangen)
if (cachedExtensionId) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:135',message:'getExtensionId: found via cache',data:{cachedExtensionId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return cachedExtensionId;
}
// Retry-Mechanismus: Warte auf postMessage vom Content Script
for (let attempt = 0; attempt < 10; attempt++) {
if (cachedExtensionId) {
return cachedExtensionId;
}
// Warte kurz vor dem nächsten Versuch (außer beim letzten Versuch)
if (attempt < 9) {
await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay
}
}
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:150',message:'getExtensionId: not found after retries',data:{hasWindow:typeof window!=='undefined'},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return null; // Nicht verfügbar
} catch (e) {
return null;
}
}
/**
* Parst eine eBay-URL über die Browser-Extension
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL
* @returns {Promise<{sellerId: string, shopName: string, market: string, status: "active" | "unknown", stats?: object}>}
* @throws {Error} - "Extension not available" oder andere Fehler
*/
async function parseViaExtension(url) {
// Validierung
if (!url || typeof url !== 'string') {
throw new Error("Invalid URL");
}
// Methode 1: chrome.runtime.sendMessage (externally_connectable)
// Versuche zuerst mit Extension-ID, dann ohne (Chrome findet Extension automatisch bei externally_connectable)
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
try {
const extensionId = await getExtensionId();
// Versuche chrome.runtime.sendMessage (mit oder ohne Extension-ID)
return new Promise((resolve, reject) => {
const message = {
action: "PARSE_URL",
url: url
};
// SendMessage-Callback
const sendMessageCallback = (response) => {
// Check for Chrome runtime errors
if (chrome.runtime.lastError) {
const errorMsg = chrome.runtime.lastError.message || "Extension communication error";
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:158',message:'parseViaExtension: chrome.runtime.sendMessage error',data:{error:errorMsg,extensionId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
reject(new Error(errorMsg));
return;
}
if (response && response.ok && response.data) {
// Ensure stats object is included (even if empty)
const data = {
sellerId: response.data.sellerId || "",
shopName: response.data.shopName || "",
market: response.data.market || "US",
status: response.data.status || "unknown",
stats: response.data.stats || {}
};
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:160',message:'parseViaExtension: response data from extension',data:{hasStats:!!response.data.stats,itemsSold:response.data.stats?.itemsSold,stats:response.data.stats},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
resolve(data);
} else {
reject(new Error(response?.error || "Extension parsing failed"));
}
};
// Benötigt Extension-ID (sendMessage von Webseiten aus erfordert immer Extension-ID)
if (!extensionId) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:175',message:'parseViaExtension: no extension ID',data:{hasWindowExtensionId:!!(typeof window!=='undefined'&&window.__EBAY_EXTENSION_ID__)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
reject(new Error("Extension ID not available"));
return;
}
chrome.runtime.sendMessage(extensionId, message, sendMessageCallback);
// Timeout nach 20s (Extension hat intern 15s, hier etwas mehr Puffer)
setTimeout(() => {
reject(new Error("Extension timeout"));
}, 20000);
});
} catch (error) {
// Chrome API Fehler: Fallback zu window.postMessage wenn möglich
if (error.message && !error.message.includes("Extension")) {
throw error;
}
// Weiter zu Methode 2
}
}
// Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden)
if (window.__EBAY_EXTENSION__ === true) {
return new Promise((resolve, reject) => {
const messageId = `parse_${Date.now()}_${Math.random()}`;
// Listener für Antwort
const responseHandler = (event) => {
if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) {
return;
}
window.removeEventListener('message', responseHandler);
if (event.data?.ok && event.data?.data) {
const data = {
sellerId: event.data.data.sellerId || "",
shopName: event.data.data.shopName || "",
market: event.data.data.market || "US",
status: event.data.data.status || "unknown",
stats: event.data.data.stats || {}
};
resolve(data);
} else {
reject(new Error(event.data?.error || "Extension parsing failed"));
}
};
window.addEventListener('message', responseHandler);
// Sende Request via postMessage
window.postMessage({
source: 'eship-webapp',
action: 'PARSE_URL',
url: url,
messageId: messageId
}, '*');
// Timeout
setTimeout(() => {
window.removeEventListener('message', responseHandler);
reject(new Error("Extension timeout"));
}, 20000);
});
}
// Keine Extension verfügbar
throw new Error("Extension not available");
}
/**
* Parst eine eBay-URL über die Browser-Extension für Produkt-Scan
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL
* @param {string} accountId - Account-ID (wird an Extension übergeben)
* @returns {Promise<Array>} - Array von Produkt-Items
* @throws {Error} - "Extension not available" oder andere Fehler
*/
export async function parseViaExtensionScanProducts(url, accountId) {
// Validierung
if (!url || typeof url !== 'string') {
throw new Error("Invalid URL");
}
if (!accountId || typeof accountId !== 'string') {
throw new Error("Invalid accountId");
}
// Methode 1: chrome.runtime.sendMessage (externally_connectable)
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
try {
const extensionId = await getExtensionId();
// Versuche chrome.runtime.sendMessage (mit oder ohne Extension-ID)
return new Promise((resolve, reject) => {
const message = {
action: "SCAN_PRODUCTS",
url: url,
accountId: accountId
};
// SendMessage-Callback
const sendMessageCallback = (response) => {
// Check for Chrome runtime errors
if (chrome.runtime.lastError) {
const errorMsg = chrome.runtime.lastError.message || "Extension communication error";
reject(new Error(errorMsg));
return;
}
if (response && response.ok && response.data) {
const items = response.data.items || [];
const meta = response.data.meta || response.meta || {};
// Wenn items leer oder ok:false, werfe Fehler mit meta
if (!Array.isArray(items) || items.length === 0) {
const errorMsg = response?.error || "no_items_found";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta;
reject(error);
return;
}
// Erfolg: gib items zurück
resolve(items);
} else {
// Fehler: sende error + meta
const meta = response?.meta || {};
const errorMsg = response?.error || "Extension scanning failed";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta;
reject(error);
}
};
// Benötigt Extension-ID (sendMessage von Webseiten aus erfordert immer Extension-ID)
if (!extensionId) {
reject(new Error("Extension ID not available"));
return;
}
chrome.runtime.sendMessage(extensionId, message, sendMessageCallback);
// Timeout nach 20s (Extension hat intern 20s)
setTimeout(() => {
reject(new Error("Extension timeout"));
}, 20000);
});
} catch (error) {
// Chrome API Fehler: weiter zu Methode 2
if (error.message && !error.message.includes("Extension")) {
throw error;
}
// Weiter zu Methode 2
}
}
// Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden)
if (typeof window !== 'undefined' && window.__EBAY_EXTENSION__ === true) {
return new Promise((resolve, reject) => {
const messageId = `scan_${Date.now()}_${Math.random()}`;
// Listener für Antwort
const responseHandler = (event) => {
if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) {
return;
}
window.removeEventListener('message', responseHandler);
if (event.data?.ok && event.data?.data) {
const items = event.data.data.items || event.data.items || [];
const meta = event.data.data.meta || event.data.meta || {};
// Wenn items leer oder ok:false, werfe Fehler mit meta
if (!Array.isArray(items) || items.length === 0) {
const errorMsg = event.data?.error || "no_items_found";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta;
reject(error);
return;
}
// Erfolg: gib items zurück
resolve(items);
} else {
// Fehler: sende error + meta
const meta = event.data?.meta || event.data?.data?.meta || {};
const errorMsg = event.data?.error || "Extension scanning failed";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta;
reject(error);
}
};
window.addEventListener('message', responseHandler);
// Sende Request via postMessage
window.postMessage({
source: 'eship-webapp',
action: 'SCAN_PRODUCTS',
url: url,
accountId: accountId,
messageId: messageId
}, '*');
// Timeout
setTimeout(() => {
window.removeEventListener('message', responseHandler);
reject(new Error("Extension timeout"));
}, 20000);
});
}
// Keine Extension verfügbar - kein Stub für Produkt-Scan (User will echte Daten)
throw new Error("Extension not available");
}
/**
* Parst eine eBay-URL mit Stub-Logik (deterministisch, keine Network-Calls)
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL
* @returns {Promise<{sellerId: string, shopName: string, market: string, status: "active" | "unknown"}>}
* @throws {Error} - Wenn URL ungültig ist oder keine eBay-URL
*/
async function parseViaStub(url) {
// Validierung: Muss gültige URL sein
let urlObj;
try {
urlObj = new URL(url);
} catch (e) {
throw new Error("Bitte gib eine gültige URL ein.");
}
// Validierung: Muss eBay-URL sein
const hostname = urlObj.hostname.toLowerCase();
if (!hostname.includes('ebay.')) {
throw new Error("Bitte gib eine gültige eBay-URL ein.");
}
// Stub-Implementierung: Deterministische Daten aus URL generieren
const hash = stableHash(url);
const market = extractMarketFromUrl(url);
// Seller ID: Deterministic aus URL-Hash
// Format: "ebay_" + hash (first 10 chars)
const sellerId = `ebay_${hash.slice(0, 10)}`;
// Shop Name: Generiert aus Hash (last 4 chars als Suffix)
const shopNameSuffix = hash.slice(-4);
const shopName = `eBay Seller ${shopNameSuffix}`;
// Status: Immer "active" für Stub
const status = "active";
return {
sellerId,
shopName,
market,
status,
stats: {
itemsSold: null, // Stub liefert keine echten Daten
},
};
}
/**
* Parst eine eBay-URL und extrahiert Account-Daten (Facade)
* Versucht zuerst Extension-Pfad, fällt zurück auf Stub-Implementierung
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL
* @returns {Promise<{sellerId: string, shopName: string, market: string, status: "active" | "unknown", stats?: object}>}
* @throws {Error} - Wenn URL ungültig ist oder keine eBay-URL
*/
export async function parseEbayAccount(url) {
// Versuche IMMER Extension-Pfad zuerst (auch wenn Flag nicht gesetzt)
// parseViaExtension prüft selbst, ob Extension verfügbar ist
// #region agent log
const extAvailable = isExtensionAvailable();
const hasChromeRuntime = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage;
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:292',message:'parseEbayAccount: route decision',data:{extAvailable,hasChromeRuntime,url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
try {
// Versuche Extension zu nutzen (auch wenn Flag nicht gesetzt - parseViaExtension prüft selbst)
return await parseViaExtension(url);
} catch (e) {
// Extension-Fehler: Fallback zu Stub
console.warn("Extension parsing failed, falling back to stub:", e.message);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:299',message:'parseEbayAccount: extension error, using stub',data:{error:e.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
}
// Fallback: Stub-Implementierung
const stubResult = await parseViaStub(url);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:304',message:'parseEbayAccount: stub result',data:{itemsSold:stubResult.stats?.itemsSold},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return stubResult;
}

View File

@@ -0,0 +1,258 @@
/**
* Products Service
* CRUD-Operationen für Products aus Appwrite
* Filtert nach product_account_id für Account-Scoping
*/
import { databases, databaseId } from "../lib/appwrite";
import { Query, ID } from "appwrite";
import { getAccountById, deriveMarketFromAccount, deriveCurrencyFromMarket, updateAccountLastScanAt } from "./accountsService";
import { parseViaExtensionScanProducts } from "./ebayParserService";
const productsCollectionId = import.meta.env.VITE_APPWRITE_PRODUCTS_COLLECTION_ID || "products";
/**
* Lädt alle Products für einen bestimmten Account
* @param {string} accountId - ID des Accounts (product_account_id)
* @param {Object} [options] - Optionale Parameter
* @param {number} [options.limit] - Maximale Anzahl Ergebnisse (default: 25)
* @param {number} [options.offset] - Offset für Pagination (default: 0)
* @param {string} [options.orderBy] - Feld zum Sortieren (default: "$createdAt")
* @param {string} [options.orderType] - Sortier-Richtung: "asc" oder "desc" (default: "desc")
* @returns {Promise<Array>} Array von Product-Dokumenten
*/
export async function listProductsByAccount(accountId, options = {}) {
if (!accountId) {
console.warn("listProductsByAccount: accountId fehlt");
return [];
}
const {
limit = 25,
offset = 0,
orderBy = "$createdAt",
orderType = "desc",
} = options;
try {
const queries = [
Query.equal("product_account_id", accountId),
Query.orderDesc(orderBy), // orderType wird über Desc/Asc gehandhabt
Query.limit(limit),
Query.offset(offset),
];
// Wenn orderType "asc" ist, verwende orderAsc statt orderDesc
if (orderType === "asc") {
queries[1] = Query.orderAsc(orderBy);
}
const response = await databases.listDocuments(
databaseId,
productsCollectionId,
queries
);
return response.documents;
} catch (e) {
console.error("Fehler beim Laden der Products:", e);
// Wenn Collection nicht existiert oder Berechtigungen fehlen
if (e.code === 404 || e.code === 401 || e.type === 'collection_not_found') {
// Placeholder: Collection existiert noch nicht
console.warn("Products-Collection existiert noch nicht oder ist nicht verfügbar. Gebe leeres Array zurück.");
return [];
}
throw e;
}
}
/**
* Lädt ein einzelnes Product nach ID
* @param {string} productId - ID des Products
* @returns {Promise<Object|null>} Product-Dokument oder null
*/
export async function getProductById(productId) {
if (!productId) {
console.warn("getProductById: productId fehlt");
return null;
}
try {
const document = await databases.getDocument(
databaseId,
productsCollectionId,
productId
);
return document;
} catch (e) {
// 404 bedeutet, dass das Dokument nicht existiert
if (e.code === 404 || e.type === 'document_not_found') {
return null;
}
console.error("Fehler beim Laden des Products:", e);
throw e;
}
}
/**
* Scannt Produkte für einen Account über die Extension (echte eBay-Daten)
* @param {string} accountId - ID des Accounts
* @returns {Promise<{ created: number, updated: number }>} Anzahl erstellter/aktualisierter Produkte
*/
export async function scanProductsForAccount(accountId) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
try {
// 1. Lade Account, um URL und andere Daten abzuleiten
const account = await getAccountById(accountId);
if (!account) {
throw new Error("Account nicht gefunden");
}
if (!account.account_url) {
throw new Error("Account-URL fehlt. Bitte Account refreshen.");
}
// 2. Extension aufrufen: scanne Produkte
const items = await parseViaExtensionScanProducts(account.account_url, accountId);
// 3. Response-Items zu Product-Schema mappen
const mappedProducts = items.map(item => {
// Validiere, dass platformProductId vorhanden ist
if (!item.platformProductId) {
console.warn("Item ohne platformProductId übersprungen:", item);
return null;
}
return {
product_platform_product_id: item.platformProductId,
product_title: item.title || "",
product_price: item.price ?? undefined, // undefined statt null für Appwrite
product_currency: item.currency ?? undefined, // auto-fill from market if undefined
product_url: item.url || "",
product_status: item.status ?? "unknown",
product_category: item.category ?? "unknown",
product_condition: item.condition ?? "unknown"
};
}).filter(Boolean); // Entferne null-Einträge
// 4. upsertProductsForAccount aufrufen
const result = await upsertProductsForAccount(accountId, mappedProducts);
// 5. account_last_scan_at aktualisieren
await updateAccountLastScanAt(accountId, new Date().toISOString());
// 6. Return { created, updated }
return result;
} catch (e) {
console.error("Fehler beim Scannen der Produkte:", e);
throw e;
}
}
/**
* Erstellt oder aktualisiert Produkte für einen Account (vorbereitet für Extension-Scans)
* @param {string} accountId - ID des Accounts
* @param {Array<Object>} products - Array von Produkt-Daten (kann partiell sein)
* @returns {Promise<{ created: number, updated: number }>} Anzahl erstellter/aktualisierter Produkte
*/
export async function upsertProductsForAccount(accountId, products) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
if (!Array.isArray(products) || products.length === 0) {
return { created: 0, updated: 0 };
}
try {
// Lade Account, um Market und andere Daten abzuleiten
const account = await getAccountById(accountId);
if (!account) {
throw new Error("Account nicht gefunden");
}
// Leite Market und Currency aus Account ab
const market = deriveMarketFromAccount(account);
if (market === "UNKNOWN") {
throw new Error(
"Market konnte nicht erkannt werden. Bitte Account refreshen."
);
}
const currency = deriveCurrencyFromMarket(market);
const platform = "ebay"; // Enum-Werte sind lowercase gemäß Fehlermeldung: [amazon], [ebay]
// Lade bestehende Produkte für Duplikat-Prüfung
const existingProducts = await listProductsByAccount(accountId, {
limit: 1000,
offset: 0,
});
// Erstelle Map von platform_product_id -> bestehendes Produkt
const existingProductsMap = new Map();
existingProducts.forEach((p) => {
if (p.product_platform_product_id) {
existingProductsMap.set(p.product_platform_product_id, p);
}
});
let created = 0;
let updated = 0;
// Verarbeite jedes Produkt
for (const productData of products) {
// Validiere, dass platform_product_id vorhanden ist
if (!productData.product_platform_product_id) {
console.warn("Produkt ohne product_platform_product_id übersprungen:", productData);
continue;
}
const platformProductId = productData.product_platform_product_id;
const existingProduct = existingProductsMap.get(platformProductId);
// Ergänze fehlende erforderliche Felder aus Account
const fullProductData = {
product_account_id: accountId,
product_platform: platform,
product_platform_market: market,
product_currency: currency,
...productData, // Produkt-Daten überschreiben Account-Daten wo vorhanden
};
try {
if (existingProduct) {
// Aktualisiere bestehendes Produkt
await databases.updateDocument(
databaseId,
productsCollectionId,
existingProduct.$id,
fullProductData
);
updated++;
} else {
// Erstelle neues Produkt
await databases.createDocument(
databaseId,
productsCollectionId,
ID.unique(),
fullProductData
);
created++;
}
} catch (e) {
console.error(`Fehler beim Upsert des Produkts "${platformProductId}":`, e);
// Weiter mit nächstem Produkt
continue;
}
}
return { created, updated };
} catch (e) {
console.error("Fehler beim Upsert der Produkte:", e);
throw e;
}
}

14
Server/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})