Update: Neue Seiten und Komponenten hinzugefügt
This commit is contained in:
8
Server/public/assets/platforms/README.md
Normal file
8
Server/public/assets/platforms/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Platform-Logos
|
||||||
|
|
||||||
|
Füge hier Logo-Dateien ein (PNG oder SVG, transparent, möglichst hohe Auflösung):
|
||||||
|
|
||||||
|
- `ebay.png` – eBay-Logo („dickes“ Wortmarken-Logo)
|
||||||
|
- `amazon.png` – Amazon-Logo
|
||||||
|
|
||||||
|
Ohne lokale Dateien werden Fallback-Logos (Wikimedia Commons) genutzt.
|
||||||
@@ -3,7 +3,9 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { Sidebar, SidebarBody, SidebarLink } from "./components/sidebar";
|
import { Sidebar, SidebarBody, SidebarLink } from "./components/sidebar";
|
||||||
import {
|
import {
|
||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
|
IconBan,
|
||||||
IconBrandTabler,
|
IconBrandTabler,
|
||||||
|
IconChartLine,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserBolt,
|
IconUserBolt,
|
||||||
IconShoppingBag,
|
IconShoppingBag,
|
||||||
@@ -13,6 +15,9 @@ import { cn } from "./lib/utils";
|
|||||||
import { BackgroundRippleEffect } from "./components/layout/BackgroundRippleEffect";
|
import { BackgroundRippleEffect } from "./components/layout/BackgroundRippleEffect";
|
||||||
import { Dashboard } from "./components/dashboard/Dashboard";
|
import { Dashboard } from "./components/dashboard/Dashboard";
|
||||||
import { AccountsPage } from "./pages/AccountsPage";
|
import { AccountsPage } from "./pages/AccountsPage";
|
||||||
|
import { ItemsPage } from "./pages/ItemsPage";
|
||||||
|
import { BlacklistPage } from "./pages/BlacklistPage";
|
||||||
|
import { AnalysisPage } from "./pages/AnalysisPage";
|
||||||
import LogoutButton from "./components/ui/LogoutButton";
|
import LogoutButton from "./components/ui/LogoutButton";
|
||||||
import { OnboardingGate } from "./components/onboarding/OnboardingGate";
|
import { OnboardingGate } from "./components/onboarding/OnboardingGate";
|
||||||
import { SidebarHeader } from "./components/sidebar/SidebarHeader";
|
import { SidebarHeader } from "./components/sidebar/SidebarHeader";
|
||||||
@@ -390,11 +395,22 @@ export default function App() {
|
|||||||
navigate("/");
|
navigate("/");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Analysis",
|
||||||
|
href: "#/analysis",
|
||||||
|
icon: (
|
||||||
|
<IconChartLine className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
||||||
|
),
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/analysis");
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Accounts",
|
label: "Accounts",
|
||||||
href: "#/accounts",
|
href: "#/accounts",
|
||||||
icon: (
|
icon: (
|
||||||
<IconShoppingBag className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
<IconUserBolt className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
||||||
),
|
),
|
||||||
disabled: scanning,
|
disabled: scanning,
|
||||||
onClick: (e) => {
|
onClick: (e) => {
|
||||||
@@ -403,11 +419,26 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Profile",
|
label: "Items",
|
||||||
href: "#",
|
href: "#/items",
|
||||||
icon: (
|
icon: (
|
||||||
<IconUserBolt className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
<IconShoppingBag className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
||||||
),
|
),
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/items");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Blacklist",
|
||||||
|
href: "#/blacklist",
|
||||||
|
icon: (
|
||||||
|
<IconBan className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
||||||
|
),
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/blacklist");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
@@ -416,17 +447,6 @@ export default function App() {
|
|||||||
<IconSettings className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
|
<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
|
// Rendere Content basierend auf Route
|
||||||
@@ -434,6 +454,15 @@ export default function App() {
|
|||||||
if (route === "/accounts") {
|
if (route === "/accounts") {
|
||||||
return <AccountsPage />;
|
return <AccountsPage />;
|
||||||
}
|
}
|
||||||
|
if (route === "/items") {
|
||||||
|
return <ItemsPage />;
|
||||||
|
}
|
||||||
|
if (route === "/blacklist") {
|
||||||
|
return <BlacklistPage />;
|
||||||
|
}
|
||||||
|
if (route === "/analysis") {
|
||||||
|
return <AnalysisPage />;
|
||||||
|
}
|
||||||
// Default: Dashboard
|
// Default: Dashboard
|
||||||
return <Dashboard />;
|
return <Dashboard />;
|
||||||
};
|
};
|
||||||
|
|||||||
129
Server/src/components/ScrollSnapDummyPage.jsx
Normal file
129
Server/src/components/ScrollSnapDummyPage.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { useScrollSnap } from "./dashboard/hooks/useScrollSnap";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const SECTION_IDS = ["s1", "s2", "s3", "s4"];
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: "s1", label: "Overview" },
|
||||||
|
{ id: "s2", label: "Accounts" },
|
||||||
|
{ id: "s3", label: "Products" },
|
||||||
|
{ id: "s4", label: "Page 4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DummySection({ sectionId, title, pageTitle, onJumpToSection, activeSection }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={sectionId}
|
||||||
|
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)]">{pageTitle}</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">
|
||||||
|
{SECTIONS.map(({ id, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onJumpToSection(id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl px-3 py-2.5 text-xs transition-all active:translate-y-[1px]",
|
||||||
|
activeSection === id
|
||||||
|
? "border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
|
||||||
|
: "border border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="m-0 text-xl tracking-wide text-[var(--text)]">{title}</h2>
|
||||||
|
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
|
||||||
|
Dummy section – smooth scroll categories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-6 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="text-sm text-[var(--muted)]">
|
||||||
|
This is <strong className="text-[var(--text)]">{title}</strong>. Use the buttons above to jump between Overview, Accounts, Products, and Page 4.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollSnapDummyPage({ pageTitle }) {
|
||||||
|
const { scrollToSection, activeSection } = useScrollSnap(SECTION_IDS);
|
||||||
|
|
||||||
|
const handleJumpToSection = (sectionId) => {
|
||||||
|
scrollToSection(sectionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<DummySection
|
||||||
|
sectionId="s1"
|
||||||
|
title="Overview"
|
||||||
|
pageTitle={pageTitle}
|
||||||
|
onJumpToSection={handleJumpToSection}
|
||||||
|
activeSection={activeSection}
|
||||||
|
/>
|
||||||
|
<DummySection
|
||||||
|
sectionId="s2"
|
||||||
|
title="Accounts"
|
||||||
|
pageTitle={pageTitle}
|
||||||
|
onJumpToSection={handleJumpToSection}
|
||||||
|
activeSection={activeSection}
|
||||||
|
/>
|
||||||
|
<DummySection
|
||||||
|
sectionId="s3"
|
||||||
|
title="Products"
|
||||||
|
pageTitle={pageTitle}
|
||||||
|
onJumpToSection={handleJumpToSection}
|
||||||
|
activeSection={activeSection}
|
||||||
|
/>
|
||||||
|
<DummySection
|
||||||
|
sectionId="s4"
|
||||||
|
title="Page 4"
|
||||||
|
pageTitle={pageTitle}
|
||||||
|
onJumpToSection={handleJumpToSection}
|
||||||
|
activeSection={activeSection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,8 @@ export const useScrollSnap = (sectionIds) => {
|
|||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const sections = sectionIds.map(id => document.getElementById(id)).filter(Boolean);
|
const sections = sectionIds.map(id => document.getElementById(id)).filter(Boolean);
|
||||||
|
if (sections.length === 0) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
|||||||
43
Server/src/components/ui/bento-grid.jsx
Normal file
43
Server/src/components/ui/bento-grid.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export const BentoGrid = ({ className, children }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto grid max-w-7xl grid-cols-1 gap-4 md:auto-rows-[18rem] md:grid-cols-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BentoGridItem = ({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
header,
|
||||||
|
icon,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/bento row-span-1 flex flex-col justify-between space-y-4 rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="transition duration-200 group-hover/bento:translate-x-2">
|
||||||
|
{icon}
|
||||||
|
<div className="mt-2 mb-2 font-sans font-bold text-neutral-600 dark:text-neutral-200">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans text-xs font-normal text-neutral-600 dark:text-neutral-300">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,241 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { IconPlus, IconChevronDown, IconX, IconRefresh } from "@tabler/icons-react";
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconX,
|
||||||
|
IconRefresh,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChartBar,
|
||||||
|
IconSettings,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useHashRoute } from "../lib/routing";
|
import { useHashRoute } from "../lib/routing";
|
||||||
import {
|
import {
|
||||||
setActiveAccountId,
|
setActiveAccountId,
|
||||||
|
getActiveAccountId,
|
||||||
getAccountDisplayName,
|
getAccountDisplayName,
|
||||||
} from "../services/accountService";
|
} from "../services/accountService";
|
||||||
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService";
|
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService";
|
||||||
import { getAuthUser } from "../lib/appwrite";
|
import { getAuthUser } from "../lib/appwrite";
|
||||||
import { parseEbayAccount } from "../services/ebayParserService";
|
import { parseEbayAccount } from "../services/ebayParserService";
|
||||||
import { DataTable } from "../components/dashboard/ui/DataTable";
|
import { BentoGrid, BentoGridItem } from "../components/ui/bento-grid";
|
||||||
|
|
||||||
|
function AccountNameCard({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
platformAccountId,
|
||||||
|
accounts,
|
||||||
|
displayedAccountId,
|
||||||
|
onSelectAccount,
|
||||||
|
className,
|
||||||
|
}) {
|
||||||
|
const containerRef = React.useRef(null);
|
||||||
|
const measureRef = React.useRef(null);
|
||||||
|
const listRef = React.useRef(null);
|
||||||
|
const [fontSize, setFontSize] = React.useState(48);
|
||||||
|
const [listOpen, setListOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const otherAccounts = React.useMemo(
|
||||||
|
() => accounts.filter((acc) => (acc.$id || acc.id) !== displayedAccountId),
|
||||||
|
[accounts, displayedAccountId]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!listOpen) return;
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (listRef.current && !listRef.current.contains(e.target)) setListOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [listOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const cont = containerRef.current;
|
||||||
|
const meas = measureRef.current;
|
||||||
|
if (!cont || !meas || !name) return;
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const w = cont.clientWidth;
|
||||||
|
if (w <= 0) return;
|
||||||
|
let fs = 48;
|
||||||
|
meas.style.fontSize = `${fs}px`;
|
||||||
|
while (meas.scrollWidth > w && fs > 12) {
|
||||||
|
fs -= 2;
|
||||||
|
meas.style.fontSize = `${fs}px`;
|
||||||
|
}
|
||||||
|
setFontSize(fs);
|
||||||
|
};
|
||||||
|
|
||||||
|
fit();
|
||||||
|
const ro = new ResizeObserver(fit);
|
||||||
|
ro.observe(cont);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative flex w-full shrink-0 flex-col items-start justify-start overflow-hidden text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref={measureRef}
|
||||||
|
className="pointer-events-none invisible absolute left-0 top-0 whitespace-nowrap font-bold leading-tight text-[var(--text)]"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
{url ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)] hover:underline"
|
||||||
|
style={{ fontSize: `${fontSize}px` }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)]"
|
||||||
|
style={{ fontSize: `${fontSize}px` }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{platformAccountId != null && platformAccountId !== "" && (
|
||||||
|
<div className="mt-1 shrink-0 text-sm text-[var(--muted)]">
|
||||||
|
ID: {platformAccountId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={listRef} className="relative mt-2 flex shrink-0 flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-[var(--muted)]">Account wechseln</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setListOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center justify-between rounded-xl border border-[var(--line)] bg-white/5 px-3 py-2 text-left text-sm text-[var(--text)] outline-none transition-colors hover:border-[rgba(106,166,255,0.4)] focus:border-[rgba(106,166,255,0.5)] dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<span>Account wählen…</span>
|
||||||
|
<IconChevronDown className={cn("h-4 w-4 shrink-0 transition-transform", listOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
{listOpen && (
|
||||||
|
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-xl border border-[var(--line)] bg-white shadow-lg dark:bg-neutral-800">
|
||||||
|
{otherAccounts.length === 0 ? (
|
||||||
|
<li className="px-3 py-2 text-xs text-[var(--muted)]">Keine weiteren Accounts</li>
|
||||||
|
) : (
|
||||||
|
otherAccounts.map((acc) => {
|
||||||
|
const id = acc.$id || acc.id;
|
||||||
|
const label = getAccountDisplayName(acc) || id;
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectAccount(id);
|
||||||
|
setListOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center px-3 py-2 text-left text-sm text-[var(--text)] transition-colors hover:bg-white/10 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_LOGOS = {
|
||||||
|
ebay: {
|
||||||
|
local: "/assets/platforms/ebay.png",
|
||||||
|
fallback: "https://upload.wikimedia.org/wikipedia/commons/1/1b/EBay_logo.svg",
|
||||||
|
},
|
||||||
|
amazon: {
|
||||||
|
local: "/assets/platforms/amazon.png",
|
||||||
|
fallback: "https://upload.wikimedia.org/wikipedia/commons/a/a9/Amazon_logo.svg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function PlatformLogoCard({ platform, market, className }) {
|
||||||
|
const key = (platform || "").toLowerCase();
|
||||||
|
const cfg = PLATFORM_LOGOS[key];
|
||||||
|
const [src, setSrc] = React.useState(cfg ? cfg.local : null);
|
||||||
|
const [usedFallback, setUsedFallback] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const c = PLATFORM_LOGOS[key];
|
||||||
|
if (!c) {
|
||||||
|
setSrc(null);
|
||||||
|
setUsedFallback(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSrc(c.local);
|
||||||
|
setUsedFallback(false);
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
const onError = React.useCallback(() => {
|
||||||
|
if (usedFallback || !cfg) return;
|
||||||
|
setSrc(cfg.fallback);
|
||||||
|
setUsedFallback(true);
|
||||||
|
}, [cfg, usedFallback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"row-span-1 relative flex h-full min-h-[12rem] items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cfg ? (
|
||||||
|
<img
|
||||||
|
src={src || cfg.fallback}
|
||||||
|
alt=""
|
||||||
|
onError={onError}
|
||||||
|
className="max-h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--muted)]">{platform || "–"}</span>
|
||||||
|
)}
|
||||||
|
{market != null && String(market).trim() !== "" && (
|
||||||
|
<div className="absolute bottom-2 right-2 font-bold text-[var(--text)]">
|
||||||
|
{String(market).trim().toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RangCard({ rank, className }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"row-span-1 flex h-full min-h-[12rem] flex-col items-center justify-center rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-[var(--muted)]">Rang</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-[var(--text)]">{rank ?? "–"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const AccountsPage = () => {
|
export const AccountsPage = () => {
|
||||||
const { navigate } = useHashRoute();
|
const { navigate } = useHashRoute();
|
||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
const [formSuccess, setFormSuccess] = useState("");
|
const [formSuccess, setFormSuccess] = useState("");
|
||||||
@@ -32,6 +250,9 @@ export const AccountsPage = () => {
|
|||||||
const [refreshingAccountId, setRefreshingAccountId] = useState(null);
|
const [refreshingAccountId, setRefreshingAccountId] = useState(null);
|
||||||
const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" });
|
const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" });
|
||||||
|
|
||||||
|
// Nur ein Account wird angezeigt; Wechsel über Dropdown
|
||||||
|
const [displayedAccountId, setDisplayedAccountId] = useState(null);
|
||||||
|
|
||||||
// Form-Felder (nur noch URL)
|
// Form-Felder (nur noch URL)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
account_url: "",
|
account_url: "",
|
||||||
@@ -42,6 +263,21 @@ export const AccountsPage = () => {
|
|||||||
loadAccounts();
|
loadAccounts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// displayedAccountId setzen sobald Accounts geladen (aktiv oder erster)
|
||||||
|
useEffect(() => {
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
setDisplayedAccountId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = getActiveAccountId();
|
||||||
|
const hasActive = accounts.some((a) => (a.$id || a.id) === active);
|
||||||
|
if (hasActive) {
|
||||||
|
setDisplayedAccountId(active);
|
||||||
|
} else {
|
||||||
|
setDisplayedAccountId(accounts[0].$id || accounts[0].id);
|
||||||
|
}
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
async function loadAccounts() {
|
async function loadAccounts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -61,11 +297,9 @@ export const AccountsPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectAccount = (account) => {
|
const handleDisplayedAccountChange = (accountId) => {
|
||||||
const accountId = account.$id || account.id;
|
setDisplayedAccountId(accountId);
|
||||||
setActiveAccountId(accountId);
|
setActiveAccountId(accountId);
|
||||||
// Navigiere zurück zum Dashboard
|
|
||||||
navigate("/");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshAccount = async (account) => {
|
const handleRefreshAccount = async (account) => {
|
||||||
@@ -236,106 +470,12 @@ export const AccountsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spalten für die Tabelle
|
const displayedAccount =
|
||||||
const columns = [
|
accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null;
|
||||||
"Account Name",
|
|
||||||
"Platform",
|
|
||||||
"Platform Account ID",
|
|
||||||
"Market",
|
|
||||||
"Account URL",
|
|
||||||
"Sales",
|
|
||||||
"Last Scan",
|
|
||||||
...(showAdvanced ? ["Owner User ID"] : []),
|
|
||||||
"Action",
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderCell = (col, row) => {
|
const BentoHeader = () => (
|
||||||
if (col === "Action") {
|
<div className="flex flex-1 w-full h-full min-h-[4rem] rounded-xl border border-neutral-100 dark:border-white/[0.2] bg-neutral-50 dark:bg-neutral-800" />
|
||||||
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 === "Sales") {
|
|
||||||
const sales = row.account_sells;
|
|
||||||
if (sales === null || sales === undefined) return "-";
|
|
||||||
// Format number with thousand separators
|
|
||||||
return new Intl.NumberFormat("de-DE").format(sales);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (col === "Last Scan") {
|
|
||||||
const lastScan = row.account_updated_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 (
|
return (
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
@@ -359,72 +499,6 @@ export const AccountsPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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)]">
|
|
||||||
Sales (Auto)
|
|
||||||
</span>
|
|
||||||
: Anzahl der verkauften Artikel wird automatisch aus dem eBay-Profil gelesen.
|
|
||||||
</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 */}
|
{/* Toast Notification */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{refreshToast.show && (
|
{refreshToast.show && (
|
||||||
@@ -444,24 +518,77 @@ export const AccountsPage = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Tabelle */}
|
{/* Bento Grid – nur ein Account */}
|
||||||
<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="flex flex-1 flex-col gap-8 overflow-y-auto">
|
||||||
<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 ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
|
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
|
||||||
Loading accounts...
|
Loading accounts...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : !displayedAccount ? (
|
||||||
<DataTable columns={columns} data={accounts} renderCell={renderCell} />
|
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
|
||||||
)}
|
No accounts yet. Add one above.
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const account = displayedAccount;
|
||||||
|
const accountId = account.$id || account.id;
|
||||||
|
const isRefreshing = refreshingAccountId === accountId;
|
||||||
|
const name = getAccountDisplayName(account) || "–";
|
||||||
|
const url = account.account_url;
|
||||||
|
const sales =
|
||||||
|
account.account_sells != null
|
||||||
|
? new Intl.NumberFormat("de-DE").format(account.account_sells)
|
||||||
|
: "–";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BentoGrid key={accountId} className="max-w-4xl mx-auto md:auto-rows-[18rem]">
|
||||||
|
<AccountNameCard
|
||||||
|
name={name}
|
||||||
|
url={url}
|
||||||
|
platformAccountId={account.account_platform_account_id}
|
||||||
|
accounts={accounts}
|
||||||
|
displayedAccountId={displayedAccountId}
|
||||||
|
onSelectAccount={handleDisplayedAccountChange}
|
||||||
|
className="md:col-span-1"
|
||||||
|
/>
|
||||||
|
<PlatformLogoCard
|
||||||
|
platform={account.account_platform}
|
||||||
|
market={account.account_platform_market}
|
||||||
|
className="md:col-span-1"
|
||||||
|
/>
|
||||||
|
<RangCard rank={undefined} className="md:col-span-1" />
|
||||||
|
<BentoGridItem
|
||||||
|
title="Sales"
|
||||||
|
description={sales}
|
||||||
|
header={<BentoHeader />}
|
||||||
|
icon={<IconChartBar className="h-4 w-4 text-neutral-500" />}
|
||||||
|
className="md:col-span-2"
|
||||||
|
/>
|
||||||
|
<BentoGridItem
|
||||||
|
title="Actions"
|
||||||
|
description={
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRefreshAccount(account)}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs transition-all active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
"border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
|
||||||
|
)}
|
||||||
|
title="Account aktualisieren"
|
||||||
|
>
|
||||||
|
<IconRefresh className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
header={<BentoHeader />}
|
||||||
|
icon={<IconSettings className="h-4 w-4 text-neutral-500" />}
|
||||||
|
/>
|
||||||
|
</BentoGrid>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Account Form Modal */}
|
{/* Add Account Form Modal */}
|
||||||
|
|||||||
7
Server/src/pages/AnalysisPage.jsx
Normal file
7
Server/src/pages/AnalysisPage.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
|
||||||
|
|
||||||
|
export const AnalysisPage = () => {
|
||||||
|
return <ScrollSnapDummyPage pageTitle="Analysis" />;
|
||||||
|
};
|
||||||
7
Server/src/pages/BlacklistPage.jsx
Normal file
7
Server/src/pages/BlacklistPage.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
|
||||||
|
|
||||||
|
export const BlacklistPage = () => {
|
||||||
|
return <ScrollSnapDummyPage pageTitle="Blacklist" />;
|
||||||
|
};
|
||||||
7
Server/src/pages/ItemsPage.jsx
Normal file
7
Server/src/pages/ItemsPage.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
|
||||||
|
|
||||||
|
export const ItemsPage = () => {
|
||||||
|
return <ScrollSnapDummyPage pageTitle="Items" />;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user