Convert Server from submodule to normal files
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
1
Server
Submodule Server deleted from 6100df3f83
3
Server/appwrite.json
Normal file
3
Server/appwrite.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"projectId": "696b82bb0036d2e547ad"
|
||||
}
|
||||
15
Server/backend/package.json
Normal file
15
Server/backend/package.json
Normal 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
54
Server/backend/server.js
Normal 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
13
Server/index.html
Normal 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
5634
Server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
Server/package.json
Normal file
41
Server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
Server/public/assets/background.png
Normal file
BIN
Server/public/assets/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 778 KiB |
BIN
Server/public/assets/logos/logo-horizontal.png
Normal file
BIN
Server/public/assets/logos/logo-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
Server/public/assets/logos/logo-square.png
Normal file
BIN
Server/public/assets/logos/logo-square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Server/public/assets/logos/logo-vertical.png
Normal file
BIN
Server/public/assets/logos/logo-vertical.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
376
Server/src/App.jsx
Normal file
376
Server/src/App.jsx
Normal 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 },
|
||||
};
|
||||
146
Server/src/components/dashboard/Dashboard.jsx
Normal file
146
Server/src/components/dashboard/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
Server/src/components/dashboard/hooks/usePagination.js
Normal file
39
Server/src/components/dashboard/hooks/usePagination.js
Normal 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
|
||||
};
|
||||
};
|
||||
41
Server/src/components/dashboard/hooks/useScrollSnap.js
Normal file
41
Server/src/components/dashboard/hooks/useScrollSnap.js
Normal 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 };
|
||||
};
|
||||
232
Server/src/components/dashboard/sections/AccountsSection.jsx
Normal file
232
Server/src/components/dashboard/sections/AccountsSection.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
240
Server/src/components/dashboard/sections/InsightsSection.jsx
Normal file
240
Server/src/components/dashboard/sections/InsightsSection.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
211
Server/src/components/dashboard/sections/OverviewSection.jsx
Normal file
211
Server/src/components/dashboard/sections/OverviewSection.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
394
Server/src/components/dashboard/sections/ProductsSection.jsx
Normal file
394
Server/src/components/dashboard/sections/ProductsSection.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
Server/src/components/dashboard/ui/DataTable.jsx
Normal file
36
Server/src/components/dashboard/ui/DataTable.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
Server/src/components/dashboard/ui/Filters.jsx
Normal file
39
Server/src/components/dashboard/ui/Filters.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
Server/src/components/dashboard/ui/InsightCard.jsx
Normal file
42
Server/src/components/dashboard/ui/InsightCard.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
Server/src/components/dashboard/ui/KPICard.jsx
Normal file
19
Server/src/components/dashboard/ui/KPICard.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
Server/src/components/dashboard/ui/Pagination.jsx
Normal file
27
Server/src/components/dashboard/ui/Pagination.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
Server/src/components/layout/BackgroundImage.jsx
Normal file
19
Server/src/components/layout/BackgroundImage.jsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
120
Server/src/components/layout/BackgroundRippleEffect.jsx
Normal file
120
Server/src/components/layout/BackgroundRippleEffect.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
Server/src/components/layout/Logo.jsx
Normal file
16
Server/src/components/layout/Logo.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
Server/src/components/layout/LogoSquare.jsx
Normal file
15
Server/src/components/layout/LogoSquare.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
Server/src/components/layout/LogoVertical.jsx
Normal file
15
Server/src/components/layout/LogoVertical.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
477
Server/src/components/onboarding/OnboardingGate.jsx
Normal file
477
Server/src/components/onboarding/OnboardingGate.jsx
Normal 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",
|
||||
},
|
||||
};
|
||||
151
Server/src/components/sidebar/Sidebar.jsx
Normal file
151
Server/src/components/sidebar/Sidebar.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
199
Server/src/components/sidebar/SidebarHeader.jsx
Normal file
199
Server/src/components/sidebar/SidebarHeader.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
Server/src/components/sidebar/index.js
Normal file
1
Server/src/components/sidebar/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar, SidebarBody, SidebarLink, useSidebar } from "./Sidebar.jsx";
|
||||
83
Server/src/components/ui/LogoutButton.jsx
Normal file
83
Server/src/components/ui/LogoutButton.jsx
Normal 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;
|
||||
392
Server/src/components/ui/Shuffle.jsx
Normal file
392
Server/src/components/ui/Shuffle.jsx
Normal 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;
|
||||
52
Server/src/components/ui/colourful-text.jsx
Normal file
52
Server/src/components/ui/colourful-text.jsx
Normal 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>
|
||||
));
|
||||
}
|
||||
107
Server/src/components/ui/iphone-notification.jsx
Normal file
107
Server/src/components/ui/iphone-notification.jsx
Normal 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,
|
||||
},
|
||||
};
|
||||
97
Server/src/components/ui/multi-step-loader.jsx
Normal file
97
Server/src/components/ui/multi-step-loader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
Server/src/components/ui/placeholders-and-vanish-input.jsx
Normal file
262
Server/src/components/ui/placeholders-and-vanish-input.jsx
Normal 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
43
Server/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Server/src/lib/appwrite.js
Normal file
59
Server/src/lib/appwrite.js
Normal 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
39
Server/src/lib/routing.js
Normal 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
6
Server/src/lib/utils.js
Normal 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
10
Server/src/main.jsx
Normal 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>,
|
||||
)
|
||||
656
Server/src/pages/AccountsPage.jsx
Normal file
656
Server/src/pages/AccountsPage.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
99
Server/src/services/accountService.js
Normal file
99
Server/src/services/accountService.js
Normal 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";
|
||||
}
|
||||
339
Server/src/services/accountsService.js
Normal file
339
Server/src/services/accountsService.js
Normal 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;
|
||||
}
|
||||
}
|
||||
301
Server/src/services/dashboardService.js
Normal file
301
Server/src/services/dashboardService.js
Normal 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;
|
||||
}
|
||||
}
|
||||
515
Server/src/services/ebayParserService.js
Normal file
515
Server/src/services/ebayParserService.js
Normal 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;
|
||||
}
|
||||
258
Server/src/services/productsService.js
Normal file
258
Server/src/services/productsService.js
Normal 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
14
Server/vite.config.js
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user