Compare commits
5 Commits
poolLinkTo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a36fc051ca | |||
| 4429a1b9c0 | |||
|
|
0375ec0714 | ||
|
|
2313a8e9ae | ||
| 40e9f0e7b6 |
12
ToDo.txt
12
ToDo.txt
@ -22,11 +22,20 @@ Last updated: 2026-01-20
|
||||
=== SEAZN TODOS ===
|
||||
(Compromised User / Pool )
|
||||
|
||||
• [x] Compromised User Fix (SAT)
|
||||
• [x] Pools Complete Setup check and refactor -- Implementing Logging Layout from Alex -- Talk with him (SAT)
|
||||
• [x] Adjust and add Functionality for Download Acc Data and Delete Acc (SAT)
|
||||
• [X] News Management (own pages for news) + Adjust the Dashboard to Display Latest news
|
||||
• [ ] Unified Modal Design
|
||||
• [ ] Autorefresh of Site??
|
||||
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
|
||||
• [x] Remove irrelevant statuses in userverify filter
|
||||
• [ ] User Status 1 Feld das wir nicht benutzen
|
||||
• [ ] Pool mulit user actions (select 5 -> add to pool)
|
||||
• [x] reset edit templates
|
||||
• [x] "Suspended" status should actually do something
|
||||
• [] Matrix shit (tiefe dynamisch einstellen)
|
||||
• [x] Git
|
||||
• [] Switching status -> confirmation modal
|
||||
• [] mobile scroll bug with double page on top
|
||||
• [] search modal unify -> only return userId(s)
|
||||
@ -34,13 +43,10 @@ Last updated: 2026-01-20
|
||||
|
||||
|
||||
|
||||
|
||||
================================================================================
|
||||
QUICK SHARED / CROSSOVER ITEMS
|
||||
================================================================================
|
||||
|
||||
• [] Summary & Details bei abo abschluss - Country nur DE + Delivery Interval richtig? oder nur monthly
|
||||
• [] Wieviele Abos können auf einen pool verlinkt werden?
|
||||
• [ ] Security Headers
|
||||
• [ ] Dependency Management
|
||||
• [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept
|
||||
|
||||
4019
package-lock.json
generated
4019
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@ -14,55 +14,55 @@
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lottiefiles/react-lottie-player": "^3.6.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindplus/elements": "^1.0.22",
|
||||
"@tailwindplus/elements": "^1.0.15",
|
||||
"@tailwindui/react": "^0.1.1",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.6.13",
|
||||
"country-flag-icons": "^1.5.21",
|
||||
"country-select-js": "^2.1.0",
|
||||
"gsap": "^3.14.2",
|
||||
"intl-tel-input": "^26.4.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"motion": "^12.34.1",
|
||||
"next": "^16.1.6",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"intl-tel-input": "^25.15.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.0.7",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.12",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0",
|
||||
"winston": "^3.19.0",
|
||||
"three": "^0.167.1",
|
||||
"winston": "^3.17.0",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^25",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.3.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-env": "^11.1.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"postcss-preset-env": "^10.4.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import useContractManagement from '../hooks/useContractManagement';
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||
|
||||
type Props = {
|
||||
editingTemplateId?: string | null;
|
||||
@ -18,14 +17,12 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
|
||||
const [lang, setLang] = useState<'en' | 'de'>('en');
|
||||
const [type, setType] = useState<'contract' | 'bill' | 'invoice' | 'other'>('contract');
|
||||
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
|
||||
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
|
||||
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
|
||||
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
|
||||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
|
||||
const [publishConfirmMessage, setPublishConfirmMessage] = useState('')
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
@ -54,7 +51,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
setHtmlCode(tpl.html || '');
|
||||
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
|
||||
setLang((tpl.lang as any) || 'en');
|
||||
setType(((tpl.type as any) || 'contract') as 'contract' | 'bill' | 'invoice' | 'other');
|
||||
setType((tpl.type as any) || 'contract');
|
||||
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
|
||||
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
|
||||
setEditingMeta({
|
||||
@ -155,7 +152,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
lang
|
||||
)
|
||||
|
||||
const doSave = async (publish: boolean) => {
|
||||
const save = async (publish: boolean) => {
|
||||
const html = htmlCode.trim();
|
||||
// NEW: validate all fields
|
||||
if (!canSave) {
|
||||
@ -163,6 +160,14 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
return;
|
||||
}
|
||||
|
||||
if (publish && type === 'contract') {
|
||||
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
|
||||
const ok = window.confirm(
|
||||
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setStatusMsg(null);
|
||||
|
||||
@ -213,21 +218,6 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (publish: boolean) => {
|
||||
if (publish && type === 'contract') {
|
||||
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
|
||||
setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the same user type and language.`)
|
||||
setPublishConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
await doSave(publish)
|
||||
}
|
||||
|
||||
const confirmPublish = async () => {
|
||||
setPublishConfirmOpen(false)
|
||||
await doSave(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{editingMeta && (
|
||||
@ -283,13 +273,12 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'invoice' | 'other')}
|
||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')}
|
||||
required
|
||||
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
{type === 'contract' && (
|
||||
@ -333,22 +322,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
<div className="space-y-3">
|
||||
{type === 'invoice' && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||
<p className="font-semibold">Invoice template variables</p>
|
||||
<p className="mt-1">Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.</p>
|
||||
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span> to render invoice line items.</p>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={htmlCode}
|
||||
onChange={(e) => setHtmlCode(e.target.value)}
|
||||
placeholder="Paste your full HTML (or snippet) here…"
|
||||
required
|
||||
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={htmlCode}
|
||||
onChange={(e) => setHtmlCode(e.target.value)}
|
||||
placeholder="Paste your full HTML (or snippet) here…"
|
||||
required
|
||||
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPreview && (
|
||||
@ -382,16 +362,6 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
||||
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
||||
</div>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={publishConfirmOpen}
|
||||
pending={saving}
|
||||
title="Activate template now?"
|
||||
description={publishConfirmMessage || 'This will activate this template.'}
|
||||
confirmText="Activate"
|
||||
onClose={() => !saving && setPublishConfirmOpen(false)}
|
||||
onConfirm={confirmPublish}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import useContractManagement from '../hooks/useContractManagement';
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||
|
||||
type Props = {
|
||||
refreshKey?: number;
|
||||
@ -38,7 +37,6 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
const [items, setItems] = useState<ContractTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null)
|
||||
|
||||
const {
|
||||
listTemplates,
|
||||
@ -85,39 +83,29 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshKey]);
|
||||
|
||||
const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
|
||||
const onToggleState = async (id: string, current: string) => {
|
||||
const target = current === 'published' ? 'inactive' : 'active';
|
||||
|
||||
// Confirmation: activating a contract/GDPR will deactivate other active templates of the same kind
|
||||
if (target === 'active') {
|
||||
const tpl = items.find((i) => i.id === id);
|
||||
if (tpl?.type === 'contract') {
|
||||
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
|
||||
const ok = window.confirm(
|
||||
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
||||
// Update clicked item immediately, then refresh list to reflect any auto-deactivations.
|
||||
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
|
||||
await load();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onToggleState = async (id: string, current: string) => {
|
||||
const target = current === 'published' ? 'inactive' : 'active';
|
||||
if (target === 'active') {
|
||||
const tpl = items.find((i) => i.id === id);
|
||||
if (tpl?.type === 'contract') {
|
||||
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
|
||||
setPendingToggle({
|
||||
id,
|
||||
target,
|
||||
requiresConfirm: true,
|
||||
message: `This will deactivate other active ${kind} templates that apply to the same user type and language.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await executeToggleState(id, target);
|
||||
};
|
||||
|
||||
const confirmToggleState = async () => {
|
||||
if (!pendingToggle) return
|
||||
await executeToggleState(pendingToggle.id, pendingToggle.target)
|
||||
setPendingToggle(null)
|
||||
}
|
||||
|
||||
const onPreview = (id: string) => openPreviewInNewTab(id);
|
||||
|
||||
const onGenPdf = async (id: string) => {
|
||||
@ -136,9 +124,6 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||
For invoice emails, provide active invoice templates for the language/user type combinations you need (en/de × personal/company/both). If no active invoice template matches, backend falls back to text-only email.
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
placeholder="Search templates…"
|
||||
@ -163,7 +148,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
<StatusBadge status={c.status} />
|
||||
{c.type && (
|
||||
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
||||
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : c.type === 'invoice' ? 'Invoice' : 'Other'}
|
||||
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.type === 'contract' && (
|
||||
@ -203,15 +188,6 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(pendingToggle?.requiresConfirm)}
|
||||
title="Activate template now?"
|
||||
description={pendingToggle?.message || 'This action will update template activation status.'}
|
||||
confirmText="Activate"
|
||||
onClose={() => setPendingToggle(null)}
|
||||
onConfirm={confirmToggleState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,18 +4,12 @@ import PageLayout from '../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVatRates } from './hooks/getTaxes'
|
||||
import { useAdminInvoices } from './hooks/getInvoices'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
export default function FinanceManagementPage() {
|
||||
const router = useRouter()
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
||||
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
||||
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
||||
const [diagLoading, setDiagLoading] = useState(false)
|
||||
const [diagError, setDiagError] = useState('')
|
||||
const [diagData, setDiagData] = useState<any | null>(null)
|
||||
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
||||
|
||||
// NEW: fetch invoices from backend
|
||||
const {
|
||||
@ -73,47 +67,6 @@ export default function FinanceManagementPage() {
|
||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
||||
}
|
||||
|
||||
const runPoolCheck = async (invoiceId: string | number) => {
|
||||
setDiagLoading(true)
|
||||
setDiagError('')
|
||||
setDiagData(null)
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok || body?.success === false) {
|
||||
setDiagError(body?.message || `Check failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
setDiagData(body?.data || null)
|
||||
} catch (e: any) {
|
||||
setDiagError(e?.message || 'Network error')
|
||||
} finally {
|
||||
setDiagLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportInvoice = (inv: AdminInvoice) => {
|
||||
const pretty = JSON.stringify(inv, null, 2)
|
||||
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `invoice-${inv.invoice_number || inv.id}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
@ -225,51 +178,6 @@ export default function FinanceManagementPage() {
|
||||
{invError}
|
||||
</div>
|
||||
)}
|
||||
{(diagLoading || diagError || diagData) && (
|
||||
<div className="rounded-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
|
||||
{diagLoading && <div className="text-blue-800">Checking pool inflow...</div>}
|
||||
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
||||
{!diagLoading && !diagError && diagData && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
|
||||
<div className="text-gray-700">
|
||||
Status: <span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span> • Reason: <span className="font-mono">{diagData.reason}</span>
|
||||
</div>
|
||||
{diagData.ok && (
|
||||
<div className="text-gray-700">
|
||||
Abonement: <span className="font-medium">{diagData.abonement_id}</span> • Will book: <span className="font-medium">{diagData.will_book_count}</span> • Already booked: <span className="font-medium">{diagData.already_booked_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-blue-900">
|
||||
<th className="pr-3 py-1">Pool</th>
|
||||
<th className="pr-3 py-1">Coffee</th>
|
||||
<th className="pr-3 py-1">Capsules</th>
|
||||
<th className="pr-3 py-1">Net Amount</th>
|
||||
<th className="pr-3 py-1">Booked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diagData.candidates.map((c: any) => (
|
||||
<tr key={`${c.pool_id}-${c.coffee_table_id}`}>
|
||||
<td className="pr-3 py-1">{c.pool_name}</td>
|
||||
<td className="pr-3 py-1">#{c.coffee_table_id}</td>
|
||||
<td className="pr-3 py-1">{c.capsules_count}</td>
|
||||
<td className="pr-3 py-1">€{Number(c.amount_net ?? 0).toFixed(2)}</td>
|
||||
<td className="pr-3 py-1">{c.already_booked ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-blue-50 text-left text-blue-900">
|
||||
@ -321,24 +229,8 @@ export default function FinanceManagementPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 space-x-2">
|
||||
<button
|
||||
onClick={() => setSelectedInvoice(inv)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportInvoice(inv)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runPoolCheck(inv.id)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
Pool check
|
||||
</button>
|
||||
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
|
||||
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@ -346,25 +238,6 @@ export default function FinanceManagementPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedInvoice && (
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-semibold text-[#1C2B4A]">
|
||||
Invoice details: {selectedInvoice.invoice_number ?? selectedInvoice.id}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedInvoice(null)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-white"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(selectedInvoice, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,8 +4,7 @@ import React from 'react'
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other'; subscription_coffee_id: number | null }) => void | Promise<void>
|
||||
subscriptions: Array<{ id: number; title: string }>
|
||||
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) => void | Promise<void>
|
||||
creating: boolean
|
||||
error?: string
|
||||
success?: string
|
||||
@ -16,7 +15,6 @@ export default function CreateNewPoolModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreate,
|
||||
subscriptions,
|
||||
creating,
|
||||
error,
|
||||
success,
|
||||
@ -26,7 +24,6 @@ export default function CreateNewPoolModal({
|
||||
const [description, setDescription] = React.useState('')
|
||||
const [price, setPrice] = React.useState('0.00')
|
||||
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
|
||||
const [subscriptionCoffeeId, setSubscriptionCoffeeId] = React.useState<string>('')
|
||||
|
||||
const isDisabled = creating || !!success
|
||||
|
||||
@ -36,7 +33,6 @@ export default function CreateNewPoolModal({
|
||||
setDescription('')
|
||||
setPrice('0.00')
|
||||
setPoolType('other')
|
||||
setSubscriptionCoffeeId('')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
@ -77,13 +73,7 @@ export default function CreateNewPoolModal({
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
onCreate({
|
||||
pool_name: poolName,
|
||||
description,
|
||||
price: parseFloat(price) || 0,
|
||||
pool_type: poolType,
|
||||
subscription_coffee_id: subscriptionCoffeeId ? Number(subscriptionCoffeeId) : null,
|
||||
})
|
||||
onCreate({ pool_name: poolName, description, price: parseFloat(price) || 0, pool_type: poolType })
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
@ -110,7 +100,7 @@ export default function CreateNewPoolModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Price per capsule (net)</label>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Price (per capsule)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@ -122,7 +112,6 @@ export default function CreateNewPoolModal({
|
||||
disabled={isDisabled}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">This value is stored as net price.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label>
|
||||
@ -136,20 +125,6 @@ export default function CreateNewPoolModal({
|
||||
<option value="coffee">Coffee</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Linked Subscription</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
value={subscriptionCoffeeId}
|
||||
onChange={e => setSubscriptionCoffeeId(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="">No subscription selected (set later)</option>
|
||||
{subscriptions.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
@ -162,7 +137,7 @@ export default function CreateNewPoolModal({
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
|
||||
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
|
||||
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); clearMessages(); }}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Reset
|
||||
|
||||
@ -4,7 +4,6 @@ export type AddPoolPayload = {
|
||||
pool_name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
subscription_coffee_id?: number | null;
|
||||
pool_type: 'coffee' | 'other';
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
@ -7,8 +7,6 @@ export type AdminPool = {
|
||||
pool_name: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
subscription_coffee_id?: number | null;
|
||||
subscription_title?: string | null;
|
||||
pool_type?: 'coffee' | 'other';
|
||||
is_active?: boolean;
|
||||
membersCount: number;
|
||||
@ -64,9 +62,7 @@ export function useAdminPools() {
|
||||
id: String(item.id),
|
||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||
description: String(item.description ?? ''),
|
||||
price: Number(item.price_net ?? item.price ?? 0),
|
||||
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
||||
subscription_title: item.subscription_title ?? null,
|
||||
price: Number(item.price ?? 0),
|
||||
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||
is_active: Boolean(item.is_active),
|
||||
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||
@ -104,9 +100,7 @@ export function useAdminPools() {
|
||||
id: String(item.id),
|
||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||
description: String(item.description ?? ''),
|
||||
price: Number(item.price_net ?? item.price ?? 0),
|
||||
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
||||
subscription_title: item.subscription_title ?? null,
|
||||
price: Number(item.price ?? 0),
|
||||
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||
is_active: Boolean(item.is_active),
|
||||
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||
|
||||
@ -8,8 +8,6 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||
import { AdminAPI } from '../../../utils/api'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||
|
||||
type PoolUser = {
|
||||
id: string
|
||||
@ -52,8 +50,6 @@ function PoolManagePageInner() {
|
||||
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
||||
const poolDescription = searchParams.get('description') ?? ''
|
||||
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
||||
const initialSubscriptionId = searchParams.get('subscription_coffee_id') ?? ''
|
||||
const subscriptionTitle = searchParams.get('subscription_title') ?? ''
|
||||
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
||||
const poolIsActive = searchParams.get('is_active') === 'true'
|
||||
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
||||
@ -79,13 +75,6 @@ function PoolManagePageInner() {
|
||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
||||
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
||||
const [removeError, setRemoveError] = React.useState<string>('')
|
||||
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
|
||||
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
|
||||
const [linkedSubscriptionId, setLinkedSubscriptionId] = React.useState<string>(initialSubscriptionId)
|
||||
const [savingSubscription, setSavingSubscription] = React.useState(false)
|
||||
const [subscriptionMessage, setSubscriptionMessage] = React.useState('')
|
||||
const [subscriptionError, setSubscriptionError] = React.useState('')
|
||||
const [currentSubscriptionTitle, setCurrentSubscriptionTitle] = React.useState(subscriptionTitle)
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
@ -118,76 +107,6 @@ function PoolManagePageInner() {
|
||||
void fetchMembers()
|
||||
}, [token, poolId])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const response = await authFetch(`${base}/api/admin/coffee`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
return
|
||||
}
|
||||
const rows = await response.json().catch(() => [])
|
||||
const mapped = Array.isArray(rows)
|
||||
? rows
|
||||
.map((r: any) => ({ id: Number(r?.id), title: String(r?.title || '').trim() }))
|
||||
.filter((r: { id: number; title: string }) => Number.isFinite(r.id) && r.id > 0 && !!r.title)
|
||||
: []
|
||||
if (!cancelled) setSubscriptions(mapped)
|
||||
} catch {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
}
|
||||
}
|
||||
void loadSubscriptions()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
async function saveLinkedSubscription() {
|
||||
if (!poolId || poolId === 'pool-unknown') return
|
||||
setSavingSubscription(true)
|
||||
setSubscriptionError('')
|
||||
setSubscriptionMessage('')
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const payload = {
|
||||
subscription_coffee_id: linkedSubscriptionId ? Number(linkedSubscriptionId) : null,
|
||||
}
|
||||
const response = await authFetch(`${base}/api/admin/pools/${encodeURIComponent(String(poolId))}/subscription`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok || body?.success === false) {
|
||||
setSubscriptionError(body?.message || `Failed to update (${response.status})`)
|
||||
return
|
||||
}
|
||||
const selectedTitle = subscriptions.find(s => String(s.id) === String(linkedSubscriptionId))?.title || ''
|
||||
setCurrentSubscriptionTitle(selectedTitle)
|
||||
setSubscriptionMessage(`Linked subscription updated${selectedTitle ? `: ${selectedTitle}` : ' (not linked)'}.`)
|
||||
} catch (e: any) {
|
||||
setSubscriptionError(e?.message || 'Failed to update linked subscription.')
|
||||
} finally {
|
||||
setSavingSubscription(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Early return AFTER all hooks are declared to keep consistent order
|
||||
if (!authChecked) return null
|
||||
|
||||
@ -295,14 +214,10 @@ function PoolManagePageInner() {
|
||||
}
|
||||
|
||||
async function removeMember(userId: string) {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
const user = users.find(u => u.id === userId)
|
||||
const label = user?.name || user?.email || 'this user'
|
||||
setRemoveConfirm({ userId, label })
|
||||
}
|
||||
|
||||
async function confirmRemoveMember() {
|
||||
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
|
||||
const userId = removeConfirm.userId
|
||||
if (!window.confirm(`Remove ${label} from this pool?`)) return
|
||||
setRemoveError('')
|
||||
setRemovingMemberId(userId)
|
||||
try {
|
||||
@ -312,7 +227,6 @@ function PoolManagePageInner() {
|
||||
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
||||
} finally {
|
||||
setRemovingMemberId(null)
|
||||
setRemoveConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -341,10 +255,6 @@ function PoolManagePageInner() {
|
||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Price/capsule (net): € {Number(poolPrice || 0).toFixed(2)}</span>
|
||||
<span>•</span>
|
||||
<span>Subscription: {currentSubscriptionTitle || 'Not linked'}</span>
|
||||
<span>•</span>
|
||||
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span className="text-gray-500">ID: {poolId}</span>
|
||||
@ -362,39 +272,6 @@ function PoolManagePageInner() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 mb-8 relative z-0">
|
||||
<h2 className="text-lg font-semibold text-blue-900 mb-3">Linked Subscription</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Subscription</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
value={linkedSubscriptionId}
|
||||
onChange={e => setLinkedSubscriptionId(e.target.value)}
|
||||
>
|
||||
<option value="">No subscription linked</option>
|
||||
{subscriptions.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Current: {currentSubscriptionTitle || 'Not linked'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveLinkedSubscription}
|
||||
disabled={savingSubscription}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition disabled:opacity-60"
|
||||
>
|
||||
{savingSubscription ? 'Saving…' : 'Save Link'}
|
||||
</button>
|
||||
</div>
|
||||
{subscriptionMessage && (
|
||||
<div className="mt-3 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">{subscriptionMessage}</div>
|
||||
)}
|
||||
{subscriptionError && (
|
||||
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{subscriptionError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats (now zero until backend wired) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
||||
@ -645,17 +522,6 @@ function PoolManagePageInner() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(removeConfirm)}
|
||||
pending={Boolean(removingMemberId)}
|
||||
intent="danger"
|
||||
title="Remove member from pool?"
|
||||
description={`This will remove ${removeConfirm?.label || 'this user'} from the pool.`}
|
||||
confirmText="Remove"
|
||||
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
||||
onConfirm={confirmRemoveMember}
|
||||
/>
|
||||
</div>
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
|
||||
@ -11,16 +11,12 @@ import { useRouter } from 'next/navigation'
|
||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
|
||||
type Pool = {
|
||||
id: string
|
||||
pool_name: string
|
||||
description?: string
|
||||
price?: number
|
||||
subscription_coffee_id?: number | null
|
||||
subscription_title?: string | null
|
||||
pool_type?: 'coffee' | 'other'
|
||||
is_active?: boolean
|
||||
membersCount: number
|
||||
@ -36,18 +32,15 @@ export default function PoolManagementPage() {
|
||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
|
||||
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
|
||||
|
||||
// Token and API URL
|
||||
const token = useAuthStore(s => s.accessToken)
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
|
||||
// Replace local fetch with hook
|
||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||
const [pools, setPools] = React.useState<Pool[]>([])
|
||||
const [showInactive, setShowInactive] = React.useState(false)
|
||||
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!loading && !error) {
|
||||
@ -55,46 +48,10 @@ export default function PoolManagementPage() {
|
||||
}
|
||||
}, [initialPools, loading, error])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const response = await authFetch(`${base}/api/admin/coffee`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
return
|
||||
}
|
||||
|
||||
const rows = await response.json().catch(() => [])
|
||||
const mapped = Array.isArray(rows)
|
||||
? rows
|
||||
.map((r: any) => ({ id: Number(r?.id), title: String(r?.title || '').trim() }))
|
||||
.filter((r: { id: number; title: string }) => Number.isFinite(r.id) && r.id > 0 && !!r.title)
|
||||
: []
|
||||
if (!cancelled) setSubscriptions(mapped)
|
||||
} catch {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
}
|
||||
}
|
||||
void loadSubscriptions()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
||||
|
||||
// REPLACED: handleCreatePool to accept data from modal with new schema fields
|
||||
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other'; subscription_coffee_id: number | null }) {
|
||||
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) {
|
||||
setCreateError('')
|
||||
setCreateSuccess('')
|
||||
const pool_name = data.pool_name.trim()
|
||||
@ -105,14 +62,7 @@ export default function PoolManagementPage() {
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await addPool({
|
||||
pool_name,
|
||||
description: description || undefined,
|
||||
price: data.price,
|
||||
subscription_coffee_id: data.subscription_coffee_id,
|
||||
pool_type: data.pool_type,
|
||||
is_active: true,
|
||||
})
|
||||
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
|
||||
if (res.ok && res.body?.data) {
|
||||
setCreateSuccess('Pool created successfully.')
|
||||
await refresh?.()
|
||||
@ -131,28 +81,26 @@ export default function PoolManagementPage() {
|
||||
}
|
||||
|
||||
async function handleArchive(poolId: string) {
|
||||
setPoolStatusConfirm({ poolId, action: 'archive' })
|
||||
const confirmed = window.confirm('Archive this pool? Users will no longer be able to join or use it.')
|
||||
if (!confirmed) return
|
||||
setArchiveError('')
|
||||
const res = await setPoolInactive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || 'Failed to deactivate pool.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetActive(poolId: string) {
|
||||
setPoolStatusConfirm({ poolId, action: 'activate' })
|
||||
}
|
||||
|
||||
async function confirmPoolStatusChange() {
|
||||
if (!poolStatusConfirm) return
|
||||
const { poolId, action } = poolStatusConfirm
|
||||
setPoolStatusPending(true)
|
||||
const confirmed = window.confirm('Unarchive this pool and make it active again?')
|
||||
if (!confirmed) return
|
||||
setArchiveError('')
|
||||
try {
|
||||
const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || (action === 'archive' ? 'Failed to deactivate pool.' : 'Failed to activate pool.'))
|
||||
}
|
||||
} finally {
|
||||
setPoolStatusPending(false)
|
||||
setPoolStatusConfirm(null)
|
||||
const res = await setPoolActive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || 'Failed to activate pool.')
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,9 +219,6 @@ export default function PoolManagementPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Subscription: {pool.subscription_title || 'Not linked'}
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-500">Members</span>
|
||||
@ -296,8 +241,6 @@ export default function PoolManagementPage() {
|
||||
description: pool.description ?? '',
|
||||
price: String(pool.price ?? 0),
|
||||
pool_type: pool.pool_type ?? 'other',
|
||||
subscription_coffee_id: pool.subscription_coffee_id != null ? String(pool.subscription_coffee_id) : '',
|
||||
subscription_title: pool.subscription_title ?? '',
|
||||
is_active: pool.is_active ? 'true' : 'false',
|
||||
createdAt: pool.createdAt ?? '',
|
||||
})
|
||||
@ -342,28 +285,12 @@ export default function PoolManagementPage() {
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
|
||||
onCreate={handleCreatePool}
|
||||
subscriptions={subscriptions}
|
||||
creating={creating}
|
||||
error={createError}
|
||||
success={createSuccess}
|
||||
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
|
||||
/>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(poolStatusConfirm)}
|
||||
pending={poolStatusPending}
|
||||
intent={poolStatusConfirm?.action === 'archive' ? 'danger' : 'default'}
|
||||
title={poolStatusConfirm?.action === 'archive' ? 'Archive pool?' : 'Activate pool?'}
|
||||
description={
|
||||
poolStatusConfirm?.action === 'archive'
|
||||
? 'Users will no longer be able to join or use this pool while archived.'
|
||||
: 'This pool will be active again and available for use.'
|
||||
}
|
||||
confirmText={poolStatusConfirm?.action === 'archive' ? 'Archive' : 'Set Active'}
|
||||
onClose={() => { if (!poolStatusPending) setPoolStatusConfirm(null) }}
|
||||
onConfirm={confirmPoolStatusChange}
|
||||
/>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</PageTransitionEffect>
|
||||
|
||||
@ -25,74 +25,54 @@ export function useActiveCoffees() {
|
||||
|
||||
useEffect(() => {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
const url = `${base}/api/admin/coffee/active`;
|
||||
|
||||
const candidateUrls = [
|
||||
`${base}/api/coffee/active`,
|
||||
`${base}/api/admin/coffee/active`,
|
||||
];
|
||||
|
||||
console.log('[useActiveCoffees] Fetching active coffees from candidates:', candidateUrls);
|
||||
console.log('[useActiveCoffees] Fetching active coffees from:', url);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tryFetch = async () => {
|
||||
let lastError: string | null = null;
|
||||
authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (response) => {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
console.log('[useActiveCoffees] Response status:', response.status);
|
||||
console.log('[useActiveCoffees] Response content-type:', contentType);
|
||||
|
||||
for (const url of candidateUrls) {
|
||||
try {
|
||||
const response = await authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
console.log('[useActiveCoffees] Response for', url, response.status, contentType);
|
||||
|
||||
if (!response.ok || !contentType.includes('application/json')) {
|
||||
const text = await response.text().catch(() => '');
|
||||
lastError = `Request failed: ${response.status} ${text.slice(0, 160)}`;
|
||||
|
||||
if (response.status === 404) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const data: ActiveCoffee[] =
|
||||
Array.isArray(json?.data) ? json.data :
|
||||
Array.isArray(json) ? json :
|
||||
[];
|
||||
|
||||
const mapped: CoffeeItem[] = data
|
||||
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
||||
.map((coffee) => {
|
||||
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price;
|
||||
return {
|
||||
id: String(coffee.id),
|
||||
name: coffee.title || `Coffee ${coffee.id}`,
|
||||
description: coffee.description || '',
|
||||
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
||||
image: coffee.pictureUrl || '',
|
||||
};
|
||||
});
|
||||
|
||||
setCoffees(mapped);
|
||||
return;
|
||||
} catch (e: any) {
|
||||
lastError = e?.message || 'Failed to load active coffees';
|
||||
if (!response.ok || !contentType.includes('application/json')) {
|
||||
const text = await response.text().catch(() => '');
|
||||
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200));
|
||||
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
lastError ||
|
||||
'Active coffee list endpoint is not available. Please restart/update the backend and try again.'
|
||||
);
|
||||
};
|
||||
const json = await response.json();
|
||||
console.log('[useActiveCoffees] Raw JSON response:', json);
|
||||
|
||||
tryFetch()
|
||||
const data: ActiveCoffee[] =
|
||||
Array.isArray(json?.data) ? json.data :
|
||||
Array.isArray(json) ? json :
|
||||
[]
|
||||
console.log('[useActiveCoffees] Parsed coffee data:', data);
|
||||
|
||||
const mapped: CoffeeItem[] = data
|
||||
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
||||
.map((coffee) => {
|
||||
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price
|
||||
return {
|
||||
id: String(coffee.id),
|
||||
name: coffee.title || `Coffee ${coffee.id}`,
|
||||
description: coffee.description || '',
|
||||
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
||||
image: coffee.pictureUrl || '',
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[useActiveCoffees] Mapped coffee items:', mapped)
|
||||
setCoffees(mapped)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('[useActiveCoffees] Error fetching coffees:', error);
|
||||
setError(error?.message || 'Failed to load active coffees');
|
||||
|
||||
@ -7,7 +7,6 @@ import { useActiveCoffees } from './hooks/getActiveCoffees';
|
||||
export default function CoffeeAbonnementPage() {
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch active coffees from the backend
|
||||
@ -32,20 +31,22 @@ export default function CoffeeAbonnementPage() {
|
||||
[selectedEntries]
|
||||
);
|
||||
|
||||
// NEW: enforce selected plan size (60 or 120 capsules)
|
||||
// NEW: enforce exactly 120 capsules (12 packs)
|
||||
const totalCapsules = useMemo(
|
||||
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||
[selectedEntries]
|
||||
);
|
||||
const packsSelected = totalCapsules / 10;
|
||||
const requiredPacks = selectedPlanCapsules / 10;
|
||||
const canProceed = packsSelected === requiredPacks;
|
||||
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
|
||||
|
||||
const TAX_RATE = 0.07;
|
||||
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
||||
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
||||
|
||||
const proceedToSummary = () => {
|
||||
if (!canProceed) return;
|
||||
try {
|
||||
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
||||
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
|
||||
} catch {}
|
||||
router.push('/coffee-abonnements/summary');
|
||||
};
|
||||
@ -56,8 +57,6 @@ export default function CoffeeAbonnementPage() {
|
||||
if (id in copy) {
|
||||
delete copy[id];
|
||||
} else {
|
||||
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0);
|
||||
if (total + 10 > selectedPlanCapsules) return prev;
|
||||
copy[id] = 10;
|
||||
}
|
||||
return copy;
|
||||
@ -67,10 +66,8 @@ export default function CoffeeAbonnementPage() {
|
||||
const changeQuantity = (id: string, delta: number) => {
|
||||
setSelections((prev) => {
|
||||
if (!(id in prev)) return prev;
|
||||
const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
|
||||
const maxForCoffee = Math.min(120, selectedPlanCapsules - otherTotal);
|
||||
const next = prev[id] + delta;
|
||||
if (next < 10 || next > maxForCoffee) return prev;
|
||||
if (next < 10 || next > 120) return prev;
|
||||
const updated = { ...prev, [id]: next };
|
||||
setBump((b) => ({ ...b, [id]: true }));
|
||||
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
||||
@ -100,37 +97,7 @@ export default function CoffeeAbonnementPage() {
|
||||
|
||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</h2>
|
||||
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPlanCapsules(60)}
|
||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
||||
selectedPlanCapsules === 60
|
||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">60 piece abo</div>
|
||||
<div className="text-xs text-gray-600">6 packs of 10 capsules</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPlanCapsules(120)}
|
||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
||||
selectedPlanCapsules === 120
|
||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">120 piece abo</div>
|
||||
<div className="text-xs text-gray-600">12 packs of 10 capsules</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
@ -149,15 +116,6 @@ export default function CoffeeAbonnementPage() {
|
||||
{coffees.map((coffee) => {
|
||||
const active = coffee.id in selections;
|
||||
const qty = selections[coffee.id] || 0;
|
||||
const remainingCapsules = selectedPlanCapsules - totalCapsules;
|
||||
const maxForCoffee = active
|
||||
? Math.min(120, qty + remainingCapsules)
|
||||
: 0;
|
||||
const sliderMax = Math.max(10, maxForCoffee);
|
||||
const sliderProgress = sliderMax <= 10
|
||||
? 100
|
||||
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
|
||||
const canAddCoffee = active || remainingCapsules >= 10;
|
||||
return (
|
||||
<div
|
||||
key={coffee.id}
|
||||
@ -200,13 +158,10 @@ export default function CoffeeAbonnementPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCoffee(coffee.id)}
|
||||
disabled={!canAddCoffee}
|
||||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||||
active
|
||||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
||||
: canAddCoffee
|
||||
? 'border-gray-300 hover:bg-gray-100'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{active ? 'Remove' : 'Add'}
|
||||
@ -224,7 +179,6 @@ export default function CoffeeAbonnementPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => changeQuantity(coffee.id, -10)}
|
||||
disabled={qty <= 10}
|
||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||
>
|
||||
-10
|
||||
@ -233,7 +187,7 @@ export default function CoffeeAbonnementPage() {
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={sliderMax}
|
||||
max={120}
|
||||
step={10}
|
||||
value={qty}
|
||||
onChange={(e) =>
|
||||
@ -243,9 +197,9 @@ export default function CoffeeAbonnementPage() {
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
||||
sliderProgress +
|
||||
((qty - 10) / (120 - 10)) * 100 +
|
||||
'%,#e5e7eb ' +
|
||||
sliderProgress +
|
||||
((qty - 10) / (120 - 10)) * 100 +
|
||||
'%,#e5e7eb 100%)',
|
||||
height: '6px',
|
||||
borderRadius: '999px',
|
||||
@ -254,7 +208,6 @@ export default function CoffeeAbonnementPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeQuantity(coffee.id, +10)}
|
||||
disabled={qty + 10 > maxForCoffee}
|
||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||
>
|
||||
+10
|
||||
@ -277,7 +230,7 @@ export default function CoffeeAbonnementPage() {
|
||||
|
||||
{/* Section 2: Compact preview + next steps */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">3. Preview</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">2. Preview</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
||||
{selectedEntries.length === 0 && (
|
||||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
||||
@ -307,11 +260,11 @@ export default function CoffeeAbonnementPage() {
|
||||
|
||||
{/* Packs/capsules summary and validation hint (refined design) */}
|
||||
<div className="text-xs text-gray-700">
|
||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{packsSelected !== requiredPacks && (
|
||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10).
|
||||
{packsSelected !== 12 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||||
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
|
||||
Please select exactly 120 capsules (12 packs).
|
||||
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -343,7 +296,7 @@ export default function CoffeeAbonnementPage() {
|
||||
</button>
|
||||
{!canProceed && (
|
||||
<p className="text-xs text-gray-600">
|
||||
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
|
||||
You can continue once exactly 120 capsules (12 packs) are selected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,6 @@ 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
|
||||
@ -23,7 +22,7 @@ export type SubscribeAboInput = {
|
||||
frequency?: string
|
||||
startDate?: string
|
||||
// NEW: logged-in user id
|
||||
referred_by?: number | string
|
||||
referred_by?: number
|
||||
}
|
||||
|
||||
type Abonement = any
|
||||
@ -42,13 +41,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 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.')
|
||||
const hasRecipientFields = !!(input.recipient_name || input.recipient_email || input.recipient_notes)
|
||||
if (hasRecipientFields && !input.recipient_name) {
|
||||
throw new Error('recipient_name is required when gifting to a non-account recipient.')
|
||||
}
|
||||
|
||||
// NEW: validate customer fields (required in UI)
|
||||
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency'] as const
|
||||
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency','startDate'] as const
|
||||
const missing = requiredFields.filter(k => {
|
||||
const v = (input as any)[k]
|
||||
return typeof v !== 'string' || v.trim() === ''
|
||||
@ -61,7 +60,6 @@ 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,
|
||||
@ -71,18 +69,18 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
||||
city: input.city,
|
||||
country: input.country?.toUpperCase?.() ?? input.country,
|
||||
frequency: input.frequency,
|
||||
startDate: input.startDate || undefined,
|
||||
startDate: input.startDate,
|
||||
}
|
||||
if (hasItems) {
|
||||
body.items = input.items!.map(i => ({
|
||||
coffeeId: i.coffeeId,
|
||||
quantity: i.quantity != null ? i.quantity : 1,
|
||||
}))
|
||||
// NEW: enforce supported package sizes
|
||||
// NEW: enforce exactly 12 packs
|
||||
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
|
||||
if (sumPacks !== 6 && sumPacks !== 12) {
|
||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12')
|
||||
throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).')
|
||||
if (sumPacks !== 12) {
|
||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12')
|
||||
throw new Error('Order must contain exactly 12 packs (120 capsules).')
|
||||
}
|
||||
} else {
|
||||
body.coffeeId = input.coffeeId
|
||||
@ -90,9 +88,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 (!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
|
||||
if (input.recipient_name) body.recipient_name = input.recipient_name
|
||||
if (input.recipient_email) body.recipient_email = input.recipient_email
|
||||
if (input.recipient_notes) body.recipient_notes = input.recipient_notes
|
||||
// NEW: always include referred_by if provided
|
||||
if (input.referred_by != null) body.referred_by = input.referred_by
|
||||
|
||||
|
||||
@ -10,10 +10,7 @@ import useAuthStore from '../../store/authStore'
|
||||
export default function SummaryPage() {
|
||||
const router = useRouter();
|
||||
const { coffees, loading, error } = useActiveCoffees();
|
||||
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: '',
|
||||
@ -23,10 +20,7 @@ export default function SummaryPage() {
|
||||
city: '',
|
||||
country: 'DE',
|
||||
frequency: 'monatlich',
|
||||
startDate: '',
|
||||
recipientEmail: '',
|
||||
recipientName: '',
|
||||
recipientNotes: '',
|
||||
startDate: ''
|
||||
});
|
||||
const [showThanks, setShowThanks] = useState(false);
|
||||
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
|
||||
@ -40,10 +34,6 @@ export default function SummaryPage() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('coffeeSelections');
|
||||
if (raw) setSelections(JSON.parse(raw));
|
||||
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules');
|
||||
if (rawPlan === '60' || rawPlan === '120') {
|
||||
setSelectedPlanCapsules(Number(rawPlan) as 60 | 120);
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
@ -74,20 +64,16 @@ export default function SummaryPage() {
|
||||
[selectedEntries]
|
||||
)
|
||||
const totalPacks = totalCapsules / 10
|
||||
const requiredPacks = selectedPlanCapsules / 10
|
||||
|
||||
const token = useAuthStore.getState().accessToken
|
||||
console.info('[SummaryPage] token prefix:', token ? `${token.substring(0, 12)}…` : null)
|
||||
// NEW: capture logged-in user id for referral
|
||||
const rawUserId = user?.id
|
||||
const currentUserId = typeof rawUserId === 'number'
|
||||
? rawUserId
|
||||
: (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
|
||||
const currentUserId = useAuthStore.getState().user?.id
|
||||
console.info('[SummaryPage] currentUserId:', currentUserId)
|
||||
|
||||
// Countries list from backend VAT rates (fallback to current country if list empty)
|
||||
const countryOptions = useMemo(() => {
|
||||
const currentCode = (form.country || 'DE').toUpperCase();
|
||||
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [currentCode]
|
||||
if (!opts.includes(currentCode)) opts.unshift(currentCode)
|
||||
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()]
|
||||
console.info('[SummaryPage] countryOptions:', opts)
|
||||
return opts
|
||||
}, [vatRates, form.country]);
|
||||
@ -146,71 +132,20 @@ 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.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pick = (...values: any[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
setSubmitError(null);
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
firstName: pick(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName,
|
||||
lastName: pick(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName,
|
||||
email: pick(user.email, user.mail) || prev.email,
|
||||
street: pick(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street,
|
||||
postalCode: pick(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode,
|
||||
city: pick(user.city, user.addressCity, user.town, user.address?.city) || prev.city,
|
||||
country: (pick(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(),
|
||||
}));
|
||||
};
|
||||
|
||||
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 &&
|
||||
hasRequiredSelfFields &&
|
||||
hasRequiredGiftFields;
|
||||
totalPacks === 12 && // CHANGED: require exactly 12 packs
|
||||
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
|
||||
|
||||
const backToSelection = () => router.push('/coffee-abonnements');
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || submitLoading) return
|
||||
// NEW: guard (defensive) — backend requires selected package size
|
||||
if (totalPacks !== requiredPacks) {
|
||||
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
|
||||
// NEW: guard (defensive) — backend requires exactly 12 packs
|
||||
if (totalPacks !== 12) {
|
||||
setSubmitError('Order must contain exactly 12 packs (120 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 {
|
||||
@ -222,7 +157,6 @@ 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(),
|
||||
@ -232,10 +166,7 @@ export default function SummaryPage() {
|
||||
city: form.city.trim(),
|
||||
country: form.country.trim(),
|
||||
frequency: form.frequency.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),
|
||||
startDate: form.startDate.trim(),
|
||||
// NEW: always include referred_by if available
|
||||
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
|
||||
}
|
||||
@ -245,7 +176,6 @@ export default function SummaryPage() {
|
||||
await subscribeAbo(payload)
|
||||
setShowThanks(true);
|
||||
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
|
||||
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {}
|
||||
} catch (e: any) {
|
||||
setSubmitError(e?.message || 'Subscription could not be created.');
|
||||
} finally {
|
||||
@ -320,37 +250,6 @@ export default function SummaryPage() {
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillFromLoggedInData}
|
||||
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
|
||||
>
|
||||
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>
|
||||
@ -394,42 +293,9 @@ export default function SummaryPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Start date (optional)</label>
|
||||
<label className="block text-sm font-medium mb-1">Start date</label>
|
||||
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
{!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}
|
||||
@ -443,13 +309,7 @@ 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">
|
||||
{isForSelf
|
||||
? 'Please select coffees and fill all required buyer fields.'
|
||||
: 'Please select coffees and fill all required buyer fields plus recipient email.'}
|
||||
</p>
|
||||
)}
|
||||
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -482,10 +342,10 @@ export default function SummaryPage() {
|
||||
</div>
|
||||
{/* Validation summary (refined design) */}
|
||||
<div className="mt-2 text-xs text-gray-700">
|
||||
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{totalPacks !== requiredPacks && (
|
||||
Selected: {totalCapsules} capsules ({totalPacks} packs of 10).
|
||||
{totalPacks !== 12 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||||
Exactly {requiredPacks} packs ({selectedPlanCapsules} capsules) are required.
|
||||
Exactly 12 packs (120 capsules) are required.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -511,9 +371,7 @@ 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">
|
||||
{isForSelf ? 'Subscription created.' : 'Subscription created, invitation sent.'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-600">We have received your order.</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
|
||||
|
||||
@ -20,7 +20,6 @@ import {
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import ConfirmActionModal from './modals/ConfirmActionModal'
|
||||
|
||||
interface UserDetailModalProps {
|
||||
isOpen: boolean
|
||||
@ -74,12 +73,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
const [docsLoading, setDocsLoading] = useState(false)
|
||||
const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({})
|
||||
const [selectedFile, setSelectedFile] = useState<{ contract?: string; gdpr?: string }>({})
|
||||
const [moveConfirm, setMoveConfirm] = useState<{
|
||||
documentId?: number
|
||||
targetType: 'contract' | 'gdpr'
|
||||
filename?: string | null
|
||||
objectKey?: string
|
||||
} | null>(null)
|
||||
|
||||
const missingIdOrContract = !!userDetails?.userStatus && (
|
||||
userDetails.userStatus.documents_uploaded !== 1 ||
|
||||
@ -303,12 +296,10 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
|
||||
const moveContractDoc = async (documentId: number | undefined, targetType: 'contract' | 'gdpr', filename?: string | null, objectKey?: string) => {
|
||||
if (!userId || !token) return
|
||||
setMoveConfirm({ documentId, targetType, filename, objectKey })
|
||||
}
|
||||
|
||||
const confirmMoveContractDoc = async () => {
|
||||
if (!userId || !token || !moveConfirm) return
|
||||
const { documentId, targetType, objectKey } = moveConfirm
|
||||
const label = targetType === 'gdpr' ? 'GDPR' : 'Contract'
|
||||
const name = filename ? `\n\nFile: ${filename}` : ''
|
||||
const ok = window.confirm(`Move this document to ${label}?${name}`)
|
||||
if (!ok) return
|
||||
const loadingKey = objectKey || String(documentId || '')
|
||||
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
|
||||
try {
|
||||
@ -318,7 +309,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
console.error('UserDetailModal.moveContractDoc error:', e)
|
||||
} finally {
|
||||
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
|
||||
setMoveConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -953,21 +943,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(moveConfirm)}
|
||||
pending={Boolean(moveConfirm && moveLoading[(moveConfirm.objectKey || String(moveConfirm.documentId || ''))])}
|
||||
title={`Move document to ${moveConfirm?.targetType === 'gdpr' ? 'GDPR' : 'Contract'}?`}
|
||||
description="This will reclassify the selected document under the chosen contract type."
|
||||
confirmText="Move document"
|
||||
onClose={() => setMoveConfirm(null)}
|
||||
onConfirm={confirmMoveContractDoc}
|
||||
extraContent={
|
||||
moveConfirm?.filename ? (
|
||||
<div className="text-xs text-gray-600">File: {moveConfirm.filename}</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import ConfirmActionModal from "../modals/ConfirmActionModal";
|
||||
|
||||
type DeleteConfirmationModalProps = {
|
||||
open: boolean;
|
||||
@ -24,18 +23,44 @@ export default function DeleteConfirmationModal({
|
||||
onCancel,
|
||||
children,
|
||||
}: DeleteConfirmationModalProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<ConfirmActionModal
|
||||
open={open}
|
||||
pending={loading}
|
||||
intent="danger"
|
||||
title={title}
|
||||
description={description}
|
||||
confirmText={confirmText}
|
||||
cancelText={cancelText}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onCancel}
|
||||
extraContent={children}
|
||||
/>
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-red-100">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" className="text-red-600">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-4">{description}</p>
|
||||
{children}
|
||||
<div className="flex items-center justify-end gap-2 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Deleting…" : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Fragment } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type ConfirmIntent = 'default' | 'danger'
|
||||
|
||||
interface ConfirmActionModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
pending?: boolean
|
||||
intent?: ConfirmIntent
|
||||
onClose: () => void
|
||||
onConfirm: () => Promise<void> | void
|
||||
extraContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function ConfirmActionModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
pending = false,
|
||||
intent = 'default',
|
||||
onClose,
|
||||
onConfirm,
|
||||
extraContent,
|
||||
}: ConfirmActionModalProps) {
|
||||
const [displayData, setDisplayData] = React.useState({
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
cancelText,
|
||||
intent,
|
||||
extraContent: extraContent ?? null,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
setDisplayData({
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
cancelText,
|
||||
intent,
|
||||
extraContent: extraContent ?? null,
|
||||
})
|
||||
}, [open, title, description, confirmText, cancelText, intent, extraContent])
|
||||
|
||||
const activeIntent = displayData.intent
|
||||
|
||||
const confirmButtonClass =
|
||||
activeIntent === 'danger'
|
||||
? 'inline-flex items-center rounded-md border border-red-300 bg-red-600 px-3 py-2 text-sm text-white hover:bg-red-700 disabled:opacity-60'
|
||||
: 'inline-flex items-center rounded-md border border-[#8D6B1D] bg-[#8D6B1D] px-3 py-2 text-sm text-white hover:bg-[#7A5E1A] disabled:opacity-60'
|
||||
|
||||
const iconColorClass = activeIntent === 'danger' ? 'text-red-600' : 'text-amber-600'
|
||||
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={pending ? () => {} : onClose} className="relative z-[1100]">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-all ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-2 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="transition-all ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-2 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl ring-1 ring-black/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0">
|
||||
<ExclamationTriangleIcon className={`h-6 w-6 ${iconColorClass}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-900">
|
||||
{displayData.title}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p>{displayData.description}</p>
|
||||
{displayData.extraContent ? <div className="mt-3">{displayData.extraContent}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
{displayData.cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={onConfirm}
|
||||
className={confirmButtonClass}
|
||||
>
|
||||
{displayData.confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
@ -658,15 +658,6 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push('/profile/subscriptions')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
My Subscriptions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main navigation (info + links + referral + ADMIN LAST) */}
|
||||
|
||||
@ -1,227 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
|
||||
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled'
|
||||
|
||||
const normalizeInvoiceStatus = (rawStatus?: string | null): UiLifecycleStatus => {
|
||||
const status = (rawStatus || '').toLowerCase()
|
||||
if (!status) return 'issued'
|
||||
if (status === 'cancelled' || status === 'canceled' || status === 'void') return 'cancelled'
|
||||
if (status === 'pause' || status === 'paused') return 'pause'
|
||||
if (status === 'finished' || status === 'paid' || status === 'closed' || status === 'settled') return 'finished'
|
||||
if (status === 'ongoing' || status === 'active' || status === 'processing') return 'ongoing'
|
||||
if (status === 'issued' || status === 'draft' || status === 'pending') return 'issued'
|
||||
return 'issued'
|
||||
}
|
||||
|
||||
const statusBadgeClass = (status: UiLifecycleStatus) => {
|
||||
if (status === 'ongoing') return 'bg-green-100 text-green-800'
|
||||
if (status === 'pause') return 'bg-amber-100 text-amber-800'
|
||||
if (status === 'cancelled') return 'bg-red-100 text-red-700'
|
||||
if (status === 'finished') return 'bg-gray-200 text-gray-700'
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
const displayStatus = (status: UiLifecycleStatus) =>
|
||||
status === 'pause'
|
||||
? 'Pause'
|
||||
: status === 'cancelled'
|
||||
? 'Cancelled'
|
||||
: status === 'ongoing'
|
||||
? 'Ongoing'
|
||||
: status === 'finished'
|
||||
? 'Finished'
|
||||
: 'Issued'
|
||||
|
||||
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">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(normalizeInvoiceStatus(invoice.status))}`}
|
||||
>
|
||||
{displayStatus(normalizeInvoiceStatus(invoice.status))}
|
||||
</span>
|
||||
</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,17 +1,8 @@
|
||||
import React from 'react'
|
||||
import { useMyAboStatus } from '../hooks/getAbo'
|
||||
import { useReferredAbos } from '../hooks/getAbo'
|
||||
|
||||
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])
|
||||
export default function UserAbo() {
|
||||
const { data: abos, loading, error } = useReferredAbos()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -37,18 +28,18 @@ export default function UserAbo({ onAboChange }: Props) {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscription</h2>
|
||||
{(!hasAbo || !abonement) ? (
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
{(!abos || abos.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">
|
||||
You currently don’t have an active subscription.
|
||||
No subscriptions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:gap-4">
|
||||
{(() => {
|
||||
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) => (
|
||||
{abos.map(abo => {
|
||||
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
|
||||
const started = abo.startedAt ? new Date(abo.startedAt).toLocaleDateString() : '—'
|
||||
const coffees = (abo.pack_breakdown || abo.items || []).map((it, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-white text-[#1C2B4A] px-3 py-1.5 text-xs font-medium border border-gray-200 shadow-sm ring-1 ring-gray-100 hover:shadow-md hover:ring-gray-200 transition"
|
||||
@ -62,14 +53,14 @@ export default function UserAbo({ onAboChange }: Props) {
|
||||
</span>
|
||||
))
|
||||
return (
|
||||
<div key={abonement.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
|
||||
<div key={abo.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">{abonement.name || 'Coffee Subscription'}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Next billing: {nextBilling}
|
||||
{' • '}Frequency: {abonement.frequency ?? '—'}
|
||||
{' • '}Country: {(abonement.country ?? '').toUpperCase() || '—'}
|
||||
{' • '}Frequency: {abo.frequency ?? '—'}
|
||||
{' • '}Country: {(abo.country ?? '').toUpperCase() || '—'}
|
||||
{' • '}Started: {started}
|
||||
</p>
|
||||
</div>
|
||||
@ -93,12 +84,15 @@ export default function UserAbo({ onAboChange }: Props) {
|
||||
</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">
|
||||
Current plan
|
||||
Manage
|
||||
</button>
|
||||
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||
View history
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
|
||||
type EditAboItem = {
|
||||
coffeeId: string | number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export async function editSubscriptionContent(abonementId: string | number, items: EditAboItem[]) {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/abonements/${abonementId}/content`
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ items }),
|
||||
})
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const json = ct.includes('application/json') ? await res.json().catch(() => ({})) : null
|
||||
if (!res.ok || !json?.success) {
|
||||
throw new Error(json?.message || `Update failed: ${res.status}`)
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
export async function changeSubscriptionStatus(
|
||||
abonementId: string | number,
|
||||
targetStatus: 'ongoing' | 'pause' | 'cancelled'
|
||||
) {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
|
||||
const action =
|
||||
targetStatus === 'pause'
|
||||
? 'pause'
|
||||
: targetStatus === 'ongoing'
|
||||
? 'resume'
|
||||
: 'cancel'
|
||||
|
||||
const url = `${base}/api/abonements/${abonementId}/${action}`
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const json = ct.includes('application/json') ? await res.json().catch(() => ({})) : null
|
||||
if (!res.ok || !json?.success) {
|
||||
throw new Error(json?.message || `Status update failed: ${res.status}`)
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
type AbonementItem = {
|
||||
coffeeName?: string
|
||||
@ -7,9 +8,9 @@ type AbonementItem = {
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type CurrentAbo = {
|
||||
export type ReferredAbo = {
|
||||
id: number | string
|
||||
status: 'active' | 'paused' | 'canceled' | 'cancelled' | 'expired' | 'issued' | 'ongoing' | 'finished' | 'pause'
|
||||
status: 'active' | 'paused' | 'canceled'
|
||||
nextBillingAt?: string | null
|
||||
email?: string
|
||||
price?: string | number
|
||||
@ -19,43 +20,10 @@ export type CurrentAbo = {
|
||||
frequency?: string
|
||||
country?: string
|
||||
startedAt?: string | null
|
||||
endedAt?: string | null
|
||||
createdAt?: string | null
|
||||
currency?: string | null
|
||||
}
|
||||
|
||||
function mapAbonement(rawAbo: any): CurrentAbo {
|
||||
return {
|
||||
id: rawAbo.id,
|
||||
status: (rawAbo.status || 'active') as CurrentAbo['status'],
|
||||
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,
|
||||
endedAt: rawAbo.ended_at ?? rawAbo.endedAt ?? null,
|
||||
createdAt: rawAbo.created_at ?? rawAbo.createdAt ?? null,
|
||||
currency: rawAbo.currency ?? null,
|
||||
pack_breakdown: Array.isArray(rawAbo.pack_breakdown)
|
||||
? rawAbo.pack_breakdown.map((it: any) => ({
|
||||
coffeeName: it.coffee_name ?? it.coffee_title ?? 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,
|
||||
}
|
||||
}
|
||||
|
||||
export function useMyAbonements(refreshKey: number = 0) {
|
||||
const [data, setData] = useState<CurrentAbo[]>([])
|
||||
export function useReferredAbos() {
|
||||
const [data, setData] = useState<ReferredAbo[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@ -66,77 +34,52 @@ export function useMyAbonements(refreshKey: number = 0) {
|
||||
setError(null)
|
||||
try {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/abonements/mine`
|
||||
const user = useAuthStore.getState().user
|
||||
const userId = user?.id
|
||||
const email = user?.email
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
const url = `${base}/api/abonements/referred`
|
||||
|
||||
console.info('[useReferredAbos] Preparing POST', url, {
|
||||
userId: userId ?? null,
|
||||
userEmail: email ?? null,
|
||||
})
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const isJson = ct.includes('application/json')
|
||||
const json = isJson ? await res.json().catch(() => ({})) : null
|
||||
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||
|
||||
const rows = Array.isArray(json?.data) ? json.data : []
|
||||
const mapped = rows.map((row: any) => mapAbonement(row))
|
||||
|
||||
if (active) setData(mapped)
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message || 'Failed to load subscriptions.')
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [refreshKey])
|
||||
|
||||
return { data, loading, error }
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/abonements/mine/status`
|
||||
|
||||
console.info('[useMyAboStatus] GET', url)
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'GET',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ userId: userId ?? null, email: email ?? null }),
|
||||
})
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const isJson = ct.includes('application/json')
|
||||
const json = isJson ? await res.json().catch(() => ({})) : null
|
||||
console.info('[useMyAboStatus] Response', res.status, json)
|
||||
console.info('[useReferredAbos] Response', res.status, json)
|
||||
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||
|
||||
const rawAbo = json?.data?.abonement ?? null
|
||||
const mappedAbo: CurrentAbo | null = rawAbo ? mapAbonement(rawAbo) : null
|
||||
|
||||
if (active) {
|
||||
setHasAbo(Boolean(json?.data?.hasAbo))
|
||||
setAbonement(mappedAbo)
|
||||
}
|
||||
const list: ReferredAbo[] = Array.isArray(json.data)
|
||||
? json.data.map((raw: any) => ({
|
||||
id: raw.id,
|
||||
status: raw.status,
|
||||
nextBillingAt: raw.next_billing_at ?? raw.nextBillingAt ?? null,
|
||||
email: raw.email,
|
||||
price: raw.price,
|
||||
name: `${raw.first_name ?? ''} ${raw.last_name ?? ''}`.trim() || 'Coffee Subscription',
|
||||
frequency: raw.frequency,
|
||||
country: raw.country,
|
||||
startedAt: raw.started_at ?? null,
|
||||
pack_breakdown: Array.isArray(raw.pack_breakdown)
|
||||
? raw.pack_breakdown.map((it: any) => ({
|
||||
coffeeName: it.coffee_name,
|
||||
coffeeId: it.coffee_table_id,
|
||||
quantity: it.packs, // packs count
|
||||
}))
|
||||
: undefined,
|
||||
}))
|
||||
: []
|
||||
if (active) setData(list)
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message || 'Failed to load subscriptions.')
|
||||
} finally {
|
||||
@ -146,5 +89,5 @@ export function useMyAboStatus() {
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return { hasAbo, abonement, loading, error }
|
||||
return { data, loading, error }
|
||||
}
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@ -10,10 +10,10 @@ import BasicInformation from './components/basicInformation'
|
||||
import MediaSection from './components/mediaSection'
|
||||
import BankInformation from './components/bankInformation'
|
||||
import EditModal from './components/editModal'
|
||||
import UserAbo from './components/userAbo'
|
||||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||
import { useProfileData } from './hooks/getProfileData'
|
||||
import { useMedia } from './hooks/getMedia'
|
||||
import { useMyAbonements } from './hooks/getAbo'
|
||||
import { editProfileBasic } from './hooks/editProfile'
|
||||
import { authFetch } from '../utils/authFetch'
|
||||
|
||||
@ -106,7 +106,6 @@ export default function ProfilePage() {
|
||||
// Fetch hooks can run with undefined userId; they should handle it internally
|
||||
const { data: profileDataApi, loading: profileLoading } = useProfileData(userId, refreshKey)
|
||||
const { data: mediaData, loading: mediaLoading } = useMedia(userId, refreshKey)
|
||||
const { data: subscriptions, loading: subscriptionsLoading, error: subscriptionsError } = useMyAbonements(refreshKey)
|
||||
|
||||
// Redirect only after hydration + auth ready
|
||||
useEffect(() => {
|
||||
@ -394,54 +393,8 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Bank Info, Media */}
|
||||
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||
<section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
View your active subscriptions, included items and subscription details on a dedicated page.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/profile/subscriptions')}
|
||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Open subscriptions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-white/60 bg-white/70 p-3">
|
||||
{subscriptionsLoading ? (
|
||||
<p className="text-sm text-gray-600">Loading subscriptions…</p>
|
||||
) : subscriptionsError ? (
|
||||
<p className="text-sm text-red-700">{subscriptionsError}</p>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">You don’t have any subscriptions yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{subscriptions.map((subscription) => {
|
||||
const packs = (subscription.pack_breakdown || subscription.items || []).reduce(
|
||||
(sum, item) => sum + (Number(item.quantity) || 0),
|
||||
0,
|
||||
)
|
||||
const started = subscription.startedAt || subscription.createdAt
|
||||
const startedLabel = started ? new Date(started).toLocaleDateString('de-DE') : '—'
|
||||
return (
|
||||
<li key={subscription.id} className="flex items-center justify-between gap-3 text-sm border-b border-gray-200/60 pb-2 last:border-0 last:pb-0">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">{subscription.name || `Subscription #${subscription.id}`}</p>
|
||||
<p className="text-xs text-gray-600">Started: {startedLabel} • Packs: {packs}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{String(subscription.status || 'ongoing')}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* --- My Abo Section (above bank info) --- */}
|
||||
<UserAbo />
|
||||
{/* --- Edit Bank Information Section --- */}
|
||||
<BankInformation
|
||||
profileData={profileData}
|
||||
|
||||
@ -1,520 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import BlueBlurryBackground from '../../components/background/blueblurry'
|
||||
import { useMyAbonements } from '../hooks/getAbo'
|
||||
import FinanceInvoices from '../components/financeInvoices'
|
||||
import { useActiveCoffees } from '../../coffee-abonnements/hooks/getActiveCoffees'
|
||||
import { changeSubscriptionStatus, editSubscriptionContent } from '../hooks/editAbo'
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
|
||||
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled'
|
||||
|
||||
const normalizeSubscriptionStatus = (rawStatus?: string | null): UiLifecycleStatus => {
|
||||
const status = (rawStatus || '').toLowerCase()
|
||||
if (status === 'pause' || status === 'paused') return 'pause'
|
||||
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
|
||||
if (status === 'finished' || status === 'expired') return 'finished'
|
||||
if (status === 'issued') return 'issued'
|
||||
return 'ongoing'
|
||||
}
|
||||
|
||||
const statusBadgeClass = (status: UiLifecycleStatus) => {
|
||||
if (status === 'ongoing') return 'bg-green-100 text-green-800'
|
||||
if (status === 'pause') return 'bg-amber-100 text-amber-800'
|
||||
if (status === 'cancelled') return 'bg-red-100 text-red-700'
|
||||
if (status === 'finished') return 'bg-gray-200 text-gray-700'
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
const displayStatus = (status: UiLifecycleStatus) =>
|
||||
status === 'pause'
|
||||
? 'Pause'
|
||||
: status === 'cancelled'
|
||||
? 'Cancelled'
|
||||
: status === 'ongoing'
|
||||
? 'Ongoing'
|
||||
: status === 'finished'
|
||||
? 'Finished'
|
||||
: 'Issued'
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
const formatMoney = (value?: string | number | null, currency?: string | null) => {
|
||||
if (value == null || value === '') return '—'
|
||||
const amount = typeof value === 'string' ? Number(value) : value
|
||||
if (!Number.isFinite(Number(amount))) return String(value)
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(Number(amount))
|
||||
}
|
||||
|
||||
export default function ProfileSubscriptionsPage() {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(state => state.user)
|
||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||
const [hasHydrated, setHasHydrated] = React.useState(false)
|
||||
const [selectedAboId, setSelectedAboId] = React.useState<string | number | null>(null)
|
||||
const [refreshKey, setRefreshKey] = React.useState(0)
|
||||
const [editingContent, setEditingContent] = React.useState(false)
|
||||
const [draftItems, setDraftItems] = React.useState<Record<string, number>>({})
|
||||
const [savingContent, setSavingContent] = React.useState(false)
|
||||
const [contentError, setContentError] = React.useState<string | null>(null)
|
||||
const [statusBusy, setStatusBusy] = React.useState(false)
|
||||
const [statusError, setStatusError] = React.useState<string | null>(null)
|
||||
const [statusConfirmTarget, setStatusConfirmTarget] = React.useState<'ongoing' | 'pause' | 'cancelled' | null>(null)
|
||||
|
||||
const { data: subscriptions, loading, error } = useMyAbonements(refreshKey)
|
||||
const { coffees, loading: coffeesLoading, error: coffeesError } = useActiveCoffees()
|
||||
|
||||
useEffect(() => { setHasHydrated(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !isAuthReady) return
|
||||
if (!user) router.replace('/login')
|
||||
}, [hasHydrated, isAuthReady, user, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscriptions.length) {
|
||||
setSelectedAboId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedAboId(prev => {
|
||||
if (prev != null && subscriptions.some((sub) => String(sub.id) === String(prev))) return prev
|
||||
return subscriptions[0].id
|
||||
})
|
||||
}, [subscriptions])
|
||||
|
||||
const coffeeImageById = React.useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
coffees.forEach((coffee) => {
|
||||
if (coffee.image) {
|
||||
map[String(coffee.id)] = coffee.image
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [coffees])
|
||||
|
||||
if (!hasHydrated || !isAuthReady || !user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedAbo = subscriptions.find((sub) => String(sub.id) === String(selectedAboId)) || null
|
||||
const status = normalizeSubscriptionStatus(selectedAbo?.status)
|
||||
const includedItems = selectedAbo?.pack_breakdown || selectedAbo?.items || []
|
||||
const totalPacks = includedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0)
|
||||
const draftTotalPacks = Object.values(draftItems).reduce((sum, qty) => sum + (Number(qty) || 0), 0)
|
||||
const canChangeContent = status === 'ongoing' || status === 'pause' || status === 'issued'
|
||||
|
||||
const startEditingContent = () => {
|
||||
if (!selectedAbo) return
|
||||
const nextDraft: Record<string, number> = {}
|
||||
;(selectedAbo.pack_breakdown || selectedAbo.items || []).forEach((item) => {
|
||||
const key = String(item.coffeeId ?? '')
|
||||
if (!key) return
|
||||
nextDraft[key] = (nextDraft[key] || 0) + (Number(item.quantity) || 0)
|
||||
})
|
||||
setDraftItems(nextDraft)
|
||||
setEditingContent(true)
|
||||
setContentError(null)
|
||||
}
|
||||
|
||||
const updateDraftQty = (coffeeId: string, value: number) => {
|
||||
setDraftItems((prev) => ({ ...prev, [coffeeId]: Math.max(0, Math.floor(Number(value) || 0)) }))
|
||||
}
|
||||
|
||||
const cancelEditingContent = () => {
|
||||
setEditingContent(false)
|
||||
setDraftItems({})
|
||||
setContentError(null)
|
||||
}
|
||||
|
||||
const saveContentChanges = async () => {
|
||||
if (!selectedAbo) return
|
||||
setContentError(null)
|
||||
const items = Object.entries(draftItems)
|
||||
.filter(([, qty]) => Number(qty) > 0)
|
||||
.map(([coffeeId, quantity]) => ({ coffeeId, quantity: Number(quantity) }))
|
||||
|
||||
if (!items.length) {
|
||||
setContentError('Please select at least one coffee with quantity greater than 0.')
|
||||
return
|
||||
}
|
||||
if (draftTotalPacks !== 6 && draftTotalPacks !== 12) {
|
||||
setContentError('Total packs must be exactly 6 or 12.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingContent(true)
|
||||
await editSubscriptionContent(selectedAbo.id, items)
|
||||
setEditingContent(false)
|
||||
setRefreshKey((k) => k + 1)
|
||||
} catch (e: any) {
|
||||
setContentError(e?.message || 'Failed to update subscription content.')
|
||||
} finally {
|
||||
setSavingContent(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onStatusAction = async (targetStatus: 'ongoing' | 'pause' | 'cancelled') => {
|
||||
if (!selectedAbo) return
|
||||
setStatusError(null)
|
||||
try {
|
||||
setStatusBusy(true)
|
||||
await changeSubscriptionStatus(selectedAbo.id, targetStatus)
|
||||
setEditingContent(false)
|
||||
setRefreshKey((k) => k + 1)
|
||||
} catch (e: any) {
|
||||
setStatusError(e?.message || 'Failed to update subscription status.')
|
||||
} finally {
|
||||
setStatusBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openStatusConfirm = (targetStatus: 'ongoing' | 'pause' | 'cancelled') => {
|
||||
setStatusConfirmTarget(targetStatus)
|
||||
setStatusError(null)
|
||||
}
|
||||
|
||||
const closeStatusConfirm = () => {
|
||||
if (statusBusy) return
|
||||
setStatusConfirmTarget(null)
|
||||
}
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
if (!statusConfirmTarget) return
|
||||
await onStatusAction(statusConfirmTarget)
|
||||
setStatusConfirmTarget(null)
|
||||
}
|
||||
|
||||
const confirmTitle =
|
||||
statusConfirmTarget === 'pause'
|
||||
? 'Pause subscription?'
|
||||
: statusConfirmTarget === 'ongoing'
|
||||
? 'Resume subscription?'
|
||||
: 'Cancel subscription?'
|
||||
|
||||
const confirmDescription =
|
||||
statusConfirmTarget === 'pause'
|
||||
? 'Your subscription will be paused. Billing and deliveries remain stopped until you resume it.'
|
||||
: statusConfirmTarget === 'ongoing'
|
||||
? 'Your subscription will become ongoing again for the next billing cycle.'
|
||||
: 'Your subscription will be cancelled and cannot continue automatically. This action is intended to stop future cycles.'
|
||||
|
||||
const confirmButtonText =
|
||||
statusConfirmTarget === 'pause'
|
||||
? 'Yes, pause'
|
||||
: statusConfirmTarget === 'ongoing'
|
||||
? 'Yes, resume'
|
||||
: 'Yes, cancel subscription'
|
||||
|
||||
return (
|
||||
<PageLayout className="bg-transparent text-gray-900">
|
||||
<BlueBlurryBackground>
|
||||
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">My Subscriptions</h1>
|
||||
<p className="text-gray-600 mt-2">Select any subscription to view details and included items.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Back to profile
|
||||
</button>
|
||||
</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 subscriptions…
|
||||
</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>
|
||||
) : subscriptions.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">
|
||||
You don’t have any subscriptions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">All subscriptions</h2>
|
||||
<p className="text-xs text-gray-600">{subscriptions.length} total</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{subscriptions.map((subscription) => {
|
||||
const isSelected = String(subscription.id) === String(selectedAboId)
|
||||
const subscriptionStatus = normalizeSubscriptionStatus(subscription.status)
|
||||
const packs = (subscription.pack_breakdown || subscription.items || []).reduce(
|
||||
(sum, item) => sum + (Number(item.quantity) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={subscription.id}
|
||||
onClick={() => setSelectedAboId(subscription.id)}
|
||||
className={`text-left rounded-md border px-3 py-3 transition ${
|
||||
isSelected
|
||||
? 'border-[#8D6B1D] bg-white shadow-md'
|
||||
: 'border-gray-200 bg-white/80 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{subscription.name || `Subscription #${subscription.id}`}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusBadgeClass(subscriptionStatus)}`}
|
||||
>
|
||||
{displayStatus(subscriptionStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">ID: {subscription.id}</p>
|
||||
<p className="text-xs text-gray-600">Started: {formatDate(subscription.startedAt || subscription.createdAt)}</p>
|
||||
<p className="text-xs text-gray-600">Included packs: {packs}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedAbo && (
|
||||
<>
|
||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Subscription details</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{selectedAbo.name || 'Coffee Subscription'}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(status)}`}
|
||||
>
|
||||
{displayStatus(status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
{(status === 'ongoing' || status === 'issued') && (
|
||||
<button
|
||||
onClick={() => openStatusConfirm('pause')}
|
||||
disabled={statusBusy}
|
||||
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"
|
||||
>
|
||||
{statusBusy ? 'Updating…' : 'Pause subscription'}
|
||||
</button>
|
||||
)}
|
||||
{status === 'pause' && (
|
||||
<button
|
||||
onClick={() => openStatusConfirm('ongoing')}
|
||||
disabled={statusBusy}
|
||||
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"
|
||||
>
|
||||
{statusBusy ? 'Updating…' : 'Resume subscription'}
|
||||
</button>
|
||||
)}
|
||||
{(status === 'ongoing' || status === 'pause' || status === 'issued') && (
|
||||
<button
|
||||
onClick={() => openStatusConfirm('cancelled')}
|
||||
disabled={statusBusy}
|
||||
className="rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{statusBusy ? 'Updating…' : 'Cancel subscription'}
|
||||
</button>
|
||||
)}
|
||||
{(status === 'finished' || status === 'cancelled') && (
|
||||
<p className="text-xs text-gray-600">No further status changes are available for this subscription.</p>
|
||||
)}
|
||||
</div>
|
||||
{statusError && (
|
||||
<p className="mt-2 text-xs text-red-600">{statusError}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Subscription ID</p>
|
||||
<p className="font-medium text-gray-900">{selectedAbo.id}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Price</p>
|
||||
<p className="font-medium text-gray-900">{formatMoney(selectedAbo.price, selectedAbo.currency)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Frequency</p>
|
||||
<p className="font-medium text-gray-900">{selectedAbo.frequency || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Country</p>
|
||||
<p className="font-medium text-gray-900">{(selectedAbo.country || '').toUpperCase() || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Started</p>
|
||||
<p className="font-medium text-gray-900">{formatDate(selectedAbo.startedAt || selectedAbo.createdAt)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Next billing</p>
|
||||
<p className="font-medium text-gray-900">{formatDate(selectedAbo.nextBillingAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Included in your subscription</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{includedItems.length} item(s), {totalPacks} total pack(s)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Changes apply from your next billing cycle.</p>
|
||||
</div>
|
||||
{!editingContent && canChangeContent && (
|
||||
<button
|
||||
onClick={startEditingContent}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Change coffees for next month
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canChangeContent && (
|
||||
<p className="mt-3 text-xs text-gray-600">
|
||||
Coffee content can only be changed while a subscription is issued, ongoing, or paused.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{includedItems.length === 0 ? (
|
||||
<div className="mt-4 rounded-md bg-white/80 border border-gray-200 p-3 text-sm text-gray-600">
|
||||
No included items were returned for this subscription.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{includedItems.map((item, index) => {
|
||||
const imageUrl = item.coffeeId ? coffeeImageById[String(item.coffeeId)] : ''
|
||||
return (
|
||||
<div key={`${item.coffeeId || 'coffee'}-${index}`} className="rounded-md bg-white/80 border border-gray-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900">{item.coffeeName || `Coffee #${item.coffeeId || index + 1}`}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">Coffee ID: {item.coffeeId || '—'}</p>
|
||||
<p className="text-xs text-gray-600">Packs included: {Number(item.quantity) || 0}</p>
|
||||
</div>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.coffeeName || `Coffee #${item.coffeeId || index + 1}`}
|
||||
className="h-14 w-14 rounded-md object-cover border border-gray-200 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-14 w-14 rounded-md bg-gray-100 border border-gray-200 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingContent && canChangeContent && (
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Edit coffee content</h3>
|
||||
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (must be 6 or 12)</p>
|
||||
</div>
|
||||
|
||||
{coffeesLoading ? (
|
||||
<p className="text-sm text-gray-600">Loading available coffees…</p>
|
||||
) : coffeesError ? (
|
||||
<p className="text-sm text-red-600">{coffeesError}</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-72 overflow-auto pr-1">
|
||||
{coffees.map((coffee) => {
|
||||
const key = String(coffee.id)
|
||||
const qty = draftItems[key] || 0
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-gray-200 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{coffee.name}</p>
|
||||
<p className="text-xs text-gray-600 line-clamp-1">{coffee.description || 'No description'}</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={qty}
|
||||
onChange={(e) => updateDraftQty(key, Number(e.target.value))}
|
||||
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentError && (
|
||||
<p className="mt-3 text-xs text-red-600">{contentError}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveContentChanges}
|
||||
disabled={savingContent || coffeesLoading}
|
||||
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"
|
||||
>
|
||||
{savingContent ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditingContent}
|
||||
disabled={savingContent}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FinanceInvoices abonementId={selectedAbo.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BlueBlurryBackground>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(statusConfirmTarget)}
|
||||
pending={statusBusy}
|
||||
title={confirmTitle}
|
||||
description={confirmDescription}
|
||||
confirmText={confirmButtonText}
|
||||
intent={statusConfirmTarget === 'cancelled' ? 'danger' : 'default'}
|
||||
onClose={closeStatusConfirm}
|
||||
onConfirm={confirmStatusChange}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
import { Fragment } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
interface DeactivateReferralLinkModalProps {
|
||||
open: boolean
|
||||
@ -20,25 +22,77 @@ export default function DeactivateReferralLinkModal({
|
||||
onConfirm,
|
||||
}: DeactivateReferralLinkModalProps) {
|
||||
return (
|
||||
<ConfirmActionModal
|
||||
open={open}
|
||||
pending={pending}
|
||||
intent="danger"
|
||||
title="Deactivate referral link?"
|
||||
description="This will immediately deactivate the selected referral link so it can no longer be used."
|
||||
confirmText="Deactivate"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
extraContent={
|
||||
linkPreview ? (
|
||||
<div>
|
||||
<span className="text-xs uppercase text-gray-500">Link</span>
|
||||
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
|
||||
{linkPreview}
|
||||
</div>
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={onClose} className="relative z-[1100]">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-all ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-2 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="transition-all ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-2 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl ring-1 ring-black/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-900">
|
||||
Deactivate referral link?
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p>This will immediately deactivate the selected referral link so it can no longer be used.</p>
|
||||
{linkPreview && (
|
||||
<div className="mt-3">
|
||||
<span className="text-xs uppercase text-gray-500">Link</span>
|
||||
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
|
||||
{linkPreview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={onConfirm}
|
||||
className="inline-flex items-center rounded-md border border-red-300 bg-red-600 px-3 py-2 text-sm text-white hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
{pending ? 'Deactivating…' : 'Deactivate'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user