Compare commits
7 Commits
main
...
poolLinkTo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97596720f5 | ||
|
|
ea12c2ec0b | ||
|
|
7526e5c2e5 | ||
|
|
ab003be9fa | ||
|
|
b164f73b43 | ||
|
|
004a8f4baa | ||
|
|
49aee7b7ff |
12
ToDo.txt
12
ToDo.txt
@ -22,20 +22,11 @@ Last updated: 2026-01-20
|
|||||||
=== SEAZN TODOS ===
|
=== SEAZN TODOS ===
|
||||||
(Compromised User / Pool )
|
(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
|
• [ ] Unified Modal Design
|
||||||
• [ ] Autorefresh of Site??
|
• [ ] Autorefresh of Site??
|
||||||
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
|
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
|
||||||
• [x] Remove irrelevant statuses in userverify filter
|
|
||||||
• [ ] User Status 1 Feld das wir nicht benutzen
|
• [ ] User Status 1 Feld das wir nicht benutzen
|
||||||
• [ ] Pool mulit user actions (select 5 -> add to pool)
|
• [ ] 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
|
• [] Switching status -> confirmation modal
|
||||||
• [] mobile scroll bug with double page on top
|
• [] mobile scroll bug with double page on top
|
||||||
• [] search modal unify -> only return userId(s)
|
• [] search modal unify -> only return userId(s)
|
||||||
@ -43,10 +34,13 @@ Last updated: 2026-01-20
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
QUICK SHARED / CROSSOVER ITEMS
|
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
|
• [ ] Security Headers
|
||||||
• [ ] Dependency Management
|
• [ ] Dependency Management
|
||||||
• [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept
|
• [ ] 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
58
package.json
58
package.json
@ -14,55 +14,55 @@
|
|||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@lottiefiles/react-lottie-player": "^3.6.0",
|
"@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/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindplus/elements": "^1.0.15",
|
"@tailwindplus/elements": "^1.0.22",
|
||||||
"@tailwindui/react": "^0.1.1",
|
"@tailwindui/react": "^0.1.1",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"country-flag-icons": "^1.5.21",
|
"country-flag-icons": "^1.6.13",
|
||||||
"country-select-js": "^2.1.0",
|
"country-select-js": "^2.1.0",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"intl-tel-input": "^25.15.0",
|
"intl-tel-input": "^26.4.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.574.0",
|
||||||
"motion": "^12.23.22",
|
"motion": "^12.34.1",
|
||||||
"next": "^16.0.7",
|
"next": "^16.1.6",
|
||||||
"pdfjs-dist": "^5.4.149",
|
"pdfjs-dist": "^5.4.624",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.4",
|
||||||
"react-easy-crop": "^5.5.6",
|
"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-hot-toast": "^2.6.0",
|
||||||
"react-pdf": "^10.1.0",
|
"react-pdf": "^10.3.0",
|
||||||
"react-phone-number-input": "^3.4.12",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.167.1",
|
"three": "^0.182.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.19.0",
|
||||||
"yup": "^1.7.1",
|
"yup": "^1.7.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^25",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.24",
|
||||||
"baseline-browser-mapping": "^2.9.14",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"eslint": "^9",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^16.4.0",
|
"globals": "^17.3.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-env": "^10.4.0",
|
"postcss-preset-env": "^11.1.3",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import useContractManagement from '../hooks/useContractManagement';
|
import useContractManagement from '../hooks/useContractManagement';
|
||||||
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editingTemplateId?: string | null;
|
editingTemplateId?: string | null;
|
||||||
@ -17,12 +18,14 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const [lang, setLang] = useState<'en' | 'de'>('en');
|
const [lang, setLang] = useState<'en' | 'de'>('en');
|
||||||
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
|
const [type, setType] = useState<'contract' | 'bill' | 'invoice' | 'other'>('contract');
|
||||||
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
|
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
|
||||||
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
|
|
||||||
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
|
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);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
@ -51,7 +54,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
setHtmlCode(tpl.html || '');
|
setHtmlCode(tpl.html || '');
|
||||||
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
|
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
|
||||||
setLang((tpl.lang as any) || 'en');
|
setLang((tpl.lang as any) || 'en');
|
||||||
setType((tpl.type as any) || 'contract');
|
setType(((tpl.type as any) || 'contract') as 'contract' | 'bill' | 'invoice' | 'other');
|
||||||
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
|
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
|
||||||
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
|
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
|
||||||
setEditingMeta({
|
setEditingMeta({
|
||||||
@ -152,7 +155,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
lang
|
lang
|
||||||
)
|
)
|
||||||
|
|
||||||
const save = async (publish: boolean) => {
|
const doSave = async (publish: boolean) => {
|
||||||
const html = htmlCode.trim();
|
const html = htmlCode.trim();
|
||||||
// NEW: validate all fields
|
// NEW: validate all fields
|
||||||
if (!canSave) {
|
if (!canSave) {
|
||||||
@ -160,14 +163,6 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
return;
|
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);
|
setSaving(true);
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
|
|
||||||
@ -218,6 +213,21 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const save = async (publish: boolean) => {
|
||||||
|
if (publish && type === 'contract') {
|
||||||
|
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
|
||||||
|
setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the same user type and language.`)
|
||||||
|
setPublishConfirmOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await doSave(publish)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPublish = async () => {
|
||||||
|
setPublishConfirmOpen(false)
|
||||||
|
await doSave(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{editingMeta && (
|
{editingMeta && (
|
||||||
@ -273,12 +283,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')}
|
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'invoice' | 'other')}
|
||||||
required
|
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"
|
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="contract">Contract</option>
|
||||||
<option value="bill">Bill</option>
|
<option value="bill">Bill</option>
|
||||||
|
<option value="invoice">Invoice</option>
|
||||||
<option value="other">Other</option>
|
<option value="other">Other</option>
|
||||||
</select>
|
</select>
|
||||||
{type === 'contract' && (
|
{type === 'contract' && (
|
||||||
@ -322,6 +333,14 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isPreview && (
|
{!isPreview && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{type === 'invoice' && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||||
|
<p className="font-semibold">Invoice template variables</p>
|
||||||
|
<p className="mt-1">Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.</p>
|
||||||
|
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span> to render invoice line items.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
value={htmlCode}
|
value={htmlCode}
|
||||||
onChange={(e) => setHtmlCode(e.target.value)}
|
onChange={(e) => setHtmlCode(e.target.value)}
|
||||||
@ -329,6 +348,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
required
|
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"
|
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 && (
|
{isPreview && (
|
||||||
@ -362,6 +382,16 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
||||||
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import useContractManagement from '../hooks/useContractManagement';
|
import useContractManagement from '../hooks/useContractManagement';
|
||||||
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
@ -37,6 +38,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
const [items, setItems] = useState<ContractTemplate[]>([]);
|
const [items, setItems] = useState<ContractTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
|
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
listTemplates,
|
listTemplates,
|
||||||
@ -83,29 +85,39 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
const onToggleState = async (id: string, current: string) => {
|
const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
|
||||||
const target = current === 'published' ? 'inactive' : 'active';
|
|
||||||
|
|
||||||
// Confirmation: activating a contract/GDPR will deactivate other active templates of the same kind
|
|
||||||
if (target === 'active') {
|
|
||||||
const tpl = items.find((i) => i.id === id);
|
|
||||||
if (tpl?.type === 'contract') {
|
|
||||||
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
|
|
||||||
const ok = window.confirm(
|
|
||||||
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
||||||
// Update clicked item immediately, then refresh list to reflect any auto-deactivations.
|
// 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));
|
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
|
||||||
await load();
|
await load();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleState = async (id: string, current: string) => {
|
||||||
|
const target = current === 'published' ? 'inactive' : 'active';
|
||||||
|
if (target === 'active') {
|
||||||
|
const tpl = items.find((i) => i.id === id);
|
||||||
|
if (tpl?.type === 'contract') {
|
||||||
|
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
|
||||||
|
setPendingToggle({
|
||||||
|
id,
|
||||||
|
target,
|
||||||
|
requiresConfirm: true,
|
||||||
|
message: `This will deactivate other active ${kind} templates that apply to the same user type and language.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await executeToggleState(id, target);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmToggleState = async () => {
|
||||||
|
if (!pendingToggle) return
|
||||||
|
await executeToggleState(pendingToggle.id, pendingToggle.target)
|
||||||
|
setPendingToggle(null)
|
||||||
|
}
|
||||||
|
|
||||||
const onPreview = (id: string) => openPreviewInNewTab(id);
|
const onPreview = (id: string) => openPreviewInNewTab(id);
|
||||||
|
|
||||||
const onGenPdf = async (id: string) => {
|
const onGenPdf = async (id: string) => {
|
||||||
@ -124,6 +136,9 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||||
|
For invoice emails, provide active invoice templates for the language/user type combinations you need (en/de × personal/company/both). If no active invoice template matches, backend falls back to text-only email.
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
placeholder="Search templates…"
|
placeholder="Search templates…"
|
||||||
@ -148,7 +163,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
<StatusBadge status={c.status} />
|
<StatusBadge status={c.status} />
|
||||||
{c.type && (
|
{c.type && (
|
||||||
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
||||||
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'}
|
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : c.type === 'invoice' ? 'Invoice' : 'Other'}
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
{c.type === 'contract' && (
|
{c.type === 'contract' && (
|
||||||
@ -188,6 +203,15 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
|
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,18 @@ import PageLayout from '../../components/PageLayout'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useVatRates } from './hooks/getTaxes'
|
import { useVatRates } from './hooks/getTaxes'
|
||||||
import { useAdminInvoices } from './hooks/getInvoices'
|
import { useAdminInvoices } from './hooks/getInvoices'
|
||||||
|
import useAuthStore from '../../store/authStore'
|
||||||
|
|
||||||
export default function FinanceManagementPage() {
|
export default function FinanceManagementPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const accessToken = useAuthStore(s => s.accessToken)
|
||||||
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
||||||
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
||||||
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
||||||
|
const [diagLoading, setDiagLoading] = useState(false)
|
||||||
|
const [diagError, setDiagError] = useState('')
|
||||||
|
const [diagData, setDiagData] = useState<any | null>(null)
|
||||||
|
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
||||||
|
|
||||||
// NEW: fetch invoices from backend
|
// NEW: fetch invoices from backend
|
||||||
const {
|
const {
|
||||||
@ -67,6 +73,47 @@ export default function FinanceManagementPage() {
|
|||||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||||
@ -178,6 +225,51 @@ export default function FinanceManagementPage() {
|
|||||||
{invError}
|
{invError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(diagLoading || diagError || diagData) && (
|
||||||
|
<div className="rounded-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
|
||||||
|
{diagLoading && <div className="text-blue-800">Checking pool inflow...</div>}
|
||||||
|
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
||||||
|
{!diagLoading && !diagError && diagData && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
|
||||||
|
<div className="text-gray-700">
|
||||||
|
Status: <span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span> • Reason: <span className="font-mono">{diagData.reason}</span>
|
||||||
|
</div>
|
||||||
|
{diagData.ok && (
|
||||||
|
<div className="text-gray-700">
|
||||||
|
Abonement: <span className="font-medium">{diagData.abonement_id}</span> • Will book: <span className="font-medium">{diagData.will_book_count}</span> • Already booked: <span className="font-medium">{diagData.already_booked_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-blue-900">
|
||||||
|
<th className="pr-3 py-1">Pool</th>
|
||||||
|
<th className="pr-3 py-1">Coffee</th>
|
||||||
|
<th className="pr-3 py-1">Capsules</th>
|
||||||
|
<th className="pr-3 py-1">Net Amount</th>
|
||||||
|
<th className="pr-3 py-1">Booked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{diagData.candidates.map((c: any) => (
|
||||||
|
<tr key={`${c.pool_id}-${c.coffee_table_id}`}>
|
||||||
|
<td className="pr-3 py-1">{c.pool_name}</td>
|
||||||
|
<td className="pr-3 py-1">#{c.coffee_table_id}</td>
|
||||||
|
<td className="pr-3 py-1">{c.capsules_count}</td>
|
||||||
|
<td className="pr-3 py-1">€{Number(c.amount_net ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="pr-3 py-1">{c.already_booked ? 'yes' : 'no'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-blue-50 text-left text-blue-900">
|
<tr className="bg-blue-50 text-left text-blue-900">
|
||||||
@ -229,8 +321,24 @@ export default function FinanceManagementPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 space-x-2">
|
<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
|
||||||
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
|
onClick={() => setSelectedInvoice(inv)}
|
||||||
|
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => exportInvoice(inv)}
|
||||||
|
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => runPoolCheck(inv.id)}
|
||||||
|
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Pool check
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@ -238,6 +346,25 @@ export default function FinanceManagementPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedInvoice && (
|
||||||
|
<div className="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-sm font-semibold text-[#1C2B4A]">
|
||||||
|
Invoice details: {selectedInvoice.invoice_number ?? selectedInvoice.id}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedInvoice(null)}
|
||||||
|
className="text-xs rounded border px-2 py-1 hover:bg-white"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(selectedInvoice, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import React from 'react'
|
|||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
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
|
creating: boolean
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
@ -15,6 +16,7 @@ export default function CreateNewPoolModal({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
subscriptions,
|
||||||
creating,
|
creating,
|
||||||
error,
|
error,
|
||||||
success,
|
success,
|
||||||
@ -24,6 +26,7 @@ export default function CreateNewPoolModal({
|
|||||||
const [description, setDescription] = React.useState('')
|
const [description, setDescription] = React.useState('')
|
||||||
const [price, setPrice] = React.useState('0.00')
|
const [price, setPrice] = React.useState('0.00')
|
||||||
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
|
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
|
||||||
|
const [subscriptionCoffeeId, setSubscriptionCoffeeId] = React.useState<string>('')
|
||||||
|
|
||||||
const isDisabled = creating || !!success
|
const isDisabled = creating || !!success
|
||||||
|
|
||||||
@ -33,6 +36,7 @@ export default function CreateNewPoolModal({
|
|||||||
setDescription('')
|
setDescription('')
|
||||||
setPrice('0.00')
|
setPrice('0.00')
|
||||||
setPoolType('other')
|
setPoolType('other')
|
||||||
|
setSubscriptionCoffeeId('')
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
@ -73,7 +77,13 @@ export default function CreateNewPoolModal({
|
|||||||
onSubmit={e => {
|
onSubmit={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
clearMessages()
|
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"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
@ -100,7 +110,7 @@ export default function CreateNewPoolModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@ -112,6 +122,7 @@ export default function CreateNewPoolModal({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">This value is stored as net price.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label>
|
<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>
|
<option value="coffee">Coffee</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -137,7 +162,7 @@ export default function CreateNewPoolModal({
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export type AddPoolPayload = {
|
|||||||
pool_name: string;
|
pool_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
subscription_coffee_id?: number | null;
|
||||||
pool_type: 'coffee' | 'other';
|
pool_type: 'coffee' | 'other';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,8 @@ export type AdminPool = {
|
|||||||
pool_name: string;
|
pool_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
|
subscription_coffee_id?: number | null;
|
||||||
|
subscription_title?: string | null;
|
||||||
pool_type?: 'coffee' | 'other';
|
pool_type?: 'coffee' | 'other';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
membersCount: number;
|
membersCount: number;
|
||||||
@ -62,7 +64,9 @@ export function useAdminPools() {
|
|||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
description: String(item.description ?? ''),
|
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',
|
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
is_active: Boolean(item.is_active),
|
is_active: Boolean(item.is_active),
|
||||||
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||||
@ -100,7 +104,9 @@ export function useAdminPools() {
|
|||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
description: String(item.description ?? ''),
|
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',
|
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
is_active: Boolean(item.is_active),
|
is_active: Boolean(item.is_active),
|
||||||
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
|||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||||
import { AdminAPI } from '../../../utils/api'
|
import { AdminAPI } from '../../../utils/api'
|
||||||
|
import { authFetch } from '../../../utils/authFetch'
|
||||||
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||||
|
|
||||||
type PoolUser = {
|
type PoolUser = {
|
||||||
id: string
|
id: string
|
||||||
@ -50,6 +52,8 @@ function PoolManagePageInner() {
|
|||||||
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
||||||
const poolDescription = searchParams.get('description') ?? ''
|
const poolDescription = searchParams.get('description') ?? ''
|
||||||
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
||||||
|
const initialSubscriptionId = searchParams.get('subscription_coffee_id') ?? ''
|
||||||
|
const subscriptionTitle = searchParams.get('subscription_title') ?? ''
|
||||||
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
||||||
const poolIsActive = searchParams.get('is_active') === 'true'
|
const poolIsActive = searchParams.get('is_active') === 'true'
|
||||||
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
||||||
@ -75,6 +79,13 @@ function PoolManagePageInner() {
|
|||||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
const [savingMembers, setSavingMembers] = React.useState(false)
|
||||||
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
||||||
const [removeError, setRemoveError] = React.useState<string>('')
|
const [removeError, setRemoveError] = React.useState<string>('')
|
||||||
|
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
|
||||||
|
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
|
||||||
|
const [linkedSubscriptionId, setLinkedSubscriptionId] = React.useState<string>(initialSubscriptionId)
|
||||||
|
const [savingSubscription, setSavingSubscription] = React.useState(false)
|
||||||
|
const [subscriptionMessage, setSubscriptionMessage] = React.useState('')
|
||||||
|
const [subscriptionError, setSubscriptionError] = React.useState('')
|
||||||
|
const [currentSubscriptionTitle, setCurrentSubscriptionTitle] = React.useState(subscriptionTitle)
|
||||||
|
|
||||||
async function fetchMembers() {
|
async function fetchMembers() {
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
@ -107,6 +118,76 @@ function PoolManagePageInner() {
|
|||||||
void fetchMembers()
|
void fetchMembers()
|
||||||
}, [token, poolId])
|
}, [token, poolId])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function loadSubscriptions() {
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const response = await authFetch(`${base}/api/admin/coffee`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (!cancelled) setSubscriptions([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rows = await response.json().catch(() => [])
|
||||||
|
const mapped = Array.isArray(rows)
|
||||||
|
? rows
|
||||||
|
.map((r: any) => ({ id: Number(r?.id), title: String(r?.title || '').trim() }))
|
||||||
|
.filter((r: { id: number; title: string }) => Number.isFinite(r.id) && r.id > 0 && !!r.title)
|
||||||
|
: []
|
||||||
|
if (!cancelled) setSubscriptions(mapped)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setSubscriptions([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadSubscriptions()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
async function saveLinkedSubscription() {
|
||||||
|
if (!poolId || poolId === 'pool-unknown') return
|
||||||
|
setSavingSubscription(true)
|
||||||
|
setSubscriptionError('')
|
||||||
|
setSubscriptionMessage('')
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const payload = {
|
||||||
|
subscription_coffee_id: linkedSubscriptionId ? Number(linkedSubscriptionId) : null,
|
||||||
|
}
|
||||||
|
const response = await authFetch(`${base}/api/admin/pools/${encodeURIComponent(String(poolId))}/subscription`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
setSubscriptionError(body?.message || `Failed to update (${response.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selectedTitle = subscriptions.find(s => String(s.id) === String(linkedSubscriptionId))?.title || ''
|
||||||
|
setCurrentSubscriptionTitle(selectedTitle)
|
||||||
|
setSubscriptionMessage(`Linked subscription updated${selectedTitle ? `: ${selectedTitle}` : ' (not linked)'}.`)
|
||||||
|
} catch (e: any) {
|
||||||
|
setSubscriptionError(e?.message || 'Failed to update linked subscription.')
|
||||||
|
} finally {
|
||||||
|
setSavingSubscription(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Early return AFTER all hooks are declared to keep consistent order
|
// Early return AFTER all hooks are declared to keep consistent order
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
|
|
||||||
@ -214,10 +295,14 @@ function PoolManagePageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeMember(userId: string) {
|
async function removeMember(userId: string) {
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
const user = users.find(u => u.id === userId)
|
const user = users.find(u => u.id === userId)
|
||||||
const label = user?.name || user?.email || 'this user'
|
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('')
|
setRemoveError('')
|
||||||
setRemovingMemberId(userId)
|
setRemovingMemberId(userId)
|
||||||
try {
|
try {
|
||||||
@ -227,6 +312,7 @@ function PoolManagePageInner() {
|
|||||||
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
||||||
} finally {
|
} finally {
|
||||||
setRemovingMemberId(null)
|
setRemovingMemberId(null)
|
||||||
|
setRemoveConfirm(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +341,10 @@ function PoolManagePageInner() {
|
|||||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
{!poolIsActive ? 'Inactive' : 'Active'}
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
|
<span>Price/capsule (net): € {Number(poolPrice || 0).toFixed(2)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Subscription: {currentSubscriptionTitle || 'Not linked'}</span>
|
||||||
|
<span>•</span>
|
||||||
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className="text-gray-500">ID: {poolId}</span>
|
<span className="text-gray-500">ID: {poolId}</span>
|
||||||
@ -272,6 +362,39 @@ function PoolManagePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 mb-8 relative z-0">
|
||||||
|
<h2 className="text-lg font-semibold text-blue-900 mb-3">Linked Subscription</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-blue-900 mb-1">Subscription</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
|
value={linkedSubscriptionId}
|
||||||
|
onChange={e => setLinkedSubscriptionId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">No subscription linked</option>
|
||||||
|
{subscriptions.map((s) => (
|
||||||
|
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Current: {currentSubscriptionTitle || 'Not linked'}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={saveLinkedSubscription}
|
||||||
|
disabled={savingSubscription}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{savingSubscription ? 'Saving…' : 'Save Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{subscriptionMessage && (
|
||||||
|
<div className="mt-3 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">{subscriptionMessage}</div>
|
||||||
|
)}
|
||||||
|
{subscriptionError && (
|
||||||
|
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{subscriptionError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats (now zero until backend wired) */}
|
{/* Stats (now zero until backend wired) */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
||||||
@ -522,6 +645,17 @@ function PoolManagePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</PageTransitionEffect>
|
</PageTransitionEffect>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,12 +11,16 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
||||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||||
|
import { authFetch } from '../../utils/authFetch'
|
||||||
|
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||||
|
|
||||||
type Pool = {
|
type Pool = {
|
||||||
id: string
|
id: string
|
||||||
pool_name: string
|
pool_name: string
|
||||||
description?: string
|
description?: string
|
||||||
price?: number
|
price?: number
|
||||||
|
subscription_coffee_id?: number | null
|
||||||
|
subscription_title?: string | null
|
||||||
pool_type?: 'coffee' | 'other'
|
pool_type?: 'coffee' | 'other'
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
membersCount: number
|
membersCount: number
|
||||||
@ -32,15 +36,18 @@ export default function PoolManagementPage() {
|
|||||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||||
|
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
|
||||||
|
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
|
||||||
|
|
||||||
// Token and API URL
|
// Token and API URL
|
||||||
const token = useAuthStore.getState().accessToken
|
const token = useAuthStore(s => s.accessToken)
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
// Replace local fetch with hook
|
// Replace local fetch with hook
|
||||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||||
const [pools, setPools] = React.useState<Pool[]>([])
|
const [pools, setPools] = React.useState<Pool[]>([])
|
||||||
const [showInactive, setShowInactive] = React.useState(false)
|
const [showInactive, setShowInactive] = React.useState(false)
|
||||||
|
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!loading && !error) {
|
if (!loading && !error) {
|
||||||
@ -48,10 +55,46 @@ export default function PoolManagementPage() {
|
|||||||
}
|
}
|
||||||
}, [initialPools, loading, error])
|
}, [initialPools, loading, error])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function loadSubscriptions() {
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const response = await authFetch(`${base}/api/admin/coffee`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (!cancelled) setSubscriptions([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await response.json().catch(() => [])
|
||||||
|
const mapped = Array.isArray(rows)
|
||||||
|
? rows
|
||||||
|
.map((r: any) => ({ id: Number(r?.id), title: String(r?.title || '').trim() }))
|
||||||
|
.filter((r: { id: number; title: string }) => Number.isFinite(r.id) && r.id > 0 && !!r.title)
|
||||||
|
: []
|
||||||
|
if (!cancelled) setSubscriptions(mapped)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setSubscriptions([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadSubscriptions()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
||||||
|
|
||||||
// REPLACED: handleCreatePool to accept data from modal with new schema fields
|
// REPLACED: handleCreatePool to accept data from modal with new schema fields
|
||||||
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) {
|
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other'; subscription_coffee_id: number | null }) {
|
||||||
setCreateError('')
|
setCreateError('')
|
||||||
setCreateSuccess('')
|
setCreateSuccess('')
|
||||||
const pool_name = data.pool_name.trim()
|
const pool_name = data.pool_name.trim()
|
||||||
@ -62,7 +105,14 @@ export default function PoolManagementPage() {
|
|||||||
}
|
}
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
|
const res = await addPool({
|
||||||
|
pool_name,
|
||||||
|
description: description || undefined,
|
||||||
|
price: data.price,
|
||||||
|
subscription_coffee_id: data.subscription_coffee_id,
|
||||||
|
pool_type: data.pool_type,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
if (res.ok && res.body?.data) {
|
if (res.ok && res.body?.data) {
|
||||||
setCreateSuccess('Pool created successfully.')
|
setCreateSuccess('Pool created successfully.')
|
||||||
await refresh?.()
|
await refresh?.()
|
||||||
@ -81,26 +131,28 @@ export default function PoolManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleArchive(poolId: string) {
|
async function handleArchive(poolId: string) {
|
||||||
const confirmed = window.confirm('Archive this pool? Users will no longer be able to join or use it.')
|
setPoolStatusConfirm({ poolId, action: 'archive' })
|
||||||
if (!confirmed) return
|
|
||||||
setArchiveError('')
|
|
||||||
const res = await setPoolInactive(poolId)
|
|
||||||
if (res.ok) {
|
|
||||||
await refresh?.()
|
|
||||||
} else {
|
|
||||||
setArchiveError(res.message || 'Failed to deactivate pool.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetActive(poolId: string) {
|
async function handleSetActive(poolId: string) {
|
||||||
const confirmed = window.confirm('Unarchive this pool and make it active again?')
|
setPoolStatusConfirm({ poolId, action: 'activate' })
|
||||||
if (!confirmed) return
|
}
|
||||||
|
|
||||||
|
async function confirmPoolStatusChange() {
|
||||||
|
if (!poolStatusConfirm) return
|
||||||
|
const { poolId, action } = poolStatusConfirm
|
||||||
|
setPoolStatusPending(true)
|
||||||
setArchiveError('')
|
setArchiveError('')
|
||||||
const res = await setPoolActive(poolId)
|
try {
|
||||||
|
const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await refresh?.()
|
await refresh?.()
|
||||||
} else {
|
} else {
|
||||||
setArchiveError(res.message || 'Failed to activate pool.')
|
setArchiveError(res.message || (action === 'archive' ? 'Failed to deactivate pool.' : 'Failed to activate pool.'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPoolStatusPending(false)
|
||||||
|
setPoolStatusConfirm(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +271,9 @@ export default function PoolManagementPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
|
Subscription: {pool.subscription_title || 'Not linked'}
|
||||||
|
</p>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Members</span>
|
<span className="text-gray-500">Members</span>
|
||||||
@ -241,6 +296,8 @@ export default function PoolManagementPage() {
|
|||||||
description: pool.description ?? '',
|
description: pool.description ?? '',
|
||||||
price: String(pool.price ?? 0),
|
price: String(pool.price ?? 0),
|
||||||
pool_type: pool.pool_type ?? 'other',
|
pool_type: pool.pool_type ?? 'other',
|
||||||
|
subscription_coffee_id: pool.subscription_coffee_id != null ? String(pool.subscription_coffee_id) : '',
|
||||||
|
subscription_title: pool.subscription_title ?? '',
|
||||||
is_active: pool.is_active ? 'true' : 'false',
|
is_active: pool.is_active ? 'true' : 'false',
|
||||||
createdAt: pool.createdAt ?? '',
|
createdAt: pool.createdAt ?? '',
|
||||||
})
|
})
|
||||||
@ -285,12 +342,28 @@ export default function PoolManagementPage() {
|
|||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
|
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
|
||||||
onCreate={handleCreatePool}
|
onCreate={handleCreatePool}
|
||||||
|
subscriptions={subscriptions}
|
||||||
creating={creating}
|
creating={creating}
|
||||||
error={createError}
|
error={createError}
|
||||||
success={createSuccess}
|
success={createSuccess}
|
||||||
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
|
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 />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</PageTransitionEffect>
|
</PageTransitionEffect>
|
||||||
|
|||||||
@ -25,54 +25,74 @@ export function useActiveCoffees() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
authFetch(url, {
|
const tryFetch = async () => {
|
||||||
|
let lastError: string | null = null;
|
||||||
|
|
||||||
|
for (const url of candidateUrls) {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
});
|
||||||
.then(async (response) => {
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
console.log('[useActiveCoffees] Response status:', response.status);
|
console.log('[useActiveCoffees] Response for', url, response.status, contentType);
|
||||||
console.log('[useActiveCoffees] Response content-type:', contentType);
|
|
||||||
|
|
||||||
if (!response.ok || !contentType.includes('application/json')) {
|
if (!response.ok || !contentType.includes('application/json')) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200));
|
lastError = `Request failed: ${response.status} ${text.slice(0, 160)}`;
|
||||||
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`);
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(lastError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
console.log('[useActiveCoffees] Raw JSON response:', json);
|
|
||||||
|
|
||||||
const data: ActiveCoffee[] =
|
const data: ActiveCoffee[] =
|
||||||
Array.isArray(json?.data) ? json.data :
|
Array.isArray(json?.data) ? json.data :
|
||||||
Array.isArray(json) ? json :
|
Array.isArray(json) ? json :
|
||||||
[]
|
[];
|
||||||
console.log('[useActiveCoffees] Parsed coffee data:', data);
|
|
||||||
|
|
||||||
const mapped: CoffeeItem[] = data
|
const mapped: CoffeeItem[] = data
|
||||||
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
||||||
.map((coffee) => {
|
.map((coffee) => {
|
||||||
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price
|
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price;
|
||||||
return {
|
return {
|
||||||
id: String(coffee.id),
|
id: String(coffee.id),
|
||||||
name: coffee.title || `Coffee ${coffee.id}`,
|
name: coffee.title || `Coffee ${coffee.id}`,
|
||||||
description: coffee.description || '',
|
description: coffee.description || '',
|
||||||
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
||||||
image: coffee.pictureUrl || '',
|
image: coffee.pictureUrl || '',
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
console.log('[useActiveCoffees] Mapped coffee items:', mapped)
|
setCoffees(mapped);
|
||||||
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) => {
|
.catch((error: any) => {
|
||||||
console.error('[useActiveCoffees] Error fetching coffees:', error);
|
console.error('[useActiveCoffees] Error fetching coffees:', error);
|
||||||
setError(error?.message || 'Failed to load active coffees');
|
setError(error?.message || 'Failed to load active coffees');
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useActiveCoffees } from './hooks/getActiveCoffees';
|
|||||||
export default function CoffeeAbonnementPage() {
|
export default function CoffeeAbonnementPage() {
|
||||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Fetch active coffees from the backend
|
// Fetch active coffees from the backend
|
||||||
@ -31,22 +32,20 @@ export default function CoffeeAbonnementPage() {
|
|||||||
[selectedEntries]
|
[selectedEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
// NEW: enforce exactly 120 capsules (12 packs)
|
// NEW: enforce selected plan size (60 or 120 capsules)
|
||||||
const totalCapsules = useMemo(
|
const totalCapsules = useMemo(
|
||||||
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||||
[selectedEntries]
|
[selectedEntries]
|
||||||
);
|
);
|
||||||
const packsSelected = totalCapsules / 10;
|
const packsSelected = totalCapsules / 10;
|
||||||
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
|
const requiredPacks = selectedPlanCapsules / 10;
|
||||||
|
const canProceed = packsSelected === requiredPacks;
|
||||||
const TAX_RATE = 0.07;
|
|
||||||
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
|
||||||
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
|
||||||
|
|
||||||
const proceedToSummary = () => {
|
const proceedToSummary = () => {
|
||||||
if (!canProceed) return;
|
if (!canProceed) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
||||||
|
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
|
||||||
} catch {}
|
} catch {}
|
||||||
router.push('/coffee-abonnements/summary');
|
router.push('/coffee-abonnements/summary');
|
||||||
};
|
};
|
||||||
@ -57,6 +56,8 @@ export default function CoffeeAbonnementPage() {
|
|||||||
if (id in copy) {
|
if (id in copy) {
|
||||||
delete copy[id];
|
delete copy[id];
|
||||||
} else {
|
} else {
|
||||||
|
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0);
|
||||||
|
if (total + 10 > selectedPlanCapsules) return prev;
|
||||||
copy[id] = 10;
|
copy[id] = 10;
|
||||||
}
|
}
|
||||||
return copy;
|
return copy;
|
||||||
@ -66,8 +67,10 @@ export default function CoffeeAbonnementPage() {
|
|||||||
const changeQuantity = (id: string, delta: number) => {
|
const changeQuantity = (id: string, delta: number) => {
|
||||||
setSelections((prev) => {
|
setSelections((prev) => {
|
||||||
if (!(id in prev)) return 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;
|
const next = prev[id] + delta;
|
||||||
if (next < 10 || next > 120) return prev;
|
if (next < 10 || next > maxForCoffee) return prev;
|
||||||
const updated = { ...prev, [id]: next };
|
const updated = { ...prev, [id]: next };
|
||||||
setBump((b) => ({ ...b, [id]: true }));
|
setBump((b) => ({ ...b, [id]: true }));
|
||||||
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
||||||
@ -97,7 +100,37 @@ export default function CoffeeAbonnementPage() {
|
|||||||
|
|
||||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||||
<section>
|
<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 && (
|
{error && (
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
<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) => {
|
{coffees.map((coffee) => {
|
||||||
const active = coffee.id in selections;
|
const active = coffee.id in selections;
|
||||||
const qty = selections[coffee.id] || 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={coffee.id}
|
key={coffee.id}
|
||||||
@ -158,10 +200,13 @@ export default function CoffeeAbonnementPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleCoffee(coffee.id)}
|
onClick={() => toggleCoffee(coffee.id)}
|
||||||
|
disabled={!canAddCoffee}
|
||||||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||||||
active
|
active
|
||||||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
? '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'}
|
{active ? 'Remove' : 'Add'}
|
||||||
@ -179,6 +224,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => changeQuantity(coffee.id, -10)}
|
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"
|
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||||
>
|
>
|
||||||
-10
|
-10
|
||||||
@ -187,7 +233,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={10}
|
min={10}
|
||||||
max={120}
|
max={sliderMax}
|
||||||
step={10}
|
step={10}
|
||||||
value={qty}
|
value={qty}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -197,9 +243,9 @@ export default function CoffeeAbonnementPage() {
|
|||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
||||||
((qty - 10) / (120 - 10)) * 100 +
|
sliderProgress +
|
||||||
'%,#e5e7eb ' +
|
'%,#e5e7eb ' +
|
||||||
((qty - 10) / (120 - 10)) * 100 +
|
sliderProgress +
|
||||||
'%,#e5e7eb 100%)',
|
'%,#e5e7eb 100%)',
|
||||||
height: '6px',
|
height: '6px',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
@ -208,6 +254,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => changeQuantity(coffee.id, +10)}
|
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"
|
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||||
>
|
>
|
||||||
+10
|
+10
|
||||||
@ -230,7 +277,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
|
|
||||||
{/* Section 2: Compact preview + next steps */}
|
{/* Section 2: Compact preview + next steps */}
|
||||||
<section>
|
<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">
|
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
||||||
{selectedEntries.length === 0 && (
|
{selectedEntries.length === 0 && (
|
||||||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
<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) */}
|
{/* Packs/capsules summary and validation hint (refined design) */}
|
||||||
<div className="text-xs text-gray-700">
|
<div className="text-xs text-gray-700">
|
||||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10).
|
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||||
{packsSelected !== 12 && (
|
{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">
|
<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).
|
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||||
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
|
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -296,7 +343,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
</button>
|
</button>
|
||||||
{!canProceed && (
|
{!canProceed && (
|
||||||
<p className="text-xs text-gray-600">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type SubscribeAboInput = {
|
|||||||
billing_interval?: string
|
billing_interval?: string
|
||||||
interval_count?: number
|
interval_count?: number
|
||||||
is_auto_renew?: boolean
|
is_auto_renew?: boolean
|
||||||
|
is_for_self?: boolean
|
||||||
target_user_id?: number
|
target_user_id?: number
|
||||||
recipient_name?: string
|
recipient_name?: string
|
||||||
recipient_email?: string
|
recipient_email?: string
|
||||||
@ -22,7 +23,7 @@ export type SubscribeAboInput = {
|
|||||||
frequency?: string
|
frequency?: string
|
||||||
startDate?: string
|
startDate?: string
|
||||||
// NEW: logged-in user id
|
// NEW: logged-in user id
|
||||||
referred_by?: number
|
referred_by?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Abonement = any
|
type Abonement = any
|
||||||
@ -41,13 +42,13 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
|||||||
const hasItems = Array.isArray(input.items) && input.items.length > 0
|
const hasItems = Array.isArray(input.items) && input.items.length > 0
|
||||||
if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required')
|
if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required')
|
||||||
|
|
||||||
const hasRecipientFields = !!(input.recipient_name || input.recipient_email || input.recipient_notes)
|
const isForSelf = input.is_for_self ?? true
|
||||||
if (hasRecipientFields && !input.recipient_name) {
|
if (!isForSelf && (!input.recipient_email || input.recipient_email.trim() === '')) {
|
||||||
throw new Error('recipient_name is required when gifting to a non-account recipient.')
|
throw new Error('recipient_email is required when subscription is for someone else.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: validate customer fields (required in UI)
|
// NEW: validate customer fields (required in UI)
|
||||||
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency','startDate'] as const
|
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency'] as const
|
||||||
const missing = requiredFields.filter(k => {
|
const missing = requiredFields.filter(k => {
|
||||||
const v = (input as any)[k]
|
const v = (input as any)[k]
|
||||||
return typeof v !== 'string' || v.trim() === ''
|
return typeof v !== 'string' || v.trim() === ''
|
||||||
@ -60,6 +61,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
|||||||
billing_interval: input.billing_interval ?? 'month',
|
billing_interval: input.billing_interval ?? 'month',
|
||||||
interval_count: input.interval_count ?? 1,
|
interval_count: input.interval_count ?? 1,
|
||||||
is_auto_renew: input.is_auto_renew ?? true,
|
is_auto_renew: input.is_auto_renew ?? true,
|
||||||
|
is_for_self: isForSelf,
|
||||||
// NEW: include customer fields
|
// NEW: include customer fields
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
lastName: input.lastName,
|
lastName: input.lastName,
|
||||||
@ -69,18 +71,18 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
|||||||
city: input.city,
|
city: input.city,
|
||||||
country: input.country?.toUpperCase?.() ?? input.country,
|
country: input.country?.toUpperCase?.() ?? input.country,
|
||||||
frequency: input.frequency,
|
frequency: input.frequency,
|
||||||
startDate: input.startDate,
|
startDate: input.startDate || undefined,
|
||||||
}
|
}
|
||||||
if (hasItems) {
|
if (hasItems) {
|
||||||
body.items = input.items!.map(i => ({
|
body.items = input.items!.map(i => ({
|
||||||
coffeeId: i.coffeeId,
|
coffeeId: i.coffeeId,
|
||||||
quantity: i.quantity != null ? i.quantity : 1,
|
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)
|
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
|
||||||
if (sumPacks !== 12) {
|
if (sumPacks !== 6 && sumPacks !== 12) {
|
||||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12')
|
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12')
|
||||||
throw new Error('Order must contain exactly 12 packs (120 capsules).')
|
throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body.coffeeId = input.coffeeId
|
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
|
// NEW: always include available recipient fields and target_user_id when provided
|
||||||
if (input.target_user_id != null) body.target_user_id = input.target_user_id
|
if (input.target_user_id != null) body.target_user_id = input.target_user_id
|
||||||
if (input.recipient_name) body.recipient_name = input.recipient_name
|
if (!isForSelf && input.recipient_email) body.recipient_email = input.recipient_email
|
||||||
if (input.recipient_email) body.recipient_email = input.recipient_email
|
if (!isForSelf && input.recipient_name) body.recipient_name = input.recipient_name
|
||||||
if (input.recipient_notes) body.recipient_notes = input.recipient_notes
|
if (!isForSelf && input.recipient_notes) body.recipient_notes = input.recipient_notes
|
||||||
// NEW: always include referred_by if provided
|
// NEW: always include referred_by if provided
|
||||||
if (input.referred_by != null) body.referred_by = input.referred_by
|
if (input.referred_by != null) body.referred_by = input.referred_by
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import useAuthStore from '../../store/authStore'
|
|||||||
export default function SummaryPage() {
|
export default function SummaryPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { coffees, loading, error } = useActiveCoffees();
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||||
|
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||||||
|
const [isForSelf, setIsForSelf] = useState(true);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@ -20,7 +23,10 @@ export default function SummaryPage() {
|
|||||||
city: '',
|
city: '',
|
||||||
country: 'DE',
|
country: 'DE',
|
||||||
frequency: 'monatlich',
|
frequency: 'monatlich',
|
||||||
startDate: ''
|
startDate: '',
|
||||||
|
recipientEmail: '',
|
||||||
|
recipientName: '',
|
||||||
|
recipientNotes: '',
|
||||||
});
|
});
|
||||||
const [showThanks, setShowThanks] = useState(false);
|
const [showThanks, setShowThanks] = useState(false);
|
||||||
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
|
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
|
||||||
@ -34,6 +40,10 @@ export default function SummaryPage() {
|
|||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem('coffeeSelections');
|
const raw = sessionStorage.getItem('coffeeSelections');
|
||||||
if (raw) setSelections(JSON.parse(raw));
|
if (raw) setSelections(JSON.parse(raw));
|
||||||
|
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules');
|
||||||
|
if (rawPlan === '60' || rawPlan === '120') {
|
||||||
|
setSelectedPlanCapsules(Number(rawPlan) as 60 | 120);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -64,16 +74,20 @@ export default function SummaryPage() {
|
|||||||
[selectedEntries]
|
[selectedEntries]
|
||||||
)
|
)
|
||||||
const totalPacks = totalCapsules / 10
|
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
|
// 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)
|
console.info('[SummaryPage] currentUserId:', currentUserId)
|
||||||
|
|
||||||
// Countries list from backend VAT rates (fallback to current country if list empty)
|
// Countries list from backend VAT rates (fallback to current country if list empty)
|
||||||
const countryOptions = useMemo(() => {
|
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)
|
console.info('[SummaryPage] countryOptions:', opts)
|
||||||
return opts
|
return opts
|
||||||
}, [vatRates, form.country]);
|
}, [vatRates, form.country]);
|
||||||
@ -132,20 +146,71 @@ export default function SummaryPage() {
|
|||||||
setForm(prev => ({ ...prev, [name]: value }));
|
setForm(prev => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecipientNotes = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillFromLoggedInData = () => {
|
||||||
|
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 =
|
const canSubmit =
|
||||||
selectedEntries.length > 0 &&
|
selectedEntries.length > 0 &&
|
||||||
totalPacks === 12 && // CHANGED: require exactly 12 packs
|
totalPacks === requiredPacks &&
|
||||||
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
|
hasRequiredSelfFields &&
|
||||||
|
hasRequiredGiftFields;
|
||||||
|
|
||||||
const backToSelection = () => router.push('/coffee-abonnements');
|
const backToSelection = () => router.push('/coffee-abonnements');
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!canSubmit || submitLoading) return
|
if (!canSubmit || submitLoading) return
|
||||||
// NEW: guard (defensive) — backend requires exactly 12 packs
|
// NEW: guard (defensive) — backend requires selected package size
|
||||||
if (totalPacks !== 12) {
|
if (totalPacks !== requiredPacks) {
|
||||||
setSubmitError('Order must contain exactly 12 packs (120 capsules).')
|
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!isForSelf && !form.recipientEmail.trim()) {
|
||||||
|
setSubmitError('Recipient email is required when the subscription is for someone else.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setSubmitLoading(true)
|
setSubmitLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -157,6 +222,7 @@ export default function SummaryPage() {
|
|||||||
billing_interval: 'month',
|
billing_interval: 'month',
|
||||||
interval_count: 1,
|
interval_count: 1,
|
||||||
is_auto_renew: true,
|
is_auto_renew: true,
|
||||||
|
is_for_self: isForSelf,
|
||||||
// NEW: pass customer fields
|
// NEW: pass customer fields
|
||||||
firstName: form.firstName.trim(),
|
firstName: form.firstName.trim(),
|
||||||
lastName: form.lastName.trim(),
|
lastName: form.lastName.trim(),
|
||||||
@ -166,7 +232,10 @@ export default function SummaryPage() {
|
|||||||
city: form.city.trim(),
|
city: form.city.trim(),
|
||||||
country: form.country.trim(),
|
country: form.country.trim(),
|
||||||
frequency: form.frequency.trim(),
|
frequency: form.frequency.trim(),
|
||||||
startDate: form.startDate.trim(),
|
startDate: form.startDate.trim() || undefined,
|
||||||
|
recipient_email: isForSelf ? undefined : form.recipientEmail.trim(),
|
||||||
|
recipient_name: isForSelf ? undefined : (form.recipientName.trim() || undefined),
|
||||||
|
recipient_notes: isForSelf ? undefined : (form.recipientNotes.trim() || undefined),
|
||||||
// NEW: always include referred_by if available
|
// NEW: always include referred_by if available
|
||||||
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
|
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
|
||||||
}
|
}
|
||||||
@ -176,6 +245,7 @@ export default function SummaryPage() {
|
|||||||
await subscribeAbo(payload)
|
await subscribeAbo(payload)
|
||||||
setShowThanks(true);
|
setShowThanks(true);
|
||||||
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
|
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
|
||||||
|
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setSubmitError(e?.message || 'Subscription could not be created.');
|
setSubmitError(e?.message || 'Subscription could not be created.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -250,6 +320,37 @@ export default function SummaryPage() {
|
|||||||
<section className="lg:col-span-2">
|
<section className="lg:col-span-2">
|
||||||
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
|
<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">
|
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fillFromLoggedInData}
|
||||||
|
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
|
||||||
|
>
|
||||||
|
Fill fields with logged in data
|
||||||
|
</button>
|
||||||
|
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsForSelf(true)}
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition ${
|
||||||
|
isForSelf
|
||||||
|
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||||||
|
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
For me
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsForSelf(false)}
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition ${
|
||||||
|
!isForSelf
|
||||||
|
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||||||
|
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
For someone else
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* inputs translated */}
|
{/* inputs translated */}
|
||||||
<div>
|
<div>
|
||||||
@ -293,9 +394,42 @@ export default function SummaryPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Start date</label>
|
<label className="block text-sm font-medium mb-1">Start date (optional)</label>
|
||||||
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
|
{!isForSelf && (
|
||||||
|
<>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Recipient email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="recipientEmail"
|
||||||
|
value={form.recipientEmail}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Recipient name (optional)</label>
|
||||||
|
<input
|
||||||
|
name="recipientName"
|
||||||
|
value={form.recipientName}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Recipient note (optional)</label>
|
||||||
|
<textarea
|
||||||
|
name="recipientNotes"
|
||||||
|
value={form.recipientNotes}
|
||||||
|
onChange={handleRecipientNotes}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
@ -309,7 +443,13 @@ export default function SummaryPage() {
|
|||||||
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>}
|
{!canSubmit && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
{isForSelf
|
||||||
|
? 'Please select coffees and fill all required buyer fields.'
|
||||||
|
: 'Please select coffees and fill all required buyer fields plus recipient email.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -342,10 +482,10 @@ export default function SummaryPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Validation summary (refined design) */}
|
{/* Validation summary (refined design) */}
|
||||||
<div className="mt-2 text-xs text-gray-700">
|
<div className="mt-2 text-xs text-gray-700">
|
||||||
Selected: {totalCapsules} capsules ({totalPacks} packs of 10).
|
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||||
{totalPacks !== 12 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -371,7 +511,9 @@ export default function SummaryPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
|
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600">We have received your order.</p>
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
|
{isForSelf ? 'Subscription created.' : 'Subscription created, invitation sent.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
|
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
import ConfirmActionModal from './modals/ConfirmActionModal'
|
||||||
|
|
||||||
interface UserDetailModalProps {
|
interface UserDetailModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@ -73,6 +74,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
const [docsLoading, setDocsLoading] = useState(false)
|
const [docsLoading, setDocsLoading] = useState(false)
|
||||||
const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({})
|
const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({})
|
||||||
const [selectedFile, setSelectedFile] = useState<{ contract?: string; gdpr?: string }>({})
|
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 && (
|
const missingIdOrContract = !!userDetails?.userStatus && (
|
||||||
userDetails.userStatus.documents_uploaded !== 1 ||
|
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) => {
|
const moveContractDoc = async (documentId: number | undefined, targetType: 'contract' | 'gdpr', filename?: string | null, objectKey?: string) => {
|
||||||
if (!userId || !token) return
|
if (!userId || !token) return
|
||||||
const label = targetType === 'gdpr' ? 'GDPR' : 'Contract'
|
setMoveConfirm({ documentId, targetType, filename, objectKey })
|
||||||
const name = filename ? `\n\nFile: ${filename}` : ''
|
}
|
||||||
const ok = window.confirm(`Move this document to ${label}?${name}`)
|
|
||||||
if (!ok) return
|
const confirmMoveContractDoc = async () => {
|
||||||
|
if (!userId || !token || !moveConfirm) return
|
||||||
|
const { documentId, targetType, objectKey } = moveConfirm
|
||||||
const loadingKey = objectKey || String(documentId || '')
|
const loadingKey = objectKey || String(documentId || '')
|
||||||
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
|
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
|
||||||
try {
|
try {
|
||||||
@ -309,6 +318,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
console.error('UserDetailModal.moveContractDoc error:', e)
|
console.error('UserDetailModal.moveContractDoc error:', e)
|
||||||
} finally {
|
} finally {
|
||||||
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
|
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
|
||||||
|
setMoveConfirm(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -943,6 +953,21 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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>
|
</Transition.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import ConfirmActionModal from "../modals/ConfirmActionModal";
|
||||||
|
|
||||||
type DeleteConfirmationModalProps = {
|
type DeleteConfirmationModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -23,44 +24,18 @@ export default function DeleteConfirmationModal({
|
|||||||
onCancel,
|
onCancel,
|
||||||
children,
|
children,
|
||||||
}: DeleteConfirmationModalProps) {
|
}: DeleteConfirmationModalProps) {
|
||||||
if (!open) return null;
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<ConfirmActionModal
|
||||||
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
|
open={open}
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
pending={loading}
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
|
intent="danger"
|
||||||
<div className="p-6">
|
title={title}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
description={description}
|
||||||
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-red-100">
|
confirmText={confirmText}
|
||||||
<svg width="24" height="24" fill="none" stroke="currentColor" className="text-red-600">
|
cancelText={cancelText}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
onConfirm={onConfirm}
|
||||||
</svg>
|
onClose={onCancel}
|
||||||
</div>
|
extraContent={children}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -658,6 +658,15 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Main navigation (info + links + referral + ADMIN LAST) */}
|
{/* Main navigation (info + links + referral + ADMIN LAST) */}
|
||||||
|
|||||||
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 React from 'react'
|
||||||
import { useReferredAbos } from '../hooks/getAbo'
|
import { useMyAboStatus } from '../hooks/getAbo'
|
||||||
|
|
||||||
export default function UserAbo() {
|
type Props = {
|
||||||
const { data: abos, loading, error } = useReferredAbos()
|
onAboChange?: (aboId: string | number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserAbo({ onAboChange }: Props) {
|
||||||
|
const { hasAbo, abonement, loading, error } = useMyAboStatus()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!onAboChange) return
|
||||||
|
onAboChange(abonement?.id ?? null)
|
||||||
|
}, [abonement?.id, onAboChange])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -28,18 +37,18 @@ export default function UserAbo() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<h2 className="text-lg font-semibold text-gray-900">My Subscription</h2>
|
||||||
{(!abos || abos.length === 0) ? (
|
{(!hasAbo || !abonement) ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
||||||
No subscriptions yet.
|
You currently don’t have an active subscription.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:gap-4">
|
<div className="grid gap-3 sm:gap-4">
|
||||||
{abos.map(abo => {
|
{(() => {
|
||||||
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
|
const status = (abonement.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||||
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
|
const nextBilling = abonement.nextBillingAt ? new Date(abonement.nextBillingAt).toLocaleDateString() : '—'
|
||||||
const started = abo.startedAt ? new Date(abo.startedAt).toLocaleDateString() : '—'
|
const started = abonement.startedAt ? new Date(abonement.startedAt).toLocaleDateString() : '—'
|
||||||
const coffees = (abo.pack_breakdown || abo.items || []).map((it, i) => (
|
const coffees = (abonement.pack_breakdown || abonement.items || []).map((it, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-white text-[#1C2B4A] px-3 py-1.5 text-xs font-medium border border-gray-200 shadow-sm ring-1 ring-gray-100 hover:shadow-md hover:ring-gray-200 transition"
|
className="inline-flex items-center gap-1.5 rounded-full bg-white text-[#1C2B4A] px-3 py-1.5 text-xs font-medium border border-gray-200 shadow-sm ring-1 ring-gray-100 hover:shadow-md hover:ring-gray-200 transition"
|
||||||
@ -53,14 +62,14 @@ export default function UserAbo() {
|
|||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
return (
|
return (
|
||||||
<div key={abo.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
|
<div key={abonement.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
|
<p className="text-sm font-medium text-gray-900">{abonement.name || 'Coffee Subscription'}</p>
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">
|
||||||
Next billing: {nextBilling}
|
Next billing: {nextBilling}
|
||||||
{' • '}Frequency: {abo.frequency ?? '—'}
|
{' • '}Frequency: {abonement.frequency ?? '—'}
|
||||||
{' • '}Country: {(abo.country ?? '').toUpperCase() || '—'}
|
{' • '}Country: {(abonement.country ?? '').toUpperCase() || '—'}
|
||||||
{' • '}Started: {started}
|
{' • '}Started: {started}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -84,15 +93,12 @@ export default function UserAbo() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||||
Manage
|
Current plan
|
||||||
</button>
|
|
||||||
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
|
||||||
View history
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
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 { useEffect, useState } from 'react'
|
||||||
import { authFetch } from '../../utils/authFetch'
|
import { authFetch } from '../../utils/authFetch'
|
||||||
import useAuthStore from '../../store/authStore'
|
|
||||||
|
|
||||||
type AbonementItem = {
|
type AbonementItem = {
|
||||||
coffeeName?: string
|
coffeeName?: string
|
||||||
@ -8,9 +7,9 @@ type AbonementItem = {
|
|||||||
quantity: number
|
quantity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReferredAbo = {
|
export type CurrentAbo = {
|
||||||
id: number | string
|
id: number | string
|
||||||
status: 'active' | 'paused' | 'canceled'
|
status: 'active' | 'paused' | 'canceled' | 'cancelled' | 'expired' | 'issued' | 'ongoing' | 'finished' | 'pause'
|
||||||
nextBillingAt?: string | null
|
nextBillingAt?: string | null
|
||||||
email?: string
|
email?: string
|
||||||
price?: string | number
|
price?: string | number
|
||||||
@ -20,10 +19,43 @@ export type ReferredAbo = {
|
|||||||
frequency?: string
|
frequency?: string
|
||||||
country?: string
|
country?: string
|
||||||
startedAt?: string | null
|
startedAt?: string | null
|
||||||
|
endedAt?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
currency?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReferredAbos() {
|
function mapAbonement(rawAbo: any): CurrentAbo {
|
||||||
const [data, setData] = useState<ReferredAbo[]>([])
|
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 [loading, setLoading] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@ -34,52 +66,77 @@ export function useReferredAbos() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||||
const user = useAuthStore.getState().user
|
const url = `${base}/api/abonements/mine`
|
||||||
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 res = await authFetch(url, {
|
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: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ userId: userId ?? null, email: email ?? null }),
|
|
||||||
})
|
})
|
||||||
const ct = res.headers.get('content-type') || ''
|
const ct = res.headers.get('content-type') || ''
|
||||||
const isJson = ct.includes('application/json')
|
const isJson = ct.includes('application/json')
|
||||||
const json = isJson ? await res.json().catch(() => ({})) : null
|
const json = isJson ? await res.json().catch(() => ({})) : null
|
||||||
console.info('[useReferredAbos] Response', res.status, json)
|
console.info('[useMyAboStatus] Response', res.status, json)
|
||||||
|
|
||||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||||
const list: ReferredAbo[] = Array.isArray(json.data)
|
|
||||||
? json.data.map((raw: any) => ({
|
const rawAbo = json?.data?.abonement ?? null
|
||||||
id: raw.id,
|
const mappedAbo: CurrentAbo | null = rawAbo ? mapAbonement(rawAbo) : null
|
||||||
status: raw.status,
|
|
||||||
nextBillingAt: raw.next_billing_at ?? raw.nextBillingAt ?? null,
|
if (active) {
|
||||||
email: raw.email,
|
setHasAbo(Boolean(json?.data?.hasAbo))
|
||||||
price: raw.price,
|
setAbonement(mappedAbo)
|
||||||
name: `${raw.first_name ?? ''} ${raw.last_name ?? ''}`.trim() || 'Coffee Subscription',
|
}
|
||||||
frequency: raw.frequency,
|
|
||||||
country: raw.country,
|
|
||||||
startedAt: raw.started_at ?? null,
|
|
||||||
pack_breakdown: Array.isArray(raw.pack_breakdown)
|
|
||||||
? raw.pack_breakdown.map((it: any) => ({
|
|
||||||
coffeeName: it.coffee_name,
|
|
||||||
coffeeId: it.coffee_table_id,
|
|
||||||
quantity: it.packs, // packs count
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
if (active) setData(list)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (active) setError(e?.message || 'Failed to load subscriptions.')
|
if (active) setError(e?.message || 'Failed to load subscriptions.')
|
||||||
} finally {
|
} finally {
|
||||||
@ -89,5 +146,5 @@ export function useReferredAbos() {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { data, loading, error }
|
return { hasAbo, abonement, loading, error }
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/app/profile/hooks/getAboInvoices.ts
Normal file
107
src/app/profile/hooks/getAboInvoices.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { authFetch } from '../../utils/authFetch'
|
||||||
|
|
||||||
|
export type AboInvoice = {
|
||||||
|
id: string | number
|
||||||
|
invoiceNumber?: string
|
||||||
|
issuedAt?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
totalNet?: number | string | null
|
||||||
|
totalTax?: number | string | null
|
||||||
|
totalGross?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
status?: string | null
|
||||||
|
htmlUrl?: string | null
|
||||||
|
pdfUrl?: string | null
|
||||||
|
downloadUrl?: string | null
|
||||||
|
fileUrl?: string | null
|
||||||
|
objectKey?: string | null
|
||||||
|
raw: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickFirst = (...vals: any[]) => {
|
||||||
|
for (const value of vals) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAboInvoices(abonementId?: string | number | null) {
|
||||||
|
const [data, setData] = useState<AboInvoice[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
if (!abonementId) {
|
||||||
|
setData([])
|
||||||
|
setLoading(false)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||||
|
const url = `${base}/api/abonements/${abonementId}/invoices`
|
||||||
|
|
||||||
|
console.info('[useAboInvoices] GET', url)
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
const isJson = ct.includes('application/json')
|
||||||
|
const json = isJson ? await res.json().catch(() => ({})) : null
|
||||||
|
|
||||||
|
console.info('[useAboInvoices] Response', res.status, json)
|
||||||
|
|
||||||
|
if (!res.ok || !json?.success) {
|
||||||
|
throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.isArray(json?.data) ? json.data : []
|
||||||
|
const mapped: AboInvoice[] = list.map((row: any) => ({
|
||||||
|
id: pickFirst(row.id, row._id, row.invoice_id, row.invoiceId) as string | number,
|
||||||
|
invoiceNumber: pickFirst(row.invoiceNumber, row.invoice_number, row.number) as string,
|
||||||
|
issuedAt: pickFirst(row.issuedAt, row.issued_at, row.invoiceDate, row.invoice_date) as string | null,
|
||||||
|
createdAt: pickFirst(row.createdAt, row.created_at) as string | null,
|
||||||
|
totalNet: pickFirst(row.totalNet, row.total_net),
|
||||||
|
totalTax: pickFirst(row.totalTax, row.total_tax),
|
||||||
|
totalGross: pickFirst(row.totalGross, row.total_gross, row.amount),
|
||||||
|
currency: pickFirst(row.currency, row.totalCurrency, row.total_currency) as string | null,
|
||||||
|
status: pickFirst(row.status, row.state) as string | null,
|
||||||
|
htmlUrl: pickFirst(row.htmlUrl, row.html_url, row.invoice_html_url) as string | null,
|
||||||
|
pdfUrl: pickFirst(row.pdfUrl, row.pdf_url, row.invoice_pdf_url) as string | null,
|
||||||
|
downloadUrl: pickFirst(row.downloadUrl, row.download_url) as string | null,
|
||||||
|
fileUrl: pickFirst(row.fileUrl, row.file_url, row.url) as string | null,
|
||||||
|
objectKey: pickFirst(row.objectKey, row.object_key, row.storageKey, row.storage_key) as string | null,
|
||||||
|
raw: row,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sorted = mapped.sort((a, b) => {
|
||||||
|
const da = new Date(a.issuedAt || a.createdAt || 0).getTime()
|
||||||
|
const db = new Date(b.issuedAt || b.createdAt || 0).getTime()
|
||||||
|
return db - da
|
||||||
|
})
|
||||||
|
|
||||||
|
if (active) setData(sorted)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (active) {
|
||||||
|
setError(e?.message || 'Failed to load invoices.')
|
||||||
|
setData([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => { active = false }
|
||||||
|
}, [abonementId])
|
||||||
|
|
||||||
|
return { data, loading, error }
|
||||||
|
}
|
||||||
@ -10,10 +10,10 @@ import BasicInformation from './components/basicInformation'
|
|||||||
import MediaSection from './components/mediaSection'
|
import MediaSection from './components/mediaSection'
|
||||||
import BankInformation from './components/bankInformation'
|
import BankInformation from './components/bankInformation'
|
||||||
import EditModal from './components/editModal'
|
import EditModal from './components/editModal'
|
||||||
import UserAbo from './components/userAbo'
|
|
||||||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||||
import { useProfileData } from './hooks/getProfileData'
|
import { useProfileData } from './hooks/getProfileData'
|
||||||
import { useMedia } from './hooks/getMedia'
|
import { useMedia } from './hooks/getMedia'
|
||||||
|
import { useMyAbonements } from './hooks/getAbo'
|
||||||
import { editProfileBasic } from './hooks/editProfile'
|
import { editProfileBasic } from './hooks/editProfile'
|
||||||
import { authFetch } from '../utils/authFetch'
|
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
|
// Fetch hooks can run with undefined userId; they should handle it internally
|
||||||
const { data: profileDataApi, loading: profileLoading } = useProfileData(userId, refreshKey)
|
const { data: profileDataApi, loading: profileLoading } = useProfileData(userId, refreshKey)
|
||||||
const { data: mediaData, loading: mediaLoading } = useMedia(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
|
// Redirect only after hydration + auth ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -393,8 +394,54 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
{/* Bank Info, Media */}
|
{/* Bank Info, Media */}
|
||||||
<div className="space-y-6 sm:space-y-8 mb-8">
|
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||||
{/* --- My Abo Section (above bank info) --- */}
|
<section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
<UserAbo />
|
<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 --- */}
|
{/* --- Edit Bank Information Section --- */}
|
||||||
<BankInformation
|
<BankInformation
|
||||||
profileData={profileData}
|
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'
|
'use client'
|
||||||
|
|
||||||
import { Fragment } from 'react'
|
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
|
||||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
|
||||||
|
|
||||||
interface DeactivateReferralLinkModalProps {
|
interface DeactivateReferralLinkModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -22,77 +20,25 @@ export default function DeactivateReferralLinkModal({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: DeactivateReferralLinkModalProps) {
|
}: DeactivateReferralLinkModalProps) {
|
||||||
return (
|
return (
|
||||||
<Transition show={open} as={Fragment}>
|
<ConfirmActionModal
|
||||||
<Dialog onClose={onClose} className="relative z-[1100]">
|
open={open}
|
||||||
<Transition.Child
|
pending={pending}
|
||||||
as={Fragment}
|
intent="danger"
|
||||||
enter="transition-opacity ease-out duration-200"
|
title="Deactivate referral link?"
|
||||||
enterFrom="opacity-0"
|
description="This will immediately deactivate the selected referral link so it can no longer be used."
|
||||||
enterTo="opacity-100"
|
confirmText="Deactivate"
|
||||||
leave="transition-opacity ease-in duration-150"
|
onClose={onClose}
|
||||||
leaveFrom="opacity-100"
|
onConfirm={onConfirm}
|
||||||
leaveTo="opacity-0"
|
extraContent={
|
||||||
>
|
linkPreview ? (
|
||||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
|
<div>
|
||||||
</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>
|
<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">
|
<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}
|
{linkPreview}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="inline-flex items-center rounded-md border border-red-300 bg-red-600 px-3 py-2 text-sm text-white hover:bg-red-700 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? 'Deactivating…' : 'Deactivate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user