feat: add backend coffeeabo
This commit is contained in:
parent
6a96b27d2e
commit
bd737e48b8
88
src/app/coffee-abonnements/hooks/getActiveCoffees.ts
Normal file
88
src/app/coffee-abonnements/hooks/getActiveCoffees.ts
Normal file
@ -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<CoffeeItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 };
|
||||||
|
}
|
||||||
@ -2,45 +2,24 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import PageLayout from '../components/PageLayout';
|
import PageLayout from '../components/PageLayout';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useActiveCoffees } from './hooks/getActiveCoffees';
|
||||||
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' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CoffeeAbonnementPage() {
|
export default function CoffeeAbonnementPage() {
|
||||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||||||
// removed: form state and handlers (moved to summary page)
|
const router = useRouter();
|
||||||
// removed: canSubmit depending on form
|
|
||||||
|
|
||||||
const router = useRouter(); // added
|
// Fetch active coffees from the backend
|
||||||
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
|
|
||||||
const selectedEntries = useMemo(
|
const selectedEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.entries(selections).map(([id, qty]) => ({
|
Object.entries(selections).map(([id, qty]) => {
|
||||||
coffee: coffees.find(c => c.id === id)!,
|
const coffee = coffees.find((c) => c.id === id);
|
||||||
quantity: qty
|
if (!coffee) return null;
|
||||||
})),
|
return { coffee, quantity: qty };
|
||||||
[selections]
|
}).filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
|
||||||
|
[selections, coffees]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPrice = useMemo(
|
const totalPrice = useMemo(
|
||||||
@ -51,11 +30,11 @@ export default function CoffeeAbonnementPage() {
|
|||||||
),
|
),
|
||||||
[selectedEntries]
|
[selectedEntries]
|
||||||
);
|
);
|
||||||
const TAX_RATE = 0.07; // 7% Steuer
|
const TAX_RATE = 0.07;
|
||||||
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
||||||
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
||||||
|
|
||||||
const canProceed = selectedEntries.length > 0; // added
|
const canProceed = selectedEntries.length > 0;
|
||||||
|
|
||||||
const proceedToSummary = () => {
|
const proceedToSummary = () => {
|
||||||
if (!canProceed) return;
|
if (!canProceed) return;
|
||||||
@ -66,25 +45,25 @@ export default function CoffeeAbonnementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleCoffee = (id: string) => {
|
const toggleCoffee = (id: string) => {
|
||||||
setSelections(prev => {
|
setSelections((prev) => {
|
||||||
const copy = { ...prev };
|
const copy = { ...prev };
|
||||||
if (id in copy) {
|
if (id in copy) {
|
||||||
delete copy[id];
|
delete copy[id];
|
||||||
} else {
|
} else {
|
||||||
copy[id] = 10; // default start quantity
|
copy[id] = 10;
|
||||||
}
|
}
|
||||||
return copy;
|
return copy;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeQuantity = (id: string, delta: number) => {
|
const changeQuantity = (id: string, delta: number) => {
|
||||||
setSelections(prev => {
|
setSelections((prev) => {
|
||||||
if (!(id in prev)) return prev;
|
if (!(id in prev)) return prev;
|
||||||
const next = prev[id] + delta;
|
const next = prev[id] + delta;
|
||||||
if (next < 10 || next > 120) return prev;
|
if (next < 10 || next > 120) return prev;
|
||||||
const updated = { ...prev, [id]: next };
|
const updated = { ...prev, [id]: next };
|
||||||
setBump(b => ({ ...b, [id]: true })); // trigger bump animation
|
setBump((b) => ({ ...b, [id]: true }));
|
||||||
setTimeout(() => setBump(b => ({ ...b, [id]: false })), 250);
|
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -93,9 +72,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
<span className="text-[#1C2B4A]">
|
<span className="text-[#1C2B4A]">Kaffee Abonnement konfigurieren</span>
|
||||||
Kaffee Abonnement konfigurieren
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
@ -114,119 +91,134 @@ export default function CoffeeAbonnementPage() {
|
|||||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold mb-4">1. Kaffeesorten & Mengen wählen</h2>
|
<h2 className="text-xl font-semibold mb-4">1. Kaffeesorten & Mengen wählen</h2>
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{coffees.map(coffee => {
|
{error && (
|
||||||
const active = coffee.id in selections;
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
const qty = selections[coffee.id] || 0;
|
{error}
|
||||||
return (
|
</div>
|
||||||
<div
|
)}
|
||||||
key={coffee.id}
|
|
||||||
className={`group rounded-xl border p-4 shadow-sm transition ${
|
{loading ? (
|
||||||
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
}`}
|
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||||||
>
|
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||||||
<div className="relative overflow-hidden rounded-md mb-3">
|
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||||||
<img
|
</div>
|
||||||
src={coffee.image}
|
) : (
|
||||||
alt={coffee.name}
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
{coffees.map((coffee) => {
|
||||||
loading="lazy"
|
const active = coffee.id in selections;
|
||||||
/>
|
const qty = selections[coffee.id] || 0;
|
||||||
{/* price badge */}
|
return (
|
||||||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
<div
|
||||||
<span
|
key={coffee.id}
|
||||||
aria-label={`Preis €${coffee.pricePer10} pro 10 Stück`}
|
className={`group rounded-xl border p-4 shadow-sm transition ${
|
||||||
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${
|
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
|
||||||
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
€{coffee.pricePer10}
|
|
||||||
</span>
|
|
||||||
{/* CHANGED: solid, readable over images */}
|
|
||||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">
|
|
||||||
pro 10 Stk
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
|
||||||
{coffee.description}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleCoffee(coffee.id)}
|
|
||||||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
|
||||||
active
|
|
||||||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
|
||||||
: 'border-gray-300 hover:bg-gray-100'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{active ? 'Entfernen' : 'Hinzufügen'}
|
<div className="relative overflow-hidden rounded-md mb-3">
|
||||||
</button>
|
{coffee.image ? (
|
||||||
{active && (
|
<img
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
src={coffee.image}
|
||||||
<div className="flex items-center justify-between">
|
alt={coffee.name}
|
||||||
<span className="text-[11px] font-medium text-gray-500">Menge (10–120)</span>
|
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-36 w-full bg-gray-100 rounded-md" />
|
||||||
|
)}
|
||||||
|
{/* price badge (per 10) */}
|
||||||
|
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center rounded-full bg-[#1C2B4A] text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`}
|
aria-label={`Preis €${coffee.pricePer10} pro 10 Stück`}
|
||||||
|
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${
|
||||||
|
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{qty} Stk
|
€{coffee.pricePer10}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">
|
||||||
<div className="flex items-center gap-2">
|
pro 10 Stk
|
||||||
<button
|
|
||||||
onClick={() => changeQuantity(coffee.id, -10)}
|
|
||||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
|
||||||
>
|
|
||||||
-10
|
|
||||||
</button>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={10}
|
|
||||||
max={120}
|
|
||||||
step={10}
|
|
||||||
value={qty}
|
|
||||||
onChange={e =>
|
|
||||||
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'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => changeQuantity(coffee.id, +10)}
|
|
||||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
|
||||||
>
|
|
||||||
+10
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span className="font-semibold text-gray-700">
|
|
||||||
€{((qty / 10) * coffee.pricePer10).toFixed(2)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-start justify-between">
|
||||||
</div>
|
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
||||||
);
|
</div>
|
||||||
})}
|
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
||||||
</div>
|
{coffee.description}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCoffee(coffee.id)}
|
||||||
|
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||||||
|
active
|
||||||
|
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
||||||
|
: 'border-gray-300 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active ? 'Entfernen' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{active && (
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium text-gray-500">Menge (10–120)</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full bg-[#1C2B4A] text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`}
|
||||||
|
>
|
||||||
|
{qty} Stk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => changeQuantity(coffee.id, -10)}
|
||||||
|
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||||
|
>
|
||||||
|
-10
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={10}
|
||||||
|
max={120}
|
||||||
|
step={10}
|
||||||
|
value={qty}
|
||||||
|
onChange={(e) =>
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => changeQuantity(coffee.id, +10)}
|
||||||
|
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||||
|
>
|
||||||
|
+10
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span className="font-semibold text-gray-700">
|
||||||
|
€{((qty / 10) * coffee.pricePer10).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section 2: Compact preview + next steps */}
|
{/* Section 2: Compact preview + next steps */}
|
||||||
@ -236,12 +228,15 @@ export default function CoffeeAbonnementPage() {
|
|||||||
{selectedEntries.length === 0 && (
|
{selectedEntries.length === 0 && (
|
||||||
<p className="text-sm text-gray-600">Noch keine Kaffees ausgewählt.</p>
|
<p className="text-sm text-gray-600">Noch keine Kaffees ausgewählt.</p>
|
||||||
)}
|
)}
|
||||||
{selectedEntries.map(entry => (
|
{selectedEntries.map((entry) => (
|
||||||
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{entry.coffee.name}</span>
|
<span className="font-medium">{entry.coffee.name}</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{entry.quantity} Stk • <span className="inline-flex items-center font-semibold text-[#1C2B4A]">€{entry.coffee.pricePer10}/10</span>
|
{entry.quantity} Stk •{' '}
|
||||||
|
<span className="inline-flex items-center font-semibold text-[#1C2B4A]">
|
||||||
|
€{entry.coffee.pricePer10}/10
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right font-semibold">
|
<div className="text-right font-semibold">
|
||||||
@ -251,7 +246,9 @@ export default function CoffeeAbonnementPage() {
|
|||||||
))}
|
))}
|
||||||
<div className="flex justify-between pt-2 border-t">
|
<div className="flex justify-between pt-2 border-t">
|
||||||
<span className="text-sm font-semibold">Gesamt (Netto)</span>
|
<span className="text-sm font-semibold">Gesamt (Netto)</span>
|
||||||
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">€{totalPrice.toFixed(2)}</span>
|
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
|
||||||
|
€{totalPrice.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={proceedToSummary}
|
onClick={proceedToSummary}
|
||||||
@ -263,11 +260,24 @@ export default function CoffeeAbonnementPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Nächste Schritte
|
Nächste Schritte
|
||||||
<svg className={`ml-2 h-5 w-5 transition-transform ${canProceed ? 'group-hover:translate-x-0.5' : ''}`} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg
|
||||||
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
|
className={`ml-2 h-5 w-5 transition-transform ${
|
||||||
|
canProceed ? 'group-hover:translate-x-0.5' : ''
|
||||||
|
}`}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{!canProceed && <p className="text-xs text-gray-500">Bitte mindestens eine Kaffeesorte wählen.</p>}
|
{!canProceed && (
|
||||||
|
<p className="text-xs text-gray-500">Bitte mindestens eine Kaffeesorte wählen.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,33 +2,11 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import PageLayout from '../../components/PageLayout';
|
import PageLayout from '../../components/PageLayout';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useActiveCoffees } from '../hooks/getActiveCoffees';
|
||||||
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: '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SummaryPage() {
|
export default function SummaryPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@ -63,11 +41,13 @@ export default function SummaryPage() {
|
|||||||
|
|
||||||
const selectedEntries = useMemo(
|
const selectedEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.entries(selections).map(([id, qty]) => {
|
Object.entries(selections)
|
||||||
const coffee = coffees.find(c => c.id === id);
|
.map(([id, qty]) => {
|
||||||
return coffee ? { coffee, quantity: qty } : null;
|
const coffee = coffees.find(c => c.id === id);
|
||||||
}).filter(Boolean) as { coffee: Coffee; quantity: number }[],
|
return coffee ? { coffee, quantity: qty } : null;
|
||||||
[selections]
|
})
|
||||||
|
.filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
|
||||||
|
[selections, coffees]
|
||||||
);
|
);
|
||||||
|
|
||||||
const TAX_RATE = 0.07;
|
const TAX_RATE = 0.07;
|
||||||
@ -124,7 +104,23 @@ export default function SummaryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedEntries.length === 0 ? (
|
{error && (
|
||||||
|
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||||
|
<p className="text-sm text-red-700 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={backToSelection}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
|
>
|
||||||
|
Zur Auswahl
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||||
|
<div className="h-20 rounded-md bg-gray-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : selectedEntries.length === 0 ? (
|
||||||
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||||
<p className="text-sm text-gray-600 mb-4">Keine Auswahl gefunden.</p>
|
<p className="text-sm text-gray-600 mb-4">Keine Auswahl gefunden.</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user