From bd737e48b8079f85b5c496e66c745273dc4e3645 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sat, 6 Dec 2025 10:06:45 +0100 Subject: [PATCH] feat: add backend coffeeabo --- .../hooks/getActiveCoffees.ts | 88 +++++ src/app/coffee-abonnements/page.tsx | 314 +++++++++--------- src/app/coffee-abonnements/summary/page.tsx | 56 ++-- 3 files changed, 276 insertions(+), 182 deletions(-) create mode 100644 src/app/coffee-abonnements/hooks/getActiveCoffees.ts diff --git a/src/app/coffee-abonnements/hooks/getActiveCoffees.ts b/src/app/coffee-abonnements/hooks/getActiveCoffees.ts new file mode 100644 index 0000000..5452ddd --- /dev/null +++ b/src/app/coffee-abonnements/hooks/getActiveCoffees.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { authFetch } from '../../utils/authFetch'; + +export type ActiveCoffee = { + id: string | number; + title: string; + description: string; + price: string | number; // price can be a string or number + pictureUrl?: string; + state: number; // 1 for active, 0 for inactive +}; + +export type CoffeeItem = { + id: string; + name: string; + description: string; + pricePer10: number; // price for 10 pieces + image: string; +}; + +export function useActiveCoffees() { + const [coffees, setCoffees] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, ''); + const url = `${base}/api/admin/coffee/active`; + + console.log('[useActiveCoffees] Fetching active coffees from:', url); + + setLoading(true); + setError(null); + + authFetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + .then(async (response) => { + const contentType = response.headers.get('content-type') || ''; + console.log('[useActiveCoffees] Response status:', response.status); + console.log('[useActiveCoffees] Response content-type:', contentType); + + if (!response.ok || !contentType.includes('application/json')) { + const text = await response.text().catch(() => ''); + console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200)); + throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`); + } + + const json = await response.json(); + console.log('[useActiveCoffees] Raw JSON response:', json); + + const data: ActiveCoffee[] = + Array.isArray(json?.data) ? json.data : + Array.isArray(json) ? json : + [] + console.log('[useActiveCoffees] Parsed coffee data:', data); + + const mapped: CoffeeItem[] = data + .filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1') + .map((coffee) => { + const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price + return { + id: String(coffee.id), + name: coffee.title || `Coffee ${coffee.id}`, + description: coffee.description || '', + pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0, + image: coffee.pictureUrl || '', + } + }) + + console.log('[useActiveCoffees] Mapped coffee items:', mapped) + setCoffees(mapped) + }) + .catch((error: any) => { + console.error('[useActiveCoffees] Error fetching coffees:', error); + setError(error?.message || 'Failed to load active coffees'); + setCoffees([]); + }) + .finally(() => { + setLoading(false); + console.log('[useActiveCoffees] Fetch complete. Loading state:', false); + }); + }, []); + + return { coffees, loading, error }; +} diff --git a/src/app/coffee-abonnements/page.tsx b/src/app/coffee-abonnements/page.tsx index c45f87f..8df540f 100644 --- a/src/app/coffee-abonnements/page.tsx +++ b/src/app/coffee-abonnements/page.tsx @@ -2,45 +2,24 @@ import React, { useState, useMemo } from 'react'; import PageLayout from '../components/PageLayout'; import { useRouter } from 'next/navigation'; - -interface Coffee { - id: string; - name: string; - description: string; - pricePer10: number; // price for 10 units - image: string; // added -} - -const coffees: Coffee[] = [ - { id: 'espresso', name: 'Espresso Roast', description: 'Kräftig und intensiv – dunkle Schokolade, geröstete Gewürze und ein langer, satt-cremiger Nachhall. Ideal für morgens oder den energiereichen Nachmittagsschub.', pricePer10: 12, image: 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&w=400&q=60' }, - { id: 'crema', name: 'Caffè Crema', description: 'Ausgewogen & mild – samtiger Körper mit hellen Honig- und Mandelnoten. Ein sanfter, alltagstauglicher Kaffee für jede Uhrzeit und jeden Geschmack.', pricePer10: 11, image: 'https://images.unsplash.com/photo-1524079429932-1e9f39f1f268?auto=format&fit=crop&w=400&q=60' }, - { id: 'colombia', name: 'Colombia Single Origin', description: 'Fruchtige Noten – rote Beeren, leichte Zitrus-Säure und ein sauberer, heller Abgang. Gewaschen auf Höhenlagen, perfekt für Filter und French Press.', pricePer10: 13, image: 'https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=400&q=60' }, - { id: 'ethiopia', name: 'Ethiopia Sidamo', description: 'Florale Eleganz – Jasmin, Bergamotte und eine feine Tee-ähnliche Struktur. Komplex und duftend, ideal für Genießer, die Nuancen entdecken möchten.', pricePer10: 14, image: 'https://images.unsplash.com/photo-1510626176961-4b57d4b273c4?auto=format&fit=crop&w=400&q=60' }, - { id: 'sumatra', name: 'Sumatra Mandheling', description: 'Würzig & erdig – tiefer Körper, Kakao, Kräuter und dezente Tabaknoten. Vollmundig und schwer, hervorragend für Espresso-Mischungen.', pricePer10: 13, image: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=400&q=60' }, - { id: 'kenya', name: 'Kenya AA', description: 'Lebendige Säure – schwarze Johannisbeere, saftige Traube und spritzige Frische. Klar konturiert und kraftvoll, toll als Pour Over.', pricePer10: 15, image: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=400&q=60' }, - { id: 'brazil', name: 'Brazil Santos', description: 'Nussig & schokoladig – weiche Textur, geröstete Haselnuss und Milchschokolade. Sehr balanciert und vielseitig für Espresso & Milchgetränke.', pricePer10: 11, image: 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&w=400&q=60' }, - { id: 'italian', name: 'Italian Roast', description: 'Dunkel & ölig – karamellisierte Bitterstoffe, Rauch und intensive Tiefe. Klassisches Profil für kräftigen Cappuccino oder Latte.', pricePer10: 12, image: 'https://images.unsplash.com/photo-1512568400610-62da28bc8a13?auto=format&fit=crop&w=400&q=60' }, - { id: 'house', name: 'House Blend', description: 'Harmonisch & rund – milde Süße, ausgewogene Säure und sanfter Körper. Entwickelt als universeller Alltagskaffee für jeden Zubereitungsstil.', pricePer10: 10, image: 'https://images.unsplash.com/photo-1461988091159-110d53f20702?auto=format&fit=crop&w=400&q=60' }, - { id: 'decaf', name: 'Decaf Blend', description: 'Voller Geschmack – ohne Koffein. Kakao, leichte Karamell-Noten und sanfter Abgang. Entkoffeiniert im schonenden CO₂-Verfahren.', pricePer10: 12, image: 'https://images.unsplash.com/photo-1497935586351-b67a49e012bf?auto=format&fit=crop&w=400&q=60' }, - { id: 'guatemala', name: 'Guatemala Huehuetenango', description: 'Karamell & Kakao – balanciert mit einer feinen Pflaumenfrucht. Angenehme Süße und klare Struktur – großartig als Filter und Chemex.', pricePer10: 14, image: 'https://images.unsplash.com/photo-1509978778153-491c9a09a1ab?auto=format&fit=crop&w=400&q=60' }, - { id: 'limited', name: 'Limited Reserve', description: 'Exklusiv & komplex – limitierte Mikrolots mit seltenen floralen und tropischen Noten. Für besondere Momente und anspruchsvolle Tassenprofile.', pricePer10: 18, image: 'https://images.unsplash.com/photo-1541167767034-41d7a96c58f1?auto=format&fit=crop&w=400&q=60' } -]; +import { useActiveCoffees } from './hooks/getActiveCoffees'; export default function CoffeeAbonnementPage() { const [selections, setSelections] = useState>({}); const [bump, setBump] = useState>({}); - // removed: form state and handlers (moved to summary page) - // removed: canSubmit depending on form + const router = useRouter(); - const router = useRouter(); // added + // Fetch active coffees from the backend + const { coffees, loading, error } = useActiveCoffees(); const selectedEntries = useMemo( () => - Object.entries(selections).map(([id, qty]) => ({ - coffee: coffees.find(c => c.id === id)!, - quantity: qty - })), - [selections] + Object.entries(selections).map(([id, qty]) => { + const coffee = coffees.find((c) => c.id === id); + if (!coffee) return null; + return { coffee, quantity: qty }; + }).filter(Boolean) as { coffee: ReturnType['coffees'][number]; quantity: number }[], + [selections, coffees] ); const totalPrice = useMemo( @@ -51,11 +30,11 @@ export default function CoffeeAbonnementPage() { ), [selectedEntries] ); - const TAX_RATE = 0.07; // 7% Steuer + const TAX_RATE = 0.07; const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]); const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]); - const canProceed = selectedEntries.length > 0; // added + const canProceed = selectedEntries.length > 0; const proceedToSummary = () => { if (!canProceed) return; @@ -66,25 +45,25 @@ export default function CoffeeAbonnementPage() { }; const toggleCoffee = (id: string) => { - setSelections(prev => { + setSelections((prev) => { const copy = { ...prev }; if (id in copy) { delete copy[id]; } else { - copy[id] = 10; // default start quantity + copy[id] = 10; } return copy; }); }; const changeQuantity = (id: string, delta: number) => { - setSelections(prev => { + setSelections((prev) => { if (!(id in prev)) return prev; const next = prev[id] + delta; if (next < 10 || next > 120) return prev; const updated = { ...prev, [id]: next }; - setBump(b => ({ ...b, [id]: true })); // trigger bump animation - setTimeout(() => setBump(b => ({ ...b, [id]: false })), 250); + setBump((b) => ({ ...b, [id]: true })); + setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250); return updated; }); }; @@ -93,9 +72,7 @@ export default function CoffeeAbonnementPage() {

- - Kaffee Abonnement konfigurieren - + Kaffee Abonnement konfigurieren

{/* Stepper */} @@ -114,119 +91,134 @@ export default function CoffeeAbonnementPage() { {/* Section 1: Multi coffee selection + per-coffee quantity */}

1. Kaffeesorten & Mengen wählen

-
- {coffees.map(coffee => { - const active = coffee.id in selections; - const qty = selections[coffee.id] || 0; - return ( -
-
- {coffee.name} - {/* price badge */} -
- - €{coffee.pricePer10} - - {/* CHANGED: solid, readable over images */} - - pro 10 Stk - -
-
-
-

{coffee.name}

-
-

- {coffee.description} -

- - {active && ( -
-
- Menge (10–120) +
+ {coffee.image ? ( + {coffee.name} + ) : ( +
+ )} + {/* price badge (per 10) */} +
- {qty} Stk + €{coffee.pricePer10} -
-
- -
- - changeQuantity( - coffee.id, - parseInt(e.target.value, 10) - qty - ) - } - className="w-full appearance-none cursor-pointer bg-transparent" - style={{ - background: - 'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' + - ((qty - 10) / (120 - 10)) * 100 + - '%,#e5e7eb ' + - ((qty - 10) / (120 - 10)) * 100 + - '%,#e5e7eb 100%)', - height: '6px', - borderRadius: '999px' - }} - /> -
- -
-
- Subtotal - - €{((qty / 10) * coffee.pricePer10).toFixed(2)} + + pro 10 Stk
- )} -
- ); - })} -
+
+

{coffee.name}

+
+

+ {coffee.description} +

+ + {active && ( +
+
+ Menge (10–120) + + {qty} Stk + +
+
+ +
+ + changeQuantity(coffee.id, parseInt(e.target.value, 10) - qty) + } + className="w-full appearance-none cursor-pointer bg-transparent" + style={{ + background: + 'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' + + ((qty - 10) / (120 - 10)) * 100 + + '%,#e5e7eb ' + + ((qty - 10) / (120 - 10)) * 100 + + '%,#e5e7eb 100%)', + height: '6px', + borderRadius: '999px', + }} + /> +
+ +
+
+ Subtotal + + €{((qty / 10) * coffee.pricePer10).toFixed(2)} + +
+
+ )} +
+ ); + })} +
+ )}
{/* Section 2: Compact preview + next steps */} @@ -236,12 +228,15 @@ export default function CoffeeAbonnementPage() { {selectedEntries.length === 0 && (

Noch keine Kaffees ausgewählt.

)} - {selectedEntries.map(entry => ( + {selectedEntries.map((entry) => (
{entry.coffee.name} - {entry.quantity} Stk • €{entry.coffee.pricePer10}/10 + {entry.quantity} Stk •{' '} + + €{entry.coffee.pricePer10}/10 +
@@ -251,7 +246,9 @@ export default function CoffeeAbonnementPage() { ))}
Gesamt (Netto) - €{totalPrice.toFixed(2)} + + €{totalPrice.toFixed(2)} +
- {!canProceed &&

Bitte mindestens eine Kaffeesorte wählen.

} + {!canProceed && ( +

Bitte mindestens eine Kaffeesorte wählen.

+ )}
diff --git a/src/app/coffee-abonnements/summary/page.tsx b/src/app/coffee-abonnements/summary/page.tsx index 18886b8..0443ccc 100644 --- a/src/app/coffee-abonnements/summary/page.tsx +++ b/src/app/coffee-abonnements/summary/page.tsx @@ -2,33 +2,11 @@ import React, { useEffect, useMemo, useState } from 'react'; import PageLayout from '../../components/PageLayout'; import { useRouter } from 'next/navigation'; - -interface Coffee { - id: string; - name: string; - description: string; - pricePer10: number; - image: string; -} - -// duplicated from selection page to resolve details by id -const coffees: Coffee[] = [ - { id: 'espresso', name: 'Espresso Roast', description: '', pricePer10: 12, image: '' }, - { id: 'crema', name: 'Caffè Crema', description: '', pricePer10: 11, image: '' }, - { id: 'colombia', name: 'Colombia Single Origin', description: '', pricePer10: 13, image: '' }, - { id: 'ethiopia', name: 'Ethiopia Sidamo', description: '', pricePer10: 14, image: '' }, - { id: 'sumatra', name: 'Sumatra Mandheling', description: '', pricePer10: 13, image: '' }, - { id: 'kenya', name: 'Kenya AA', description: '', pricePer10: 15, image: '' }, - { id: 'brazil', name: 'Brazil Santos', description: '', pricePer10: 11, image: '' }, - { id: 'italian', name: 'Italian Roast', description: '', pricePer10: 12, image: '' }, - { id: 'house', name: 'House Blend', description: '', pricePer10: 10, image: '' }, - { id: 'decaf', name: 'Decaf Blend', description: '', pricePer10: 12, image: '' }, - { id: 'guatemala', name: 'Guatemala Huehuetenango', description: '', pricePer10: 14, image: '' }, - { id: 'limited', name: 'Limited Reserve', description: '', pricePer10: 18, image: '' } -]; +import { useActiveCoffees } from '../hooks/getActiveCoffees'; export default function SummaryPage() { const router = useRouter(); + const { coffees, loading, error } = useActiveCoffees(); const [selections, setSelections] = useState>({}); const [form, setForm] = useState({ firstName: '', @@ -63,11 +41,13 @@ export default function SummaryPage() { const selectedEntries = useMemo( () => - Object.entries(selections).map(([id, qty]) => { - const coffee = coffees.find(c => c.id === id); - return coffee ? { coffee, quantity: qty } : null; - }).filter(Boolean) as { coffee: Coffee; quantity: number }[], - [selections] + Object.entries(selections) + .map(([id, qty]) => { + const coffee = coffees.find(c => c.id === id); + return coffee ? { coffee, quantity: qty } : null; + }) + .filter(Boolean) as { coffee: ReturnType['coffees'][number]; quantity: number }[], + [selections, coffees] ); const TAX_RATE = 0.07; @@ -124,7 +104,23 @@ export default function SummaryPage() {
- {selectedEntries.length === 0 ? ( + {error && ( +
+

{error}

+ +
+ )} + + {loading ? ( +
+
+
+ ) : selectedEntries.length === 0 ? (

Keine Auswahl gefunden.