Compare commits

...

7 Commits

Author SHA1 Message Date
seaznCode
97596720f5 feat: add subscriptions display to profile page with loading and error handling 2026-02-20 21:55:02 +01:00
seaznCode
ea12c2ec0b feat: add coffee image display in subscription items and optimize status rendering 2026-02-20 21:50:34 +01:00
seaznCode
7526e5c2e5 feat: enhance user detail modal with confirmation for document moves, add subscriptions navigation, and improve invoice status handling
- Added ConfirmActionModal to UserDetailModal for document move confirmation.
- Introduced My Subscriptions button in Header for easier navigation.
- Enhanced FinanceInvoices component to display normalized invoice statuses with badges.
- Created editAbo hook for managing subscription content updates.
- Updated getAbo hook to include more subscription statuses and improved mapping logic.
- Refactored ProfilePage to link to subscriptions page and removed unused state.
- Implemented ProfileSubscriptionsPage for managing subscriptions with detailed views and actions.
- Replaced custom modal in DeactivateReferralLinkModal with ConfirmActionModal for consistency.
2026-02-20 21:45:54 +01:00
seaznCode
ab003be9fa feat: implement ConfirmActionModal for enhanced delete confirmation 2026-02-20 21:45:10 +01:00
DeathKaioken
b164f73b43 feat: abo 2026-02-18 11:17:07 +01:00
DeathKaioken
004a8f4baa feat: abo sub + dependency 2026-02-18 10:24:31 +01:00
seaznCode
49aee7b7ff Enhance pool management functionality by adding subscription linking and diagnostics features 2026-02-17 18:13:23 +01:00
27 changed files with 4355 additions and 2126 deletions

View File

@ -22,20 +22,11 @@ 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)
@ -43,10 +34,13 @@ 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

4013
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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.0",
"@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindplus/elements": "^1.0.15",
"@tailwindplus/elements": "^1.0.22",
"@tailwindui/react": "^0.1.1",
"axios": "^1.12.2",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.21",
"country-flag-icons": "^1.6.13",
"country-select-js": "^2.1.0",
"gsap": "^3.14.2",
"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",
"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",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.63.0",
"react-hook-form": "^7.71.1",
"react-hot-toast": "^2.6.0",
"react-pdf": "^10.1.0",
"react-phone-number-input": "^3.4.12",
"react-pdf": "^10.3.0",
"react-phone-number-input": "^3.4.14",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"three": "^0.167.1",
"winston": "^3.17.0",
"three": "^0.182.0",
"winston": "^3.19.0",
"yup": "^1.7.1",
"zustand": "^5.0.8"
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.36.0",
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"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",
"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",
"postcss": "^8.5.6",
"postcss-preset-env": "^10.4.0",
"tailwindcss": "^4.1.13",
"postcss-preset-env": "^11.1.3",
"tailwindcss": "^4.1.18",
"typescript": "^5"
}
}
}

View File

@ -2,6 +2,7 @@
import React, { useEffect, useRef, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
type Props = {
editingTemplateId?: string | null;
@ -17,12 +18,14 @@ 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' | 'other'>('contract');
const [type, setType] = useState<'contract' | 'bill' | 'invoice' | '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);
@ -51,7 +54,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');
setType(((tpl.type as any) || 'contract') as 'contract' | 'bill' | 'invoice' | 'other');
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
setEditingMeta({
@ -152,7 +155,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
lang
)
const save = async (publish: boolean) => {
const doSave = async (publish: boolean) => {
const html = htmlCode.trim();
// NEW: validate all fields
if (!canSave) {
@ -160,14 +163,6 @@ 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);
@ -218,6 +213,21 @@ 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 && (
@ -273,12 +283,13 @@ 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' | 'other')}
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'invoice' | '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' && (
@ -322,13 +333,22 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
</div>
{!isPreview && (
<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 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>
)}
{isPreview && (
@ -362,6 +382,16 @@ 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>
);
}

View File

@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
type Props = {
refreshKey?: number;
@ -37,6 +38,7 @@ 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,
@ -83,29 +85,39 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]);
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;
}
}
const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
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) => {
@ -124,6 +136,9 @@ 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…"
@ -148,7 +163,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' : 'Other'}
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : c.type === 'invoice' ? 'Invoice' : 'Other'}
</Pill>
)}
{c.type === 'contract' && (
@ -188,6 +203,15 @@ 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>
);
}

View File

@ -4,12 +4,18 @@ 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 {
@ -67,6 +73,47 @@ 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">
@ -178,6 +225,51 @@ 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">
@ -229,8 +321,24 @@ export default function FinanceManagementPage() {
</span>
</td>
<td className="px-3 py-2 space-x-2">
<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>
<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>
</td>
</tr>
))
@ -238,6 +346,25 @@ 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>

View File

@ -4,7 +4,8 @@ import React from 'react'
interface Props {
isOpen: boolean
onClose: () => void
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) => void | Promise<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 }>
creating: boolean
error?: string
success?: string
@ -15,6 +16,7 @@ export default function CreateNewPoolModal({
isOpen,
onClose,
onCreate,
subscriptions,
creating,
error,
success,
@ -24,6 +26,7 @@ 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
@ -33,6 +36,7 @@ export default function CreateNewPoolModal({
setDescription('')
setPrice('0.00')
setPoolType('other')
setSubscriptionCoffeeId('')
}
}, [isOpen])
@ -73,7 +77,13 @@ export default function CreateNewPoolModal({
onSubmit={e => {
e.preventDefault()
clearMessages()
onCreate({ pool_name: poolName, description, price: parseFloat(price) || 0, pool_type: poolType })
onCreate({
pool_name: poolName,
description,
price: parseFloat(price) || 0,
pool_type: poolType,
subscription_coffee_id: subscriptionCoffeeId ? Number(subscriptionCoffeeId) : null,
})
}}
className="space-y-4"
>
@ -100,7 +110,7 @@ export default function CreateNewPoolModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Price (per capsule)</label>
<label className="block text-sm font-medium text-blue-900 mb-1">Price per capsule (net)</label>
<input
type="number"
step="0.01"
@ -112,6 +122,7 @@ 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>
@ -125,6 +136,20 @@ 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"
@ -137,7 +162,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'); clearMessages(); }}
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
disabled={isDisabled}
>
Reset

View File

@ -4,6 +4,7 @@ export type AddPoolPayload = {
pool_name: string;
description?: string;
price: number;
subscription_coffee_id?: number | null;
pool_type: 'coffee' | 'other';
is_active?: boolean;
};

View File

@ -7,6 +7,8 @@ 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;
@ -62,7 +64,9 @@ export function useAdminPools() {
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
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,
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
@ -100,7 +104,9 @@ export function useAdminPools() {
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
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,
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0),

View File

@ -8,6 +8,8 @@ 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
@ -50,6 +52,8 @@ 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()
@ -75,6 +79,13 @@ 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
@ -107,6 +118,76 @@ 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
@ -214,10 +295,14 @@ 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'
if (!window.confirm(`Remove ${label} from this pool?`)) return
setRemoveConfirm({ userId, label })
}
async function confirmRemoveMember() {
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
const userId = removeConfirm.userId
setRemoveError('')
setRemovingMemberId(userId)
try {
@ -227,6 +312,7 @@ function PoolManagePageInner() {
setRemoveError(e?.message || 'Failed to remove user from pool.')
} finally {
setRemovingMemberId(null)
setRemoveConfirm(null)
}
}
@ -255,6 +341,10 @@ 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>
@ -272,6 +362,39 @@ 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">
@ -522,6 +645,17 @@ 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>
)

View File

@ -11,12 +11,16 @@ 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
@ -32,15 +36,18 @@ 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.getState().accessToken
const token = useAuthStore(s => s.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) {
@ -48,10 +55,46 @@ 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' }) {
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other'; subscription_coffee_id: number | null }) {
setCreateError('')
setCreateSuccess('')
const pool_name = data.pool_name.trim()
@ -62,7 +105,14 @@ export default function PoolManagementPage() {
}
setCreating(true)
try {
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
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,
})
if (res.ok && res.body?.data) {
setCreateSuccess('Pool created successfully.')
await refresh?.()
@ -81,26 +131,28 @@ export default function PoolManagementPage() {
}
async function handleArchive(poolId: string) {
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.')
}
setPoolStatusConfirm({ poolId, action: 'archive' })
}
async function handleSetActive(poolId: string) {
const confirmed = window.confirm('Unarchive this pool and make it active again?')
if (!confirmed) return
setPoolStatusConfirm({ poolId, action: 'activate' })
}
async function confirmPoolStatusChange() {
if (!poolStatusConfirm) return
const { poolId, action } = poolStatusConfirm
setPoolStatusPending(true)
setArchiveError('')
const res = await setPoolActive(poolId)
if (res.ok) {
await refresh?.()
} else {
setArchiveError(res.message || 'Failed to activate pool.')
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)
}
}
@ -219,6 +271,9 @@ 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>
@ -241,6 +296,8 @@ 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 ?? '',
})
@ -285,12 +342,28 @@ 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>

View File

@ -25,54 +25,74 @@ export function useActiveCoffees() {
useEffect(() => {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
const url = `${base}/api/admin/coffee/active`;
console.log('[useActiveCoffees] Fetching active coffees from:', url);
const candidateUrls = [
`${base}/api/coffee/active`,
`${base}/api/admin/coffee/active`,
];
console.log('[useActiveCoffees] Fetching active coffees from candidates:', candidateUrls);
setLoading(true);
setError(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);
const tryFetch = async () => {
let lastError: string | null = null;
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)}`);
}
for (const url of candidateUrls) {
try {
const response = await authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
});
const json = await response.json();
console.log('[useActiveCoffees] Raw JSON response:', json);
const contentType = response.headers.get('content-type') || '';
console.log('[useActiveCoffees] Response for', url, response.status, contentType);
const data: ActiveCoffee[] =
Array.isArray(json?.data) ? json.data :
Array.isArray(json) ? json :
[]
console.log('[useActiveCoffees] Parsed coffee data:', data);
if (!response.ok || !contentType.includes('application/json')) {
const text = await response.text().catch(() => '');
lastError = `Request failed: ${response.status} ${text.slice(0, 160)}`;
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 || '',
if (response.status === 404) {
continue;
}
})
throw new Error(lastError);
}
console.log('[useActiveCoffees] Mapped coffee items:', mapped)
setCoffees(mapped)
})
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';
}
}
throw new Error(
lastError ||
'Active coffee list endpoint is not available. Please restart/update the backend and try again.'
);
};
tryFetch()
.catch((error: any) => {
console.error('[useActiveCoffees] Error fetching coffees:', error);
setError(error?.message || 'Failed to load active coffees');

View File

@ -7,6 +7,7 @@ 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
@ -31,22 +32,20 @@ export default function CoffeeAbonnementPage() {
[selectedEntries]
);
// NEW: enforce exactly 120 capsules (12 packs)
// NEW: enforce selected plan size (60 or 120 capsules)
const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
[selectedEntries]
);
const packsSelected = totalCapsules / 10;
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
const TAX_RATE = 0.07;
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
const requiredPacks = selectedPlanCapsules / 10;
const canProceed = packsSelected === requiredPacks;
const proceedToSummary = () => {
if (!canProceed) return;
try {
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
} catch {}
router.push('/coffee-abonnements/summary');
};
@ -57,6 +56,8 @@ 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;
@ -66,8 +67,10 @@ 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 > 120) return prev;
if (next < 10 || next > maxForCoffee) return prev;
const updated = { ...prev, [id]: next };
setBump((b) => ({ ...b, [id]: true }));
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
@ -97,7 +100,37 @@ export default function CoffeeAbonnementPage() {
{/* Section 1: Multi coffee selection + per-coffee quantity */}
<section>
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
<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>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@ -116,6 +149,15 @@ 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}
@ -158,10 +200,13 @@ 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'
: 'border-gray-300 hover:bg-gray-100'
: canAddCoffee
? 'border-gray-300 hover:bg-gray-100'
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{active ? 'Remove' : 'Add'}
@ -179,6 +224,7 @@ 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
@ -187,7 +233,7 @@ export default function CoffeeAbonnementPage() {
<input
type="range"
min={10}
max={120}
max={sliderMax}
step={10}
value={qty}
onChange={(e) =>
@ -197,9 +243,9 @@ export default function CoffeeAbonnementPage() {
style={{
background:
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
((qty - 10) / (120 - 10)) * 100 +
sliderProgress +
'%,#e5e7eb ' +
((qty - 10) / (120 - 10)) * 100 +
sliderProgress +
'%,#e5e7eb 100%)',
height: '6px',
borderRadius: '999px',
@ -208,6 +254,7 @@ 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
@ -230,7 +277,7 @@ export default function CoffeeAbonnementPage() {
{/* Section 2: Compact preview + next steps */}
<section>
<h2 className="text-xl font-semibold mb-4">2. Preview</h2>
<h2 className="text-xl font-semibold mb-4">3. 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>
@ -260,11 +307,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).
{packsSelected !== 12 && (
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected !== requiredPacks && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Please select exactly 120 capsules (12 packs).
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
</span>
)}
</div>
@ -296,7 +343,7 @@ export default function CoffeeAbonnementPage() {
</button>
{!canProceed && (
<p className="text-xs text-gray-600">
You can continue once exactly 120 capsules (12 packs) are selected.
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
</p>
)}
</div>

View File

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

View File

@ -10,7 +10,10 @@ 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: '',
@ -20,7 +23,10 @@ export default function SummaryPage() {
city: '',
country: 'DE',
frequency: 'monatlich',
startDate: ''
startDate: '',
recipientEmail: '',
recipientName: '',
recipientNotes: '',
});
const [showThanks, setShowThanks] = useState(false);
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
@ -34,6 +40,10 @@ 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 {}
}, []);
@ -64,16 +74,20 @@ 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 currentUserId = useAuthStore.getState().user?.id
const rawUserId = user?.id
const currentUserId = typeof rawUserId === 'number'
? rawUserId
: (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
console.info('[SummaryPage] currentUserId:', currentUserId)
// Countries list from backend VAT rates (fallback to current country if list empty)
const countryOptions = useMemo(() => {
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()]
const currentCode = (form.country || 'DE').toUpperCase();
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [currentCode]
if (!opts.includes(currentCode)) opts.unshift(currentCode)
console.info('[SummaryPage] countryOptions:', opts)
return opts
}, [vatRates, form.country]);
@ -132,20 +146,71 @@ 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 === 12 && // CHANGED: require exactly 12 packs
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
totalPacks === requiredPacks &&
hasRequiredSelfFields &&
hasRequiredGiftFields;
const backToSelection = () => router.push('/coffee-abonnements');
const submit = async () => {
if (!canSubmit || submitLoading) return
// NEW: guard (defensive) — backend requires exactly 12 packs
if (totalPacks !== 12) {
setSubmitError('Order must contain exactly 12 packs (120 capsules).')
// NEW: guard (defensive) — backend requires selected package size
if (totalPacks !== requiredPacks) {
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
return
}
if (!isForSelf && !form.recipientEmail.trim()) {
setSubmitError('Recipient email is required when the subscription is for someone else.')
return
}
setSubmitError(null)
setSubmitLoading(true)
try {
@ -157,6 +222,7 @@ export default function SummaryPage() {
billing_interval: 'month',
interval_count: 1,
is_auto_renew: true,
is_for_self: isForSelf,
// NEW: pass customer fields
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
@ -166,7 +232,10 @@ export default function SummaryPage() {
city: form.city.trim(),
country: form.country.trim(),
frequency: form.frequency.trim(),
startDate: form.startDate.trim(),
startDate: form.startDate.trim() || undefined,
recipient_email: isForSelf ? undefined : form.recipientEmail.trim(),
recipient_name: isForSelf ? undefined : (form.recipientName.trim() || undefined),
recipient_notes: isForSelf ? undefined : (form.recipientNotes.trim() || undefined),
// NEW: always include referred_by if available
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
}
@ -176,6 +245,7 @@ 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 {
@ -250,6 +320,37 @@ 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>
@ -293,9 +394,42 @@ export default function SummaryPage() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Start date</label>
<label className="block text-sm font-medium mb-1">Start date (optional)</label>
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
{!isForSelf && (
<>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient email</label>
<input
type="email"
name="recipientEmail"
value={form.recipientEmail}
onChange={handleInput}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient name (optional)</label>
<input
name="recipientName"
value={form.recipientName}
onChange={handleInput}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient note (optional)</label>
<textarea
name="recipientNotes"
value={form.recipientNotes}
onChange={handleRecipientNotes}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
rows={3}
/>
</div>
</>
)}
</div>
<button
onClick={submit}
@ -309,7 +443,13 @@ export default function SummaryPage() {
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>}
{!canSubmit && (
<p className="text-xs text-gray-500 mt-2">
{isForSelf
? 'Please select coffees and fill all required buyer fields.'
: 'Please select coffees and fill all required buyer fields plus recipient email.'}
</p>
)}
</div>
</section>
@ -342,10 +482,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).
{totalPacks !== 12 && (
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{totalPacks !== requiredPacks && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Exactly 12 packs (120 capsules) are required.
Exactly {requiredPacks} packs ({selectedPlanCapsules} capsules) are required.
</span>
)}
</div>
@ -371,7 +511,9 @@ export default function SummaryPage() {
</svg>
</div>
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
<p className="mt-1 text-sm text-gray-600">We have received your order.</p>
<p className="mt-1 text-sm text-gray-600">
{isForSelf ? 'Subscription created.' : 'Subscription created, invitation sent.'}
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">

View File

@ -20,6 +20,7 @@ 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
@ -73,6 +74,12 @@ 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 ||
@ -296,10 +303,12 @@ 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
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
setMoveConfirm({ documentId, targetType, filename, objectKey })
}
const confirmMoveContractDoc = async () => {
if (!userId || !token || !moveConfirm) return
const { documentId, targetType, objectKey } = moveConfirm
const loadingKey = objectKey || String(documentId || '')
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
try {
@ -309,6 +318,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
console.error('UserDetailModal.moveContractDoc error:', e)
} finally {
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
setMoveConfirm(null)
}
}
@ -943,6 +953,21 @@ 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>
)
}

View File

@ -1,4 +1,5 @@
import React from "react";
import ConfirmActionModal from "../modals/ConfirmActionModal";
type DeleteConfirmationModalProps = {
open: boolean;
@ -23,44 +24,18 @@ export default function DeleteConfirmationModal({
onCancel,
children,
}: DeleteConfirmationModalProps) {
if (!open) return null;
return (
<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>
<ConfirmActionModal
open={open}
pending={loading}
intent="danger"
title={title}
description={description}
confirmText={confirmText}
cancelText={cancelText}
onConfirm={onConfirm}
onClose={onCancel}
extraContent={children}
/>
);
}

View File

@ -0,0 +1,132 @@
'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>
)
}

View File

@ -658,6 +658,15 @@ 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) */}

View File

@ -0,0 +1,227 @@
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>
)
}

View File

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

View File

@ -0,0 +1,56 @@
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
}

View File

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

View File

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

View File

@ -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,6 +106,7 @@ 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(() => {
@ -393,8 +394,54 @@ export default function ProfilePage() {
{/* Bank Info, Media */}
<div className="space-y-6 sm:space-y-8 mb-8">
{/* --- My Abo Section (above bank info) --- */}
<UserAbo />
<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 dont 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>
{/* --- Edit Bank Information Section --- */}
<BankInformation
profileData={profileData}

View File

@ -0,0 +1,520 @@
'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 dont 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>
)
}

View File

@ -1,8 +1,6 @@
'use client'
import { Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
interface DeactivateReferralLinkModalProps {
open: boolean
@ -22,77 +20,25 @@ export default function DeactivateReferralLinkModal({
onConfirm,
}: DeactivateReferralLinkModalProps) {
return (
<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>
<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>
</div>
</div>
</Dialog>
</Transition>
) : null
}
/>
)
}