feat: abo sub + dependency
This commit is contained in:
parent
49aee7b7ff
commit
004a8f4baa
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",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lottiefiles/react-lottie-player": "^3.6.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindplus/elements": "^1.0.15",
|
||||
"@tailwindplus/elements": "^1.0.22",
|
||||
"@tailwindui/react": "^0.1.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.21",
|
||||
"country-flag-icons": "^1.6.13",
|
||||
"country-select-js": "^2.1.0",
|
||||
"gsap": "^3.14.2",
|
||||
"intl-tel-input": "^25.15.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.0.7",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"intl-tel-input": "^26.4.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"motion": "^12.34.1",
|
||||
"next": "^16.1.6",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.12",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.167.1",
|
||||
"winston": "^3.17.0",
|
||||
"three": "^0.182.0",
|
||||
"winston": "^3.19.0",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.4.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-env": "^10.4.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"postcss-preset-env": "^11.1.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
|
||||
const [lang, setLang] = useState<'en' | 'de'>('en');
|
||||
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
|
||||
const [type, setType] = useState<'contract' | 'bill' | 'invoice' | 'other'>('contract');
|
||||
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
|
||||
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
@ -51,7 +51,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
setHtmlCode(tpl.html || '');
|
||||
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
|
||||
setLang((tpl.lang as any) || 'en');
|
||||
setType((tpl.type as any) || 'contract');
|
||||
setType(((tpl.type as any) || 'contract') as 'contract' | 'bill' | 'invoice' | 'other');
|
||||
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
|
||||
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
|
||||
setEditingMeta({
|
||||
@ -273,12 +273,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')}
|
||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'invoice' | 'other')}
|
||||
required
|
||||
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
{type === 'contract' && (
|
||||
@ -322,13 +323,22 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
<textarea
|
||||
value={htmlCode}
|
||||
onChange={(e) => setHtmlCode(e.target.value)}
|
||||
placeholder="Paste your full HTML (or snippet) here…"
|
||||
required
|
||||
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{type === 'invoice' && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||
<p className="font-semibold">Invoice template variables</p>
|
||||
<p className="mt-1">Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.</p>
|
||||
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span> to render invoice line items.</p>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={htmlCode}
|
||||
onChange={(e) => setHtmlCode(e.target.value)}
|
||||
placeholder="Paste your full HTML (or snippet) here…"
|
||||
required
|
||||
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPreview && (
|
||||
|
||||
@ -124,6 +124,9 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||
For invoice emails, provide active invoice templates for the language/user type combinations you need (en/de × personal/company/both). If no active invoice template matches, backend falls back to text-only email.
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
placeholder="Search templates…"
|
||||
@ -148,7 +151,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
||||
<StatusBadge status={c.status} />
|
||||
{c.type && (
|
||||
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
||||
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'}
|
||||
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : c.type === 'invoice' ? 'Invoice' : 'Other'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.type === 'contract' && (
|
||||
|
||||
@ -7,6 +7,7 @@ import { useActiveCoffees } from './hooks/getActiveCoffees';
|
||||
export default function CoffeeAbonnementPage() {
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch active coffees from the backend
|
||||
@ -31,22 +32,20 @@ export default function CoffeeAbonnementPage() {
|
||||
[selectedEntries]
|
||||
);
|
||||
|
||||
// NEW: enforce exactly 120 capsules (12 packs)
|
||||
// NEW: enforce selected plan size (60 or 120 capsules)
|
||||
const totalCapsules = useMemo(
|
||||
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||
[selectedEntries]
|
||||
);
|
||||
const packsSelected = totalCapsules / 10;
|
||||
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
|
||||
|
||||
const TAX_RATE = 0.07;
|
||||
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
||||
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
||||
const requiredPacks = selectedPlanCapsules / 10;
|
||||
const canProceed = packsSelected === requiredPacks;
|
||||
|
||||
const proceedToSummary = () => {
|
||||
if (!canProceed) return;
|
||||
try {
|
||||
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
||||
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
|
||||
} catch {}
|
||||
router.push('/coffee-abonnements/summary');
|
||||
};
|
||||
@ -57,6 +56,8 @@ export default function CoffeeAbonnementPage() {
|
||||
if (id in copy) {
|
||||
delete copy[id];
|
||||
} else {
|
||||
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0);
|
||||
if (total + 10 > selectedPlanCapsules) return prev;
|
||||
copy[id] = 10;
|
||||
}
|
||||
return copy;
|
||||
@ -66,8 +67,10 @@ export default function CoffeeAbonnementPage() {
|
||||
const changeQuantity = (id: string, delta: number) => {
|
||||
setSelections((prev) => {
|
||||
if (!(id in prev)) return prev;
|
||||
const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
|
||||
const maxForCoffee = Math.min(120, selectedPlanCapsules - otherTotal);
|
||||
const next = prev[id] + delta;
|
||||
if (next < 10 || next > 120) return prev;
|
||||
if (next < 10 || next > maxForCoffee) return prev;
|
||||
const updated = { ...prev, [id]: next };
|
||||
setBump((b) => ({ ...b, [id]: true }));
|
||||
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
||||
@ -97,7 +100,37 @@ export default function CoffeeAbonnementPage() {
|
||||
|
||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</h2>
|
||||
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPlanCapsules(60)}
|
||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
||||
selectedPlanCapsules === 60
|
||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">60 piece abo</div>
|
||||
<div className="text-xs text-gray-600">6 packs of 10 capsules</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPlanCapsules(120)}
|
||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
||||
selectedPlanCapsules === 120
|
||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">120 piece abo</div>
|
||||
<div className="text-xs text-gray-600">12 packs of 10 capsules</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
@ -116,6 +149,15 @@ export default function CoffeeAbonnementPage() {
|
||||
{coffees.map((coffee) => {
|
||||
const active = coffee.id in selections;
|
||||
const qty = selections[coffee.id] || 0;
|
||||
const remainingCapsules = selectedPlanCapsules - totalCapsules;
|
||||
const maxForCoffee = active
|
||||
? Math.min(120, qty + remainingCapsules)
|
||||
: 0;
|
||||
const sliderMax = Math.max(10, maxForCoffee);
|
||||
const sliderProgress = sliderMax <= 10
|
||||
? 100
|
||||
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
|
||||
const canAddCoffee = active || remainingCapsules >= 10;
|
||||
return (
|
||||
<div
|
||||
key={coffee.id}
|
||||
@ -158,10 +200,13 @@ export default function CoffeeAbonnementPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCoffee(coffee.id)}
|
||||
disabled={!canAddCoffee}
|
||||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||||
active
|
||||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
||||
: 'border-gray-300 hover:bg-gray-100'
|
||||
: canAddCoffee
|
||||
? 'border-gray-300 hover:bg-gray-100'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{active ? 'Remove' : 'Add'}
|
||||
@ -179,6 +224,7 @@ export default function CoffeeAbonnementPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => changeQuantity(coffee.id, -10)}
|
||||
disabled={qty <= 10}
|
||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||
>
|
||||
-10
|
||||
@ -187,7 +233,7 @@ export default function CoffeeAbonnementPage() {
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={120}
|
||||
max={sliderMax}
|
||||
step={10}
|
||||
value={qty}
|
||||
onChange={(e) =>
|
||||
@ -197,9 +243,9 @@ export default function CoffeeAbonnementPage() {
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
||||
((qty - 10) / (120 - 10)) * 100 +
|
||||
sliderProgress +
|
||||
'%,#e5e7eb ' +
|
||||
((qty - 10) / (120 - 10)) * 100 +
|
||||
sliderProgress +
|
||||
'%,#e5e7eb 100%)',
|
||||
height: '6px',
|
||||
borderRadius: '999px',
|
||||
@ -208,6 +254,7 @@ export default function CoffeeAbonnementPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeQuantity(coffee.id, +10)}
|
||||
disabled={qty + 10 > maxForCoffee}
|
||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||
>
|
||||
+10
|
||||
@ -230,7 +277,7 @@ export default function CoffeeAbonnementPage() {
|
||||
|
||||
{/* Section 2: Compact preview + next steps */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">2. Preview</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">3. Preview</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
||||
{selectedEntries.length === 0 && (
|
||||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
||||
@ -260,11 +307,11 @@ export default function CoffeeAbonnementPage() {
|
||||
|
||||
{/* Packs/capsules summary and validation hint (refined design) */}
|
||||
<div className="text-xs text-gray-700">
|
||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10).
|
||||
{packsSelected !== 12 && (
|
||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{packsSelected !== requiredPacks && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||||
Please select exactly 120 capsules (12 packs).
|
||||
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
|
||||
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -296,7 +343,7 @@ export default function CoffeeAbonnementPage() {
|
||||
</button>
|
||||
{!canProceed && (
|
||||
<p className="text-xs text-gray-600">
|
||||
You can continue once exactly 120 capsules (12 packs) are selected.
|
||||
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -76,11 +76,11 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
||||
coffeeId: i.coffeeId,
|
||||
quantity: i.quantity != null ? i.quantity : 1,
|
||||
}))
|
||||
// NEW: enforce exactly 12 packs
|
||||
// NEW: enforce supported package sizes
|
||||
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
|
||||
if (sumPacks !== 12) {
|
||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12')
|
||||
throw new Error('Order must contain exactly 12 packs (120 capsules).')
|
||||
if (sumPacks !== 6 && sumPacks !== 12) {
|
||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12')
|
||||
throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).')
|
||||
}
|
||||
} else {
|
||||
body.coffeeId = input.coffeeId
|
||||
|
||||
@ -10,7 +10,9 @@ import useAuthStore from '../../store/authStore'
|
||||
export default function SummaryPage() {
|
||||
const router = useRouter();
|
||||
const { coffees, loading, error } = useActiveCoffees();
|
||||
const user = useAuthStore(state => state.user)
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@ -34,6 +36,10 @@ export default function SummaryPage() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('coffeeSelections');
|
||||
if (raw) setSelections(JSON.parse(raw));
|
||||
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules');
|
||||
if (rawPlan === '60' || rawPlan === '120') {
|
||||
setSelectedPlanCapsules(Number(rawPlan) as 60 | 120);
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
@ -64,16 +70,20 @@ export default function SummaryPage() {
|
||||
[selectedEntries]
|
||||
)
|
||||
const totalPacks = totalCapsules / 10
|
||||
const requiredPacks = selectedPlanCapsules / 10
|
||||
|
||||
const token = useAuthStore.getState().accessToken
|
||||
console.info('[SummaryPage] token prefix:', token ? `${token.substring(0, 12)}…` : null)
|
||||
// NEW: capture logged-in user id for referral
|
||||
const currentUserId = useAuthStore.getState().user?.id
|
||||
const rawUserId = user?.id
|
||||
const currentUserId = typeof rawUserId === 'number'
|
||||
? rawUserId
|
||||
: (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
|
||||
console.info('[SummaryPage] currentUserId:', currentUserId)
|
||||
|
||||
// Countries list from backend VAT rates (fallback to current country if list empty)
|
||||
const countryOptions = useMemo(() => {
|
||||
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()]
|
||||
const currentCode = (form.country || 'DE').toUpperCase();
|
||||
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [currentCode]
|
||||
if (!opts.includes(currentCode)) opts.unshift(currentCode)
|
||||
console.info('[SummaryPage] countryOptions:', opts)
|
||||
return opts
|
||||
}, [vatRates, form.country]);
|
||||
@ -132,18 +142,44 @@ export default function SummaryPage() {
|
||||
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 canSubmit =
|
||||
selectedEntries.length > 0 &&
|
||||
totalPacks === 12 && // CHANGED: require exactly 12 packs
|
||||
totalPacks === requiredPacks &&
|
||||
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
|
||||
|
||||
const backToSelection = () => router.push('/coffee-abonnements');
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || submitLoading) return
|
||||
// NEW: guard (defensive) — backend requires exactly 12 packs
|
||||
if (totalPacks !== 12) {
|
||||
setSubmitError('Order must contain exactly 12 packs (120 capsules).')
|
||||
// NEW: guard (defensive) — backend requires selected package size
|
||||
if (totalPacks !== requiredPacks) {
|
||||
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
|
||||
return
|
||||
}
|
||||
setSubmitError(null)
|
||||
@ -176,6 +212,7 @@ export default function SummaryPage() {
|
||||
await subscribeAbo(payload)
|
||||
setShowThanks(true);
|
||||
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
|
||||
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {}
|
||||
} catch (e: any) {
|
||||
setSubmitError(e?.message || 'Subscription could not be created.');
|
||||
} finally {
|
||||
@ -250,6 +287,13 @@ export default function SummaryPage() {
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillFromLoggedInData}
|
||||
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
|
||||
>
|
||||
Fill fields with logged in data
|
||||
</button>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* inputs translated */}
|
||||
<div>
|
||||
@ -342,10 +386,10 @@ export default function SummaryPage() {
|
||||
</div>
|
||||
{/* Validation summary (refined design) */}
|
||||
<div className="mt-2 text-xs text-gray-700">
|
||||
Selected: {totalCapsules} capsules ({totalPacks} packs of 10).
|
||||
{totalPacks !== 12 && (
|
||||
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{totalPacks !== requiredPacks && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||||
Exactly 12 packs (120 capsules) are required.
|
||||
Exactly {requiredPacks} packs ({selectedPlanCapsules} capsules) are required.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user