feat: abo
This commit is contained in:
parent
004a8f4baa
commit
b164f73b43
@ -7,6 +7,7 @@ export type SubscribeAboInput = {
|
||||
billing_interval?: string
|
||||
interval_count?: number
|
||||
is_auto_renew?: boolean
|
||||
is_for_self?: boolean
|
||||
target_user_id?: number
|
||||
recipient_name?: string
|
||||
recipient_email?: string
|
||||
@ -22,7 +23,7 @@ export type SubscribeAboInput = {
|
||||
frequency?: string
|
||||
startDate?: string
|
||||
// NEW: logged-in user id
|
||||
referred_by?: number
|
||||
referred_by?: number | string
|
||||
}
|
||||
|
||||
type Abonement = any
|
||||
@ -41,13 +42,13 @@ 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.')
|
||||
const isForSelf = input.is_for_self ?? true
|
||||
if (!isForSelf && (!input.recipient_email || input.recipient_email.trim() === '')) {
|
||||
throw new Error('recipient_email is required when subscription is for someone else.')
|
||||
}
|
||||
|
||||
// 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 v = (input as any)[k]
|
||||
return typeof v !== 'string' || v.trim() === ''
|
||||
@ -60,6 +61,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
||||
billing_interval: input.billing_interval ?? 'month',
|
||||
interval_count: input.interval_count ?? 1,
|
||||
is_auto_renew: input.is_auto_renew ?? true,
|
||||
is_for_self: isForSelf,
|
||||
// NEW: include customer fields
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
@ -69,7 +71,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
||||
city: input.city,
|
||||
country: input.country?.toUpperCase?.() ?? input.country,
|
||||
frequency: input.frequency,
|
||||
startDate: input.startDate,
|
||||
startDate: input.startDate || undefined,
|
||||
}
|
||||
if (hasItems) {
|
||||
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
|
||||
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
|
||||
if (!isForSelf && input.recipient_email) body.recipient_email = input.recipient_email
|
||||
if (!isForSelf && input.recipient_name) body.recipient_name = input.recipient_name
|
||||
if (!isForSelf && 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
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ export default function SummaryPage() {
|
||||
const user = useAuthStore(state => state.user)
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||||
const [isForSelf, setIsForSelf] = useState(true);
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@ -22,7 +23,10 @@ export default function SummaryPage() {
|
||||
city: '',
|
||||
country: 'DE',
|
||||
frequency: 'monatlich',
|
||||
startDate: ''
|
||||
startDate: '',
|
||||
recipientEmail: '',
|
||||
recipientName: '',
|
||||
recipientNotes: '',
|
||||
});
|
||||
const [showThanks, setShowThanks] = useState(false);
|
||||
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
|
||||
@ -142,6 +146,11 @@ export default function SummaryPage() {
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleRecipientNotes = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const fillFromLoggedInData = () => {
|
||||
if (!user) {
|
||||
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 =
|
||||
selectedEntries.length > 0 &&
|
||||
totalPacks === requiredPacks &&
|
||||
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
|
||||
hasRequiredSelfFields &&
|
||||
hasRequiredGiftFields;
|
||||
|
||||
const backToSelection = () => router.push('/coffee-abonnements');
|
||||
|
||||
@ -182,6 +206,11 @@ export default function SummaryPage() {
|
||||
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
|
||||
return
|
||||
}
|
||||
if (!isForSelf && !form.recipientEmail.trim()) {
|
||||
setSubmitError('Recipient email is required when the subscription is for someone else.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitError(null)
|
||||
setSubmitLoading(true)
|
||||
try {
|
||||
@ -193,6 +222,7 @@ export default function SummaryPage() {
|
||||
billing_interval: 'month',
|
||||
interval_count: 1,
|
||||
is_auto_renew: true,
|
||||
is_for_self: isForSelf,
|
||||
// NEW: pass customer fields
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
@ -202,7 +232,10 @@ export default function SummaryPage() {
|
||||
city: form.city.trim(),
|
||||
country: form.country.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
|
||||
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
|
||||
}
|
||||
@ -294,6 +327,30 @@ export default function SummaryPage() {
|
||||
>
|
||||
Fill fields with logged in data
|
||||
</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">
|
||||
{/* inputs translated */}
|
||||
<div>
|
||||
@ -337,9 +394,42 @@ export default function SummaryPage() {
|
||||
</select>
|
||||
</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]" />
|
||||
</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>
|
||||
<button
|
||||
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" />
|
||||
</svg>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@ -415,7 +511,9 @@ export default function SummaryPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<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">
|
||||
<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 { useReferredAbos } from '../hooks/getAbo'
|
||||
import { useMyAboStatus } from '../hooks/getAbo'
|
||||
|
||||
export default function UserAbo() {
|
||||
const { data: abos, loading, error } = useReferredAbos()
|
||||
type Props = {
|
||||
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) {
|
||||
return (
|
||||
@ -28,18 +37,18 @@ export default function UserAbo() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
{(!abos || abos.length === 0) ? (
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscription</h2>
|
||||
{(!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">
|
||||
No subscriptions yet.
|
||||
You currently don’t have an active subscription.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm: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) => (
|
||||
{(() => {
|
||||
const status = (abonement.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||
const nextBilling = abonement.nextBillingAt ? new Date(abonement.nextBillingAt).toLocaleDateString() : '—'
|
||||
const started = abonement.startedAt ? new Date(abonement.startedAt).toLocaleDateString() : '—'
|
||||
const coffees = (abonement.pack_breakdown || abonement.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"
|
||||
@ -53,14 +62,14 @@ export default function UserAbo() {
|
||||
</span>
|
||||
))
|
||||
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>
|
||||
<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">
|
||||
Next billing: {nextBilling}
|
||||
{' • '}Frequency: {abo.frequency ?? '—'}
|
||||
{' • '}Country: {(abo.country ?? '').toUpperCase() || '—'}
|
||||
{' • '}Frequency: {abonement.frequency ?? '—'}
|
||||
{' • '}Country: {(abonement.country ?? '').toUpperCase() || '—'}
|
||||
{' • '}Started: {started}
|
||||
</p>
|
||||
</div>
|
||||
@ -84,15 +93,12 @@ export default function UserAbo() {
|
||||
</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
|
||||
Current plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
type AbonementItem = {
|
||||
coffeeName?: string
|
||||
@ -8,7 +7,7 @@ type AbonementItem = {
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type ReferredAbo = {
|
||||
export type CurrentAbo = {
|
||||
id: number | string
|
||||
status: 'active' | 'paused' | 'canceled'
|
||||
nextBillingAt?: string | null
|
||||
@ -22,8 +21,9 @@ export type ReferredAbo = {
|
||||
startedAt?: string | null
|
||||
}
|
||||
|
||||
export function useReferredAbos() {
|
||||
const [data, setData] = useState<ReferredAbo[]>([])
|
||||
export function useMyAboStatus() {
|
||||
const [hasAbo, setHasAbo] = useState<boolean>(false)
|
||||
const [abonement, setAbonement] = useState<CurrentAbo | null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@ -34,52 +34,55 @@ export function useReferredAbos() {
|
||||
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/mine/status`
|
||||
|
||||
const url = `${base}/api/abonements/referred`
|
||||
|
||||
console.info('[useReferredAbos] Preparing POST', url, {
|
||||
userId: userId ?? null,
|
||||
userEmail: email ?? null,
|
||||
})
|
||||
console.info('[useMyAboStatus] GET', url)
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
method: 'GET',
|
||||
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)
|
||||
console.info('[useMyAboStatus] 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
|
||||
|
||||
const rawAbo = json?.data?.abonement ?? null
|
||||
const mappedAbo: CurrentAbo | null = rawAbo
|
||||
? {
|
||||
id: rawAbo.id,
|
||||
status: (rawAbo.status || 'active') as 'active' | 'paused' | 'canceled',
|
||||
nextBillingAt: rawAbo.next_billing_at ?? rawAbo.nextBillingAt ?? null,
|
||||
email: rawAbo.email,
|
||||
price: rawAbo.price,
|
||||
name: `${rawAbo.first_name ?? ''} ${rawAbo.last_name ?? ''}`.trim() || rawAbo.name || 'Coffee Subscription',
|
||||
frequency: rawAbo.frequency,
|
||||
country: rawAbo.country,
|
||||
startedAt: rawAbo.started_at ?? rawAbo.startedAt ?? null,
|
||||
pack_breakdown: Array.isArray(rawAbo.pack_breakdown)
|
||||
? rawAbo.pack_breakdown.map((it: any) => ({
|
||||
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,
|
||||
}))
|
||||
: []
|
||||
if (active) setData(list)
|
||||
}
|
||||
: null
|
||||
|
||||
if (active) {
|
||||
setHasAbo(Boolean(json?.data?.hasAbo))
|
||||
setAbonement(mappedAbo)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message || 'Failed to load subscriptions.')
|
||||
} finally {
|
||||
@ -89,5 +92,5 @@ export function useReferredAbos() {
|
||||
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 EditModal from './components/editModal'
|
||||
import UserAbo from './components/userAbo'
|
||||
import FinanceInvoices from './components/financeInvoices'
|
||||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||
import { useProfileData } from './hooks/getProfileData'
|
||||
import { useMedia } from './hooks/getMedia'
|
||||
@ -96,6 +97,7 @@ export default function ProfilePage() {
|
||||
const [editModalError, setEditModalError] = React.useState<string | null>(null)
|
||||
const [downloadLoading, setDownloadLoading] = React.useState(false)
|
||||
const [downloadError, setDownloadError] = React.useState<string | null>(null)
|
||||
const [currentAboId, setCurrentAboId] = React.useState<string | number | null>(null)
|
||||
|
||||
useEffect(() => { setHasHydrated(true) }, [])
|
||||
|
||||
@ -394,7 +396,9 @@ export default function ProfilePage() {
|
||||
{/* Bank Info, Media */}
|
||||
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||
{/* --- My Abo Section (above bank info) --- */}
|
||||
<UserAbo />
|
||||
<UserAbo onAboChange={setCurrentAboId} />
|
||||
{/* --- Finance Section (invoices by current abo) --- */}
|
||||
<FinanceInvoices abonementId={currentAboId} />
|
||||
{/* --- Edit Bank Information Section --- */}
|
||||
<BankInformation
|
||||
profileData={profileData}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user