dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
Showing only changes of commit 9fcb5a50a7 - Show all commits

View File

@ -13,6 +13,17 @@ import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml'
import SignaturePad from './components/SignaturePad'
import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog'
import { createReferralLink } from '../../referral-management/hooks/generateReferralLink'
import SubscribeGuard from '../components/SubscribeGuard'
import {
COFFEE_SELECTIONS_STORAGE_KEY,
COFFEE_SELECTIONS_UNIT_STORAGE_KEY,
getOrderPackError,
getRemainingMinPacks,
MAX_ABO_PACKS,
MIN_ABO_PACKS,
normalizeStoredSelections,
packsToCapsules,
} from '../lib/orderRules'
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A'];
@ -65,8 +76,17 @@ function pickFirstString(...values: unknown[]): string {
// ── shared input class
const inputCls = 'block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent';
const labelCls = 'block text-sm font-semibold text-slate-700 mb-1';
const requiredMarkCls = 'ml-1 text-red-500';
export default function SummaryPage() {
return (
<SubscribeGuard>
<SummaryPageContent />
</SubscribeGuard>
)
}
function SummaryPageContent() {
const { t } = useTranslation();
const router = useRouter();
const { coffees, loading, error } = useActiveCoffees();
@ -79,7 +99,6 @@ export default function SummaryPage() {
const [contractPdfLoading, setContractPdfLoading] = useState(false)
const [contractPdfError, setContractPdfError] = useState<string | null>(null)
const [selections, setSelections] = useState<Record<string, number>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
const [signatureDataUrl, setSignatureDataUrl] = useState('')
const [form, setForm] = useState({
firstName: '',
@ -103,6 +122,7 @@ export default function SummaryPage() {
signingCity: '',
});
const [showThanks, setShowThanks] = useState(false);
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [guestMailtoHref, setGuestMailtoHref] = useState<string>('')
const [guestInviteLink, setGuestInviteLink] = useState<string>('')
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
@ -316,11 +336,14 @@ export default function SummaryPage() {
useEffect(() => {
try {
const raw = sessionStorage.getItem('coffeeSelections');
if (raw) setSelections(JSON.parse(raw));
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules');
const parsedPlan = rawPlan ? Number(rawPlan) : null;
if (parsedPlan && Number.isInteger(parsedPlan) && parsedPlan >= 60 && parsedPlan % 10 === 0) setSelectedPlanCapsules(parsedPlan);
const raw = sessionStorage.getItem(COFFEE_SELECTIONS_STORAGE_KEY);
const unit = sessionStorage.getItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
setSelections(normalizeStoredSelections(parsed, unit));
}
}
} catch {}
}, []);
@ -340,9 +363,10 @@ export default function SummaryPage() {
[selections, coffees]
);
const totalCapsules = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries])
const totalPacks = totalCapsules / 10
const requiredPacks = selectedPlanCapsules / 10
const totalPacks = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries])
const totalCapsules = useMemo(() => packsToCapsules(totalPacks), [totalPacks])
const orderPackError = useMemo(() => getOrderPackError(totalPacks), [totalPacks])
const remainingMinPacks = useMemo(() => getRemainingMinPacks(totalPacks), [totalPacks])
const rawUserId = user?.id
const currentUserId = typeof rawUserId === 'number' ? rawUserId : (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
@ -381,8 +405,8 @@ export default function SummaryPage() {
return () => { active = false; };
}, [form.country, vatRates]);
const totalPrice = useMemo(() => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0), [selectedEntries]);
const shippingFee = useMemo(() => resolveShippingFee(selectedPlanCapsules), [resolveShippingFee, selectedPlanCapsules]);
const totalPrice = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity * e.coffee.pricePer10, 0), [selectedEntries]);
const shippingFee = useMemo(() => resolveShippingFee(totalCapsules), [resolveShippingFee, totalCapsules]);
const netWithShipping = useMemo(() => totalPrice + shippingFee, [totalPrice, shippingFee]);
const effectiveTaxRate = isReverseCharge ? 0 : taxRate
const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]);
@ -420,20 +444,50 @@ export default function SummaryPage() {
const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== ''
const hasSigningCity = form.signingCity.trim() !== ''
const hasSignature = signatureDataUrl.trim() !== ''
const canSubmit = selectedEntries.length > 0 && totalPacks === requiredPacks && hasRequiredSelfFields && hasRequiredInvoiceFields && hasSigningCity && hasSignature;
const canSubmit = selectedEntries.length > 0 && !orderPackError && hasRequiredSelfFields && hasRequiredInvoiceFields && hasSigningCity && hasSignature;
const canAttemptSubmit = selectedEntries.length > 0 && !orderPackError && !submitLoading
const firstNameError = hasAttemptedSubmit && form.firstName.trim() === ''
const lastNameError = hasAttemptedSubmit && form.lastName.trim() === ''
const emailError = hasAttemptedSubmit && form.email.trim() === ''
const streetError = hasAttemptedSubmit && form.street.trim() === ''
const postalCodeError = hasAttemptedSubmit && form.postalCode.trim() === ''
const cityError = hasAttemptedSubmit && form.city.trim() === ''
const countryError = hasAttemptedSubmit && form.country.trim() === ''
const invoiceEmailError = hasAttemptedSubmit && !form.invoiceSameAsShipping && form.invoiceEmail.trim() === ''
const signingCityError = hasAttemptedSubmit && !hasSigningCity
const signatureError = hasAttemptedSubmit && !hasSignature
const getFieldClassName = (hasError: boolean, extraClassName = '') => {
const errorClassName = hasError ? 'border-red-400 focus:ring-red-400' : ''
return `${inputCls} ${errorClassName} ${extraClassName}`.trim()
}
const renderLabel = (label: string, required = false) => (
<>
{label}
{required && <span className={requiredMarkCls}>*</span>}
</>
)
const backToSelection = () => router.push('/coffee-abonnements');
const submit = async () => {
if (!canSubmit || submitLoading) return
if (totalPacks !== requiredPacks) { setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`); return }
if (submitLoading) return
setHasAttemptedSubmit(true)
if (selectedEntries.length === 0) {
setSubmitError(`Order must contain at least ${MIN_ABO_PACKS} packs (${packsToCapsules(MIN_ABO_PACKS)} capsules).`)
return
}
if (orderPackError) { setSubmitError(orderPackError); return }
if (!hasRequiredSelfFields || !hasRequiredInvoiceFields) {
setSubmitError('Please fill in all required fields.')
return
}
if (!hasSigningCity) { setSubmitError('Signing city is required.'); return }
if (!hasSignature) { setSubmitError('Signature is required.'); return }
setSubmitError(null)
setSubmitLoading(true)
try {
const payload: SubscribeAboInput = {
items: selectedEntries.map(entry => ({ coffeeId: entry.coffee.id, quantity: Math.round(entry.quantity / 10) })),
items: selectedEntries.map(entry => ({ coffeeId: entry.coffee.id, quantity: entry.quantity })),
billing_interval: 'month', interval_count: 1, is_auto_renew: true, is_for_self: true,
firstName: form.firstName.trim(), lastName: form.lastName.trim(), email: form.email.trim(),
street: form.street.trim(), postalCode: form.postalCode.trim(), city: form.city.trim(), country: form.country.trim(),
@ -451,9 +505,10 @@ export default function SummaryPage() {
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
}
await subscribeAbo(payload)
setHasAttemptedSubmit(false)
setGuestMailtoHref(''); setGuestInviteLink(''); setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {}
try { sessionStorage.removeItem(COFFEE_SELECTIONS_STORAGE_KEY); } catch {}
try { sessionStorage.removeItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY); } catch {}
} catch (e: any) {
setSubmitError(e?.message || 'Subscription could not be created.');
} finally {
@ -467,7 +522,7 @@ export default function SummaryPage() {
className="min-h-screen"
style={{ background: 'radial-gradient(circle at top left,rgba(251,191,36,0.10),transparent 22%),radial-gradient(circle at top right,rgba(56,189,248,0.10),transparent 24%),linear-gradient(180deg,#f8fafc 0%,#f8fafc 50%,#eef2ff 100%)' }}
>
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
@ -541,32 +596,32 @@ export default function SummaryPage() {
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={labelCls}>{t('autofix.kfe9527d8')}</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel(t('autofix.kfe9527d8'), true)}</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className={getFieldClassName(firstNameError)} />
</div>
<div>
<label className={labelCls}>{t('autofix.k6a2c64e8')}</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel(t('autofix.k6a2c64e8'), true)}</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className={getFieldClassName(lastNameError)} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>Email</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel('Email', true)}</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className={getFieldClassName(emailError)} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>{t('autofix.kd1a2772d')}</label>
<input name="street" value={form.street} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel(t('autofix.kd1a2772d'), true)}</label>
<input name="street" value={form.street} onChange={handleInput} className={getFieldClassName(streetError)} />
</div>
<div>
<label className={labelCls}>ZIP</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel('ZIP', true)}</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={getFieldClassName(postalCodeError)} />
</div>
<div>
<label className={labelCls}>City</label>
<input name="city" value={form.city} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel('City', true)}</label>
<input name="city" value={form.city} onChange={handleInput} className={getFieldClassName(cityError)} />
</div>
<div>
<label className={labelCls}>Country</label>
<select name="country" value={form.country} onChange={handleInput} className={inputCls}>
<label className={labelCls}>{renderLabel('Country', true)}</label>
<select name="country" value={form.country} onChange={handleInput} className={getFieldClassName(countryError)}>
{countryOptions.map(code => <option key={code} value={code}>{code}</option>)}
</select>
</div>
@ -635,8 +690,8 @@ export default function SummaryPage() {
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className={inputCls} />
</div>
<div>
<label className={labelCls}>Email</label>
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={inputCls} />
<label className={labelCls}>{renderLabel('Email', true)}</label>
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={getFieldClassName(invoiceEmailError)} />
</div>
</div>
)}
@ -666,27 +721,27 @@ export default function SummaryPage() {
<div className="space-y-4">
<div>
<label className={labelCls}>Ort (Signing City) *</label>
<label className={labelCls}>{renderLabel('Ort (Signing City)', true)}</label>
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput}
className={`${inputCls} max-w-xs ${!hasSigningCity && submitError ? 'border-red-400 focus:ring-red-400' : ''}`}
className={getFieldClassName(signingCityError, 'max-w-xs')}
placeholder={t('autofix.k1f0b2c48')} />
{!hasSigningCity && submitError && <p className="mt-1 text-xs text-red-600">{t('autofix.k516705dd')}</p>}
{signingCityError && <p className="mt-1 text-xs text-red-600">{t('autofix.k516705dd')}</p>}
</div>
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={!hasSignature && submitError ? 'Signature is required.' : null} />
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={signatureError ? 'Signature is required.' : null} />
</div>
<button
onClick={submit}
disabled={!canSubmit || submitLoading}
disabled={!canAttemptSubmit}
className={`w-full rounded-xl px-5 py-3.5 text-sm font-semibold transition inline-flex items-center justify-center gap-2 shadow-sm ${
canSubmit && !submitLoading ? 'bg-slate-900 text-white hover:bg-slate-700' : 'bg-slate-100 text-slate-400 cursor-not-allowed'
canAttemptSubmit ? 'bg-slate-900 text-white hover:bg-slate-700' : 'bg-slate-100 text-slate-400 cursor-not-allowed'
}`}
>
{submitLoading && <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /></svg>}
{submitLoading ? t('autofix.k27b5b842') : t('autofix.k737db983')}
{!submitLoading && <svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>}
</button>
{!canSubmit && <p className="text-xs text-slate-500 text-center">{t('autofix.k1824f78d')}</p>}
{!canSubmit && <p className="text-xs text-slate-500 text-center">{orderPackError ?? 'Fill in all required fields marked with * and provide your signature to finish the subscription.'}</p>}
</div>
</section>
@ -700,7 +755,7 @@ export default function SummaryPage() {
{selectedEntries.map(entry => (
<div key={entry.coffee.id} className="flex gap-3 items-start">
{/* Coffee picture */}
<div className="flex-shrink-0 w-16 h-16 rounded-xl overflow-hidden border border-slate-100 bg-slate-50">
<div className="shrink-0 w-16 h-16 rounded-xl overflow-hidden border border-slate-100 bg-slate-50">
{entry.coffee.image ? (
<img src={entry.coffee.image} alt={entry.coffee.name} className="w-full h-full object-cover" />
) : (
@ -761,10 +816,13 @@ export default function SummaryPage() {
{/* Pack validation */}
<div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600">
<span className="font-semibold">{totalCapsules}</span> capsules selected ({totalPacks} packs). Target: <span className="font-semibold">{selectedPlanCapsules}</span> ({requiredPacks} packs).
{totalPacks !== requiredPacks && (
<span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
<div className="text-slate-500">{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs.</div>
{orderPackError && (
<div className="mt-1 rounded-lg bg-red-50 border border-red-200 text-red-700 px-2 py-1 font-medium">
Exactly {requiredPacks} packs required.
{remainingMinPacks > 0
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
: orderPackError}
</div>
)}
</div>