initial commit

This commit is contained in:
2025-08-28 14:24:06 +00:00
commit 2105518e85
53 changed files with 11236 additions and 0 deletions

View 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>
);
}