feat: abo + profile section

This commit is contained in:
DeathKaioken 2025-12-13 11:17:48 +01:00
parent b8a67f0a2b
commit ac358d4d7d
8 changed files with 624 additions and 91 deletions

View File

@ -173,19 +173,37 @@ export default function CreateSubscriptionPage() {
</div>
</div>
{/* Description moved right after picture */}
{/* Title moved above description */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label>
<input
id="title"
name="title"
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
placeholder="Title"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
{/* Description now after title */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-blue-900">Description</label>
<textarea id="description" name="description" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" rows={3} placeholder="Describe the product" value={description} onChange={e => setDescription(e.target.value)} />
<textarea
id="description"
name="description"
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
rows={3}
placeholder="Describe the product"
value={description}
onChange={e => setDescription(e.target.value)}
/>
<p className="mt-1 text-xs text-gray-600">Shown to users in the shop and checkout.</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label>
<input id="title" name="title" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
</div>
{/* Price */}
<div>
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label>

View File

@ -30,12 +30,19 @@ export default function CoffeeAbonnementPage() {
),
[selectedEntries]
);
// NEW: enforce exactly 120 capsules (12 packs)
const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
[selectedEntries]
);
const packsSelected = totalCapsules / 10;
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
const TAX_RATE = 0.07;
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
const canProceed = selectedEntries.length > 0;
const proceedToSummary = () => {
if (!canProceed) return;
try {
@ -72,25 +79,25 @@ export default function CoffeeAbonnementPage() {
<PageLayout>
<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">
<span className="text-[#1C2B4A]">Kaffee Abonnement konfigurieren</span>
<span className="text-[#1C2B4A]">Configure Coffee Subscription</span>
</h1>
{/* Stepper */}
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="flex items-center">
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
<span className="ml-2 font-medium">Auswahl</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
<div className="flex items-center opacity-60">
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
<span className="ml-2 font-medium">Zusammenfassung</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
{/* Section 1: Multi coffee selection + per-coffee quantity */}
<section>
<h2 className="text-xl font-semibold mb-4">1. Kaffeesorten & Mengen wählen</h2>
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@ -130,7 +137,7 @@ export default function CoffeeAbonnementPage() {
{/* price badge (per 10) */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
<span
aria-label={`Preis €${coffee.pricePer10} pro 10 Stück`}
aria-label={`Price €${coffee.pricePer10} per 10 capsules`}
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'
}`}
@ -138,7 +145,7 @@ export default function CoffeeAbonnementPage() {
{coffee.pricePer10}
</span>
<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
per 10 pcs
</span>
</div>
</div>
@ -157,16 +164,16 @@ export default function CoffeeAbonnementPage() {
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{active ? 'Entfernen' : 'Hinzufügen'}
{active ? 'Remove' : 'Add'}
</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 (10120)</span>
<span className="text-[11px] font-medium text-gray-500">Quantity (10120)</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
{qty} pcs
</span>
</div>
<div className="flex items-center gap-2">
@ -223,10 +230,10 @@ export default function CoffeeAbonnementPage() {
{/* Section 2: Compact preview + next steps */}
<section>
<h2 className="text-xl font-semibold mb-4">2. Vorschau</h2>
<h2 className="text-xl font-semibold mb-4">2. Preview</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
{selectedEntries.length === 0 && (
<p className="text-sm text-gray-600">Noch keine Kaffees ausgewählt.</p>
<p className="text-sm text-gray-600">No coffees selected yet.</p>
)}
{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">
@ -245,11 +252,23 @@ export default function CoffeeAbonnementPage() {
</div>
))}
<div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Gesamt (Netto)</span>
<span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
{totalPrice.toFixed(2)}
</span>
</div>
{/* Packs/capsules summary and validation hint (refined design) */}
<div className="text-xs text-gray-700">
Selected: {totalCapsules} capsules ({packsSelected} packs of 10).
{packsSelected !== 12 && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Please select exactly 120 capsules (12 packs).
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
</span>
)}
</div>
<button
onClick={proceedToSummary}
disabled={!canProceed}
@ -259,7 +278,7 @@ export default function CoffeeAbonnementPage() {
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
}`}
>
Nächste Schritte
Next steps
<svg
className={`ml-2 h-5 w-5 transition-transform ${
canProceed ? 'group-hover:translate-x-0.5' : ''
@ -276,7 +295,9 @@ export default function CoffeeAbonnementPage() {
</svg>
</button>
{!canProceed && (
<p className="text-xs text-gray-500">Bitte mindestens eine Kaffeesorte wählen.</p>
<p className="text-xs text-gray-600">
You can continue once exactly 120 capsules (12 packs) are selected.
</p>
)}
</div>
</section>

View File

@ -10,22 +10,83 @@ const normalizeVatRate = (rate: number | null | undefined): number | null => {
return rate > 1 ? rate / 100 : rate;
};
const toNumber = (v: any): number | null => {
if (v == null) return null;
const n = typeof v === 'string' ? Number(v) : (typeof v === 'number' ? v : Number(v));
return Number.isFinite(n) ? n : null;
};
const getCode = (row: any): string => {
const raw = row?.countryCode ?? row?.code ?? row?.country ?? row?.country_code;
return typeof raw === 'string' ? raw.toUpperCase() : '';
};
const getRateRaw = (row: any): number | null => {
// support multiple field names and string numbers
const raw = row?.standardRate ?? row?.rate ?? row?.ratePercent ?? row?.standard_rate;
const num = toNumber(raw);
return num;
};
const getRateNormalized = (row: any): number | null => {
const num = getRateRaw(row);
return normalizeVatRate(num);
};
/**
* Fetches the standard VAT rate for a given ISO country code.
* Returns null if not found or on any error.
*/
export async function getStandardVatRate(countryCode: string): Promise<number | null> {
try {
const res = await authFetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`, { method: "GET" });
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`;
console.info('[VAT] getStandardVatRate -> GET', url, { countryCode });
const res = await authFetch(url, { method: "GET" });
console.info('[VAT] getStandardVatRate status:', res.status);
if (!res.ok) return null;
const data = await res.json().catch(() => null);
if (!data || !Array.isArray(data)) return null;
const raw = await res.json().catch(() => null);
const arr = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
console.info('[VAT] getStandardVatRate parsed length:', Array.isArray(arr) ? arr.length : 0);
if (!Array.isArray(arr) || arr.length === 0) return null;
const upper = countryCode.toUpperCase();
const match = (data as VatRate[]).find(r => r.countryCode.toUpperCase() === upper);
return normalizeVatRate(match?.standardRate);
} catch {
const match = arr.find((r: any) => getCode(r) === upper);
const normalized = match ? getRateNormalized(match) : null;
console.info('[VAT] getStandardVatRate match:', {
upper,
resolvedCode: match ? getCode(match) : null,
rawRate: match ? getRateRaw(match) : null,
normalized
});
return normalized;
} catch (e) {
console.error('[VAT] getStandardVatRate error:', e);
return null;
}
}
export type VatRateEntry = { code: string; rate: number | null }
export async function getVatRates(): Promise<VatRateEntry[]> {
try {
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`;
console.info('[VAT] getVatRates -> GET', url);
const res = await authFetch(url, { method: 'GET' });
console.info('[VAT] getVatRates status:', res.status);
if (!res.ok) return [];
const raw = await res.json().catch(() => null);
const arr = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
console.info('[VAT] getVatRates parsed length:', Array.isArray(arr) ? arr.length : 0);
if (!Array.isArray(arr) || arr.length === 0) return [];
const mapped = arr.map((r: any) => ({
code: getCode(r),
rate: getRateNormalized(r)
})).filter((r: VatRateEntry) => !!r.code);
console.info('[VAT] getVatRates mapped sample:', mapped.slice(0, 5));
return mapped;
} catch (e) {
console.error('[VAT] getVatRates error:', e);
return [];
}
}

View File

@ -0,0 +1,138 @@
import { authFetch } from '../../../utils/authFetch'
export type SubscribeAboItem = { coffeeId: string | number; quantity?: number }
export type SubscribeAboInput = {
coffeeId?: string | number // optional when items provided
items?: SubscribeAboItem[] // NEW: whole order in one call
billing_interval?: string
interval_count?: number
is_auto_renew?: boolean
target_user_id?: number
recipient_name?: string
recipient_email?: string
recipient_notes?: string
// NEW: customer fields
firstName?: string
lastName?: string
email?: string
street?: string
postalCode?: string
city?: string
country?: string
frequency?: string
startDate?: string
// NEW: logged-in user id
referred_by?: number
}
type Abonement = any
type HistoryEvent = any
const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const parseJson = async (res: Response) => {
const ct = res.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
const json = isJson ? await res.json().catch(() => ({})) : null
return { json, isJson }
}
export async function subscribeAbo(input: SubscribeAboInput) {
const hasItems = Array.isArray(input.items) && input.items.length > 0
if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required')
const hasRecipientFields = !!(input.recipient_name || input.recipient_email || input.recipient_notes)
if (hasRecipientFields && !input.recipient_name) {
throw new Error('recipient_name is required when gifting to a non-account recipient.')
}
// NEW: validate customer fields (required in UI)
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency','startDate'] as const
const missing = requiredFields.filter(k => {
const v = (input as any)[k]
return typeof v !== 'string' || v.trim() === ''
})
if (missing.length) {
throw new Error(`Missing required fields: ${missing.join(', ')}`)
}
const body: any = {
billing_interval: input.billing_interval ?? 'month',
interval_count: input.interval_count ?? 1,
is_auto_renew: input.is_auto_renew ?? true,
// NEW: include customer fields
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
street: input.street,
postalCode: input.postalCode,
city: input.city,
country: input.country?.toUpperCase?.() ?? input.country,
frequency: input.frequency,
startDate: input.startDate,
}
if (hasItems) {
body.items = input.items!.map(i => ({
coffeeId: i.coffeeId,
quantity: i.quantity != null ? i.quantity : 1,
}))
// NEW: enforce exactly 12 packs
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
if (sumPacks !== 12) {
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12')
throw new Error('Order must contain exactly 12 packs (120 capsules).')
}
} else {
body.coffeeId = input.coffeeId
// single-item legacy path — backend expects bundle, prefer items usage
}
// NEW: always include available recipient fields and target_user_id when provided
if (input.target_user_id != null) body.target_user_id = input.target_user_id
if (input.recipient_name) body.recipient_name = input.recipient_name
if (input.recipient_email) body.recipient_email = input.recipient_email
if (input.recipient_notes) body.recipient_notes = input.recipient_notes
// NEW: always include referred_by if provided
if (input.referred_by != null) body.referred_by = input.referred_by
const url = `${apiBase}/api/abonements/subscribe`
console.info('[subscribeAbo] POST', url, { body })
// NEW: explicit JSON preview that matches the actual request body
console.info('[subscribeAbo] Body JSON:', JSON.stringify(body))
const res = await authFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
credentials: 'include',
})
const { json } = await parseJson(res)
console.info('[subscribeAbo] Response', res.status, json)
if (!res.ok || !json?.success) throw new Error(json?.message || `Subscribe failed: ${res.status}`)
return json.data as Abonement
}
async function postAction(url: string) {
const res = await authFetch(url, { method: 'POST', headers: { Accept: 'application/json' }, credentials: 'include' })
const { json } = await parseJson(res)
if (!res.ok || !json?.success) throw new Error(json?.message || `Request failed: ${res.status}`)
return json.data as Abonement
}
export const pauseAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/pause`)
export const resumeAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/resume`)
export const cancelAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/cancel`)
export const renewAbo = (id: string | number) => postAction(`${apiBase}/admin/abonements/${id}/renew`)
export async function getMyAbonements(status?: string) {
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
const res = await authFetch(`${apiBase}/abonements/mine${qs}`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' })
const { json } = await parseJson(res)
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
return (json.data || []) as Abonement[]
}
export async function getAboHistory(id: string | number) {
const res = await authFetch(`${apiBase}/abonements/${id}/history`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' })
const { json } = await parseJson(res)
if (!res.ok || !json?.success) throw new Error(json?.message || `History failed: ${res.status}`)
return (json.data || []) as HistoryEvent[]
}

View File

@ -3,15 +3,9 @@ import React, { useEffect, useMemo, useState } from 'react';
import PageLayout from '../../components/PageLayout';
import { useRouter } from 'next/navigation';
import { useActiveCoffees } from '../hooks/getActiveCoffees';
import { getStandardVatRate } from './hooks/getTaxRate';
const FALLBACK_RATES: Record<string, number> = {
DE: 0.19,
AT: 0.20,
CH: 0.077,
FR: 0.20,
NL: 0.21,
};
import { getStandardVatRate, getVatRates } from './hooks/getTaxRate';
import { subscribeAbo } from './hooks/subscribeAbo';
import useAuthStore from '../../store/authStore'
export default function SummaryPage() {
const router = useRouter();
@ -30,7 +24,10 @@ export default function SummaryPage() {
});
const [showThanks, setShowThanks] = useState(false);
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
const [taxRate, setTaxRate] = useState(FALLBACK_RATES['DE'] ?? 0.07);
const [taxRate, setTaxRate] = useState(0.07); // minimal fallback only
const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette
useEffect(() => {
@ -61,24 +58,74 @@ export default function SummaryPage() {
[selections, coffees]
);
// NEW: computed packs/capsules for validation
const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0),
[selectedEntries]
)
const totalPacks = totalCapsules / 10
const token = useAuthStore.getState().accessToken
console.info('[SummaryPage] token prefix:', token ? `${token.substring(0, 12)}` : null)
// NEW: capture logged-in user id for referral
const currentUserId = useAuthStore.getState().user?.id
console.info('[SummaryPage] currentUserId:', currentUserId)
// Countries list from backend VAT rates (fallback to current country if list empty)
const countryOptions = useMemo(() => {
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()]
console.info('[SummaryPage] countryOptions:', opts)
return opts
}, [vatRates, form.country]);
// Load VAT rates list from backend and set initial taxRate
useEffect(() => {
let active = true;
(async () => {
const fallback = FALLBACK_RATES[form.country] ?? 0.07;
const rate = await getStandardVatRate(form.country);
if (active) setTaxRate(rate ?? fallback);
console.info('[SummaryPage] Loading vat rates (mount). country:', form.country)
const list = await getVatRates();
if (!active) return;
console.info('[SummaryPage] getVatRates result count:', list.length)
setVatRates(list);
const upper = form.country.toUpperCase();
const match = list.find(r => r.code === upper);
if (match?.rate != null) {
console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper)
setTaxRate(match.rate);
} else {
const rate = await getStandardVatRate(form.country);
console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper)
setTaxRate(rate ?? 0.07);
}
})();
return () => {
active = false;
};
}, [form.country]);
return () => { active = false; };
}, []); // mount-only
// Update taxRate when country changes (from backend only)
useEffect(() => {
let active = true;
(async () => {
const upper = form.country.toUpperCase();
console.info('[SummaryPage] Country changed:', upper)
const fromList = vatRates.find(r => r.code === upper)?.rate;
if (fromList != null) {
console.info('[SummaryPage] taxRate from existing list:', fromList)
if (active) setTaxRate(fromList);
return;
}
const rate = await getStandardVatRate(form.country);
console.info('[SummaryPage] taxRate via getStandardVatRate:', rate)
if (active) setTaxRate(rate ?? 0.07);
})();
return () => { active = false; };
}, [form.country, vatRates]);
const totalPrice = useMemo(
() => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0),
[selectedEntries]
);
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
@ -87,15 +134,53 @@ export default function SummaryPage() {
const canSubmit =
selectedEntries.length > 0 &&
totalPacks === 12 && // CHANGED: require exactly 12 packs
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
const backToSelection = () => router.push('/coffee-abonnements');
const submit = () => {
if (!canSubmit) return;
setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
// here you would post data to your API
const submit = async () => {
if (!canSubmit || submitLoading) return
// NEW: guard (defensive) — backend requires exactly 12 packs
if (totalPacks !== 12) {
setSubmitError('Order must contain exactly 12 packs (120 capsules).')
return
}
setSubmitError(null)
setSubmitLoading(true)
try {
const payload = {
items: selectedEntries.map(entry => ({
coffeeId: entry.coffee.id,
quantity: Math.round(entry.quantity / 10), // packs
})),
billing_interval: 'month',
interval_count: 1,
is_auto_renew: true,
// NEW: pass customer fields
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
email: form.email.trim(),
street: form.street.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim(),
frequency: form.frequency.trim(),
startDate: form.startDate.trim(),
// NEW: always include referred_by if available
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
}
console.info('[SummaryPage] subscribeAbo payload:', payload)
// NEW: explicit JSON preview to match request body
console.info('[SummaryPage] subscribeAbo payload JSON:', JSON.stringify(payload))
await subscribeAbo(payload)
setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
} catch (e: any) {
setSubmitError(e?.message || 'Subscription could not be created.');
} finally {
setSubmitLoading(false);
}
};
return (
@ -103,13 +188,13 @@ export default function SummaryPage() {
<div className="mx-auto max-w-6xl px-4 py-10 space-y-8 bg-gradient-to-b from-white to-[#1C2B4A0D]">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">
<span className="text-[#1C2B4A]">Zusammenfassung & Daten</span>
<span className="text-[#1C2B4A]">Summary & Details</span>
</h1>
<button
onClick={backToSelection}
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
>
Zurück zur Auswahl
Back to selection
</button>
</div>
@ -117,12 +202,12 @@ export default function SummaryPage() {
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="flex items-center opacity-60">
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">1</span>
<span className="ml-2 font-medium">Auswahl</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
<div className="flex items-center">
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">2</span>
<span className="ml-2 font-medium">Zusammenfassung</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
@ -133,123 +218,137 @@ export default function SummaryPage() {
onClick={backToSelection}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Zur Auswahl
Back to selection
</button>
</div>
)}
{/* submit error */}
{submitError && (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-red-700">{submitError}</p>
</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">
<p className="text-sm text-gray-600 mb-4">Keine Auswahl gefunden.</p>
<p className="text-sm text-gray-600 mb-4">No selection found.</p>
<button
onClick={backToSelection}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Zur Auswahl
Back to selection
</button>
</div>
) : (
<div className="grid gap-8 lg:grid-cols-3">
{/* Left: Customer data */}
<section className="lg:col-span-2">
<h2 className="text-xl font-semibold mb-4">1. Deine Daten</h2>
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
<div className="grid gap-4 sm:grid-cols-2">
{/* replace focus rings */}
{/* inputs translated */}
<div>
<label className="block text-sm font-medium mb-1">Vorname</label>
<label className="block text-sm font-medium mb-1">First name</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Nachname</label>
<label className="block text-sm font-medium mb-1">Last name</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">E-Mail</label>
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Straße & Nr.</label>
<label className="block text-sm font-medium mb-1">Street & No.</label>
<input name="street" value={form.street} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">PLZ</label>
<label className="block text-sm font-medium mb-1">ZIP</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Stadt</label>
<label className="block text-sm font-medium mb-1">City</label>
<input name="city" value={form.city} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Land</label>
<label className="block text-sm font-medium mb-1">Country</label>
<select name="country" value={form.country} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
<option value="DE">Deutschland</option>
<option value="AT">Österreich</option>
<option value="CH">Schweiz</option>
<option value="FR">Frankreich</option>
<option value="NL">Niederlande</option>
{countryOptions.map(code => (
<option key={code} value={code}>{code}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Lieferintervall</label>
<label className="block text-sm font-medium mb-1">Delivery interval</label>
<select name="frequency" value={form.frequency} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
<option value="monatlich">Monatlich</option>
<option value="zweimonatlich">Alle 2 Monate</option>
<option value="vierteljährlich">Vierteljährlich</option>
<option value="monatlich">Monthly</option>
<option value="zweimonatlich">Every 2 months</option>
<option value="vierteljährlich">Quarterly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Startdatum</label>
<label className="block text-sm font-medium mb-1">Start date</label>
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
</div>
<button
onClick={submit}
disabled={!canSubmit}
disabled={!canSubmit || submitLoading}
className={`group w-full mt-6 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
canSubmit ? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg' : 'bg-gray-200 text-gray-600 cursor-not-allowed'
canSubmit && !submitLoading ? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg' : 'bg-gray-200 text-gray-600 cursor-not-allowed'
}`}
>
Abonnement abschließen
{submitLoading ? 'Creating…' : 'Complete subscription'}
<svg className={`ml-2 h-5 w-5 transition-transform ${canSubmit ? '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>
</button>
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Bitte mindestens eine Kaffeesorte wählen und alle Felder ausfüllen.</p>}
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>}
</div>
</section>
{/* Right: Order summary */}
<section className="lg:col-span-1">
<h2 className="text-xl font-semibold mb-4">2. Deine Auswahl</h2>
<h2 className="text-xl font-semibold mb-4">2. Your selection</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg lg:sticky lg:top-6">
{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 className="flex flex-col">
<span className="font-medium">{entry.coffee.name}</span>
<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} pcs <span className="inline-flex items-center font-semibold text-[#1C2B4A]">{entry.coffee.pricePer10}/10</span>
</span>
</div>
<div className="text-right font-semibold">{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div>
</div>
))}
<div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Gesamt (Netto)</span>
<span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{totalPrice.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm">Steuer ({(taxRate * 100).toFixed(1)}%)</span>
<span className="text-sm">Tax ({(taxRate * 100).toFixed(1)}%)</span>
<span className="text-sm font-medium">{taxAmount.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Gesamt inkl. Steuer</span>
<span className="text-sm font-semibold">Total incl. tax</span>
<span className="text-xl font-extrabold text-[#1C2B4A]">{totalWithTax.toFixed(2)}</span>
</div>
{/* Validation summary (refined design) */}
<div className="mt-2 text-xs text-gray-700">
Selected: {totalCapsules} capsules ({totalPacks} packs of 10).
{totalPacks !== 12 && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Exactly 12 packs (120 capsules) are required.
</span>
)}
</div>
</div>
</section>
</div>
@ -271,15 +370,15 @@ export default function SummaryPage() {
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="text-2xl font-bold">Danke für dein Abo!</h3>
<p className="mt-1 text-sm text-gray-600">Wir haben deine Bestellung erhalten.</p>
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
<p className="mt-1 text-sm text-gray-600">We have received your order.</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
Zur Auswahl
Back to selection
</button>
<button onClick={() => setShowThanks(false)} className="rounded-lg border border-gray-300 px-4 py-2 font-semibold hover:bg-gray-50">
Schließen
Close
</button>
</div>

View File

@ -0,0 +1,100 @@
import React from 'react'
import { useReferredAbos } from '../hooks/getAbo'
export default function UserAbo() {
const { data: abos, loading, error } = useReferredAbos()
if (loading) {
return (
<section className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
<div className="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-600">
Loading subscriptions
</div>
</section>
)
}
if (error) {
return (
<section className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
</section>
)
}
return (
<section className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
{(!abos || abos.length === 0) ? (
<div className="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-600">
No subscriptions yet.
</div>
) : (
<div className="grid gap-4">
{abos.map(abo => {
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
const started = abo.startedAt ? new Date(abo.startedAt).toLocaleDateString() : '—'
const coffees = (abo.pack_breakdown || abo.items || []).map((it, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 rounded-full bg-white text-[#1C2B4A] px-3 py-1.5 text-xs font-medium border border-gray-200 shadow-sm ring-1 ring-gray-100 hover:shadow-md hover:ring-gray-200 transition"
>
{/* coffee name */}
<span className="truncate max-w-[14rem]">{it.coffeeName || `Coffee #${it.coffeeId}`}</span>
{/* packs pill — CHANGED COLORS TO MATCH ACTIVE BADGE */}
<span className="inline-flex items-center rounded-full bg-green-100 text-green-800 px-2 py-0.5 text-[10px] font-semibold border border-green-200">
{it.quantity} packs
</span>
</span>
))
return (
<div key={abo.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
<p className="text-xs text-gray-600">
Next billing: {nextBilling}
{' • '}Frequency: {abo.frequency ?? '—'}
{' • '}Country: {(abo.country ?? '').toUpperCase() || '—'}
{' • '}Started: {started}
</p>
</div>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
status === 'active'
? 'bg-green-100 text-green-800'
: status === 'paused'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-700'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</div>
<div className="mt-3">
<p className="text-xs font-semibold text-gray-700 mb-1">Coffees</p>
<div className="flex flex-wrap gap-2">
{coffees}
</div>
</div>
<div className="mt-3 flex gap-2">
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
Manage
</button>
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
View history
</button>
</div>
</div>
)
})}
</div>
)}
</section>
)
}

View File

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import { authFetch } from '../../utils/authFetch'
import useAuthStore from '../../store/authStore'
type AbonementItem = {
coffeeName?: string
coffeeId?: string | number
quantity: number
}
export type ReferredAbo = {
id: number | string
status: 'active' | 'paused' | 'canceled'
nextBillingAt?: string | null
email?: string
price?: string | number
pack_breakdown?: AbonementItem[]
items?: AbonementItem[]
name?: string
frequency?: string
country?: string
startedAt?: string | null
}
export function useReferredAbos() {
const [data, setData] = useState<ReferredAbo[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
setError(null)
try {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const user = useAuthStore.getState().user
const userId = user?.id
const email = user?.email
const url = `${base}/api/abonements/referred`
console.info('[useReferredAbos] Preparing POST', url, {
userId: userId ?? null,
userEmail: email ?? null,
})
const res = await authFetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId: userId ?? null, email: email ?? null }),
})
const ct = res.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
const json = isJson ? await res.json().catch(() => ({})) : null
console.info('[useReferredAbos] Response', res.status, json)
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
const list: ReferredAbo[] = Array.isArray(json.data)
? json.data.map((raw: any) => ({
id: raw.id,
status: raw.status,
nextBillingAt: raw.next_billing_at ?? raw.nextBillingAt ?? null,
email: raw.email,
price: raw.price,
name: `${raw.first_name ?? ''} ${raw.last_name ?? ''}`.trim() || 'Coffee Subscription',
frequency: raw.frequency,
country: raw.country,
startedAt: raw.started_at ?? null,
pack_breakdown: Array.isArray(raw.pack_breakdown)
? raw.pack_breakdown.map((it: any) => ({
coffeeName: it.coffee_name,
coffeeId: it.coffee_table_id,
quantity: it.packs, // packs count
}))
: undefined,
}))
: []
if (active) setData(list)
} catch (e: any) {
if (active) setError(e?.message || 'Failed to load subscriptions.')
} finally {
if (active) setLoading(false)
}
})()
return () => { active = false }
}, [])
return { data, loading, error }
}

View File

@ -10,6 +10,7 @@ import BasicInformation from './components/basicInformation'
import MediaSection from './components/mediaSection'
import BankInformation from './components/bankInformation'
import EditModal from './components/editModal'
import UserAbo from './components/userAbo'
import {
UserCircleIcon,
EnvelopeIcon,
@ -378,6 +379,8 @@ export default function ProfilePage() {
{/* Bank Info, Media */}
<div className="space-y-8 mb-8">
{/* --- My Abo Section (above bank info) --- */}
<UserAbo />
{/* --- Edit Bank Information Section --- */}
<BankInformation
profileData={profileData}