Files
bacchus/apps/frontend/src/components/Order.jsx
2025-09-28 19:13:01 +02:00

423 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}