Update: Neue Seiten und Komponenten hinzugefügt
This commit is contained in:
@@ -3,7 +3,9 @@ import React, { useEffect, useState } from "react";
|
||||
import { Sidebar, SidebarBody, SidebarLink } from "./components/sidebar";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconBan,
|
||||
IconBrandTabler,
|
||||
IconChartLine,
|
||||
IconSettings,
|
||||
IconUserBolt,
|
||||
IconShoppingBag,
|
||||
@@ -13,6 +15,9 @@ import { cn } from "./lib/utils";
|
||||
import { BackgroundRippleEffect } from "./components/layout/BackgroundRippleEffect";
|
||||
import { Dashboard } from "./components/dashboard/Dashboard";
|
||||
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 { OnboardingGate } from "./components/onboarding/OnboardingGate";
|
||||
import { SidebarHeader } from "./components/sidebar/SidebarHeader";
|
||||
@@ -390,11 +395,22 @@ export default function App() {
|
||||
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",
|
||||
href: "#/accounts",
|
||||
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,
|
||||
onClick: (e) => {
|
||||
@@ -403,11 +419,26 @@ export default function App() {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Profile",
|
||||
href: "#",
|
||||
label: "Items",
|
||||
href: "#/items",
|
||||
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",
|
||||
@@ -416,17 +447,6 @@ export default function App() {
|
||||
<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
|
||||
@@ -434,6 +454,15 @@ export default function App() {
|
||||
if (route === "/accounts") {
|
||||
return <AccountsPage />;
|
||||
}
|
||||
if (route === "/items") {
|
||||
return <ItemsPage />;
|
||||
}
|
||||
if (route === "/blacklist") {
|
||||
return <BlacklistPage />;
|
||||
}
|
||||
if (route === "/analysis") {
|
||||
return <AnalysisPage />;
|
||||
}
|
||||
// Default: 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);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const sections = sectionIds.map(id => document.getElementById(id)).filter(Boolean);
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(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";
|
||||
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 { cn } from "../lib/utils";
|
||||
import { useHashRoute } from "../lib/routing";
|
||||
import {
|
||||
setActiveAccountId,
|
||||
getActiveAccountId,
|
||||
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";
|
||||
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 = () => {
|
||||
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("");
|
||||
@@ -32,6 +250,9 @@ export const AccountsPage = () => {
|
||||
const [refreshingAccountId, setRefreshingAccountId] = useState(null);
|
||||
const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" });
|
||||
|
||||
// Nur ein Account wird angezeigt; Wechsel über Dropdown
|
||||
const [displayedAccountId, setDisplayedAccountId] = useState(null);
|
||||
|
||||
// Form-Felder (nur noch URL)
|
||||
const [formData, setFormData] = useState({
|
||||
account_url: "",
|
||||
@@ -42,6 +263,21 @@ export const AccountsPage = () => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
// displayedAccountId setzen sobald Accounts geladen (aktiv oder erster)
|
||||
useEffect(() => {
|
||||
if (accounts.length === 0) {
|
||||
setDisplayedAccountId(null);
|
||||
return;
|
||||
}
|
||||
const active = getActiveAccountId();
|
||||
const hasActive = accounts.some((a) => (a.$id || a.id) === active);
|
||||
if (hasActive) {
|
||||
setDisplayedAccountId(active);
|
||||
} else {
|
||||
setDisplayedAccountId(accounts[0].$id || accounts[0].id);
|
||||
}
|
||||
}, [accounts]);
|
||||
|
||||
async function loadAccounts() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -61,11 +297,9 @@ export const AccountsPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAccount = (account) => {
|
||||
const accountId = account.$id || account.id;
|
||||
const handleDisplayedAccountChange = (accountId) => {
|
||||
setDisplayedAccountId(accountId);
|
||||
setActiveAccountId(accountId);
|
||||
// Navigiere zurück zum Dashboard
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleRefreshAccount = async (account) => {
|
||||
@@ -236,106 +470,12 @@ export const AccountsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Spalten für die Tabelle
|
||||
const columns = [
|
||||
"Account Name",
|
||||
"Platform",
|
||||
"Platform Account ID",
|
||||
"Market",
|
||||
"Account URL",
|
||||
"Sales",
|
||||
"Last Scan",
|
||||
...(showAdvanced ? ["Owner User ID"] : []),
|
||||
"Action",
|
||||
];
|
||||
const displayedAccount =
|
||||
accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null;
|
||||
|
||||
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 === "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 "-";
|
||||
};
|
||||
const BentoHeader = () => (
|
||||
<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" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
@@ -359,72 +499,6 @@ export const AccountsPage = () => {
|
||||
</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)]">
|
||||
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 */}
|
||||
<AnimatePresence>
|
||||
{refreshToast.show && (
|
||||
@@ -444,24 +518,77 @@ export const AccountsPage = () => {
|
||||
)}
|
||||
</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>
|
||||
{/* Bento Grid – nur ein Account */}
|
||||
<div className="flex flex-1 flex-col gap-8 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
|
||||
Loading accounts...
|
||||
</div>
|
||||
) : !displayedAccount ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
|
||||
No accounts yet. Add one above.
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const account = displayedAccount;
|
||||
const accountId = account.$id || account.id;
|
||||
const isRefreshing = refreshingAccountId === accountId;
|
||||
const name = getAccountDisplayName(account) || "–";
|
||||
const url = account.account_url;
|
||||
const sales =
|
||||
account.account_sells != null
|
||||
? new Intl.NumberFormat("de-DE").format(account.account_sells)
|
||||
: "–";
|
||||
|
||||
return (
|
||||
<BentoGrid key={accountId} className="max-w-4xl mx-auto md:auto-rows-[18rem]">
|
||||
<AccountNameCard
|
||||
name={name}
|
||||
url={url}
|
||||
platformAccountId={account.account_platform_account_id}
|
||||
accounts={accounts}
|
||||
displayedAccountId={displayedAccountId}
|
||||
onSelectAccount={handleDisplayedAccountChange}
|
||||
className="md:col-span-1"
|
||||
/>
|
||||
<PlatformLogoCard
|
||||
platform={account.account_platform}
|
||||
market={account.account_platform_market}
|
||||
className="md:col-span-1"
|
||||
/>
|
||||
<RangCard rank={undefined} className="md:col-span-1" />
|
||||
<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>
|
||||
|
||||
{/* 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