commit
e1553f8b25
12
ToDo.txt
12
ToDo.txt
@ -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
4013
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
60
package.json
60
package.json
@ -14,55 +14,55 @@
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lottiefiles/react-lottie-player": "^3.6.0",
|
||||
"@react-pdf/renderer": "^4.3.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import useContractManagement from '../hooks/useContractManagement'
|
||||
|
||||
export default function CompanySettingsPanel() {
|
||||
const { getCompanySettings, updateCompanySettings } = useContractManagement()
|
||||
|
||||
const [form, setForm] = useState({
|
||||
company_name: '',
|
||||
company_street: '',
|
||||
company_postal_city: '',
|
||||
company_country: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getCompanySettings()
|
||||
.then(data => {
|
||||
setForm({
|
||||
company_name: data.company_name || '',
|
||||
company_street: data.company_street || '',
|
||||
company_postal_city: data.company_postal_city || '',
|
||||
company_country: data.company_country || '',
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [getCompanySettings])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setForm(prev => ({ ...prev, [name]: value }))
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
try {
|
||||
await updateCompanySettings(form)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 py-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-900" />
|
||||
Loading settings…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
value={form.company_name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ProfitPlanet GmbH"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="company_street" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Street
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_street"
|
||||
name="company_street"
|
||||
value={form.company_street}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Musterstraße 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="company_postal_city" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Postal Code & City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_postal_city"
|
||||
name="company_postal_city"
|
||||
value={form.company_postal_city}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="12345 Berlin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="company_country" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_country"
|
||||
name="company_country"
|
||||
value={form.company_country}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Germany"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className={`px-5 py-2 rounded-lg text-sm font-semibold text-white transition-colors ${
|
||||
saving ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-900 hover:bg-blue-800'
|
||||
}`}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && (
|
||||
<span className="text-sm text-green-600 font-medium">Saved successfully</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@ -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' | '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' | '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,25 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (publish: boolean) => {
|
||||
if (publish) {
|
||||
let kind = type === 'contract'
|
||||
? (contractType === 'gdpr' ? 'GDPR' : 'Contract')
|
||||
: type === 'invoice'
|
||||
? 'Invoice'
|
||||
: 'Other';
|
||||
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 +287,16 @@ 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) => {
|
||||
const newType = e.target.value as 'contract' | 'invoice' | 'other';
|
||||
setType(newType);
|
||||
if (newType === 'invoice') setUserType('both');
|
||||
}}
|
||||
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' && (
|
||||
@ -292,16 +310,18 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
<option value="gdpr">GDPR</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={userType}
|
||||
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
|
||||
required
|
||||
className="w-full sm:w-40 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="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
{type !== 'invoice' && (
|
||||
<select
|
||||
value={userType}
|
||||
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
|
||||
required
|
||||
className="w-full sm:w-40 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="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
|
||||
@ -322,13 +342,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 +391,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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,43 @@ 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) {
|
||||
const kind = tpl.type === 'contract'
|
||||
? (tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract')
|
||||
: tpl.type === 'invoice'
|
||||
? 'Invoice'
|
||||
: 'Other';
|
||||
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 +140,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">
|
||||
Invoice templates always use user type "Both". Provide templates for each language (en/de). If no active invoice template matches, backend falls back to a text-only invoice.
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
placeholder="Search templates…"
|
||||
@ -143,12 +162,12 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filtered.map((c) => (
|
||||
<div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
|
||||
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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 === 'invoice' ? 'Invoice' : 'Other'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.type === 'contract' && (
|
||||
@ -156,7 +175,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.user_type && (
|
||||
{c.user_type && c.type !== 'invoice' && (
|
||||
<Pill className="bg-emerald-50 text-emerald-800 border-emerald-200">
|
||||
{c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'}
|
||||
</Pill>
|
||||
@ -188,6 +207,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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -444,6 +444,21 @@ export default function useContractManagement() {
|
||||
return Array.isArray(data) ? data : [];
|
||||
}, [authorizedFetch]);
|
||||
|
||||
// Company settings (invoice address info)
|
||||
const getCompanySettings = useCallback(async () => {
|
||||
return authorizedFetch<{ company_name: string; company_street: string; company_postal_city: string; company_country: string }>(
|
||||
'/api/admin/company-settings', { method: 'GET' }
|
||||
);
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const updateCompanySettings = useCallback(async (data: {
|
||||
company_name: string; company_street: string; company_postal_city: string; company_country: string;
|
||||
}) => {
|
||||
return authorizedFetch<{ company_name: string; company_street: string; company_postal_city: string; company_country: string }>(
|
||||
'/api/admin/company-settings', { method: 'PUT', body: JSON.stringify(data) }
|
||||
);
|
||||
}, [authorizedFetch]);
|
||||
|
||||
return {
|
||||
// templates
|
||||
listTemplates,
|
||||
@ -466,6 +481,9 @@ export default function useContractManagement() {
|
||||
activateCompanyStamp,
|
||||
deactivateCompanyStamp,
|
||||
deleteCompanyStamp,
|
||||
// company settings
|
||||
getCompanySettings,
|
||||
updateCompanySettings,
|
||||
// utils
|
||||
downloadBlobFile,
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
import ContractEditor from './components/contractEditor';
|
||||
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
||||
import CompanySettingsPanel from './components/companySettingsPanel';
|
||||
import ContractTemplateList from './components/contractTemplateList';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -98,6 +99,15 @@ export default function ContractManagementPage() {
|
||||
Company Stamp
|
||||
</h2>
|
||||
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||
Company Information
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Address details used on invoices.</p>
|
||||
<CompanySettingsPanel />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{section === 'templates' && (
|
||||
|
||||
@ -0,0 +1,535 @@
|
||||
'use client'
|
||||
|
||||
import React, { Fragment, useEffect, useState, useCallback } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import {
|
||||
XMarkIcon,
|
||||
DocumentTextIcon,
|
||||
UserIcon,
|
||||
BanknotesIcon,
|
||||
CalendarDaysIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ClockIcon,
|
||||
NoSymbolIcon,
|
||||
PencilSquareIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
|
||||
/* ---------- types ---------- */
|
||||
export type AdminInvoice = {
|
||||
id: string | number
|
||||
invoice_number?: string | null
|
||||
user_id?: string | number | null
|
||||
buyer_name?: string | null
|
||||
buyer_email?: string | null
|
||||
buyer_street?: string | null
|
||||
buyer_postal_code?: string | null
|
||||
buyer_city?: string | null
|
||||
buyer_country?: string | null
|
||||
currency?: string | null
|
||||
total_net?: number | null
|
||||
total_tax?: number | null
|
||||
total_gross?: number | null
|
||||
vat_rate?: number | null
|
||||
status?: string
|
||||
issued_at?: string | null
|
||||
due_at?: string | null
|
||||
pdf_storage_key?: string | null
|
||||
context?: any | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
type InvoiceItem = {
|
||||
id?: number
|
||||
invoice_id?: number
|
||||
product_id?: number | null
|
||||
sku?: string | null
|
||||
description?: string | null
|
||||
quantity?: number
|
||||
unit_price?: number
|
||||
tax_rate?: number | null
|
||||
line_net?: number
|
||||
line_tax?: number
|
||||
line_gross?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
type InvoicePayment = {
|
||||
id?: number
|
||||
invoice_id?: number
|
||||
payment_method?: string | null
|
||||
transaction_id?: string | null
|
||||
amount?: number | null
|
||||
paid_at?: string | null
|
||||
status?: string | null
|
||||
details?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface InvoiceDetailModalProps {
|
||||
invoice: AdminInvoice | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onStatusChanged?: () => void
|
||||
onRunPoolCheck?: (invoiceId: string | number) => void
|
||||
onExport?: (invoice: AdminInvoice) => void
|
||||
}
|
||||
|
||||
/* ---------- constants ---------- */
|
||||
const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const
|
||||
type InvoiceStatus = (typeof STATUSES)[number]
|
||||
|
||||
const STATUS_CONFIG: Record<InvoiceStatus, { label: string; bg: string; text: string; icon: React.ElementType }> = {
|
||||
draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon },
|
||||
issued: { label: 'Issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon },
|
||||
paid: { label: 'Paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon },
|
||||
overdue: { label: 'Overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon },
|
||||
canceled: { label: 'Canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon },
|
||||
}
|
||||
|
||||
function fmtDate(d?: string | null) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtDateTime(d?: string | null) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleString('de-DE', { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtMoney(v?: number | null, currency = 'EUR') {
|
||||
return `€ ${Number(v ?? 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
/* ---------- component ---------- */
|
||||
export default function InvoiceDetailModal({
|
||||
invoice,
|
||||
open,
|
||||
onClose,
|
||||
onStatusChanged,
|
||||
onRunPoolCheck,
|
||||
onExport,
|
||||
}: InvoiceDetailModalProps) {
|
||||
const token = useAuthStore((s) => s.accessToken)
|
||||
|
||||
// detail data
|
||||
const [items, setItems] = useState<InvoiceItem[]>([])
|
||||
const [payments, setPayments] = useState<InvoicePayment[]>([])
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailError, setDetailError] = useState('')
|
||||
|
||||
// status change
|
||||
const [changingStatus, setChangingStatus] = useState(false)
|
||||
const [statusMsg, setStatusMsg] = useState('')
|
||||
const [statusErr, setStatusErr] = useState('')
|
||||
const [currentStatus, setCurrentStatus] = useState<string>(invoice?.status ?? 'draft')
|
||||
|
||||
// keep current status in sync with prop
|
||||
useEffect(() => {
|
||||
if (invoice) setCurrentStatus(invoice.status ?? 'draft')
|
||||
}, [invoice])
|
||||
|
||||
// fetch detail (items + payments) when opened
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!invoice?.id || !token) return
|
||||
setDetailLoading(true)
|
||||
setDetailError('')
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||
const res = await fetch(`${base}/api/admin/invoices/${encodeURIComponent(String(invoice.id))}/detail`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok || body?.success === false) {
|
||||
setDetailError(body?.message || `Failed to load details (${res.status})`)
|
||||
return
|
||||
}
|
||||
setItems(Array.isArray(body?.data?.items) ? body.data.items : [])
|
||||
setPayments(Array.isArray(body?.data?.payments) ? body.data.payments : [])
|
||||
} catch (e: any) {
|
||||
setDetailError(e?.message || 'Network error')
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [invoice?.id, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && invoice) {
|
||||
void fetchDetail()
|
||||
} else {
|
||||
setItems([])
|
||||
setPayments([])
|
||||
setDetailError('')
|
||||
setStatusMsg('')
|
||||
setStatusErr('')
|
||||
}
|
||||
}, [open, invoice, fetchDetail])
|
||||
|
||||
// change status
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
if (!invoice?.id || !token || newStatus === currentStatus) return
|
||||
setChangingStatus(true)
|
||||
setStatusErr('')
|
||||
setStatusMsg('')
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||
const res = await fetch(`${base}/api/admin/invoices/${encodeURIComponent(String(invoice.id))}/status`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok || body?.success === false) {
|
||||
setStatusErr(body?.message || `Failed to update status (${res.status})`)
|
||||
return
|
||||
}
|
||||
setCurrentStatus(newStatus)
|
||||
// Build status message including pool booking info
|
||||
let msg = `Status updated to "${newStatus}"`
|
||||
if (body?.poolResult) {
|
||||
const pr = body.poolResult
|
||||
if (pr.error) {
|
||||
msg += ` — Pool booking error: ${pr.error}`
|
||||
} else if (pr.inserted > 0) {
|
||||
msg += ` — ${pr.inserted} pool inflow(s) booked`
|
||||
} else if (pr.reason && pr.reason !== 'ok') {
|
||||
const reasonLabels: Record<string, string> = {
|
||||
invalid_invoice_id: 'Invalid invoice ID',
|
||||
invoice_not_found: 'Invoice not found for pool booking',
|
||||
invoice_not_paid: 'Invoice not marked as paid',
|
||||
unsupported_source_type: 'Not a subscription invoice — no pool booking',
|
||||
missing_abonement_relation: 'No linked subscription — no pool booking',
|
||||
abonement_not_found: 'Linked subscription not found',
|
||||
no_breakdown_lines: 'Subscription has no capsule breakdown — no pool booking',
|
||||
no_active_system_pools: 'No active system pools found',
|
||||
}
|
||||
msg += ` — ${reasonLabels[pr.reason] || pr.reason}`
|
||||
}
|
||||
}
|
||||
setStatusMsg(msg)
|
||||
// Re-fetch detail to pick up any payment records (e.g. after marking paid)
|
||||
void fetchDetail()
|
||||
onStatusChanged?.()
|
||||
} catch (e: any) {
|
||||
setStatusErr(e?.message || 'Network error')
|
||||
} finally {
|
||||
setChangingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const statusConf = STATUS_CONFIG[(currentStatus as InvoiceStatus)] ?? STATUS_CONFIG.draft
|
||||
const StatusIcon = statusConf.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={onClose} className="relative z-[1100]">
|
||||
{/* backdrop */}
|
||||
<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>
|
||||
|
||||
{/* panel */}
|
||||
<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-3xl rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden">
|
||||
{/* ─── header ─── */}
|
||||
<div className="bg-gradient-to-r from-[#1C2B4A] to-[#2d3f66] px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<DocumentTextIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-bold text-white">
|
||||
Invoice {invoice.invoice_number ?? `#${invoice.id}`}
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-blue-200/80">
|
||||
Created {fmtDateTime(invoice.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-blue-200 hover:text-white hover:bg-white/10 transition"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── body ─── */}
|
||||
<div className="px-6 py-5 space-y-6 max-h-[75vh] overflow-y-auto">
|
||||
|
||||
{/* status badge + status switcher */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold ${statusConf.bg} ${statusConf.text}`}>
|
||||
<StatusIcon className="h-4 w-4" />
|
||||
{statusConf.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500 mr-1">Change status:</span>
|
||||
{STATUSES.map((s) => {
|
||||
const sc = STATUS_CONFIG[s]
|
||||
const active = s === currentStatus
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
disabled={active || changingStatus}
|
||||
onClick={() => handleStatusChange(s)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium border transition ${
|
||||
active
|
||||
? `${sc.bg} ${sc.text} border-transparent cursor-default`
|
||||
: 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{sc.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* status feedback */}
|
||||
{changingStatus && (
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-100 px-3 py-2 text-sm text-blue-700 flex items-center gap-2">
|
||||
<ArrowPathIcon className="h-4 w-4 animate-spin" /> Updating status…
|
||||
</div>
|
||||
)}
|
||||
{statusMsg && (
|
||||
<div className="rounded-lg bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-700 flex items-center gap-2">
|
||||
<CheckCircleIcon className="h-4 w-4" /> {statusMsg}
|
||||
</div>
|
||||
)}
|
||||
{statusErr && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700 flex items-center gap-2">
|
||||
<ExclamationCircleIcon className="h-4 w-4" /> {statusErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── two-column info cards ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Customer info */}
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
|
||||
<UserIcon className="h-4 w-4" /> Customer
|
||||
</div>
|
||||
<InfoRow label="Name" value={invoice.buyer_name} />
|
||||
<InfoRow label="Email" value={invoice.buyer_email} />
|
||||
<InfoRow label="Street" value={invoice.buyer_street} />
|
||||
<InfoRow label="City" value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
|
||||
<InfoRow label="Country" value={invoice.buyer_country} />
|
||||
<InfoRow label="User ID" value={invoice.user_id != null ? String(invoice.user_id) : null} />
|
||||
</div>
|
||||
|
||||
{/* Financial info */}
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
|
||||
<BanknotesIcon className="h-4 w-4" /> Financials
|
||||
</div>
|
||||
<InfoRow label="Net" value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
|
||||
<InfoRow label="Tax" value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
|
||||
<InfoRow label="Gross" value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
|
||||
<InfoRow label="VAT Rate" value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
|
||||
<InfoRow label="Currency" value={invoice.currency ?? 'EUR'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-2">
|
||||
<CalendarDaysIcon className="h-4 w-4" /> Dates
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<DateChip label="Issued" value={invoice.issued_at} />
|
||||
<DateChip label="Due" value={invoice.due_at} />
|
||||
<DateChip label="Created" value={invoice.created_at} />
|
||||
<DateChip label="Updated" value={invoice.updated_at} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
|
||||
<DocumentTextIcon className="h-4 w-4" /> Line Items
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" />
|
||||
<div className="h-4 w-1/2 bg-gray-200 animate-pulse rounded" />
|
||||
</div>
|
||||
) : detailError ? (
|
||||
<div className="text-sm text-red-600">{detailError}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No line items found.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-2 pr-4 font-medium">Description</th>
|
||||
<th className="pb-2 pr-4 font-medium">Qty</th>
|
||||
<th className="pb-2 pr-4 font-medium">Unit Price</th>
|
||||
<th className="pb-2 pr-4 font-medium">Tax</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Gross</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map((item, i) => (
|
||||
<tr key={item.id ?? i}>
|
||||
<td className="py-2 pr-4 text-gray-800">{item.description || '—'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{item.quantity ?? 0}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{fmtMoney(item.unit_price)}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{item.tax_rate != null ? `${item.tax_rate}%` : '—'}</td>
|
||||
<td className="py-2 pr-4 text-right font-medium text-gray-800">{fmtMoney(item.line_gross)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-gray-200">
|
||||
<td colSpan={4} className="pt-2 text-right font-semibold text-gray-700">Total</td>
|
||||
<td className="pt-2 text-right font-bold text-[#1C2B4A]">{fmtMoney(invoice.total_gross)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payments */}
|
||||
{payments.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
|
||||
<ShieldCheckIcon className="h-4 w-4" /> Payments
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-2 pr-4 font-medium">Method</th>
|
||||
<th className="pb-2 pr-4 font-medium">Transaction</th>
|
||||
<th className="pb-2 pr-4 font-medium">Amount</th>
|
||||
<th className="pb-2 pr-4 font-medium">Paid At</th>
|
||||
<th className="pb-2 pr-4 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{payments.map((p, i) => (
|
||||
<tr key={p.id ?? i}>
|
||||
<td className="py-2 pr-4 text-gray-700">{p.payment_method ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600 font-mono text-xs">{p.transaction_id ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-gray-700">{p.amount != null ? fmtMoney(p.amount) : '—'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{fmtDateTime(p.paid_at)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="inline-flex rounded-full px-2 py-0.5 text-xs font-medium bg-green-50 text-green-700">
|
||||
{p.status ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context (raw JSON if present) */}
|
||||
{invoice.context && (
|
||||
<details className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 group">
|
||||
<summary className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] cursor-pointer select-none">
|
||||
<ClockIcon className="h-4 w-4" /> Context / Metadata
|
||||
<span className="text-xs font-normal text-gray-400 ml-1">(click to expand)</span>
|
||||
</summary>
|
||||
<pre className="mt-3 text-xs text-gray-600 overflow-x-auto whitespace-pre-wrap break-all max-h-48">
|
||||
{typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── footer actions ─── */}
|
||||
<div className="bg-gray-50 border-t border-gray-100 px-6 py-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onExport?.(invoice)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4" /> Export JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRunPoolCheck?.(invoice.id)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4" /> Pool Check
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white hover:bg-[#1C2B4A]/90 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- sub-components ---------- */
|
||||
function InfoRow({ label, value, highlight = false }: { label: string; value?: string | null; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-gray-500 shrink-0">{label}</span>
|
||||
<span className={`text-right truncate ${highlight ? 'font-semibold text-[#1C2B4A]' : 'text-gray-800'}`}>
|
||||
{value || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DateChip({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="text-sm text-gray-800 font-medium">{fmtDate(value)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,13 +3,21 @@ import React, { useMemo, useState } from 'react'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVatRates } from './hooks/getTaxes'
|
||||
import { useAdminInvoices } from './hooks/getInvoices'
|
||||
import { useAdminInvoices, type AdminInvoice } from './hooks/getInvoices'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
||||
|
||||
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)
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||
|
||||
// NEW: fetch invoices from backend
|
||||
const {
|
||||
@ -67,6 +75,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 +227,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">Amount (gross)</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_gross ?? 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 +323,12 @@ 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); setDetailModalOpen(true) }}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@ -238,6 +336,17 @@ export default function FinanceManagementPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedInvoice && (
|
||||
<InvoiceDetailModal
|
||||
invoice={selectedInvoice}
|
||||
open={detailModalOpen}
|
||||
onClose={() => { setDetailModalOpen(false); setTimeout(() => setSelectedInvoice(null), 200) }}
|
||||
onStatusChanged={reload}
|
||||
onRunPoolCheck={(id) => { setDetailModalOpen(false); runPoolCheck(id) }}
|
||||
onExport={(inv) => exportInvoice(inv)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -8,13 +8,14 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||
import { AdminAPI } from '../../../utils/api'
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||
|
||||
type PoolUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
contributed: number
|
||||
joinedAt: string // NEW: member since
|
||||
share: number
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
function PoolManagePageInner() {
|
||||
@ -75,6 +76,7 @@ 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)
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
@ -91,7 +93,7 @@ function PoolManagePageInner() {
|
||||
id: String(row.id),
|
||||
name: name || String(row.email || '').trim() || 'Unnamed user',
|
||||
email: String(row.email || '').trim(),
|
||||
contributed: 0,
|
||||
share: Number(row.share ?? 0),
|
||||
joinedAt: row.joined_at || new Date().toISOString()
|
||||
}
|
||||
})
|
||||
@ -107,6 +109,35 @@ function PoolManagePageInner() {
|
||||
void fetchMembers()
|
||||
}, [token, poolId])
|
||||
|
||||
// Fetch pool inflow stats
|
||||
React.useEffect(() => {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
let cancelled = false
|
||||
async function loadStats() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const res = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!cancelled && res.ok && body?.success) {
|
||||
setTotalAmount(Number(body.data?.total_amount ?? 0))
|
||||
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
|
||||
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
|
||||
}
|
||||
} catch {
|
||||
// ignore — stats are non-critical
|
||||
}
|
||||
}
|
||||
void loadStats()
|
||||
return () => { cancelled = true }
|
||||
}, [token, poolId])
|
||||
|
||||
// Early return AFTER all hooks are declared to keep consistent order
|
||||
if (!authChecked) return null
|
||||
|
||||
@ -214,10 +245,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,34 +262,49 @@ function PoolManagePageInner() {
|
||||
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
||||
} finally {
|
||||
setRemovingMemberId(null)
|
||||
setRemoveConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
const isCore = poolName === 'Core'
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
||||
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : 'bg-gradient-to-tr from-blue-50 via-white to-blue-100'}`}>
|
||||
<Header />
|
||||
{/* main wrapper: avoid high z-index stacking */}
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
||||
<div className="max-w-7xl mx-auto relative z-0">
|
||||
{/* Header (remove sticky/z-10) */}
|
||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0">
|
||||
<header className={`backdrop-blur border-b py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0 ${
|
||||
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/90 border-blue-100'
|
||||
}`}>
|
||||
{isCore && (
|
||||
<div className="inline-flex items-center gap-1.5 self-start rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-2">
|
||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||
Core Pool — 1¢ per capsule per member
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
<div className={`h-10 w-10 rounded-lg border flex items-center justify-center ${
|
||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight">{poolName}</h1>
|
||||
<p className="text-sm text-blue-700">
|
||||
<h1 className={`text-3xl font-extrabold tracking-tight ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{poolName}</h1>
|
||||
<p className={`text-sm ${isCore ? 'text-amber-700' : 'text-blue-700'}`}>
|
||||
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600 flex-wrap">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Price/capsule (gross): € {Number(poolPrice || 0).toFixed(2)}{isCore ? ' × each member' : ''}</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>
|
||||
@ -312,7 +362,12 @@ function PoolManagePageInner() {
|
||||
{/* Unified Members card: add button + list */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
|
||||
{users.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||
@ -326,56 +381,68 @@ function PoolManagePageInner() {
|
||||
{removeError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{users.map(u => (
|
||||
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900">{u.name}</h3>
|
||||
<p className="text-xs text-gray-600">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900">
|
||||
€ {u.contributed.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
Member since:{' '}
|
||||
<span className="font-medium text-gray-900">
|
||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={() => removeMember(u.id)}
|
||||
disabled={removingMemberId === u.id}
|
||||
className="px-3 py-2 text-xs font-medium rounded-lg border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{membersLoading && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
Loading members...
|
||||
</div>
|
||||
)}
|
||||
{membersError && !membersLoading && (
|
||||
<div className="col-span-full text-center text-red-600 py-6">
|
||||
{membersError}
|
||||
</div>
|
||||
)}
|
||||
{users.length === 0 && !membersLoading && !membersError && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
No users in this pool yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{membersLoading && (
|
||||
<div className="text-center text-gray-500 italic py-8">Loading members...</div>
|
||||
)}
|
||||
{membersError && !membersLoading && (
|
||||
<div className="text-center text-red-600 py-8">{membersError}</div>
|
||||
)}
|
||||
{users.length === 0 && !membersLoading && !membersError && (
|
||||
<div className="text-center text-gray-500 italic py-8">No users in this pool yet.</div>
|
||||
)}
|
||||
|
||||
{users.length > 0 && !membersLoading && (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Name</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Email</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Member Since</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-gray-700">{isCore ? 'Total Earned' : 'Share'}</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-gray-50 transition">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-xs font-bold text-blue-800">
|
||||
{(u.name?.[0] || '?').toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">{u.email}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">
|
||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
u.share > 0
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-gray-50 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
€ {u.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={() => removeMember(u.id)}
|
||||
disabled={removingMemberId === u.id}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@ -386,7 +453,7 @@ function PoolManagePageInner() {
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="w-full max-w-2xl rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
|
||||
@ -437,7 +504,7 @@ function PoolManagePageInner() {
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
||||
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
||||
{!error && query.trim().length < 3 && (
|
||||
<div className="py-8 text-sm text-gray-500 text-center">
|
||||
@ -522,6 +589,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>
|
||||
)
|
||||
|
||||
@ -6,11 +6,10 @@ import Footer from '../../components/Footer'
|
||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||
import { useAdminPools } from './hooks/getlist'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import { addPool } from './hooks/addPool'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
|
||||
type Pool = {
|
||||
id: string
|
||||
@ -26,16 +25,9 @@ type Pool = {
|
||||
export default function PoolManagementPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Modal state
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const [createError, setCreateError] = React.useState<string>('')
|
||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||
|
||||
// Token and API URL
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
|
||||
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
|
||||
|
||||
// Replace local fetch with hook
|
||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||
@ -50,57 +42,29 @@ export default function PoolManagementPage() {
|
||||
|
||||
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' }) {
|
||||
setCreateError('')
|
||||
setCreateSuccess('')
|
||||
const pool_name = data.pool_name.trim()
|
||||
const description = data.description.trim()
|
||||
if (!pool_name) {
|
||||
setCreateError('Please provide a pool name.')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
|
||||
if (res.ok && res.body?.data) {
|
||||
setCreateSuccess('Pool created successfully.')
|
||||
await refresh?.()
|
||||
setTimeout(() => {
|
||||
setCreateModalOpen(false)
|
||||
setCreateSuccess('')
|
||||
}, 1500)
|
||||
} else {
|
||||
setCreateError(res.message || 'Failed to create pool.')
|
||||
}
|
||||
} catch {
|
||||
setCreateError('Network error while creating pool.')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,14 +108,8 @@ export default function PoolManagementPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
|
||||
<p className="text-lg text-blue-700 mt-2">Manage system pools and members.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setCreateModalOpen(true); createError && setCreateError(''); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||
>
|
||||
Create New Pool
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Show:</span>
|
||||
@ -204,14 +162,28 @@ export default function PoolManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPools.map(pool => (
|
||||
<article key={pool.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col relative z-0">
|
||||
{filteredPools.map(pool => {
|
||||
const isCore = pool.pool_name === 'Core'
|
||||
return (
|
||||
<article key={pool.id} className={`rounded-2xl border shadow p-5 flex flex-col relative z-0 ${
|
||||
isCore
|
||||
? 'bg-gradient-to-br from-amber-50 via-white to-amber-50 border-amber-300 ring-1 ring-amber-200'
|
||||
: 'bg-white border-gray-100'
|
||||
}`}>
|
||||
{isCore && (
|
||||
<div className="absolute -top-2.5 left-4 inline-flex items-center gap-1 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-bold text-white uppercase tracking-wider shadow-sm">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||
Core Pool
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
<div className={`h-9 w-9 rounded-lg border flex items-center justify-center ${
|
||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">{pool.pool_name}</h3>
|
||||
<h3 className={`text-lg font-semibold ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{pool.pool_name}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${!pool.is_active ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||
@ -268,7 +240,8 @@ export default function PoolManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{filteredPools.length === 0 && !loading && !error && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
{showInactive ? 'No inactive pools found.' : 'No active pools found.'}
|
||||
@ -280,15 +253,19 @@ export default function PoolManagementPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal for creating a new pool */}
|
||||
<CreateNewPoolModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
|
||||
onCreate={handleCreatePool}
|
||||
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 />
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,14 @@ 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>
|
||||
{/* "For someone else" is disabled for now — only self-subscriptions */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* inputs translated */}
|
||||
<div>
|
||||
@ -293,9 +371,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 +420,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 +459,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 +488,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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
132
src/app/components/modals/ConfirmActionModal.tsx
Normal file
132
src/app/components/modals/ConfirmActionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -80,6 +80,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
: 'relative'
|
||||
|
||||
const [hasReferralPerm, setHasReferralPerm] = useState(false)
|
||||
const [hasSubscribePerm, setHasSubscribePerm] = useState(false)
|
||||
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
||||
const headerElRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
@ -169,14 +170,20 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
}
|
||||
if (!user) {
|
||||
console.log('ℹ️ Header: no user, clearing permission flag')
|
||||
if (!cancelled) setHasReferralPerm(false)
|
||||
if (!cancelled) {
|
||||
setHasReferralPerm(false)
|
||||
setHasSubscribePerm(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const uid = (user as any)?.id ?? (user as any)?._id ?? (user as any)?.userId
|
||||
if (!uid) {
|
||||
console.warn('⚠️ Header: user id missing, cannot fetch permissions', user)
|
||||
if (!cancelled) setHasReferralPerm(false)
|
||||
if (!cancelled) {
|
||||
setHasReferralPerm(false)
|
||||
setHasSubscribePerm(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -216,20 +223,31 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
body
|
||||
|
||||
let can = false
|
||||
let canSub = false
|
||||
if (Array.isArray(permsSrc)) {
|
||||
// Could be array of strings or objects
|
||||
can =
|
||||
permsSrc.includes?.('can_create_referrals') ||
|
||||
permsSrc.some?.((p: any) => p?.name === 'can_create_referrals' || p?.key === 'can_create_referrals')
|
||||
canSub =
|
||||
permsSrc.includes?.('can_subscribe') ||
|
||||
permsSrc.some?.((p: any) => p?.name === 'can_subscribe' || p?.key === 'can_subscribe')
|
||||
} else if (permsSrc && typeof permsSrc === 'object') {
|
||||
can = !!permsSrc.can_create_referrals
|
||||
canSub = !!permsSrc.can_subscribe
|
||||
}
|
||||
|
||||
console.log('✅ Header: can_create_referrals =', can)
|
||||
if (!cancelled) setHasReferralPerm(!!can)
|
||||
console.log('✅ Header: can_create_referrals =', can, 'can_subscribe =', canSub)
|
||||
if (!cancelled) {
|
||||
setHasReferralPerm(!!can)
|
||||
setHasSubscribePerm(!!canSub)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Header: fetch permissions error:', e)
|
||||
if (!cancelled) setHasReferralPerm(false)
|
||||
if (!cancelled) {
|
||||
setHasReferralPerm(false)
|
||||
setHasSubscribePerm(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -487,7 +505,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{DISPLAY_ABONEMENTS && (
|
||||
{DISPLAY_ABONEMENTS && hasSubscribePerm && (
|
||||
<button
|
||||
onClick={() => router.push('/coffee-abonnements')}
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
@ -658,6 +676,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) */}
|
||||
@ -710,7 +737,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
Personal Matrix
|
||||
</button>
|
||||
)}
|
||||
{DISPLAY_ABONEMENTS && (
|
||||
{DISPLAY_ABONEMENTS && hasSubscribePerm && (
|
||||
<button
|
||||
onClick={() => { router.push('/coffee-abonnements'); 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"
|
||||
|
||||
227
src/app/profile/components/financeInvoices.tsx
Normal file
227
src/app/profile/components/financeInvoices.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,17 @@
|
||||
import React from 'react'
|
||||
import { useReferredAbos } from '../hooks/getAbo'
|
||||
import { useMyAboStatus } from '../hooks/getAbo'
|
||||
|
||||
export default function UserAbo() {
|
||||
const { data: abos, loading, error } = useReferredAbos()
|
||||
type Props = {
|
||||
onAboChange?: (aboId: string | number | null) => void
|
||||
}
|
||||
|
||||
export default function UserAbo({ onAboChange }: Props) {
|
||||
const { hasAbo, abonement, loading, error } = useMyAboStatus()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onAboChange) return
|
||||
onAboChange(abonement?.id ?? null)
|
||||
}, [abonement?.id, onAboChange])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -28,18 +37,18 @@ export default function UserAbo() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
{(!abos || abos.length === 0) ? (
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscription</h2>
|
||||
{(!hasAbo || !abonement) ? (
|
||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
||||
No subscriptions yet.
|
||||
You currently don’t have an active subscription.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:gap-4">
|
||||
{abos.map(abo => {
|
||||
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
|
||||
const started = abo.startedAt ? new Date(abo.startedAt).toLocaleDateString() : '—'
|
||||
const coffees = (abo.pack_breakdown || abo.items || []).map((it, i) => (
|
||||
{(() => {
|
||||
const status = (abonement.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||
const nextBilling = abonement.nextBillingAt ? new Date(abonement.nextBillingAt).toLocaleDateString() : '—'
|
||||
const started = abonement.startedAt ? new Date(abonement.startedAt).toLocaleDateString() : '—'
|
||||
const coffees = (abonement.pack_breakdown || abonement.items || []).map((it, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-white text-[#1C2B4A] px-3 py-1.5 text-xs font-medium border border-gray-200 shadow-sm ring-1 ring-gray-100 hover:shadow-md hover:ring-gray-200 transition"
|
||||
@ -53,14 +62,14 @@ export default function UserAbo() {
|
||||
</span>
|
||||
))
|
||||
return (
|
||||
<div key={abo.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
|
||||
<div key={abonement.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{abonement.name || 'Coffee Subscription'}</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Next billing: {nextBilling}
|
||||
{' • '}Frequency: {abo.frequency ?? '—'}
|
||||
{' • '}Country: {(abo.country ?? '').toUpperCase() || '—'}
|
||||
{' • '}Frequency: {abonement.frequency ?? '—'}
|
||||
{' • '}Country: {(abonement.country ?? '').toUpperCase() || '—'}
|
||||
{' • '}Started: {started}
|
||||
</p>
|
||||
</div>
|
||||
@ -84,15 +93,12 @@ export default function UserAbo() {
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||
Manage
|
||||
</button>
|
||||
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||
View history
|
||||
Current plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
56
src/app/profile/hooks/editAbo.ts
Normal file
56
src/app/profile/hooks/editAbo.ts
Normal 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
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
107
src/app/profile/hooks/getAboInvoices.ts
Normal file
107
src/app/profile/hooks/getAboInvoices.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
|
||||
export type AboInvoice = {
|
||||
id: string | number
|
||||
invoiceNumber?: string
|
||||
issuedAt?: string | null
|
||||
createdAt?: string | null
|
||||
totalNet?: number | string | null
|
||||
totalTax?: number | string | null
|
||||
totalGross?: number | string | null
|
||||
currency?: string | null
|
||||
status?: string | null
|
||||
htmlUrl?: string | null
|
||||
pdfUrl?: string | null
|
||||
downloadUrl?: string | null
|
||||
fileUrl?: string | null
|
||||
objectKey?: string | null
|
||||
raw: any
|
||||
}
|
||||
|
||||
const pickFirst = (...vals: any[]) => {
|
||||
for (const value of vals) {
|
||||
if (value !== undefined && value !== null && value !== '') return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function useAboInvoices(abonementId?: string | number | null) {
|
||||
const [data, setData] = useState<AboInvoice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
if (!abonementId) {
|
||||
setData([])
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/abonements/${abonementId}/invoices`
|
||||
|
||||
console.info('[useAboInvoices] GET', url)
|
||||
const res = await authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const isJson = ct.includes('application/json')
|
||||
const json = isJson ? await res.json().catch(() => ({})) : null
|
||||
|
||||
console.info('[useAboInvoices] Response', res.status, json)
|
||||
|
||||
if (!res.ok || !json?.success) {
|
||||
throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||
}
|
||||
|
||||
const list = Array.isArray(json?.data) ? json.data : []
|
||||
const mapped: AboInvoice[] = list.map((row: any) => ({
|
||||
id: pickFirst(row.id, row._id, row.invoice_id, row.invoiceId) as string | number,
|
||||
invoiceNumber: pickFirst(row.invoiceNumber, row.invoice_number, row.number) as string,
|
||||
issuedAt: pickFirst(row.issuedAt, row.issued_at, row.invoiceDate, row.invoice_date) as string | null,
|
||||
createdAt: pickFirst(row.createdAt, row.created_at) as string | null,
|
||||
totalNet: pickFirst(row.totalNet, row.total_net),
|
||||
totalTax: pickFirst(row.totalTax, row.total_tax),
|
||||
totalGross: pickFirst(row.totalGross, row.total_gross, row.amount),
|
||||
currency: pickFirst(row.currency, row.totalCurrency, row.total_currency) as string | null,
|
||||
status: pickFirst(row.status, row.state) as string | null,
|
||||
htmlUrl: pickFirst(row.htmlUrl, row.html_url, row.invoice_html_url) as string | null,
|
||||
pdfUrl: pickFirst(row.pdfUrl, row.pdf_url, row.invoice_pdf_url) as string | null,
|
||||
downloadUrl: pickFirst(row.downloadUrl, row.download_url) as string | null,
|
||||
fileUrl: pickFirst(row.fileUrl, row.file_url, row.url) as string | null,
|
||||
objectKey: pickFirst(row.objectKey, row.object_key, row.storageKey, row.storage_key) as string | null,
|
||||
raw: row,
|
||||
}))
|
||||
|
||||
const sorted = mapped.sort((a, b) => {
|
||||
const da = new Date(a.issuedAt || a.createdAt || 0).getTime()
|
||||
const db = new Date(b.issuedAt || b.createdAt || 0).getTime()
|
||||
return db - da
|
||||
})
|
||||
|
||||
if (active) setData(sorted)
|
||||
} catch (e: any) {
|
||||
if (active) {
|
||||
setError(e?.message || 'Failed to load invoices.')
|
||||
setData([])
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => { active = false }
|
||||
}, [abonementId])
|
||||
|
||||
return { data, loading, error }
|
||||
}
|
||||
@ -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 don’t have any subscriptions yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{subscriptions.map((subscription) => {
|
||||
const packs = (subscription.pack_breakdown || subscription.items || []).reduce(
|
||||
(sum, item) => sum + (Number(item.quantity) || 0),
|
||||
0,
|
||||
)
|
||||
const started = subscription.startedAt || subscription.createdAt
|
||||
const startedLabel = started ? new Date(started).toLocaleDateString('de-DE') : '—'
|
||||
return (
|
||||
<li key={subscription.id} className="flex items-center justify-between gap-3 text-sm border-b border-gray-200/60 pb-2 last:border-0 last:pb-0">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">{subscription.name || `Subscription #${subscription.id}`}</p>
|
||||
<p className="text-xs text-gray-600">Started: {startedLabel} • Packs: {packs}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{String(subscription.status || 'ongoing')}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* --- Edit Bank Information Section --- */}
|
||||
<BankInformation
|
||||
profileData={profileData}
|
||||
|
||||
520
src/app/profile/subscriptions/page.tsx
Normal file
520
src/app/profile/subscriptions/page.tsx
Normal 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 don’t have any subscriptions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">All subscriptions</h2>
|
||||
<p className="text-xs text-gray-600">{subscriptions.length} total</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{subscriptions.map((subscription) => {
|
||||
const isSelected = String(subscription.id) === String(selectedAboId)
|
||||
const subscriptionStatus = normalizeSubscriptionStatus(subscription.status)
|
||||
const packs = (subscription.pack_breakdown || subscription.items || []).reduce(
|
||||
(sum, item) => sum + (Number(item.quantity) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={subscription.id}
|
||||
onClick={() => setSelectedAboId(subscription.id)}
|
||||
className={`text-left rounded-md border px-3 py-3 transition ${
|
||||
isSelected
|
||||
? 'border-[#8D6B1D] bg-white shadow-md'
|
||||
: 'border-gray-200 bg-white/80 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{subscription.name || `Subscription #${subscription.id}`}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusBadgeClass(subscriptionStatus)}`}
|
||||
>
|
||||
{displayStatus(subscriptionStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">ID: {subscription.id}</p>
|
||||
<p className="text-xs text-gray-600">Started: {formatDate(subscription.startedAt || subscription.createdAt)}</p>
|
||||
<p className="text-xs text-gray-600">Included packs: {packs}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedAbo && (
|
||||
<>
|
||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Subscription details</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{selectedAbo.name || 'Coffee Subscription'}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(status)}`}
|
||||
>
|
||||
{displayStatus(status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
{(status === 'ongoing' || status === 'issued') && (
|
||||
<button
|
||||
onClick={() => openStatusConfirm('pause')}
|
||||
disabled={statusBusy}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{statusBusy ? 'Updating…' : 'Pause subscription'}
|
||||
</button>
|
||||
)}
|
||||
{status === 'pause' && (
|
||||
<button
|
||||
onClick={() => openStatusConfirm('ongoing')}
|
||||
disabled={statusBusy}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{statusBusy ? 'Updating…' : 'Resume subscription'}
|
||||
</button>
|
||||
)}
|
||||
{(status === 'ongoing' || status === 'pause' || status === 'issued') && (
|
||||
<button
|
||||
onClick={() => openStatusConfirm('cancelled')}
|
||||
disabled={statusBusy}
|
||||
className="rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{statusBusy ? 'Updating…' : 'Cancel subscription'}
|
||||
</button>
|
||||
)}
|
||||
{(status === 'finished' || status === 'cancelled') && (
|
||||
<p className="text-xs text-gray-600">No further status changes are available for this subscription.</p>
|
||||
)}
|
||||
</div>
|
||||
{statusError && (
|
||||
<p className="mt-2 text-xs text-red-600">{statusError}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Subscription ID</p>
|
||||
<p className="font-medium text-gray-900">{selectedAbo.id}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Price</p>
|
||||
<p className="font-medium text-gray-900">{formatMoney(selectedAbo.price, selectedAbo.currency)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Frequency</p>
|
||||
<p className="font-medium text-gray-900">{selectedAbo.frequency || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Country</p>
|
||||
<p className="font-medium text-gray-900">{(selectedAbo.country || '').toUpperCase() || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Started</p>
|
||||
<p className="font-medium text-gray-900">{formatDate(selectedAbo.startedAt || selectedAbo.createdAt)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||
<p className="text-gray-500">Next billing</p>
|
||||
<p className="font-medium text-gray-900">{formatDate(selectedAbo.nextBillingAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Included in your subscription</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{includedItems.length} item(s), {totalPacks} total pack(s)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Changes apply from your next billing cycle.</p>
|
||||
</div>
|
||||
{!editingContent && canChangeContent && (
|
||||
<button
|
||||
onClick={startEditingContent}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Change coffees for next month
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canChangeContent && (
|
||||
<p className="mt-3 text-xs text-gray-600">
|
||||
Coffee content can only be changed while a subscription is issued, ongoing, or paused.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{includedItems.length === 0 ? (
|
||||
<div className="mt-4 rounded-md bg-white/80 border border-gray-200 p-3 text-sm text-gray-600">
|
||||
No included items were returned for this subscription.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{includedItems.map((item, index) => {
|
||||
const imageUrl = item.coffeeId ? coffeeImageById[String(item.coffeeId)] : ''
|
||||
return (
|
||||
<div key={`${item.coffeeId || 'coffee'}-${index}`} className="rounded-md bg-white/80 border border-gray-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900">{item.coffeeName || `Coffee #${item.coffeeId || index + 1}`}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">Coffee ID: {item.coffeeId || '—'}</p>
|
||||
<p className="text-xs text-gray-600">Packs included: {Number(item.quantity) || 0}</p>
|
||||
</div>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.coffeeName || `Coffee #${item.coffeeId || index + 1}`}
|
||||
className="h-14 w-14 rounded-md object-cover border border-gray-200 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-14 w-14 rounded-md bg-gray-100 border border-gray-200 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingContent && canChangeContent && (
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Edit coffee content</h3>
|
||||
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (must be 6 or 12)</p>
|
||||
</div>
|
||||
|
||||
{coffeesLoading ? (
|
||||
<p className="text-sm text-gray-600">Loading available coffees…</p>
|
||||
) : coffeesError ? (
|
||||
<p className="text-sm text-red-600">{coffeesError}</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-72 overflow-auto pr-1">
|
||||
{coffees.map((coffee) => {
|
||||
const key = String(coffee.id)
|
||||
const qty = draftItems[key] || 0
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-gray-200 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{coffee.name}</p>
|
||||
<p className="text-xs text-gray-600 line-clamp-1">{coffee.description || 'No description'}</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={qty}
|
||||
onChange={(e) => updateDraftQty(key, Number(e.target.value))}
|
||||
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentError && (
|
||||
<p className="mt-3 text-xs text-red-600">{contentError}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveContentChanges}
|
||||
disabled={savingContent || coffeesLoading}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingContent ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditingContent}
|
||||
disabled={savingContent}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FinanceInvoices abonementId={selectedAbo.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BlueBlurryBackground>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(statusConfirmTarget)}
|
||||
pending={statusBusy}
|
||||
title={confirmTitle}
|
||||
description={confirmDescription}
|
||||
confirmText={confirmButtonText}
|
||||
intent={statusConfirmTarget === 'cancelled' ? 'danger' : 'default'}
|
||||
onClose={closeStatusConfirm}
|
||||
onConfirm={confirmStatusChange}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -1,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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import { useToast } from '../../components/toast/toastComponent'
|
||||
import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput'
|
||||
|
||||
interface RegisterFormProps {
|
||||
mode: 'personal' | 'company'
|
||||
setMode: (mode: 'personal' | 'company') => void
|
||||
mode: 'personal' | 'company' | 'guest'
|
||||
setMode: (mode: 'personal' | 'company' | 'guest') => void
|
||||
refToken: string | null
|
||||
onRegistered: () => void
|
||||
referrerEmail?: string
|
||||
@ -78,9 +78,19 @@ export default function RegisterForm({
|
||||
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
||||
|
||||
// Hook for backend calls
|
||||
const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister()
|
||||
const { registerPersonalReferral, registerCompanyReferral, registerGuest, error: regError } = useRegister()
|
||||
const { showToast } = useToast()
|
||||
|
||||
// Guest form state
|
||||
const [guestForm, setGuestForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
confirmEmail: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
// Animate form when mode changes
|
||||
useEffect(() => {
|
||||
setFormFade('fade-out')
|
||||
@ -350,7 +360,72 @@ export default function RegisterForm({
|
||||
const { name, value } = e.target
|
||||
setCompanyForm(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
|
||||
const handleGuestChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setGuestForm(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const validateGuestForm = (): boolean => {
|
||||
if (!guestForm.firstName.trim() || !guestForm.lastName.trim() ||
|
||||
!guestForm.email.trim() || !guestForm.confirmEmail.trim() ||
|
||||
!guestForm.password.trim() || !guestForm.confirmPassword.trim()
|
||||
) {
|
||||
setError('All fields are required')
|
||||
return false
|
||||
}
|
||||
if (guestForm.email !== guestForm.confirmEmail) {
|
||||
setError('Email addresses do not match')
|
||||
return false
|
||||
}
|
||||
if (guestForm.password !== guestForm.confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return false
|
||||
}
|
||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(guestForm.password)) {
|
||||
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
|
||||
return false
|
||||
}
|
||||
setError('')
|
||||
return true
|
||||
}
|
||||
|
||||
const handleGuestSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (loading) return
|
||||
if (!validateGuestForm()) return
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await registerGuest({
|
||||
refToken: refToken || '',
|
||||
firstName: guestForm.firstName,
|
||||
lastName: guestForm.lastName,
|
||||
email: guestForm.email,
|
||||
password: guestForm.password,
|
||||
})
|
||||
if (res.ok) {
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Registration successful',
|
||||
message: 'You can now log in to view your coffee abonnement.'
|
||||
})
|
||||
onRegistered()
|
||||
} else {
|
||||
const msg = res.message || 'Registration failed. Please try again.'
|
||||
setError(msg)
|
||||
showToast({ variant: 'error', title: 'Registration failed', message: msg })
|
||||
}
|
||||
} catch {
|
||||
const msg = 'Registration failed. Please try again.'
|
||||
setError(msg)
|
||||
showToast({ variant: 'error', title: 'Registration failed', message: msg })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Password strength indicator
|
||||
const getPasswordStrength = (password: string) => {
|
||||
let strength = 0
|
||||
@ -408,28 +483,39 @@ export default function RegisterForm({
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="bg-white/40 backdrop-blur-[18px] border border-white/35 shadow-sm p-1 rounded-lg">
|
||||
<button
|
||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||
mode === 'personal'
|
||||
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]'
|
||||
}`}
|
||||
onClick={() => setMode('personal')}
|
||||
type="button"
|
||||
>
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||
mode === 'company'
|
||||
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]'
|
||||
}`}
|
||||
onClick={() => setMode('company')}
|
||||
type="button"
|
||||
>
|
||||
Company
|
||||
</button>
|
||||
{mode === 'guest' ? (
|
||||
<button
|
||||
className="px-6 py-2 rounded-md font-semibold text-sm bg-[#8D6B1D] text-white shadow-sm cursor-default"
|
||||
type="button"
|
||||
>
|
||||
Guest
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||
mode === 'personal'
|
||||
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]'
|
||||
}`}
|
||||
onClick={() => setMode('personal')}
|
||||
type="button"
|
||||
>
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||
mode === 'company'
|
||||
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]'
|
||||
}`}
|
||||
onClick={() => setMode('company')}
|
||||
type="button"
|
||||
>
|
||||
Company
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -592,7 +678,7 @@ export default function RegisterForm({
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
) : mode === 'company' ? (
|
||||
<form onSubmit={handleCompanySubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
@ -764,6 +850,140 @@ export default function RegisterForm({
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleGuestSubmit} className="space-y-6">
|
||||
<div className="p-4 bg-amber-50/70 backdrop-blur-[18px] border border-amber-200/70 rounded-lg mb-2">
|
||||
<p className="text-amber-800 text-sm font-medium">
|
||||
You are registering as a guest. You will have access to your coffee abonnements only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="guestFirstName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
First name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="guestFirstName"
|
||||
name="firstName"
|
||||
value={guestForm.firstName}
|
||||
onChange={handleGuestChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guestLastName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Last name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="guestLastName"
|
||||
name="lastName"
|
||||
value={guestForm.lastName}
|
||||
onChange={handleGuestChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="guestEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="guestEmail"
|
||||
name="email"
|
||||
value={guestForm.email}
|
||||
onChange={handleGuestChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guestConfirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Confirm email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="guestConfirmEmail"
|
||||
name="confirmEmail"
|
||||
value={guestForm.confirmEmail}
|
||||
onChange={handleGuestChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="guestPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPersonalPassword ? 'text' : 'password'}
|
||||
id="guestPassword"
|
||||
name="password"
|
||||
value={guestForm.password}
|
||||
onChange={handleGuestChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPersonalPassword(!showPersonalPassword)}
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-slate-500 hover:text-[#8D6B1D]"
|
||||
>
|
||||
{showPersonalPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{guestForm.password && renderPasswordStrength(guestForm.password)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guestConfirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Confirm password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="guestConfirmPassword"
|
||||
name="confirmPassword"
|
||||
value={guestForm.confirmPassword}
|
||||
onChange={handleGuestChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full flex items-center justify-center py-3 px-4 rounded-lg text-white font-semibold transition-colors ${
|
||||
loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#8D6B1D] hover:bg-[#7A5E1A] focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Registration in progress...
|
||||
</>
|
||||
) : (
|
||||
'Register as Guest'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -23,6 +23,15 @@ export type CompanyReferralPayload = {
|
||||
lang?: 'de' | 'en'
|
||||
}
|
||||
|
||||
export type GuestReferralPayload = {
|
||||
refToken: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
password: string
|
||||
lang?: 'de' | 'en'
|
||||
}
|
||||
|
||||
type RegisterResult<T = any> = {
|
||||
ok: boolean
|
||||
status: number
|
||||
@ -144,11 +153,69 @@ export function useRegister() {
|
||||
}
|
||||
}
|
||||
|
||||
const registerGuest = async (payload: GuestReferralPayload): Promise<RegisterResult> => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const body = {
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
confirmEmail: payload.email,
|
||||
password: payload.password,
|
||||
confirmPassword: payload.password,
|
||||
referralEmail: undefined as string | undefined,
|
||||
lang: payload.lang || detectLang(),
|
||||
}
|
||||
|
||||
// Try to resolve referral email from token
|
||||
if (payload.refToken) {
|
||||
try {
|
||||
const infoRes = await fetch(`${base}/api/referral/info/${encodeURIComponent(payload.refToken)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
const infoJson = await infoRes.json().catch(() => null)
|
||||
if (infoJson?.referrerEmail) {
|
||||
body.referralEmail = infoJson.referrerEmail
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const url = `${base}/api/register/guest`
|
||||
console.log('🌐 useRegister: POST guest', { url })
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
console.log('📡 useRegister: guest status:', res.status, 'body:', json)
|
||||
if (!res.ok) {
|
||||
const msg = json?.message || mapError(res.status)
|
||||
setError(msg)
|
||||
return { ok: false, status: res.status, data: json, message: msg }
|
||||
}
|
||||
return { ok: true, status: res.status, data: json, message: json?.message }
|
||||
} catch (e) {
|
||||
console.error('❌ useRegister: guest error:', e)
|
||||
const msg = 'Network error. Please try again later.'
|
||||
setError(msg)
|
||||
return { ok: false, status: 0, data: null, message: msg }
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
registerPersonalReferral,
|
||||
registerCompanyReferral,
|
||||
registerGuest,
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,8 +15,9 @@ import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
||||
function RegisterPageInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const refToken = searchParams.get('ref')
|
||||
const isGuestInvite = searchParams.get('guest') === 'true'
|
||||
const [registered, setRegistered] = useState(false)
|
||||
const [mode, setMode] = useState<'personal' | 'company'>('personal')
|
||||
const [mode, setMode] = useState<'personal' | 'company' | 'guest'>(isGuestInvite ? 'guest' : 'personal')
|
||||
const router = useRouter()
|
||||
const { showToast } = useToast()
|
||||
|
||||
@ -71,6 +72,8 @@ function RegisterPageInner() {
|
||||
const res = await fetch(url, { method: 'GET', credentials: 'include' })
|
||||
const body = await res.json().catch(() => null)
|
||||
|
||||
console.log('[REGISTER] Token validation response:', { status: res.status, body })
|
||||
|
||||
const success = !!body?.success
|
||||
const isUnlimited = !!body?.isUnlimited
|
||||
const usesRemaining =
|
||||
@ -92,16 +95,18 @@ function RegisterPageInner() {
|
||||
message: 'Your invitation link is valid. You can register now.'
|
||||
})
|
||||
} else {
|
||||
const reason = body?.reason || `HTTP ${res.status}`
|
||||
setInvalidRef(true)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Invalid invitation',
|
||||
message: 'This invitation link is invalid or no longer active.'
|
||||
message: `Reason: ${reason}. This invitation link is invalid or no longer active.`
|
||||
})
|
||||
}
|
||||
setIsRefChecked(true)
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[REGISTER] Token validation network error:', err)
|
||||
if (!cancelled) {
|
||||
setInvalidRef(true)
|
||||
setIsRefChecked(true)
|
||||
@ -109,7 +114,7 @@ function RegisterPageInner() {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Network error',
|
||||
message: 'Could not validate the invitation link. Please try again.'
|
||||
message: 'Could not reach the server. Is the backend running?'
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -277,10 +282,12 @@ function RegisterPageInner() {
|
||||
<div className="relative">
|
||||
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
||||
Register now
|
||||
{mode === 'guest' ? 'Guest Registration' : 'Register now'}
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||
Create your personal or company account with Profit Planet.
|
||||
{mode === 'guest'
|
||||
? 'Register as a guest to access your coffee abonnement.'
|
||||
: 'Create your personal or company account with Profit Planet.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user