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