feat: abo + profile section
This commit is contained in:
parent
b8a67f0a2b
commit
ac358d4d7d
@ -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>
|
||||
|
||||
@ -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 (10–120)</span>
|
||||
<span className="text-[11px] font-medium text-gray-500">Quantity (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
|
||||
{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>
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
138
src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts
Normal file
138
src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts
Normal 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[]
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
100
src/app/profile/components/userAbo.tsx
Normal file
100
src/app/profile/components/userAbo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/app/profile/hooks/getAbo.ts
Normal file
93
src/app/profile/hooks/getAbo.ts
Normal 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 }
|
||||
}
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user