feat: abo sub + dependency

This commit is contained in:
DeathKaioken 2026-02-18 10:24:31 +01:00
parent 49aee7b7ff
commit 004a8f4baa
7 changed files with 2433 additions and 1832 deletions

4013
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,55 +14,55 @@
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@lottiefiles/react-lottie-player": "^3.6.0",
"@react-pdf/renderer": "^4.3.0",
"@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindplus/elements": "^1.0.15",
"@tailwindplus/elements": "^1.0.22",
"@tailwindui/react": "^0.1.1",
"axios": "^1.12.2",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.21",
"country-flag-icons": "^1.6.13",
"country-select-js": "^2.1.0",
"gsap": "^3.14.2",
"intl-tel-input": "^25.15.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.22",
"next": "^16.0.7",
"pdfjs-dist": "^5.4.149",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"intl-tel-input": "^26.4.1",
"lucide-react": "^0.574.0",
"motion": "^12.34.1",
"next": "^16.1.6",
"pdfjs-dist": "^5.4.624",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.63.0",
"react-hook-form": "^7.71.1",
"react-hot-toast": "^2.6.0",
"react-pdf": "^10.1.0",
"react-phone-number-input": "^3.4.12",
"react-pdf": "^10.3.0",
"react-phone-number-input": "^3.4.14",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"three": "^0.167.1",
"winston": "^3.17.0",
"three": "^0.182.0",
"winston": "^3.19.0",
"yup": "^1.7.1",
"zustand": "^5.0.8"
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.36.0",
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.4.0",
"autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"eslint": "^10.0.0",
"eslint-config-next": "^16.1.6",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"postcss": "^8.5.6",
"postcss-preset-env": "^10.4.0",
"tailwindcss": "^4.1.13",
"postcss-preset-env": "^11.1.3",
"tailwindcss": "^4.1.18",
"typescript": "^5"
}
}

View File

@ -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 && (

View File

@ -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' && (

View File

@ -7,6 +7,7 @@ import { useActiveCoffees } from './hooks/getActiveCoffees';
export default function CoffeeAbonnementPage() {
const [selections, setSelections] = useState<Record<string, number>>({});
const [bump, setBump] = useState<Record<string, boolean>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
const router = useRouter();
// Fetch active coffees from the backend
@ -31,22 +32,20 @@ export default function CoffeeAbonnementPage() {
[selectedEntries]
);
// NEW: enforce exactly 120 capsules (12 packs)
// NEW: enforce selected plan size (60 or 120 capsules)
const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
[selectedEntries]
);
const packsSelected = totalCapsules / 10;
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
const TAX_RATE = 0.07;
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
const requiredPacks = selectedPlanCapsules / 10;
const canProceed = packsSelected === requiredPacks;
const proceedToSummary = () => {
if (!canProceed) return;
try {
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
} catch {}
router.push('/coffee-abonnements/summary');
};
@ -57,6 +56,8 @@ export default function CoffeeAbonnementPage() {
if (id in copy) {
delete copy[id];
} else {
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0);
if (total + 10 > selectedPlanCapsules) return prev;
copy[id] = 10;
}
return copy;
@ -66,8 +67,10 @@ export default function CoffeeAbonnementPage() {
const changeQuantity = (id: string, delta: number) => {
setSelections((prev) => {
if (!(id in prev)) return prev;
const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
const maxForCoffee = Math.min(120, selectedPlanCapsules - otherTotal);
const next = prev[id] + delta;
if (next < 10 || next > 120) return prev;
if (next < 10 || next > maxForCoffee) return prev;
const updated = { ...prev, [id]: next };
setBump((b) => ({ ...b, [id]: true }));
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
@ -97,7 +100,37 @@ export default function CoffeeAbonnementPage() {
{/* Section 1: Multi coffee selection + per-coffee quantity */}
<section>
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</h2>
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => setSelectedPlanCapsules(60)}
className={`rounded-lg border px-4 py-3 text-left transition ${
selectedPlanCapsules === 60
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
<div className="font-semibold">60 piece abo</div>
<div className="text-xs text-gray-600">6 packs of 10 capsules</div>
</button>
<button
type="button"
onClick={() => setSelectedPlanCapsules(120)}
className={`rounded-lg border px-4 py-3 text-left transition ${
selectedPlanCapsules === 120
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
<div className="font-semibold">120 piece abo</div>
<div className="text-xs text-gray-600">12 packs of 10 capsules</div>
</button>
</div>
</div>
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@ -116,6 +149,15 @@ export default function CoffeeAbonnementPage() {
{coffees.map((coffee) => {
const active = coffee.id in selections;
const qty = selections[coffee.id] || 0;
const remainingCapsules = selectedPlanCapsules - totalCapsules;
const maxForCoffee = active
? Math.min(120, qty + remainingCapsules)
: 0;
const sliderMax = Math.max(10, maxForCoffee);
const sliderProgress = sliderMax <= 10
? 100
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
const canAddCoffee = active || remainingCapsules >= 10;
return (
<div
key={coffee.id}
@ -158,10 +200,13 @@ export default function CoffeeAbonnementPage() {
<button
type="button"
onClick={() => toggleCoffee(coffee.id)}
disabled={!canAddCoffee}
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
active
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
: 'border-gray-300 hover:bg-gray-100'
: canAddCoffee
? 'border-gray-300 hover:bg-gray-100'
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{active ? 'Remove' : 'Add'}
@ -179,6 +224,7 @@ export default function CoffeeAbonnementPage() {
<div className="flex items-center gap-2">
<button
onClick={() => changeQuantity(coffee.id, -10)}
disabled={qty <= 10}
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
>
-10
@ -187,7 +233,7 @@ export default function CoffeeAbonnementPage() {
<input
type="range"
min={10}
max={120}
max={sliderMax}
step={10}
value={qty}
onChange={(e) =>
@ -197,9 +243,9 @@ export default function CoffeeAbonnementPage() {
style={{
background:
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
((qty - 10) / (120 - 10)) * 100 +
sliderProgress +
'%,#e5e7eb ' +
((qty - 10) / (120 - 10)) * 100 +
sliderProgress +
'%,#e5e7eb 100%)',
height: '6px',
borderRadius: '999px',
@ -208,6 +254,7 @@ export default function CoffeeAbonnementPage() {
</div>
<button
onClick={() => changeQuantity(coffee.id, +10)}
disabled={qty + 10 > maxForCoffee}
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
>
+10
@ -230,7 +277,7 @@ export default function CoffeeAbonnementPage() {
{/* Section 2: Compact preview + next steps */}
<section>
<h2 className="text-xl font-semibold mb-4">2. Preview</h2>
<h2 className="text-xl font-semibold mb-4">3. Preview</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
{selectedEntries.length === 0 && (
<p className="text-sm text-gray-600">No coffees selected yet.</p>
@ -260,11 +307,11 @@ export default function CoffeeAbonnementPage() {
{/* Packs/capsules summary and validation hint (refined design) */}
<div className="text-xs text-gray-700">
Selected: {totalCapsules} capsules ({packsSelected} packs of 10).
{packsSelected !== 12 && (
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected !== requiredPacks && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Please select exactly 120 capsules (12 packs).
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
</span>
)}
</div>
@ -296,7 +343,7 @@ export default function CoffeeAbonnementPage() {
</button>
{!canProceed && (
<p className="text-xs text-gray-600">
You can continue once exactly 120 capsules (12 packs) are selected.
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
</p>
)}
</div>

View File

@ -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

View File

@ -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>