feat: abo
This commit is contained in:
parent
004a8f4baa
commit
b164f73b43
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
189
src/app/profile/components/financeInvoices.tsx
Normal file
189
src/app/profile/components/financeInvoices.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 don’t 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>
|
||||||
|
|||||||
@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/app/profile/hooks/getAboInvoices.ts
Normal file
107
src/app/profile/hooks/getAboInvoices.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user