initial booking

This commit is contained in:
Ola Malmgren
2026-05-22 10:50:48 +02:00
commit 4d705a1005
77 changed files with 13827 additions and 0 deletions

View File

@@ -0,0 +1,825 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { useRouter } from '@/i18n/routing';
import { formatOre, computeTotals, priceInclVatOre } from '@/lib/money';
import { isValidSeOrgNumber } from '@/lib/orgNumber';
import { useBookingStore, type Step } from '@/lib/bookingStore';
type Product = {
id: string;
sku: string;
nameSv: string;
nameEn: string;
descriptionSv: string;
descriptionEn: string;
priceOre: number;
vatBp: number;
};
type PickupSlot = {
id: string;
labelSv: string;
labelEn: string;
startsAt: string;
endsAt: string;
capacityLeft: number;
};
const ALL_STEPS: Step[] = ['products', 'details', 'pickup', 'review'];
export function BookingForm({
products,
pickupSlots,
pickupEnabled,
}: {
products: Product[];
pickupSlots: PickupSlot[];
pickupEnabled: boolean;
}) {
// Step list adapts to whether pickup is enabled at all.
const STEPS: Step[] = pickupEnabled
? ALL_STEPS
: ALL_STEPS.filter((s) => s !== 'pickup');
const t = useTranslations('booking');
const c = useTranslations('common');
const locale = useLocale() as 'sv' | 'en';
const router = useRouter();
// Persisted state — survives language switch and tab refresh.
const store = useBookingStore();
const {
step,
quantities,
contactName,
email,
phone,
orgName,
orgNumber,
address,
postalCode,
city,
country,
pickupSlotId,
notes,
setStep,
setQuantity,
patch,
reset,
} = store;
// Confirm checkbox is intentionally not persisted; keep local.
const [confirm, setConfirm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Avoid hydration mismatch: don't render store-backed UI until mounted.
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
// If the persisted step no longer exists (admin toggled pickup off mid-flow),
// bump back to the closest still-valid step.
useEffect(() => {
if (!STEPS.includes(step)) {
setStep('products');
}
}, [step, STEPS, setStep]);
const selectedItems = useMemo(() => {
return products
.map((p) => {
const qty = quantities[p.id] ?? 0;
return qty > 0
? {
product: p,
quantity: qty,
unitPriceOre: p.priceOre,
vatBp: p.vatBp,
}
: null;
})
.filter((x): x is NonNullable<typeof x> => x !== null);
}, [products, quantities]);
const totals = useMemo(
() =>
computeTotals(
selectedItems.map((i) => ({
unitPriceOre: i.unitPriceOre,
quantity: i.quantity,
vatBp: i.vatBp,
})),
),
[selectedItems],
);
const stepIndex = STEPS.indexOf(step);
// Per-step validity. Used both for the Next button and for deciding which
// step boxes are clickable.
const isStepValid = (s: Step): boolean => {
if (s === 'products') return selectedItems.length > 0;
if (s === 'details') {
return (
contactName.trim().length >= 2 &&
/^\S+@\S+\.\S+$/.test(email) &&
phone.trim().length >= 5 &&
orgName.trim().length >= 2 &&
isValidSeOrgNumber(orgNumber) &&
address.trim().length >= 2 &&
postalCode.trim().length >= 3 &&
city.trim().length >= 1
);
}
if (s === 'pickup') return true; // optional
if (s === 'review') return confirm;
return true;
};
const canGoNext = (): boolean => isStepValid(step);
// A target step is reachable if you go backwards, or if all earlier steps
// are valid (so you can jump ahead in a wizard).
const canReachStep = (target: Step): boolean => {
const targetIdx = STEPS.indexOf(target);
if (targetIdx <= stepIndex) return true;
for (let i = 0; i < targetIdx; i++) {
if (!isStepValid(STEPS[i])) return false;
}
return true;
};
const goToStep = (target: Step) => {
if (canReachStep(target)) setStep(target);
};
const goNext = () => {
const idx = STEPS.indexOf(step);
if (idx < STEPS.length - 1) setStep(STEPS[idx + 1]);
};
const goPrev = () => {
const idx = STEPS.indexOf(step);
if (idx > 0) setStep(STEPS[idx - 1]);
};
const submit = async () => {
setSubmitting(true);
setSubmitError(null);
try {
const payload = {
contactName,
email,
phone,
orgName,
orgNumber,
address,
postalCode,
city,
country,
// Drop any pickup slot the user might have selected before admin
// disabled the step.
pickupSlotId: pickupEnabled ? pickupSlotId : null,
notes: notes || null,
locale,
items: selectedItems.map((i) => ({
productId: i.product.id,
quantity: i.quantity,
})),
};
const res = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'submitFailed');
}
const data = (await res.json()) as { bookingNumber: string };
reset();
router.push(`/booking/${data.bookingNumber}`);
} catch {
setSubmitError(t('errors.submitFailed'));
setSubmitting(false);
}
};
// Render a stable placeholder before hydration so SSR markup matches.
if (!hydrated) {
return (
<div className="space-y-6">
<StepIndicator
steps={STEPS}
step="products"
goToStep={() => {}}
canReachStep={() => false}
/>
<div className="card p-6 text-sm text-ink-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<StepIndicator
steps={STEPS}
step={step}
goToStep={goToStep}
canReachStep={canReachStep}
/>
<div className="card p-4 sm:p-6">
{step === 'products' && (
<section>
<h2 className="text-lg font-semibold text-ink-900">
{t('products.title')}
</h2>
<p className="mt-1 text-sm text-ink-500">{t('products.subtitle')}</p>
<div className="mt-4 space-y-3">
{products.map((p) => {
const qty = quantities[p.id] ?? 0;
return (
<div
key={p.id}
className={`flex flex-col gap-3 rounded-lg border p-4 transition-colors sm:flex-row sm:items-center sm:justify-between ${
qty > 0
? 'border-accent-300 bg-accent-50/40'
: 'border-ink-200'
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="font-medium text-ink-900">
{locale === 'sv' ? p.nameSv : p.nameEn}
</div>
<span className="badge bg-ink-100 text-ink-600">
{p.sku}
</span>
</div>
<p className="mt-1 text-sm text-ink-500">
{locale === 'sv' ? p.descriptionSv : p.descriptionEn}
</p>
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<div className="text-sm font-medium text-ink-700">
{formatOre(priceInclVatOre(p.priceOre, p.vatBp), locale)}
<span className="text-ink-400">
{' '}
{t('products.perUnit')}
</span>
<span className="ml-1 text-xs text-ink-400">
({c('inclVat')})
</span>
</div>
{qty > 0 && (
<div className="text-sm font-semibold text-brand-700 tabular-nums">
={' '}
{formatOre(
priceInclVatOre(p.priceOre, p.vatBp) * qty,
locale,
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<button
type="button"
onClick={() => setQuantity(p.id, qty - 1)}
disabled={qty === 0}
className="btn-secondary !px-3 !py-2"
aria-label={`-1 ${p.sku}`}
>
</button>
<input
type="number"
inputMode="numeric"
min={0}
max={999}
value={qty}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setQuantity(p.id, Number.isFinite(n) ? n : 0);
}}
onFocus={(e) => e.currentTarget.select()}
className="num-input"
aria-label={`${c('quantity')} ${p.sku}`}
/>
<button
type="button"
onClick={() => setQuantity(p.id, qty + 1)}
className="btn-primary !px-3 !py-2"
aria-label={`+1 ${p.sku}`}
>
+
</button>
</div>
</div>
);
})}
</div>
{selectedItems.length === 0 && (
<p className="mt-3 text-sm text-ink-500">
{t('products.noneSelected')}
</p>
)}
</section>
)}
{step === 'details' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold text-ink-900">
{t('details.title')}
</h2>
<div>
<h3 className="text-sm font-medium text-ink-700">
{t('details.organization')}
</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field
label={t('details.orgName')}
required
value={orgName}
onChange={(v) => patch({ orgName: v })}
placeholder={t('details.orgNamePlaceholder')}
/>
<Field
label={t('details.orgNumber')}
required
value={orgNumber}
onChange={(v) => patch({ orgNumber: v })}
placeholder={t('details.orgNumberPlaceholder')}
error={
orgNumber.length > 0 && !isValidSeOrgNumber(orgNumber)
? t('errors.invalidOrgNumber')
: undefined
}
/>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-ink-700">
{t('details.contact')}
</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field
label={t('details.contactName')}
required
value={contactName}
onChange={(v) => patch({ contactName: v })}
placeholder={t('details.contactNamePlaceholder')}
/>
<Field
label={t('details.email')}
required
type="email"
value={email}
onChange={(v) => patch({ email: v })}
placeholder={t('details.emailPlaceholder')}
error={
email.length > 0 && !/^\S+@\S+\.\S+$/.test(email)
? t('errors.invalidEmail')
: undefined
}
/>
<Field
label={t('details.phone')}
required
value={phone}
onChange={(v) => patch({ phone: v })}
placeholder={t('details.phonePlaceholder')}
type="tel"
/>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-ink-700">
{t('details.invoiceAddress')}
</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field
label={t('details.address')}
required
value={address}
onChange={(v) => patch({ address: v })}
/>
</div>
<Field
label={t('details.postalCode')}
required
value={postalCode}
onChange={(v) => patch({ postalCode: v })}
/>
<Field
label={t('details.city')}
required
value={city}
onChange={(v) => patch({ city: v })}
/>
</div>
</div>
</section>
)}
{step === 'pickup' && (
<section>
<h2 className="text-lg font-semibold text-ink-900">
{t('pickup.title')}
</h2>
<p className="mt-1 text-sm text-ink-500">{t('pickup.subtitle')}</p>
{pickupSlots.length === 0 ? (
<p className="mt-4 text-sm text-ink-500">{t('pickup.noSlots')}</p>
) : (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{pickupSlots.map((s) => {
const selected = pickupSlotId === s.id;
return (
<button
key={s.id}
type="button"
onClick={() =>
patch({ pickupSlotId: selected ? null : s.id })
}
className={`rounded-lg border p-4 text-left transition-colors ${
selected
? 'border-accent-400 bg-accent-50/40 ring-2 ring-accent-300/40'
: 'border-ink-200 bg-white hover:bg-ink-50'
}`}
>
<div className="font-medium text-ink-900">
{locale === 'sv' ? s.labelSv : s.labelEn}
</div>
<div className="mt-1 text-xs text-ink-500">
{new Date(s.startsAt).toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)}
{' '}
{new Date(s.endsAt).toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ timeStyle: 'short' },
)}
</div>
<div className="mt-2 text-xs text-ink-500">
{t('pickup.capacity', { count: s.capacityLeft })}
</div>
</button>
);
})}
</div>
)}
<div className="mt-6">
<label className="label" htmlFor="notes">
{t('notes.label')}
</label>
<textarea
id="notes"
className="input mt-1 min-h-24"
placeholder={t('notes.placeholder')}
value={notes}
onChange={(e) => patch({ notes: e.target.value })}
/>
</div>
</section>
)}
{step === 'review' && (
<section className="space-y-4">
<h2 className="text-lg font-semibold text-ink-900">
{t('review.title')}
</h2>
<ReviewSection
title={c('subtotal')}
rows={selectedItems.map((i) => ({
left: `${i.quantity} × ${locale === 'sv' ? i.product.nameSv : i.product.nameEn}`,
right: formatOre(
priceInclVatOre(i.unitPriceOre, i.vatBp) * i.quantity,
locale,
),
}))}
footer={
<>
<Row
label={`${c('subtotal')} (${c('exclVat')})`}
value={formatOre(totals.subtotalOre, locale)}
muted
/>
<Row
label={c('ofWhichVat')}
value={formatOre(totals.vatOre, locale)}
muted
/>
<Row
label={`${c('total')} (${c('inclVat')})`}
value={formatOre(totals.totalOre, locale)}
bold
/>
</>
}
/>
<ReviewSection
title={t('details.organization')}
rows={[
{ left: t('details.orgName'), right: orgName },
{ left: t('details.orgNumber'), right: orgNumber },
]}
/>
<ReviewSection
title={t('details.contact')}
rows={[
{ left: t('details.contactName'), right: contactName },
{ left: t('details.email'), right: email },
{ left: t('details.phone'), right: phone },
]}
/>
<ReviewSection
title={t('details.invoiceAddress')}
rows={[
{ left: t('details.address'), right: address },
{
left: `${t('details.postalCode')} / ${t('details.city')}`,
right: `${postalCode} ${city}`,
},
]}
/>
{pickupEnabled && pickupSlotId && (
<ReviewSection
title={t('pickup.title')}
rows={(() => {
const slot = pickupSlots.find((s) => s.id === pickupSlotId);
if (!slot) return [];
return [
{
left: locale === 'sv' ? slot.labelSv : slot.labelEn,
right: new Date(slot.startsAt).toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
),
},
];
})()}
/>
)}
<label className="flex items-start gap-3 rounded-lg border border-ink-200 bg-ink-50/50 p-3 text-sm">
<input
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
/>
<span>{t('review.confirm')}</span>
</label>
{submitError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{submitError}
</div>
)}
</section>
)}
</div>
<SummaryBar
totals={totals}
itemCount={selectedItems.reduce((acc, i) => acc + i.quantity, 0)}
locale={locale}
/>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={goPrev}
disabled={stepIndex === 0 || submitting}
className="btn-secondary"
>
{t('previous')}
</button>
{step !== 'review' ? (
<button
type="button"
onClick={goNext}
disabled={!canGoNext()}
className="btn-primary"
>
{t('next')}
</button>
) : (
<button
type="button"
onClick={submit}
disabled={!canGoNext() || submitting}
className="btn-primary"
>
{submitting ? t('review.submitting') : c('submit')}
</button>
)}
</div>
</div>
);
}
function StepIndicator({
steps,
step,
goToStep,
canReachStep,
}: {
steps: Step[];
step: Step;
goToStep: (s: Step) => void;
canReachStep: (s: Step) => boolean;
}) {
const t = useTranslations('booking');
const longLabels: Record<Step, string> = {
products: t('stepProducts'),
details: t('stepDetails'),
pickup: t('stepPickup'),
review: t('stepReview'),
};
const currentIdx = steps.indexOf(step);
// Mobile: 2-up grid (with 3 steps, last cell wraps to its own row — that's
// intentional, it puts emphasis on the final step). sm+: one row.
const desktopCols = steps.length === 4 ? 'sm:grid-cols-4' : 'sm:grid-cols-3';
return (
<div className="space-y-2">
<ol className={`grid grid-cols-2 gap-1.5 ${desktopCols} sm:gap-2`}>
{steps.map((s, i) => {
const active = s === step;
const done = i < currentIdx;
const reachable = canReachStep(s);
return (
<li key={s}>
<button
type="button"
onClick={() => goToStep(s)}
disabled={!reachable}
aria-current={active ? 'step' : undefined}
className={`flex w-full items-center gap-2 rounded-lg border p-2 text-left transition-colors sm:gap-3 ${
active
? 'border-accent-400 bg-accent-50/60 text-accent-700'
: done
? 'border-brand-300 bg-brand-50/40 text-brand-700 hover:bg-brand-50'
: reachable
? 'border-ink-200 bg-white text-ink-600 hover:bg-ink-50'
: 'cursor-not-allowed border-ink-200 bg-white text-ink-300'
} ${reachable ? '' : 'opacity-70'}`}
>
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold sm:h-7 sm:w-7 ${
active
? 'bg-accent-400 text-white'
: done
? 'bg-brand-600 text-white'
: 'bg-ink-100 text-ink-500'
}`}
aria-hidden
>
{done ? '✓' : i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium leading-tight">
{longLabels[s]}
</span>
</button>
</li>
);
})}
</ol>
<p className="text-center text-xs text-ink-500 sm:hidden">
{t('stepOf', { current: currentIdx + 1, total: steps.length })} ·{' '}
<span className="font-medium text-ink-700">{longLabels[step]}</span>
</p>
</div>
);
}
function Field({
label,
value,
onChange,
required,
placeholder,
type = 'text',
error,
}: {
label: string;
value: string;
onChange: (v: string) => void;
required?: boolean;
placeholder?: string;
type?: string;
error?: string;
}) {
return (
<div>
<label className="label">
{label}
{required && <span className="ml-1 text-accent-600">*</span>}
</label>
<input
type={type}
className="input mt-1"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
/>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
);
}
function ReviewSection({
title,
rows,
footer,
}: {
title: string;
rows: { left: string; right: string }[];
footer?: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-ink-200">
<div className="border-b border-ink-200 bg-ink-50/50 px-4 py-2 text-xs font-medium uppercase tracking-wide text-ink-500">
{title}
</div>
<div className="divide-y divide-ink-100">
{rows.map((r, i) => (
<div
key={i}
className="flex items-center justify-between px-4 py-2 text-sm"
>
<span className="text-ink-600">{r.left}</span>
<span className="text-ink-900">{r.right}</span>
</div>
))}
{footer && <div className="bg-ink-50/30 px-4 py-2">{footer}</div>}
</div>
</div>
);
}
function Row({
label,
value,
muted,
bold,
}: {
label: string;
value: string;
muted?: boolean;
bold?: boolean;
}) {
return (
<div
className={`flex justify-between text-sm ${muted ? 'text-ink-500' : 'text-ink-900'} ${bold ? 'font-semibold' : ''}`}
>
<span>{label}</span>
<span className="tabular-nums">{value}</span>
</div>
);
}
function SummaryBar({
totals,
itemCount,
locale,
}: {
totals: { subtotalOre: number; vatOre: number; totalOre: number };
itemCount: number;
locale: 'sv' | 'en';
}) {
const c = useTranslations('common');
if (itemCount === 0) return null;
return (
<div className="rounded-lg border-2 border-brand-600 bg-white px-4 py-3 text-sm shadow-card">
<div className="flex items-center justify-between">
<span className="text-ink-600">
{itemCount} {c('quantity').toLowerCase()}
</span>
<div className="text-right">
<div className="text-lg font-semibold tabular-nums text-brand-700">
{formatOre(totals.totalOre, locale)}
</div>
<div className="text-[10px] uppercase tracking-wide text-ink-400">
{c('inclVat')}
</div>
</div>
</div>
<div className="text-xs text-ink-400">
{c('ofWhichVat')} {formatOre(totals.vatOre, locale)}
</div>
</div>
);
}

35
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,35 @@
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from './LanguageSwitcher';
export function Header() {
const t = useTranslations('header');
const c = useTranslations('common');
return (
<header className="border-b border-brand-700 bg-brand-600 text-white">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:py-5">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white p-1 shadow-sm sm:h-12 sm:w-12 sm:p-1.5">
<Image
src="/gasol247-logo.png"
alt="Gasol247"
width={48}
height={62}
priority
className="h-full w-auto object-contain"
/>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold sm:text-base">
{c('siteName')}
</div>
<div className="truncate text-xs text-white/80">
{t('tagline')}
</div>
</div>
</div>
<LanguageSwitcher />
</div>
</header>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { useRouter, usePathname } from '@/i18n/routing';
import { useParams } from 'next/navigation';
export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const current = (params?.locale as string) ?? 'sv';
const switchTo = (next: 'sv' | 'en') => {
router.replace(pathname, { locale: next });
};
return (
<div
className="inline-flex shrink-0 overflow-hidden rounded-lg border border-white/20 bg-white/10 text-xs font-medium backdrop-blur-sm"
role="group"
aria-label="Language switcher"
>
<button
type="button"
onClick={() => switchTo('sv')}
className={`px-3 py-1.5 transition-colors ${
current === 'sv'
? 'bg-accent-300 text-brand-900'
: 'text-white hover:bg-white/10'
}`}
>
SV
</button>
<button
type="button"
onClick={() => switchTo('en')}
className={`px-3 py-1.5 transition-colors ${
current === 'en'
? 'bg-accent-300 text-brand-900'
: 'text-white hover:bg-white/10'
}`}
>
EN
</button>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useTranslations } from 'next-intl';
const COLORS: Record<string, string> = {
PENDING: 'bg-amber-100 text-amber-800',
CONFIRMED: 'bg-sky-100 text-sky-800',
DELIVERED_PARTIAL: 'bg-blue-100 text-blue-800',
DELIVERED: 'bg-emerald-100 text-emerald-800',
RETURNED_PARTIAL: 'bg-teal-100 text-teal-800',
RETURNED: 'bg-slate-200 text-slate-800',
INVOICED: 'bg-violet-100 text-violet-800',
CANCELLED: 'bg-ink-200 text-ink-700',
};
const KNOWN = new Set([
'PENDING',
'CONFIRMED',
'DELIVERED_PARTIAL',
'DELIVERED',
'RETURNED_PARTIAL',
'RETURNED',
'INVOICED',
'CANCELLED',
] as const);
type Known = Parameters<typeof KNOWN.has>[0];
export function StatusBadge({ status }: { status: string }) {
const t = useTranslations('admin.bookings.status');
const cls = COLORS[status] ?? 'bg-ink-100 text-ink-700';
const label = KNOWN.has(status as Known) ? t(status as Known) : status;
return <span className={`badge ${cls}`}>{label}</span>;
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
// Debounced filter bar — writes search and status to the URL, which the
// server component re-reads. No submit button.
export function AdminFilters() {
const t = useTranslations('admin.bookings');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const initialQ = searchParams.get('q') ?? '';
const initialStatus = searchParams.get('status') ?? 'all';
const [q, setQ] = useState(initialQ);
const [status, setStatus] = useState(initialStatus);
// Debounced URL push when q changes.
useEffect(() => {
const handle = setTimeout(() => {
const next = new URLSearchParams(searchParams.toString());
if (q) next.set('q', q);
else next.delete('q');
// Reset to page 1 when filter changes.
next.delete('page');
startTransition(() => {
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
});
}, 250);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
const onStatusChange = (next: string) => {
setStatus(next);
const sp = new URLSearchParams(searchParams.toString());
if (next === 'all') sp.delete('status');
else sp.set('status', next);
sp.delete('page');
startTransition(() => {
router.replace(`${pathname}?${sp.toString()}`, { scroll: false });
});
};
return (
<div className="flex flex-wrap items-center gap-2">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('filters.search')}
className="input max-w-sm"
autoComplete="off"
/>
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="input max-w-xs"
>
<option value="all">{t('filters.all')}</option>
<option value="CONFIRMED">{t('status.CONFIRMED')}</option>
<option value="DELIVERED_PARTIAL">
{t('status.DELIVERED_PARTIAL')}
</option>
<option value="DELIVERED">{t('status.DELIVERED')}</option>
<option value="RETURNED_PARTIAL">
{t('status.RETURNED_PARTIAL')}
</option>
<option value="RETURNED">{t('status.RETURNED')}</option>
<option value="INVOICED">{t('status.INVOICED')}</option>
<option value="CANCELLED">{t('status.CANCELLED')}</option>
</select>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
type Admin = {
id?: string;
name: string;
email: string;
};
export function AdminUserForm({
mode,
admin,
action,
}: {
mode: 'create' | 'edit';
admin?: Admin;
action: (fd: FormData) => Promise<void>;
}) {
const t = useTranslations('admin.users');
const c = useTranslations('common');
const a: Admin = admin ?? { name: '', email: '' };
return (
<form action={action} className="card space-y-5 p-5">
{a.id && <input type="hidden" name="id" value={a.id} />}
<div className="grid gap-4 sm:grid-cols-2">
<Field
name="name"
label={t('fields.name')}
defaultValue={a.name}
required
autoFocus={mode === 'create'}
/>
<Field
name="email"
label={t('fields.email')}
type="email"
defaultValue={a.email}
autoComplete="username"
required
/>
{mode === 'create' && (
<>
<Field
name="password"
label={t('fields.password')}
hint={t('fields.passwordHint')}
type="password"
autoComplete="new-password"
required
/>
<Field
name="passwordConfirm"
label={t('fields.passwordConfirm')}
type="password"
autoComplete="new-password"
required
/>
</>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
<Link href="/admin/users" className="btn-secondary">
{c('cancel')}
</Link>
<button type="submit" className="btn-primary">
{mode === 'create' ? t('create') : t('save')}
</button>
</div>
</form>
);
}
export function PasswordChangeForm({
adminId,
action,
}: {
adminId: string;
action: (fd: FormData) => Promise<void>;
}) {
const t = useTranslations('admin.users');
return (
<form action={action} className="card space-y-5 p-5">
<h2 className="text-sm font-semibold text-ink-900">
{t('changePassword')}
</h2>
<input type="hidden" name="id" value={adminId} />
<div className="grid gap-4 sm:grid-cols-2">
<Field
name="password"
label={t('fields.newPassword')}
hint={t('fields.passwordHint')}
type="password"
autoComplete="new-password"
required
/>
<Field
name="passwordConfirm"
label={t('fields.passwordConfirm')}
type="password"
autoComplete="new-password"
required
/>
</div>
<div className="flex items-center justify-end border-t border-ink-200 pt-4">
<button type="submit" className="btn-primary">
{t('changePassword')}
</button>
</div>
</form>
);
}
function Field({
name,
label,
hint,
type = 'text',
defaultValue,
required,
autoFocus,
autoComplete,
}: {
name: string;
label: string;
hint?: string;
type?: string;
defaultValue?: string;
required?: boolean;
autoFocus?: boolean;
autoComplete?: string;
}) {
return (
<div>
<label className="label" htmlFor={name}>
{label}
{required && <span className="ml-1 text-accent-600">*</span>}
</label>
<input
id={name}
name={name}
type={type}
defaultValue={defaultValue}
required={required}
autoFocus={autoFocus}
autoComplete={autoComplete}
className="input mt-1"
/>
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState, useTransition } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import {
setItemFulfillment,
markAllDelivered,
markAllReturned,
} from '@/app/[locale]/admin/bookings/[id]/actions';
type Item = {
id: string;
sku: string;
nameSv: string;
nameEn: string;
quantity: number;
deliveredQuantity: number;
returnedQuantity: number;
};
export function FulfillmentTable({
bookingId,
items,
}: {
bookingId: string;
items: Item[];
}) {
const t = useTranslations('admin.bookings.fulfillment');
const locale = useLocale() as 'sv' | 'en';
const [pending, startTransition] = useTransition();
return (
<div className="rounded-lg border border-ink-200">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50/50 px-4 py-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{t('title')}
</h3>
<div className="flex gap-1">
<form
action={(fd) => startTransition(() => markAllDelivered(fd))}
>
<input type="hidden" name="bookingId" value={bookingId} />
<button
type="submit"
disabled={pending}
className="btn-secondary !px-2.5 !py-1 text-xs"
>
{t('deliverAll')}
</button>
</form>
<form
action={(fd) => startTransition(() => markAllReturned(fd))}
>
<input type="hidden" name="bookingId" value={bookingId} />
<button
type="submit"
disabled={pending}
className="btn-secondary !px-2.5 !py-1 text-xs"
>
{t('returnAll')}
</button>
</form>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-ink-500">
<th className="px-4 py-2">SKU</th>
<th className="px-4 py-2">
{locale === 'sv' ? 'Namn' : 'Name'}
</th>
<th className="px-4 py-2 text-right">{t('ordered')}</th>
<th className="px-4 py-2 text-right">{t('delivered')}</th>
<th className="px-4 py-2 text-right">{t('returned')}</th>
<th className="px-4 py-2 text-right">{t('outstanding')}</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{items.map((it) => (
<FulfillmentRow
key={it.id}
bookingId={bookingId}
item={it}
locale={locale}
pending={pending}
startTransition={startTransition}
/>
))}
</tbody>
</table>
</div>
</div>
);
}
function FulfillmentRow({
bookingId,
item,
locale,
pending,
startTransition,
}: {
bookingId: string;
item: Item;
locale: 'sv' | 'en';
pending: boolean;
startTransition: React.TransitionStartFunction;
}) {
const t = useTranslations('admin.bookings.fulfillment');
const [delivered, setDelivered] = useState(item.deliveredQuantity);
const [returned, setReturned] = useState(item.returnedQuantity);
const outstanding = Math.max(0, delivered - returned);
const dirty =
delivered !== item.deliveredQuantity || returned !== item.returnedQuantity;
const validDelivered =
Number.isFinite(delivered) && delivered >= 0 && delivered <= item.quantity;
const validReturned =
Number.isFinite(returned) && returned >= 0 && returned <= delivered;
const canSave = dirty && validDelivered && validReturned && !pending;
return (
<tr>
<td className="px-4 py-2 font-mono text-xs">{item.sku}</td>
<td className="px-4 py-2">
{locale === 'sv' ? item.nameSv : item.nameEn}
</td>
<td className="px-4 py-2 text-right tabular-nums text-ink-600">
{item.quantity}
</td>
<td className="px-4 py-2 text-right">
<input
type="number"
inputMode="numeric"
min={0}
max={item.quantity}
value={delivered}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setDelivered(Number.isFinite(n) ? n : 0);
}}
onFocus={(e) => e.currentTarget.select()}
className={`num-input ${validDelivered ? '' : 'border-red-400'}`}
aria-label={t('delivered')}
title={t('deliveredHint')}
/>
</td>
<td className="px-4 py-2 text-right">
<input
type="number"
inputMode="numeric"
min={0}
max={delivered}
value={returned}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setReturned(Number.isFinite(n) ? n : 0);
}}
onFocus={(e) => e.currentTarget.select()}
className={`num-input ${validReturned ? '' : 'border-red-400'}`}
aria-label={t('returned')}
title={t('returnedHint')}
/>
</td>
<td className="px-4 py-2 text-right tabular-nums">
<span
className={
outstanding > 0 ? 'font-semibold text-brand-700' : 'text-ink-400'
}
>
{outstanding}
</span>
</td>
<td className="px-4 py-2 text-right">
<form
action={(fd) => {
fd.set('delivered', String(delivered));
fd.set('returned', String(returned));
startTransition(() => setItemFulfillment(fd));
}}
>
<input type="hidden" name="bookingId" value={bookingId} />
<input type="hidden" name="itemId" value={item.id} />
<button
type="submit"
disabled={!canSave}
className="btn-primary !px-3 !py-1 text-xs"
>
{t('save')}
</button>
</form>
</td>
</tr>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import Link from 'next/link';
import { useSearchParams, usePathname } from 'next/navigation';
type Props = {
page: number;
pageSize: number;
total: number;
};
export function Pagination({ page, pageSize, total }: Props) {
const sp = useSearchParams();
const pathname = usePathname();
const pages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(total, page * pageSize);
const link = (p: number) => {
const next = new URLSearchParams(sp.toString());
if (p <= 1) next.delete('page');
else next.set('page', String(p));
return `${pathname}?${next.toString()}`;
};
return (
<div className="flex items-center justify-between gap-3 text-sm text-ink-600">
<div>
{from}{to} / {total}
</div>
<div className="flex items-center gap-1">
<Link
href={link(Math.max(1, page - 1))}
aria-disabled={page <= 1}
tabIndex={page <= 1 ? -1 : undefined}
className={`btn-secondary !px-3 !py-1.5 text-xs ${page <= 1 ? 'pointer-events-none opacity-50' : ''}`}
>
</Link>
<span className="px-2 tabular-nums">
{page} / {pages}
</span>
<Link
href={link(Math.min(pages, page + 1))}
aria-disabled={page >= pages}
tabIndex={page >= pages ? -1 : undefined}
className={`btn-secondary !px-3 !py-1.5 text-xs ${page >= pages ? 'pointer-events-none opacity-50' : ''}`}
>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
type Slot = {
id?: string;
labelSv: string;
labelEn: string;
startsAt: string; // "YYYY-MM-DDTHH:mm" local
endsAt: string;
capacity: number;
active: boolean;
};
export function PickupSlotForm({
mode,
slot,
action,
}: {
mode: 'create' | 'edit';
slot?: Slot;
action: (fd: FormData) => Promise<void>;
}) {
const t = useTranslations('admin.pickupSlots');
const c = useTranslations('common');
const s: Slot = slot ?? {
labelSv: '',
labelEn: '',
startsAt: defaultDateString(0, 9),
endsAt: defaultDateString(0, 12),
capacity: 50,
active: true,
};
return (
<form action={action} className="card space-y-5 p-5">
{s.id && <input type="hidden" name="id" value={s.id} />}
<div className="grid gap-4 sm:grid-cols-2">
<Field
name="labelSv"
label={t('fields.labelSv')}
hint={t('fields.labelHint')}
defaultValue={s.labelSv}
required
autoFocus={mode === 'create'}
/>
<Field
name="labelEn"
label={t('fields.labelEn')}
defaultValue={s.labelEn}
required
/>
<Field
name="startsAt"
label={t('fields.startsAt')}
type="datetime-local"
defaultValue={s.startsAt}
required
/>
<Field
name="endsAt"
label={t('fields.endsAt')}
type="datetime-local"
defaultValue={s.endsAt}
required
/>
<Field
name="capacity"
label={t('fields.capacity')}
hint={t('fields.capacityHint')}
type="number"
min={0}
defaultValue={String(s.capacity)}
required
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="active"
defaultChecked={s.active}
className="h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
/>
<span>{t('fields.active')}</span>
</label>
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
<Link href="/admin/pickup-slots" className="btn-secondary">
{c('cancel')}
</Link>
<button type="submit" className="btn-primary">
{mode === 'create' ? t('create') : t('save')}
</button>
</div>
</form>
);
}
function defaultDateString(daysAhead: number, hour: number): string {
const d = new Date();
d.setDate(d.getDate() + daysAhead);
d.setHours(hour, 0, 0, 0);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function Field({
name,
label,
hint,
type = 'text',
defaultValue,
required,
min,
autoFocus,
}: {
name: string;
label: string;
hint?: string;
type?: string;
defaultValue?: string;
required?: boolean;
min?: number;
autoFocus?: boolean;
}) {
return (
<div>
<label className="label" htmlFor={name}>
{label}
{required && <span className="ml-1 text-accent-600">*</span>}
</label>
<input
id={name}
name={name}
type={type}
defaultValue={defaultValue}
required={required}
min={min}
autoFocus={autoFocus}
className="input mt-1"
/>
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
type Product = {
id?: string;
sku: string;
nameSv: string;
nameEn: string;
descriptionSv: string;
descriptionEn: string;
priceOre: number;
vatBp: number;
sortOrder: number;
active: boolean;
};
export function ProductForm({
mode,
product,
action,
}: {
mode: 'create' | 'edit';
product?: Product;
action: (fd: FormData) => Promise<void>;
}) {
const t = useTranslations('admin.products');
const c = useTranslations('common');
const p: Product = product ?? {
sku: '',
nameSv: '',
nameEn: '',
descriptionSv: '',
descriptionEn: '',
priceOre: 0,
vatBp: 2500,
sortOrder: 100,
active: true,
};
return (
<form action={action} className="card space-y-5 p-5">
{p.id && <input type="hidden" name="id" value={p.id} />}
<div className="grid gap-4 sm:grid-cols-2">
<Field
name="sku"
label={t('fields.sku')}
hint={t('fields.skuHint')}
defaultValue={p.sku}
required
autoFocus={mode === 'create'}
/>
<Field
name="sortOrder"
label={t('fields.sortOrder')}
hint={t('fields.sortOrderHint')}
type="number"
min={0}
defaultValue={String(p.sortOrder)}
/>
<Field
name="nameSv"
label={t('fields.nameSv')}
defaultValue={p.nameSv}
required
/>
<Field
name="nameEn"
label={t('fields.nameEn')}
defaultValue={p.nameEn}
required
/>
<TextArea
name="descriptionSv"
label={t('fields.descriptionSv')}
defaultValue={p.descriptionSv}
/>
<TextArea
name="descriptionEn"
label={t('fields.descriptionEn')}
defaultValue={p.descriptionEn}
/>
<Field
name="priceSek"
label={t('fields.priceSek')}
type="number"
step="0.01"
min={0}
defaultValue={(p.priceOre / 100).toFixed(2)}
required
/>
<Field
name="vatPct"
label={t('fields.vatPct')}
type="number"
step="0.01"
min={0}
max={100}
defaultValue={(p.vatBp / 100).toFixed(2)}
required
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="active"
defaultChecked={p.active}
className="h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
/>
<span>{t('fields.active')}</span>
</label>
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
<Link href="/admin/products" className="btn-secondary">
{c('cancel')}
</Link>
<button type="submit" className="btn-primary">
{mode === 'create' ? t('create') : t('save')}
</button>
</div>
</form>
);
}
function Field({
name,
label,
hint,
type = 'text',
defaultValue,
required,
step,
min,
max,
autoFocus,
}: {
name: string;
label: string;
hint?: string;
type?: string;
defaultValue?: string;
required?: boolean;
step?: string;
min?: number;
max?: number;
autoFocus?: boolean;
}) {
return (
<div>
<label className="label" htmlFor={name}>
{label}
{required && <span className="ml-1 text-accent-600">*</span>}
</label>
<input
id={name}
name={name}
type={type}
defaultValue={defaultValue}
required={required}
step={step}
min={min}
max={max}
autoFocus={autoFocus}
className="input mt-1"
/>
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
</div>
);
}
function TextArea({
name,
label,
defaultValue,
}: {
name: string;
label: string;
defaultValue?: string;
}) {
return (
<div className="sm:col-span-2">
<label className="label" htmlFor={name}>
{label}
</label>
<textarea
id={name}
name={name}
defaultValue={defaultValue}
rows={2}
className="input mt-1"
/>
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
type Props = {
field: string;
label: string;
align?: 'left' | 'right';
};
export function SortHeader({ field, label, align = 'left' }: Props) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const currentSort = sp.get('sort') ?? 'createdAt';
const currentDir = sp.get('dir') === 'asc' ? 'asc' : 'desc';
const active = currentSort === field;
const next = new URLSearchParams(sp.toString());
next.set('sort', field);
next.set('dir', active && currentDir === 'desc' ? 'asc' : 'desc');
// Keep page on column toggle — feels less jumpy than resetting.
const onClick = () => {
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
};
return (
<button
type="button"
onClick={onClick}
className={`flex items-center gap-1 text-xs font-medium uppercase tracking-wide ${
active ? 'text-brand-700' : 'text-ink-500 hover:text-ink-700'
} ${align === 'right' ? 'ml-auto flex-row-reverse' : ''}`}
>
<span>{label}</span>
<span aria-hidden className="inline-block w-2 text-[10px] leading-none">
{active ? (currentDir === 'asc' ? '▲' : '▼') : '↕'}
</span>
</button>
);
}