initial commit
This commit is contained in:
400
apps/frontend/src/components/Order.jsx
Normal file
400
apps/frontend/src/components/Order.jsx
Normal file
@@ -0,0 +1,400 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user