423 lines
16 KiB
JavaScript
423 lines
16 KiB
JavaScript
import React, {
|
||
useEffect, useMemo, useState, useCallback, startTransition, useDeferredValue
|
||
} from "react";
|
||
import {
|
||
getCurrentUser, getProducts, createBooking, getFavorites,
|
||
updateFavorites, logout, API_BASE, getCategories
|
||
} from "../api";
|
||
import ProductCard from "./ProductCard";
|
||
import CartItem from "./CartItem";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
|
||
const DEFAULT_BG1 = "#0a6bff";
|
||
const DEFAULT_BG2 = "#00e0aa";
|
||
|
||
function applyOrderColorsFromStorage() {
|
||
const c1 = localStorage.getItem("order_bg1") || DEFAULT_BG1;
|
||
const c2 = localStorage.getItem("order_bg2") || DEFAULT_BG2;
|
||
document.documentElement.style.setProperty("--order-bg1", c1);
|
||
document.documentElement.style.setProperty("--order-bg2", c2);
|
||
}
|
||
|
||
// --- NEU: lokale Bestände anhand des Warenkorbs reduzieren
|
||
function decrementStockLocal(prevProducts, cart) {
|
||
const delta = {};
|
||
for (const { product, quantity } of Object.values(cart)) {
|
||
delta[product.id] = (delta[product.id] || 0) + quantity;
|
||
}
|
||
return prevProducts.map((p) =>
|
||
delta[p.id] ? { ...p, stock: (Number(p.stock) || 0) - delta[p.id] } : p
|
||
);
|
||
}
|
||
|
||
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
// deterministische Farbe je Kategorie
|
||
function hueFromString(s) {
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||
return (h >>> 0) % 360;
|
||
}
|
||
|
||
// Pastell (inaktiv) vs. kräftig + Glow (aktiv)
|
||
function chipStyleFor(cat, active) {
|
||
const strong = (h) => ({
|
||
bg: `hsl(${h} 85% 72%)`,
|
||
br: `hsla(${h} 65% 35% / .60)`,
|
||
glow: `0 0 0 5px hsla(${h} 95% 55% / .40), 0 0 22px hsla(${h} 95% 55% / .50)`,
|
||
fg: "#0b1220",
|
||
});
|
||
const soft = (h) => ({
|
||
bg: `hsl(${h} 65% 90%)`,
|
||
br: `hsla(${h} 40% 40% / .35)`,
|
||
glow: "none",
|
||
fg: "#0b1220",
|
||
});
|
||
if (cat === "Alle") return active
|
||
? { bg:"hsl(210 75% 74%)", br:"hsla(210 65% 35%/.6)", glow:"0 0 0 5px hsla(210 95% 55%/.4),0 0 22px hsla(210 95% 55%/.5)", fg:"#0b1220" }
|
||
: { bg:"hsl(210 55% 90%)", br:"hsla(210 40% 40%/.35)", glow:"none", fg:"#0b1220" };
|
||
if (cat === "Favoriten") return active
|
||
? { bg:"hsl(52 90% 72%)", br:"hsla(52 70% 35%/.6)", glow:"0 0 0 5px hsla(52 98% 50%/.4),0 0 22px hsla(52 98% 50%/.5)", fg:"#0b1220" }
|
||
: { bg:"hsl(52 80% 90%)", br:"hsla(52 55% 40%/.35)", glow:"none", fg:"#0b1220" };
|
||
const h = hueFromString(cat);
|
||
return active ? strong(h) : soft(h);
|
||
}
|
||
|
||
function ringColorForCategory(cat) {
|
||
if (!cat) return "#22c55e";
|
||
// nutzt deine bestehende Farblogik der Chips:
|
||
return chipStyleFor(cat, true).br; // starke Border-Farbe der Kategorie
|
||
}
|
||
// robustes Aktiv-Flag (API kann bool/zahl/string liefern)
|
||
function isActiveProduct(p) {
|
||
const v = p?.is_active;
|
||
return v === true || v === 1 || v === "true" || v === "1";
|
||
}
|
||
|
||
const MemoProductCard = React.memo(ProductCard);
|
||
|
||
export default function Order({ user: userProp }) {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const stateUser = location.state?.user || null;
|
||
|
||
const [user, setUser] = useState(userProp || stateUser || null);
|
||
const [products, setProducts] = useState([]);
|
||
const [categories, setCategories] = useState([]);
|
||
const [selectedCategory, setSelectedCategory] = useState("Alle");
|
||
const deferredCategory = useDeferredValue(selectedCategory); // INP
|
||
const [page, setPage] = useState(1);
|
||
|
||
const [cart, setCart] = useState({});
|
||
const [favorites, setFavorites] = useState(new Set());
|
||
const [error, setError] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const collator = useMemo(() => new Intl.Collator("de", { sensitivity: "base" }), []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
// Farben sofort anwenden + Listener registrieren
|
||
applyOrderColorsFromStorage();
|
||
const onChange = (e) => {
|
||
const { bg1, bg2 } = e.detail || {};
|
||
if (bg1) document.documentElement.style.setProperty("--order-bg1", bg1);
|
||
if (bg2) document.documentElement.style.setProperty("--order-bg2", bg2);
|
||
};
|
||
window.addEventListener("order-bg-change", onChange);
|
||
|
||
// Daten laden
|
||
(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [u, prod, cats] = await Promise.all([
|
||
userProp || stateUser ? (userProp || stateUser) : getCurrentUser(),
|
||
getProducts(),
|
||
typeof getCategories === "function" ? getCategories() : Promise.resolve(null),
|
||
]);
|
||
if (cancelled) return;
|
||
|
||
setUser(u || null);
|
||
setProducts(Array.isArray(prod) ? prod : []);
|
||
setCategories(
|
||
cats && Array.isArray(cats)
|
||
? cats
|
||
: Array.from(new Set((prod || []).map((p) => p.category).filter(Boolean)))
|
||
);
|
||
|
||
if (u?.id) {
|
||
try {
|
||
const f = await getFavorites(u.id);
|
||
if (!cancelled && Array.isArray(f)) setFavorites(new Set(f));
|
||
} catch {}
|
||
}
|
||
} catch (e) {
|
||
if (!cancelled) setError(e?.message || "Initiale Daten konnten nicht geladen werden.");
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
|
||
// Cleanup des Effects
|
||
return () => {
|
||
cancelled = true;
|
||
window.removeEventListener("order-bg-change", onChange);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [userProp, stateUser]);
|
||
|
||
|
||
const activeProducts = useMemo(
|
||
() => (products || []).filter(isActiveProduct),
|
||
[products]
|
||
);
|
||
|
||
const sortedProducts = useMemo(() => {
|
||
const arr = (activeProducts || []).slice();
|
||
arr.sort((a, b) => collator.compare(a?.name || "", b?.name || ""));
|
||
return arr;
|
||
}, [activeProducts, collator]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (deferredCategory === "Favoriten") {
|
||
return sortedProducts.filter((p) => favorites.has(p.id));
|
||
}
|
||
if (deferredCategory === "Alle") return sortedProducts;
|
||
return sortedProducts.filter((p) => p.category === deferredCategory);
|
||
}, [sortedProducts, deferredCategory, favorites]);
|
||
|
||
const onSelectCategory = useCallback((cat) => {
|
||
startTransition(() => {
|
||
setSelectedCategory(cat);
|
||
setPage(1);
|
||
});
|
||
}, []);
|
||
|
||
const DEFAULT_AVATAR = "/avatar-default.png";
|
||
|
||
const avatarSrc = useMemo(() => {
|
||
const raw = user?.avatar_url?.trim();
|
||
if (!raw) return DEFAULT_AVATAR;
|
||
if (/^(https?:|data:|blob:|\/\/)/i.test(raw)) return raw;
|
||
if (raw === DEFAULT_AVATAR) return raw;
|
||
return `${API_BASE}${raw.startsWith("/") ? raw : `/${raw}`}`;
|
||
}, [user?.avatar_url, API_BASE]);
|
||
|
||
const total = filtered.length;
|
||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
const currentPage = Math.min(page, pageCount);
|
||
const startIdx = (currentPage - 1) * PAGE_SIZE;
|
||
const endIdx = Math.min(startIdx + PAGE_SIZE, total);
|
||
const pageItems = useMemo(
|
||
() => filtered.slice(startIdx, endIdx),
|
||
[filtered, startIdx, endIdx]
|
||
);
|
||
|
||
const handleSelect = useCallback((product) => {
|
||
startTransition(() => {
|
||
setCart((prev) => {
|
||
const qty = prev[product.id]?.quantity || 0;
|
||
return { ...prev, [product.id]: { product, quantity: qty + 1 } };
|
||
});
|
||
});
|
||
}, []);
|
||
const handleIncrement = useCallback((id) =>
|
||
startTransition(() => {
|
||
setCart((prev) => ({ ...prev, [id]: { ...prev[id], quantity: prev[id].quantity + 1 } }));
|
||
}), []);
|
||
const handleDecrement = useCallback((id) =>
|
||
startTransition(() => {
|
||
setCart((prev) => {
|
||
const item = prev[id];
|
||
if (!item) return prev;
|
||
if (item.quantity <= 1) {
|
||
const { [id]: _, ...rest } = prev;
|
||
return rest;
|
||
}
|
||
return { ...prev, [id]: { ...item, quantity: item.quantity - 1 } };
|
||
});
|
||
}), []);
|
||
|
||
const handleToggleFavorite = useCallback(async (id) => {
|
||
const before = new Set(favorites);
|
||
const next = new Set(favorites);
|
||
next.has(id) ? next.delete(id) : next.add(id);
|
||
setFavorites(next);
|
||
try {
|
||
if (!user?.id) throw new Error("Kein Nutzer geladen.");
|
||
await updateFavorites(user.id, [...next]);
|
||
} catch (e) {
|
||
setFavorites(before);
|
||
setError(e?.message || "Favoriten konnten nicht gespeichert werden.");
|
||
}
|
||
}, [favorites, user?.id]);
|
||
|
||
const cartItems = useMemo(() => Object.values(cart), [cart]);
|
||
const cartTotalCents = useMemo(
|
||
() => cartItems.reduce((s, { product, quantity }) => s + product.price_cents * quantity, 0),
|
||
[cartItems]
|
||
);
|
||
|
||
const handlePay = useCallback(async () => {
|
||
const balance = user?.balance_cents ?? 0;
|
||
if (balance < cartTotalCents) { setError("Zu wenig Geld."); return; }
|
||
try {
|
||
for (const { product, quantity } of cartItems) {
|
||
await createBooking(
|
||
user.id,
|
||
product.id,
|
||
quantity,
|
||
product.price_cents * quantity
|
||
);
|
||
}
|
||
setProducts((prev) => decrementStockLocal(prev, cart));
|
||
|
||
try {
|
||
const refreshed = await getCurrentUser();
|
||
if (refreshed) setUser(refreshed);
|
||
} catch {}
|
||
|
||
setCart({});
|
||
|
||
try { await logout(); } finally { navigate("/", { replace: true }); }
|
||
} catch (e) {
|
||
setError(e?.message || "Bezahlen fehlgeschlagen.");
|
||
}
|
||
}, [cartItems, cartTotalCents, navigate, user?.balance_cents, user?.id]);
|
||
|
||
const handleLogout = useCallback(async () => {
|
||
try { await logout(); } finally { navigate("/", { replace: true }); }
|
||
}, [navigate]);
|
||
|
||
const effectiveBalanceCents = (user?.balance_cents ?? 0) - cartTotalCents;
|
||
const balance = (effectiveBalanceCents / 100).toFixed(2).replace(".", ",");
|
||
const balanceColor = effectiveBalanceCents <= 0 ? "text-red-400" : "text-green-400";
|
||
|
||
// Kategorien-UI aus aktiven Produkten ableiten (ohne useMemo)
|
||
const catsBase = (categories && categories.length)
|
||
? categories
|
||
: Array.from(new Set((activeProducts || []).map(p => p.category).filter(Boolean)));
|
||
const catsForUI = ["Alle", ...catsBase, "Favoriten"];
|
||
|
||
if (loading) {
|
||
return <div className="flex items-center justify-center h-screen bg-gray-900 text-white">Lädt …</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-screen">
|
||
{/* Produktbereich */}
|
||
<div
|
||
className="w-3/4 flex flex-col p-4"
|
||
style={{
|
||
background: `
|
||
radial-gradient(1600px 800px at 70% 0%, var(--order-bg1, ${DEFAULT_BG1}) 0%, transparent 60%),
|
||
radial-gradient(1600px 800px at 10% 100%, var(--order-bg2, ${DEFAULT_BG2}) 0%, transparent 60%),
|
||
linear-gradient(180deg,var(--order-bg2, ${DEFAULT_BG1}) 0%,var(--order-bg1, ${DEFAULT_BG2}) 100%)`
|
||
}}
|
||
>
|
||
{/* Kategorie-Chips */}
|
||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||
{catsForUI.map((cat) => {
|
||
const active = selectedCategory === cat;
|
||
const c = chipStyleFor(cat, active);
|
||
return (
|
||
<button
|
||
key={cat}
|
||
onClick={() => onSelectCategory(cat)}
|
||
className="px-4 py-2 rounded-xl font-semibold transition shadow-sm hover:shadow outline-none border focus:ring-2"
|
||
style={{
|
||
backgroundColor: c.bg,
|
||
color: c.fg,
|
||
borderColor: c.br,
|
||
boxShadow: active ? c.glow : "none",
|
||
transform: active ? "translateZ(0) scale(1.02)" : "none",
|
||
transition: "background-color .14s, border-color .14s, box-shadow .16s, transform .12s",
|
||
}}
|
||
>
|
||
{cat}
|
||
</button>
|
||
);
|
||
})}
|
||
<div className="ml-auto text-sm text-gray-400">
|
||
{total === 0 ? "0" : `${startIdx + 1}`}–{endIdx} von {total}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Grid */}
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4 overflow-auto flex-1 auto-rows-min">
|
||
{pageItems.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
style={{ contentVisibility: "auto", containIntrinsicSize: "300px 360px" }}
|
||
>
|
||
<MemoProductCard
|
||
product={p}
|
||
onSelect={handleSelect}
|
||
isFavorite={favorites.has(p.id)}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
count={cart[p.id]?.quantity || 0}
|
||
ringColor={ringColorForCategory(p.category)}
|
||
/>
|
||
</div>
|
||
))}
|
||
{pageItems.length === 0 && (
|
||
<div className="text-gray-400 italic">Keine Produkte gefunden.</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div className="mt-3 flex items-center justify-center gap-1 select-none">
|
||
<button
|
||
onClick={() => startTransition(() => setPage((p) => Math.max(1, p - 1)))}
|
||
disabled={currentPage <= 1}
|
||
className="px-3 py-1 rounded-md bg-gray-700 text-gray-200 disabled:opacity-40">‹</button>
|
||
|
||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
|
||
<button
|
||
key={n}
|
||
onClick={() => startTransition(() => setPage(n))}
|
||
className={`px-3 py-1 rounded-md ${n === currentPage ? "bg-green-500 text-white" : "bg-gray-700 text-gray-200 hover:bg-gray-600"}`}>
|
||
{n}
|
||
</button>
|
||
))}
|
||
|
||
<button
|
||
onClick={() => startTransition(() => setPage((p) => Math.min(pageCount, p + 1)))}
|
||
disabled={currentPage >= pageCount}
|
||
className="px-3 py-1 rounded-md bg-gray-700 text-gray-200 disabled:opacity-40">›</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Warenkorb */}
|
||
<div className="w-1/4 relative bg-gray-950 p-4 flex flex-col justify-between">
|
||
<div className="relative z-10 flex flex-col flex-1">
|
||
<div className="flex items-center mb-4">
|
||
<img
|
||
src={avatarSrc}
|
||
alt="Profil"
|
||
className="w-12 h-12 rounded-full mr-3 object-cover ring-1 ring-white/10"
|
||
loading="lazy" decoding="async"
|
||
onError={(e) => { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }}
|
||
/>
|
||
<span className="font-semibold text-white text-lg">
|
||
Willkommen {user?.name || user?.alias}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<div className="text-white text-sm font-bold">Kontostand:</div>
|
||
<div className={`text-2xl font-bold ${balanceColor}`}>€{balance}</div>
|
||
</div>
|
||
|
||
<div className="bg-gray-100 rounded-xl p-4 overflow-auto flex-1 mb-4">
|
||
{Object.values(cart).length === 0 && <div className="text-gray-500 italic">Warenkorb ist leer</div>}
|
||
{Object.values(cart).map((item) => (
|
||
<CartItem key={item.product.id} item={item} onIncrement={handleIncrement} onDecrement={handleDecrement} />
|
||
))}
|
||
</div>
|
||
|
||
{error && <div className="text-red-500 mb-2 truncate">{error}</div>}
|
||
<div className="text-right text-white font-semibold mb-2">
|
||
Einkaufssumme: €{(cartTotalCents / 100).toFixed(2).replace(".", ",")}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative z-10 flex gap-3 mb-3">
|
||
<button onClick={handleLogout}
|
||
className="flex-1 bg-red-700 hover:bg-red-600 text-white py-2 rounded-xl font-semibold">
|
||
Abbrechen
|
||
</button>
|
||
<button onClick={handlePay}
|
||
disabled={cartItems.length === 0}
|
||
className="flex-1 bg-green-700 hover:bg-green-600 text-white py-2 rounded-xl font-semibold disabled:opacity-50">
|
||
Bezahlen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|