feat: abo

This commit is contained in:
DeathKaioken 2026-02-18 11:17:07 +01:00
parent 004a8f4baa
commit b164f73b43
7 changed files with 483 additions and 74 deletions

View File

@ -7,6 +7,7 @@ export type SubscribeAboInput = {
billing_interval?: string billing_interval?: string
interval_count?: number interval_count?: number
is_auto_renew?: boolean is_auto_renew?: boolean
is_for_self?: boolean
target_user_id?: number target_user_id?: number
recipient_name?: string recipient_name?: string
recipient_email?: string recipient_email?: string
@ -22,7 +23,7 @@ export type SubscribeAboInput = {
frequency?: string frequency?: string
startDate?: string startDate?: string
// NEW: logged-in user id // NEW: logged-in user id
referred_by?: number referred_by?: number | string
} }
type Abonement = any type Abonement = any
@ -41,13 +42,13 @@ export async function subscribeAbo(input: SubscribeAboInput) {
const hasItems = Array.isArray(input.items) && input.items.length > 0 const hasItems = Array.isArray(input.items) && input.items.length > 0
if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required') if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required')
const hasRecipientFields = !!(input.recipient_name || input.recipient_email || input.recipient_notes) const isForSelf = input.is_for_self ?? true
if (hasRecipientFields && !input.recipient_name) { if (!isForSelf && (!input.recipient_email || input.recipient_email.trim() === '')) {
throw new Error('recipient_name is required when gifting to a non-account recipient.') throw new Error('recipient_email is required when subscription is for someone else.')
} }
// NEW: validate customer fields (required in UI) // NEW: validate customer fields (required in UI)
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency','startDate'] as const const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency'] as const
const missing = requiredFields.filter(k => { const missing = requiredFields.filter(k => {
const v = (input as any)[k] const v = (input as any)[k]
return typeof v !== 'string' || v.trim() === '' return typeof v !== 'string' || v.trim() === ''
@ -60,6 +61,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
billing_interval: input.billing_interval ?? 'month', billing_interval: input.billing_interval ?? 'month',
interval_count: input.interval_count ?? 1, interval_count: input.interval_count ?? 1,
is_auto_renew: input.is_auto_renew ?? true, is_auto_renew: input.is_auto_renew ?? true,
is_for_self: isForSelf,
// NEW: include customer fields // NEW: include customer fields
firstName: input.firstName, firstName: input.firstName,
lastName: input.lastName, lastName: input.lastName,
@ -69,7 +71,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
city: input.city, city: input.city,
country: input.country?.toUpperCase?.() ?? input.country, country: input.country?.toUpperCase?.() ?? input.country,
frequency: input.frequency, frequency: input.frequency,
startDate: input.startDate, startDate: input.startDate || undefined,
} }
if (hasItems) { if (hasItems) {
body.items = input.items!.map(i => ({ body.items = input.items!.map(i => ({
@ -88,9 +90,9 @@ export async function subscribeAbo(input: SubscribeAboInput) {
} }
// NEW: always include available recipient fields and target_user_id when provided // 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.target_user_id != null) body.target_user_id = input.target_user_id
if (input.recipient_name) body.recipient_name = input.recipient_name if (!isForSelf && input.recipient_email) body.recipient_email = input.recipient_email
if (input.recipient_email) body.recipient_email = input.recipient_email if (!isForSelf && input.recipient_name) body.recipient_name = input.recipient_name
if (input.recipient_notes) body.recipient_notes = input.recipient_notes if (!isForSelf && input.recipient_notes) body.recipient_notes = input.recipient_notes
// NEW: always include referred_by if provided // NEW: always include referred_by if provided
if (input.referred_by != null) body.referred_by = input.referred_by if (input.referred_by != null) body.referred_by = input.referred_by

View File

@ -13,6 +13,7 @@ export default function SummaryPage() {
const user = useAuthStore(state => state.user) const user = useAuthStore(state => state.user)
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
const [isForSelf, setIsForSelf] = useState(true);
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: '', firstName: '',
lastName: '', lastName: '',
@ -22,7 +23,10 @@ export default function SummaryPage() {
city: '', city: '',
country: 'DE', country: 'DE',
frequency: 'monatlich', frequency: 'monatlich',
startDate: '' startDate: '',
recipientEmail: '',
recipientName: '',
recipientNotes: '',
}); });
const [showThanks, setShowThanks] = useState(false); const [showThanks, setShowThanks] = useState(false);
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]); const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
@ -142,6 +146,11 @@ export default function SummaryPage() {
setForm(prev => ({ ...prev, [name]: value })); setForm(prev => ({ ...prev, [name]: value }));
}; };
const handleRecipientNotes = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
const fillFromLoggedInData = () => { const fillFromLoggedInData = () => {
if (!user) { if (!user) {
setSubmitError('No logged-in user data found to fill the fields.'); setSubmitError('No logged-in user data found to fill the fields.');
@ -168,10 +177,25 @@ export default function SummaryPage() {
})); }));
}; };
const requiredSelfFields: Array<keyof typeof form> = [
'firstName',
'lastName',
'email',
'street',
'postalCode',
'city',
'country',
'frequency',
]
const hasRequiredSelfFields = requiredSelfFields.every(k => form[k].trim() !== '')
const hasRequiredGiftFields = isForSelf || form.recipientEmail.trim() !== ''
const canSubmit = const canSubmit =
selectedEntries.length > 0 && selectedEntries.length > 0 &&
totalPacks === requiredPacks && totalPacks === requiredPacks &&
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true)); hasRequiredSelfFields &&
hasRequiredGiftFields;
const backToSelection = () => router.push('/coffee-abonnements'); const backToSelection = () => router.push('/coffee-abonnements');
@ -182,6 +206,11 @@ export default function SummaryPage() {
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`) setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
return return
} }
if (!isForSelf && !form.recipientEmail.trim()) {
setSubmitError('Recipient email is required when the subscription is for someone else.')
return
}
setSubmitError(null) setSubmitError(null)
setSubmitLoading(true) setSubmitLoading(true)
try { try {
@ -193,6 +222,7 @@ export default function SummaryPage() {
billing_interval: 'month', billing_interval: 'month',
interval_count: 1, interval_count: 1,
is_auto_renew: true, is_auto_renew: true,
is_for_self: isForSelf,
// NEW: pass customer fields // NEW: pass customer fields
firstName: form.firstName.trim(), firstName: form.firstName.trim(),
lastName: form.lastName.trim(), lastName: form.lastName.trim(),
@ -202,7 +232,10 @@ export default function SummaryPage() {
city: form.city.trim(), city: form.city.trim(),
country: form.country.trim(), country: form.country.trim(),
frequency: form.frequency.trim(), frequency: form.frequency.trim(),
startDate: form.startDate.trim(), startDate: form.startDate.trim() || undefined,
recipient_email: isForSelf ? undefined : form.recipientEmail.trim(),
recipient_name: isForSelf ? undefined : (form.recipientName.trim() || undefined),
recipient_notes: isForSelf ? undefined : (form.recipientNotes.trim() || undefined),
// NEW: always include referred_by if available // NEW: always include referred_by if available
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined, referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
} }
@ -294,6 +327,30 @@ export default function SummaryPage() {
> >
Fill fields with logged in data Fill fields with logged in data
</button> </button>
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsForSelf(true)}
className={`rounded-md border px-3 py-2 text-sm font-medium transition ${
isForSelf
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
For me
</button>
<button
type="button"
onClick={() => setIsForSelf(false)}
className={`rounded-md border px-3 py-2 text-sm font-medium transition ${
!isForSelf
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
For someone else
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* inputs translated */} {/* inputs translated */}
<div> <div>
@ -337,9 +394,42 @@ export default function SummaryPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Start date</label> <label className="block text-sm font-medium mb-1">Start date (optional)</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]" /> <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>
{!isForSelf && (
<>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient email</label>
<input
type="email"
name="recipientEmail"
value={form.recipientEmail}
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">Recipient name (optional)</label>
<input
name="recipientName"
value={form.recipientName}
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">Recipient note (optional)</label>
<textarea
name="recipientNotes"
value={form.recipientNotes}
onChange={handleRecipientNotes}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
rows={3}
/>
</div>
</>
)}
</div> </div>
<button <button
onClick={submit} onClick={submit}
@ -353,7 +443,13 @@ export default function SummaryPage() {
<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" /> <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>
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>} {!canSubmit && (
<p className="text-xs text-gray-500 mt-2">
{isForSelf
? 'Please select coffees and fill all required buyer fields.'
: 'Please select coffees and fill all required buyer fields plus recipient email.'}
</p>
)}
</div> </div>
</section> </section>
@ -415,7 +511,9 @@ export default function SummaryPage() {
</svg> </svg>
</div> </div>
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3> <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> <p className="mt-1 text-sm text-gray-600">
{isForSelf ? 'Subscription created.' : 'Subscription created, invitation sent.'}
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2"> <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"> <button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">

View File

@ -0,0 +1,189 @@
import React from 'react'
import { authFetch } from '../../utils/authFetch'
import { AboInvoice, useAboInvoices } from '../hooks/getAboInvoices'
type Props = {
abonementId?: string | number | null
}
const BASE_URL = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const formatDate = (value?: string | null) => {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('de-DE')
}
const formatMoney = (value?: string | number | null, currency?: string | null) => {
if (value == null || value === '') return '—'
const n = typeof value === 'string' ? Number(value) : value
if (!Number.isFinite(Number(n))) return String(value)
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency || 'EUR',
}).format(Number(n))
}
const isAbsUrl = (url: string) => /^https?:\/\//i.test(url)
const resolveInvoiceUrl = (invoice: AboInvoice) => {
const raw = invoice.pdfUrl || invoice.downloadUrl || invoice.htmlUrl || invoice.fileUrl
if (!raw) return null
return isAbsUrl(raw) ? raw : `${BASE_URL}${raw.startsWith('/') ? '' : '/'}${raw}`
}
function downloadBlob(content: Blob, fileName: string) {
const url = URL.createObjectURL(content)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export default function FinanceInvoices({ abonementId }: Props) {
const { data: invoices, loading, error } = useAboInvoices(abonementId)
const [busyId, setBusyId] = React.useState<string | number | null>(null)
const [actionError, setActionError] = React.useState<string | null>(null)
const onView = (invoice: AboInvoice) => {
setActionError(null)
const url = resolveInvoiceUrl(invoice)
if (!url) {
setActionError('No view URL is available for this invoice.')
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
const onDownload = async (invoice: AboInvoice) => {
setActionError(null)
setBusyId(invoice.id)
try {
const url = resolveInvoiceUrl(invoice)
if (url) {
const res = await authFetch(url, { method: 'GET' })
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
const blob = await res.blob()
const invoiceNo = invoice.invoiceNumber || String(invoice.id)
const ext = invoice.pdfUrl ? 'pdf' : 'html'
downloadBlob(blob, `invoice-${invoiceNo}.${ext}`)
} else {
const blob = new Blob([JSON.stringify(invoice.raw, null, 2)], { type: 'application/json' })
downloadBlob(blob, `invoice-${invoice.invoiceNumber || invoice.id}.json`)
}
} catch (e: any) {
setActionError(e?.message || 'Failed to download invoice.')
} finally {
setBusyId(null)
}
}
const onExportAll = () => {
setActionError(null)
if (!invoices.length) {
setActionError('No invoices available to export.')
return
}
const exportPayload = {
exportedAt: new Date().toISOString(),
abonementId: abonementId ?? null,
count: invoices.length,
invoices: invoices.map((inv) => ({
id: inv.id,
invoiceNumber: inv.invoiceNumber,
issuedAt: inv.issuedAt,
createdAt: inv.createdAt,
totalNet: inv.totalNet,
totalTax: inv.totalTax,
totalGross: inv.totalGross,
currency: inv.currency,
status: inv.status,
})),
}
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' })
downloadBlob(blob, `invoices-export-${new Date().toISOString().slice(0, 10)}.json`)
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-gray-900">Finance & Invoices</h2>
<button
onClick={onExportAll}
disabled={!invoices.length || loading}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
Export all invoices
</button>
</div>
{!abonementId ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
No subscription selected. Invoices will appear once you have an active subscription.
</div>
) : loading ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
Loading invoices
</div>
) : error ? (
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
{error}
</div>
) : invoices.length === 0 ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
No invoices found for this subscription.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-white/60 bg-white/70 backdrop-blur-md shadow-lg">
<table className="min-w-full text-sm">
<thead className="bg-white/80">
<tr className="text-left text-gray-700">
<th className="px-4 py-3 font-semibold">Date</th>
<th className="px-4 py-3 font-semibold">Invoice #</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Total</th>
<th className="px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id} className="border-t border-gray-200/70">
<td className="px-4 py-3 text-gray-800">{formatDate(invoice.issuedAt || invoice.createdAt)}</td>
<td className="px-4 py-3 text-gray-800">{invoice.invoiceNumber || `#${invoice.id}`}</td>
<td className="px-4 py-3 text-gray-700">{invoice.status || '—'}</td>
<td className="px-4 py-3 text-gray-900 font-medium">{formatMoney(invoice.totalGross, invoice.currency)}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
onClick={() => onView(invoice)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
>
View
</button>
<button
onClick={() => onDownload(invoice)}
disabled={busyId === invoice.id}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{busyId === invoice.id ? 'Downloading…' : 'Download'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{actionError && (
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-3 text-xs text-red-700">
{actionError}
</div>
)}
</section>
)
}

View File

@ -1,8 +1,17 @@
import React from 'react' import React from 'react'
import { useReferredAbos } from '../hooks/getAbo' import { useMyAboStatus } from '../hooks/getAbo'
export default function UserAbo() { type Props = {
const { data: abos, loading, error } = useReferredAbos() onAboChange?: (aboId: string | number | null) => void
}
export default function UserAbo({ onAboChange }: Props) {
const { hasAbo, abonement, loading, error } = useMyAboStatus()
React.useEffect(() => {
if (!onAboChange) return
onAboChange(abonement?.id ?? null)
}, [abonement?.id, onAboChange])
if (loading) { if (loading) {
return ( return (
@ -28,18 +37,18 @@ export default function UserAbo() {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2> <h2 className="text-lg font-semibold text-gray-900">My Subscription</h2>
{(!abos || abos.length === 0) ? ( {(!hasAbo || !abonement) ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg"> <div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
No subscriptions yet. You currently dont have an active subscription.
</div> </div>
) : ( ) : (
<div className="grid gap-3 sm:gap-4"> <div className="grid gap-3 sm:gap-4">
{abos.map(abo => { {(() => {
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled' const status = (abonement.status || 'active') as 'active' | 'paused' | 'canceled'
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—' const nextBilling = abonement.nextBillingAt ? new Date(abonement.nextBillingAt).toLocaleDateString() : '—'
const started = abo.startedAt ? new Date(abo.startedAt).toLocaleDateString() : '—' const started = abonement.startedAt ? new Date(abonement.startedAt).toLocaleDateString() : '—'
const coffees = (abo.pack_breakdown || abo.items || []).map((it, i) => ( const coffees = (abonement.pack_breakdown || abonement.items || []).map((it, i) => (
<span <span
key={i} 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" 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"
@ -53,14 +62,14 @@ export default function UserAbo() {
</span> </span>
)) ))
return ( return (
<div key={abo.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg"> <div key={abonement.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p> <p className="text-sm font-medium text-gray-900">{abonement.name || 'Coffee Subscription'}</p>
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Next billing: {nextBilling} Next billing: {nextBilling}
{' • '}Frequency: {abo.frequency ?? '—'} {' • '}Frequency: {abonement.frequency ?? '—'}
{' • '}Country: {(abo.country ?? '').toUpperCase() || '—'} {' • '}Country: {(abonement.country ?? '').toUpperCase() || '—'}
{' • '}Started: {started} {' • '}Started: {started}
</p> </p>
</div> </div>
@ -84,15 +93,12 @@ export default function UserAbo() {
</div> </div>
<div className="mt-3 flex gap-2"> <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"> <button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
Manage Current plan
</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> </button>
</div> </div>
</div> </div>
) )
})} })()}
</div> </div>
)} )}
</section> </section>

View File

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

View File

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { authFetch } from '../../utils/authFetch'
export type AboInvoice = {
id: string | number
invoiceNumber?: string
issuedAt?: string | null
createdAt?: string | null
totalNet?: number | string | null
totalTax?: number | string | null
totalGross?: number | string | null
currency?: string | null
status?: string | null
htmlUrl?: string | null
pdfUrl?: string | null
downloadUrl?: string | null
fileUrl?: string | null
objectKey?: string | null
raw: any
}
const pickFirst = (...vals: any[]) => {
for (const value of vals) {
if (value !== undefined && value !== null && value !== '') return value
}
return null
}
export function useAboInvoices(abonementId?: string | number | null) {
const [data, setData] = useState<AboInvoice[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
if (!abonementId) {
setData([])
setLoading(false)
setError(null)
return
}
;(async () => {
setLoading(true)
setError(null)
try {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/abonements/${abonementId}/invoices`
console.info('[useAboInvoices] GET', url)
const res = await authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
})
const ct = res.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
const json = isJson ? await res.json().catch(() => ({})) : null
console.info('[useAboInvoices] Response', res.status, json)
if (!res.ok || !json?.success) {
throw new Error(json?.message || `Fetch failed: ${res.status}`)
}
const list = Array.isArray(json?.data) ? json.data : []
const mapped: AboInvoice[] = list.map((row: any) => ({
id: pickFirst(row.id, row._id, row.invoice_id, row.invoiceId) as string | number,
invoiceNumber: pickFirst(row.invoiceNumber, row.invoice_number, row.number) as string,
issuedAt: pickFirst(row.issuedAt, row.issued_at, row.invoiceDate, row.invoice_date) as string | null,
createdAt: pickFirst(row.createdAt, row.created_at) as string | null,
totalNet: pickFirst(row.totalNet, row.total_net),
totalTax: pickFirst(row.totalTax, row.total_tax),
totalGross: pickFirst(row.totalGross, row.total_gross, row.amount),
currency: pickFirst(row.currency, row.totalCurrency, row.total_currency) as string | null,
status: pickFirst(row.status, row.state) as string | null,
htmlUrl: pickFirst(row.htmlUrl, row.html_url, row.invoice_html_url) as string | null,
pdfUrl: pickFirst(row.pdfUrl, row.pdf_url, row.invoice_pdf_url) as string | null,
downloadUrl: pickFirst(row.downloadUrl, row.download_url) as string | null,
fileUrl: pickFirst(row.fileUrl, row.file_url, row.url) as string | null,
objectKey: pickFirst(row.objectKey, row.object_key, row.storageKey, row.storage_key) as string | null,
raw: row,
}))
const sorted = mapped.sort((a, b) => {
const da = new Date(a.issuedAt || a.createdAt || 0).getTime()
const db = new Date(b.issuedAt || b.createdAt || 0).getTime()
return db - da
})
if (active) setData(sorted)
} catch (e: any) {
if (active) {
setError(e?.message || 'Failed to load invoices.')
setData([])
}
} finally {
if (active) setLoading(false)
}
})()
return () => { active = false }
}, [abonementId])
return { data, loading, error }
}

View File

@ -11,6 +11,7 @@ import MediaSection from './components/mediaSection'
import BankInformation from './components/bankInformation' import BankInformation from './components/bankInformation'
import EditModal from './components/editModal' import EditModal from './components/editModal'
import UserAbo from './components/userAbo' import UserAbo from './components/userAbo'
import FinanceInvoices from './components/financeInvoices'
import { getProfileCompletion } from './hooks/getProfileCompletion' import { getProfileCompletion } from './hooks/getProfileCompletion'
import { useProfileData } from './hooks/getProfileData' import { useProfileData } from './hooks/getProfileData'
import { useMedia } from './hooks/getMedia' import { useMedia } from './hooks/getMedia'
@ -96,6 +97,7 @@ export default function ProfilePage() {
const [editModalError, setEditModalError] = React.useState<string | null>(null) const [editModalError, setEditModalError] = React.useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = React.useState(false) const [downloadLoading, setDownloadLoading] = React.useState(false)
const [downloadError, setDownloadError] = React.useState<string | null>(null) const [downloadError, setDownloadError] = React.useState<string | null>(null)
const [currentAboId, setCurrentAboId] = React.useState<string | number | null>(null)
useEffect(() => { setHasHydrated(true) }, []) useEffect(() => { setHasHydrated(true) }, [])
@ -394,7 +396,9 @@ export default function ProfilePage() {
{/* Bank Info, Media */} {/* Bank Info, Media */}
<div className="space-y-6 sm:space-y-8 mb-8"> <div className="space-y-6 sm:space-y-8 mb-8">
{/* --- My Abo Section (above bank info) --- */} {/* --- My Abo Section (above bank info) --- */}
<UserAbo /> <UserAbo onAboChange={setCurrentAboId} />
{/* --- Finance Section (invoices by current abo) --- */}
<FinanceInvoices abonementId={currentAboId} />
{/* --- Edit Bank Information Section --- */} {/* --- Edit Bank Information Section --- */}
<BankInformation <BankInformation
profileData={profileData} profileData={profileData}