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,92 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import {
clampFulfillment,
deriveBookingStatus,
} from '@/lib/bookingStatus';
async function recomputeBookingStatus(bookingId: string) {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { items: true },
});
if (!booking) return;
const next = deriveBookingStatus(booking.status, booking.items);
if (next !== booking.status) {
await prisma.booking.update({
where: { id: bookingId },
data: { status: next },
});
}
}
export async function setItemFulfillment(formData: FormData) {
await requireAdmin();
const bookingId = String(formData.get('bookingId') ?? '');
const itemId = String(formData.get('itemId') ?? '');
const deliveredRaw = Number(formData.get('delivered') ?? 0);
const returnedRaw = Number(formData.get('returned') ?? 0);
if (!bookingId || !itemId) return;
const item = await prisma.bookingItem.findUnique({ where: { id: itemId } });
if (!item || item.bookingId !== bookingId) return;
const { delivered, returned } = clampFulfillment(
item.quantity,
deliveredRaw,
returnedRaw,
);
await prisma.bookingItem.update({
where: { id: itemId },
data: { deliveredQuantity: delivered, returnedQuantity: returned },
});
await recomputeBookingStatus(bookingId);
revalidatePath(`/admin/bookings/${bookingId}`);
revalidatePath('/admin');
}
export async function markAllDelivered(formData: FormData) {
await requireAdmin();
const bookingId = String(formData.get('bookingId') ?? '');
if (!bookingId) return;
const items = await prisma.bookingItem.findMany({ where: { bookingId } });
await prisma.$transaction(
items.map((it) =>
prisma.bookingItem.update({
where: { id: it.id },
data: { deliveredQuantity: it.quantity },
}),
),
);
await recomputeBookingStatus(bookingId);
revalidatePath(`/admin/bookings/${bookingId}`);
revalidatePath('/admin');
}
export async function markAllReturned(formData: FormData) {
await requireAdmin();
const bookingId = String(formData.get('bookingId') ?? '');
if (!bookingId) return;
const items = await prisma.bookingItem.findMany({ where: { bookingId } });
// "Return all" means: everything that was handed out comes back. If nothing
// was marked delivered we treat it as a full handout + return cycle.
await prisma.$transaction(
items.map((it) => {
const delivered = it.deliveredQuantity > 0 ? it.deliveredQuantity : it.quantity;
return prisma.bookingItem.update({
where: { id: it.id },
data: { deliveredQuantity: delivered, returnedQuantity: delivered },
});
}),
);
await recomputeBookingStatus(bookingId);
revalidatePath(`/admin/bookings/${bookingId}`);
revalidatePath('/admin');
}

View File

@@ -0,0 +1,288 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { formatOre } from '@/lib/money';
import { StatusBadge } from '@/components/StatusBadge';
import { FulfillmentTable } from '@/components/admin/FulfillmentTable';
import { sendBookingConfirmation } from '@/lib/mailjet';
export const dynamic = 'force-dynamic';
async function setStatus(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('id') ?? '');
const status = String(formData.get('status') ?? '');
if (!id || !status) return;
await prisma.booking.update({ where: { id }, data: { status } });
revalidatePath(`/admin/bookings/${id}`);
revalidatePath('/admin');
}
async function resendEmail(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const booking = await prisma.booking.findUnique({
where: { id },
include: { items: true, pickupSlot: true },
});
if (booking) {
await sendBookingConfirmation(booking);
}
revalidatePath(`/admin/bookings/${id}`);
}
export default async function BookingDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.bookings');
const c = await getTranslations('common');
const loc = locale as 'sv' | 'en';
const booking = await prisma.booking.findUnique({
where: { id },
include: { items: true, pickupSlot: true },
});
if (!booking) notFound();
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">
{t('detail.title', { number: booking.bookingNumber })}
</h1>
<StatusBadge status={booking.status} />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="card p-4 lg:col-span-2">
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{t('detail.items')}
</h2>
<table className="mt-2 w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-ink-500">
<th className="py-1">SKU</th>
<th className="py-1">{loc === 'sv' ? 'Namn' : 'Name'}</th>
<th className="py-1 text-right">{c('quantity')}</th>
<th className="py-1 text-right">{c('price')}</th>
<th className="py-1 text-right">{c('total')}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{booking.items.map((it) => (
<tr key={it.id}>
<td className="py-2 font-mono text-xs">{it.sku}</td>
<td className="py-2">
{loc === 'sv' ? it.nameSv : it.nameEn}
</td>
<td className="py-2 text-right tabular-nums">
{it.quantity}
</td>
<td className="py-2 text-right tabular-nums">
{formatOre(it.unitPriceOre, loc)}
</td>
<td className="py-2 text-right tabular-nums">
{formatOre(it.lineTotalOre, loc)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t border-ink-200">
<tr>
<td colSpan={4} className="pt-2 text-ink-600">
{c('subtotal')}
</td>
<td className="pt-2 text-right tabular-nums">
{formatOre(booking.subtotalOre, loc)}
</td>
</tr>
<tr>
<td colSpan={4} className="text-ink-500">
{c('ofWhichVat')}
</td>
<td className="text-right tabular-nums text-ink-500">
{formatOre(booking.vatOre, loc)}
</td>
</tr>
<tr>
<td colSpan={4} className="pt-1 font-semibold">
{c('total')}
</td>
<td className="pt-1 text-right font-semibold tabular-nums">
{formatOre(booking.totalOre, loc)}
</td>
</tr>
</tfoot>
</table>
{booking.notes && (
<div className="mt-4 rounded-lg bg-ink-50 p-3 text-sm">
<div className="text-xs font-medium uppercase tracking-wide text-ink-500">
Notes
</div>
<p className="mt-1 whitespace-pre-wrap text-ink-700">
{booking.notes}
</p>
</div>
)}
<div className="mt-4">
<FulfillmentTable
bookingId={booking.id}
items={booking.items.map((it) => ({
id: it.id,
sku: it.sku,
nameSv: it.nameSv,
nameEn: it.nameEn,
quantity: it.quantity,
deliveredQuantity: it.deliveredQuantity,
returnedQuantity: it.returnedQuantity,
}))}
/>
</div>
</div>
<div className="space-y-4">
<div className="card p-4">
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{t('detail.customer')}
</h2>
<dl className="mt-2 space-y-1 text-sm">
<DLRow label={loc === 'sv' ? 'Organisation' : 'Organization'} value={booking.orgName} />
<DLRow label="Org.nr" value={booking.orgNumber} />
<DLRow label={loc === 'sv' ? 'Kontakt' : 'Contact'} value={booking.contactName} />
<DLRow
label="E-post"
value={
<a className="text-brand-600 hover:underline" href={`mailto:${booking.email}`}>
{booking.email}
</a>
}
/>
<DLRow label={loc === 'sv' ? 'Telefon' : 'Phone'} value={booking.phone} />
<DLRow
label={loc === 'sv' ? 'Adress' : 'Address'}
value={
<span>
{booking.address}
<br />
{booking.postalCode} {booking.city}
<br />
{booking.country}
</span>
}
/>
</dl>
</div>
{booking.pickupSlot && (
<div className="card p-4">
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{t('detail.pickup')}
</h2>
<p className="mt-1 text-sm">
{loc === 'sv'
? booking.pickupSlot.labelSv
: booking.pickupSlot.labelEn}
</p>
<p className="mt-0.5 text-xs text-ink-500">
{booking.pickupSlot.startsAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)}
</p>
</div>
)}
<div className="card p-4">
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
Actions
</h2>
<p className="mt-1 text-xs text-ink-500">
{loc === 'sv'
? 'Status uppdateras automatiskt från utlämnings­raderna.'
: 'Status updates automatically from the handout rows.'}
</p>
<div className="mt-3 space-y-2">
{booking.status !== 'INVOICED' &&
booking.status !== 'CANCELLED' && (
<StatusButton
bookingId={booking.id}
status="INVOICED"
label={t('detail.markInvoiced')}
setStatus={setStatus}
/>
)}
{booking.status !== 'CANCELLED' && (
<StatusButton
bookingId={booking.id}
status="CANCELLED"
label={t('detail.markCancelled')}
setStatus={setStatus}
destructive
/>
)}
<form action={resendEmail}>
<input type="hidden" name="id" value={booking.id} />
<button className="btn-secondary w-full text-sm" type="submit">
{t('detail.resendEmail')}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
function StatusButton({
bookingId,
status,
label,
setStatus,
destructive,
}: {
bookingId: string;
status: string;
label: string;
setStatus: (fd: FormData) => Promise<void>;
destructive?: boolean;
}) {
return (
<form action={setStatus}>
<input type="hidden" name="id" value={bookingId} />
<input type="hidden" name="status" value={status} />
<button
type="submit"
className={`w-full text-sm ${destructive ? 'btn border border-red-200 bg-white text-red-700 hover:bg-red-50' : 'btn-secondary'}`}
>
{label}
</button>
</form>
);
}
function DLRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-3">
<dt className="text-ink-500">{label}</dt>
<dd className="text-right text-ink-900">{value}</dd>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { signOut } from '@/auth';
import { getSafeSession } from '@/lib/safeAuth';
import { Link } from '@/i18n/routing';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const session = await getSafeSession();
const t = await getTranslations('admin');
// Public login page is handled in admin/login/page.tsx — but layout still wraps it.
// For non-login admin routes we redirect when not signed in via a route segment guard.
// Here we expose `session` to the rendered children via a server util; simpler:
// we redirect from this layout only when path is NOT /admin/login. Since segment
// info isn't easily accessible, we let each page check itself. Login page will not redirect.
// We do the protection by rendering the nav only when signed in; pages must call requireAdmin().
return (
<div className="min-h-screen bg-ink-50">
<header className="border-b border-ink-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3">
<Link href="/admin" className="text-base font-semibold text-ink-900">
{t('title')}
</Link>
{session?.user && (
<nav className="hidden gap-1 sm:flex">
<Link
href="/admin"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
{t('nav.bookings')}
</Link>
<Link
href="/admin/products"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
{t('nav.products')}
</Link>
<Link
href="/admin/pickup-slots"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
{t('nav.pickupSlots')}
</Link>
<Link
href="/admin/settings"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
{t('nav.settings')}
</Link>
<Link
href="/admin/users"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
{t('nav.users')}
</Link>
</nav>
)}
</div>
<div className="flex items-center gap-3">
<LanguageSwitcher />
{session?.user && (
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/admin/login' });
}}
>
<button type="submit" className="btn-ghost text-xs">
{t('nav.signOut')}
</button>
</form>
)}
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { redirect } from 'next/navigation';
import { signIn } from '@/auth';
import { getSafeSession } from '@/lib/safeAuth';
import { Header } from '@/components/Header';
export default async function LoginPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ error?: string; from?: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const { error, from } = await searchParams;
const session = await getSafeSession();
if (session?.user) {
redirect(`/${locale === 'sv' ? '' : locale + '/'}admin`);
}
const t = await getTranslations('admin.login');
async function login(formData: FormData) {
'use server';
const email = String(formData.get('email') ?? '');
const password = String(formData.get('password') ?? '');
try {
await signIn('credentials', {
email,
password,
redirectTo: from || '/admin',
});
} catch (e) {
// Auth.js v5 throws an error to trigger the redirect; rethrow non-redirect ones.
// Errors that are not "NEXT_REDIRECT" should be re-thrown so Next.js handles routing.
if (
e &&
typeof e === 'object' &&
'digest' in e &&
typeof (e as { digest?: unknown }).digest === 'string' &&
((e as { digest: string }).digest.startsWith('NEXT_REDIRECT') ||
(e as { digest: string }).digest.startsWith('NEXT_HTTP_ERROR'))
) {
throw e;
}
redirect(`/admin/login?error=1`);
}
}
return (
<div className="min-h-screen bg-ink-50">
<Header />
<main className="mx-auto flex max-w-md flex-col px-4 py-12">
<div className="card p-6">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<form action={login} className="mt-6 space-y-4">
<div>
<label className="label" htmlFor="email">
{t('email')}
</label>
<input
id="email"
name="email"
type="email"
required
className="input mt-1"
autoComplete="username"
/>
</div>
<div>
<label className="label" htmlFor="password">
{t('password')}
</label>
<input
id="password"
name="password"
type="password"
required
className="input mt-1"
autoComplete="current-password"
/>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{t('error')}
</div>
)}
<button type="submit" className="btn-primary w-full">
{t('submit')}
</button>
</form>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import type { Prisma } from '@prisma/client';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { formatOre } from '@/lib/money';
import { StatusBadge } from '@/components/StatusBadge';
import { AdminFilters } from '@/components/admin/AdminFilters';
import { SortHeader } from '@/components/admin/SortHeader';
import { Pagination } from '@/components/admin/Pagination';
export const dynamic = 'force-dynamic';
const PAGE_SIZE = 25;
const SORT_WHITELIST: Record<string, Prisma.BookingOrderByWithRelationInput> = {
createdAt: { createdAt: 'desc' },
bookingNumber: { bookingNumber: 'desc' },
orgName: { orgName: 'desc' },
contactName: { contactName: 'desc' },
totalOre: { totalOre: 'desc' },
status: { status: 'desc' },
pickupSlot: { pickupSlot: { startsAt: 'desc' } },
};
function buildOrderBy(
sort: string,
dir: 'asc' | 'desc',
): Prisma.BookingOrderByWithRelationInput {
const base = SORT_WHITELIST[sort] ?? SORT_WHITELIST.createdAt;
// Replace direction
if ('pickupSlot' in base) {
return { pickupSlot: { startsAt: dir } };
}
const [key] = Object.keys(base);
return { [key]: dir } as Prisma.BookingOrderByWithRelationInput;
}
export default async function AdminBookingsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{
q?: string;
status?: string;
page?: string;
sort?: string;
dir?: string;
}>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.bookings');
const sp = await searchParams;
const loc = locale as 'sv' | 'en';
const page = Math.max(1, parseInt(sp.page ?? '1', 10) || 1);
const sort = sp.sort && SORT_WHITELIST[sp.sort] ? sp.sort : 'createdAt';
const dir: 'asc' | 'desc' = sp.dir === 'asc' ? 'asc' : 'desc';
const where: Prisma.BookingWhereInput = {
...(sp.status && sp.status !== 'all' ? { status: sp.status } : {}),
...(sp.q
? {
OR: [
{ bookingNumber: { contains: sp.q } },
{ orgName: { contains: sp.q } },
{ email: { contains: sp.q } },
{ contactName: { contains: sp.q } },
{ orgNumber: { contains: sp.q } },
],
}
: {}),
};
const [total, bookings] = await Promise.all([
prisma.booking.count({ where }),
prisma.booking.findMany({
where,
orderBy: buildOrderBy(sort, dir),
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
include: { pickupSlot: true },
}),
]);
// Build export URL preserving current filters (skip page/sort).
const exportParams = new URLSearchParams();
if (sp.status) exportParams.set('status', sp.status);
if (sp.q) exportParams.set('q', sp.q);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<a
href={`/api/admin/export?${exportParams.toString()}`}
className="btn-secondary text-sm"
>
{t('export')}
</a>
</div>
<AdminFilters />
<div className="card overflow-hidden">
{bookings.length === 0 ? (
<div className="p-8 text-center text-sm text-ink-500">{t('empty')}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-ink-50 text-left">
<tr>
<th className="px-4 py-2.5">
<SortHeader
field="bookingNumber"
label={t('columns.number')}
/>
</th>
<th className="px-4 py-2.5">
<SortHeader field="createdAt" label={t('columns.date')} />
</th>
<th className="px-4 py-2.5">
<SortHeader field="orgName" label={t('columns.org')} />
</th>
<th className="px-4 py-2.5">
<SortHeader
field="contactName"
label={t('columns.contact')}
/>
</th>
<th className="px-4 py-2.5">
<SortHeader
field="pickupSlot"
label={t('detail.pickup')}
/>
</th>
<th className="px-4 py-2.5 text-right">
<SortHeader
field="totalOre"
label={t('columns.total')}
align="right"
/>
</th>
<th className="px-4 py-2.5">
<SortHeader field="status" label={t('columns.status')} />
</th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{bookings.map((b) => (
<tr key={b.id} className="hover:bg-ink-50/50">
<td className="px-4 py-2.5 font-mono text-xs">
{b.bookingNumber}
</td>
<td className="px-4 py-2.5 text-ink-600">
{b.createdAt.toLocaleDateString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
)}
</td>
<td className="px-4 py-2.5">
<div className="font-medium text-ink-900">
{b.orgName}
</div>
<div className="text-xs text-ink-500">{b.orgNumber}</div>
</td>
<td className="px-4 py-2.5">
<div>{b.contactName}</div>
<div className="text-xs text-ink-500">{b.email}</div>
</td>
<td className="px-4 py-2.5">
{b.pickupSlot ? (
<>
<div className="text-ink-900">
{loc === 'sv'
? b.pickupSlot.labelSv
: b.pickupSlot.labelEn}
</div>
<div className="text-xs text-ink-500">
{b.pickupSlot.startsAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
</div>
</>
) : (
<span className="text-xs text-ink-400"></span>
)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{formatOre(b.totalOre, loc)}
</td>
<td className="px-4 py-2.5">
<StatusBadge status={b.status} />
</td>
<td className="px-4 py-2.5 text-right">
<Link
href={`/admin/bookings/${b.id}`}
className="text-brand-600 hover:underline"
>
{t('view')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{total > 0 && (
<Pagination page={page} pageSize={PAGE_SIZE} total={total} />
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { PickupSlotForm } from '@/components/admin/PickupSlotForm';
import { updatePickupSlot, deletePickupSlot } from '../actions';
export const dynamic = 'force-dynamic';
// Convert a UTC Date to a "YYYY-MM-DDTHH:mm" string in the server's local
// timezone — that's the format datetime-local inputs expect.
function toLocalInputValue(d: Date): string {
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())}`;
}
export default async function EditPickupSlotPage({
params,
searchParams,
}: {
params: Promise<{ locale: string; id: string }>;
searchParams: Promise<{ error?: string; count?: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.pickupSlots');
const c = await getTranslations('common');
const { error, count } = await searchParams;
const slot = await prisma.pickupSlot.findUnique({
where: { id },
include: { _count: { select: { bookings: true } } },
});
if (!slot) notFound();
const inUseCount = slot._count.bookings;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin/pickup-slots" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">
{t('edit')} ·{' '}
<span className="text-ink-600">
{locale === 'sv' ? slot.labelSv : slot.labelEn}
</span>
</h1>
</div>
{error === 'in-use' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
{t('cannotDelete', { count: count ?? '?' })}
</div>
)}
<PickupSlotForm
mode="edit"
slot={{
id: slot.id,
labelSv: slot.labelSv,
labelEn: slot.labelEn,
startsAt: toLocalInputValue(slot.startsAt),
endsAt: toLocalInputValue(slot.endsAt),
capacity: slot.capacity,
active: slot.active,
}}
action={updatePickupSlot}
/>
<form
action={deletePickupSlot}
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
>
<input type="hidden" name="id" value={slot.id} />
<div className="text-red-700">
{inUseCount > 0
? t('cannotDelete', { count: inUseCount })
: t('deleteConfirm')}
</div>
<button
type="submit"
disabled={inUseCount > 0}
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('delete')}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
// datetime-local inputs return "YYYY-MM-DDTHH:mm" in the browser's local
// timezone. We treat them as local time and convert via Date constructor
// (which interprets the string in the server's timezone — for a single-host
// deploy that's fine; document if you ever shard across timezones).
const slotSchema = z
.object({
labelSv: z.string().trim().min(1).max(200),
labelEn: z.string().trim().min(1).max(200),
startsAt: z.string().trim().min(1),
endsAt: z.string().trim().min(1),
capacity: z.coerce.number().int().min(0).max(10000),
active: z.preprocess(
(v) => v === 'on' || v === 'true' || v === true,
z.boolean(),
),
})
.refine((d) => new Date(d.startsAt) < new Date(d.endsAt), {
path: ['endsAt'],
message: 'invalidTime',
});
type Parsed = z.infer<typeof slotSchema>;
function toDbFields(d: Parsed) {
return {
labelSv: d.labelSv,
labelEn: d.labelEn,
startsAt: new Date(d.startsAt),
endsAt: new Date(d.endsAt),
capacity: d.capacity,
active: d.active,
};
}
function fromFormData(fd: FormData): Parsed {
return slotSchema.parse({
labelSv: fd.get('labelSv') ?? '',
labelEn: fd.get('labelEn') ?? '',
startsAt: fd.get('startsAt') ?? '',
endsAt: fd.get('endsAt') ?? '',
capacity: fd.get('capacity') ?? 0,
active: fd.get('active') ?? false,
});
}
export async function createPickupSlot(formData: FormData) {
await requireAdmin();
const data = fromFormData(formData);
await prisma.pickupSlot.create({ data: toDbFields(data) });
revalidatePath('/admin/pickup-slots');
revalidatePath('/');
redirect('/admin/pickup-slots');
}
export async function updatePickupSlot(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const data = fromFormData(formData);
await prisma.pickupSlot.update({ where: { id }, data: toDbFields(data) });
revalidatePath('/admin/pickup-slots');
revalidatePath(`/admin/pickup-slots/${id}`);
revalidatePath('/');
redirect('/admin/pickup-slots');
}
export async function togglePickupSlotActive(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const cur = await prisma.pickupSlot.findUnique({ where: { id } });
if (!cur) return;
await prisma.pickupSlot.update({
where: { id },
data: { active: !cur.active },
});
revalidatePath('/admin/pickup-slots');
revalidatePath('/');
}
export async function deletePickupSlot(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const inUse = await prisma.booking.count({ where: { pickupSlotId: id } });
if (inUse > 0) {
redirect(`/admin/pickup-slots/${id}?error=in-use&count=${inUse}`);
}
await prisma.pickupSlot.delete({ where: { id } });
revalidatePath('/admin/pickup-slots');
revalidatePath('/');
redirect('/admin/pickup-slots');
}

View File

@@ -0,0 +1,29 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { PickupSlotForm } from '@/components/admin/PickupSlotForm';
import { createPickupSlot } from '../actions';
export default async function NewPickupSlotPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.pickupSlots');
const c = await getTranslations('common');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin/pickup-slots" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
</div>
<PickupSlotForm mode="create" action={createPickupSlot} />
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { togglePickupSlotActive } from './actions';
export const dynamic = 'force-dynamic';
export default async function AdminPickupSlotsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.pickupSlots');
const loc = locale as 'sv' | 'en';
const slots = await prisma.pickupSlot.findMany({
orderBy: { startsAt: 'asc' },
include: { _count: { select: { bookings: true } } },
});
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<Link href="/admin/pickup-slots/new" className="btn-primary text-sm">
+ {t('new')}
</Link>
</div>
<div className="card overflow-hidden">
{slots.length === 0 ? (
<div className="p-8 text-center text-sm text-ink-500">
{t('empty')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wide text-ink-500">
<tr>
<th className="px-4 py-2.5">{t('columns.label')}</th>
<th className="px-4 py-2.5">{t('columns.when')}</th>
<th className="px-4 py-2.5 text-right">
{t('columns.capacity')}
</th>
<th className="px-4 py-2.5 text-right">
{t('columns.bookings')}
</th>
<th className="px-4 py-2.5 text-center">
{t('columns.active')}
</th>
<th className="px-4 py-2.5 text-right">
{t('columns.actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{slots.map((s) => {
const taken = s._count.bookings;
const left = Math.max(0, s.capacity - taken);
return (
<tr
key={s.id}
className={`hover:bg-ink-50/50 ${s.active ? '' : 'opacity-60'}`}
>
<td className="px-4 py-2.5">
<div className="font-medium text-ink-900">
{loc === 'sv' ? s.labelSv : s.labelEn}
</div>
<div className="text-xs text-ink-500">
{loc === 'sv' ? s.labelEn : s.labelSv}
</div>
</td>
<td className="px-4 py-2.5">
<div>
{s.startsAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)}
</div>
<div className="text-xs text-ink-500">
{' '}
{s.endsAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ timeStyle: 'short' },
)}
</div>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{s.capacity}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{taken}
<span className="ml-1 text-xs text-ink-400">
({left} {loc === 'sv' ? 'kvar' : 'left'})
</span>
</td>
<td className="px-4 py-2.5 text-center">
<form
action={togglePickupSlotActive}
className="inline"
>
<input type="hidden" name="id" value={s.id} />
<button
type="submit"
className={`badge ${
s.active
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
: 'bg-ink-200 text-ink-700 hover:bg-ink-300'
}`}
>
{s.active ? '✓' : '○'}
</button>
</form>
</td>
<td className="px-4 py-2.5 text-right">
<Link
href={`/admin/pickup-slots/${s.id}`}
className="text-brand-600 hover:underline"
>
{t('edit')}
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { ProductForm } from '@/components/admin/ProductForm';
import { updateProduct, deleteProduct } from '../actions';
export const dynamic = 'force-dynamic';
export default async function EditProductPage({
params,
searchParams,
}: {
params: Promise<{ locale: string; id: string }>;
searchParams: Promise<{ error?: string; count?: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.products');
const c = await getTranslations('common');
const { error, count } = await searchParams;
const product = await prisma.product.findUnique({
where: { id },
include: { _count: { select: { bookingItems: true } } },
});
if (!product) notFound();
const inUseCount = product._count.bookingItems;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin/products" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">
{t('edit')} · <span className="font-mono">{product.sku}</span>
</h1>
</div>
{error === 'in-use' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
{t('cannotDelete', { count: count ?? '?' })}
</div>
)}
<ProductForm
mode="edit"
product={{
id: product.id,
sku: product.sku,
nameSv: product.nameSv,
nameEn: product.nameEn,
descriptionSv: product.descriptionSv,
descriptionEn: product.descriptionEn,
priceOre: product.priceOre,
vatBp: product.vatBp,
sortOrder: product.sortOrder,
active: product.active,
}}
action={updateProduct}
/>
<form
action={deleteProduct}
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
>
<input type="hidden" name="id" value={product.id} />
<div className="text-red-700">
{inUseCount > 0
? t('cannotDelete', { count: inUseCount })
: t('deleteConfirm')}
</div>
<button
type="submit"
disabled={inUseCount > 0}
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('delete')}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
// Form coercion: HTML number inputs send strings; we sanitize to int/float.
const productSchema = z.object({
sku: z.string().trim().min(1).max(50),
nameSv: z.string().trim().min(1).max(200),
nameEn: z.string().trim().min(1).max(200),
descriptionSv: z.string().trim().max(2000),
descriptionEn: z.string().trim().max(2000),
priceSek: z.coerce.number().min(0).max(1_000_000),
vatPct: z.coerce.number().min(0).max(100),
sortOrder: z.coerce.number().int().min(0).max(10000),
active: z.preprocess(
(v) => v === 'on' || v === 'true' || v === true,
z.boolean(),
),
});
type Parsed = z.infer<typeof productSchema>;
function toDbFields(d: Parsed) {
return {
sku: d.sku,
nameSv: d.nameSv,
nameEn: d.nameEn,
descriptionSv: d.descriptionSv,
descriptionEn: d.descriptionEn,
priceOre: Math.round(d.priceSek * 100),
vatBp: Math.round(d.vatPct * 100),
sortOrder: d.sortOrder,
active: d.active,
};
}
function fromFormData(fd: FormData): Parsed {
const obj = {
sku: fd.get('sku') ?? '',
nameSv: fd.get('nameSv') ?? '',
nameEn: fd.get('nameEn') ?? '',
descriptionSv: fd.get('descriptionSv') ?? '',
descriptionEn: fd.get('descriptionEn') ?? '',
priceSek: fd.get('priceSek') ?? 0,
vatPct: fd.get('vatPct') ?? 25,
sortOrder: fd.get('sortOrder') ?? 0,
active: fd.get('active') ?? false,
};
return productSchema.parse(obj);
}
export async function createProduct(formData: FormData) {
await requireAdmin();
const data = fromFormData(formData);
await prisma.product.create({ data: toDbFields(data) });
revalidatePath('/admin/products');
revalidatePath('/');
redirect('/admin/products');
}
export async function updateProduct(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const data = fromFormData(formData);
await prisma.product.update({ where: { id }, data: toDbFields(data) });
revalidatePath('/admin/products');
revalidatePath(`/admin/products/${id}`);
revalidatePath('/');
redirect('/admin/products');
}
export async function toggleProductActive(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const cur = await prisma.product.findUnique({ where: { id } });
if (!cur) return;
await prisma.product.update({
where: { id },
data: { active: !cur.active },
});
revalidatePath('/admin/products');
revalidatePath('/');
}
export async function deleteProduct(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
// Guard: never delete products that have booking history — would break
// invoicing/reporting. Deactivate is the right move there.
const inUse = await prisma.bookingItem.count({ where: { productId: id } });
if (inUse > 0) {
// Soft-redirect with error hint in query.
redirect(`/admin/products/${id}?error=in-use&count=${inUse}`);
}
await prisma.product.delete({ where: { id } });
revalidatePath('/admin/products');
revalidatePath('/');
redirect('/admin/products');
}

View File

@@ -0,0 +1,29 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { ProductForm } from '@/components/admin/ProductForm';
import { createProduct } from '../actions';
export default async function NewProductPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.products');
const c = await getTranslations('common');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin/products" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
</div>
<ProductForm mode="create" action={createProduct} />
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { formatOre } from '@/lib/money';
import { toggleProductActive } from './actions';
export const dynamic = 'force-dynamic';
export default async function AdminProductsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.products');
const loc = locale as 'sv' | 'en';
const products = await prisma.product.findMany({
orderBy: [{ active: 'desc' }, { sortOrder: 'asc' }],
include: { _count: { select: { bookingItems: true } } },
});
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<Link href="/admin/products/new" className="btn-primary text-sm">
+ {t('new')}
</Link>
</div>
<div className="card overflow-hidden">
{products.length === 0 ? (
<div className="p-8 text-center text-sm text-ink-500">
{t('empty')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wide text-ink-500">
<tr>
<th className="px-4 py-2.5">{t('columns.sku')}</th>
<th className="px-4 py-2.5">{t('columns.name')}</th>
<th className="px-4 py-2.5 text-right">
{t('columns.price')}
</th>
<th className="px-4 py-2.5 text-right">
{t('columns.vat')}
</th>
<th className="px-4 py-2.5 text-center">
{t('columns.active')}
</th>
<th className="px-4 py-2.5 text-right">
{t('columns.actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{products.map((p) => (
<tr
key={p.id}
className={`hover:bg-ink-50/50 ${p.active ? '' : 'opacity-60'}`}
>
<td className="px-4 py-2.5 font-mono text-xs">{p.sku}</td>
<td className="px-4 py-2.5">
<div className="font-medium text-ink-900">
{loc === 'sv' ? p.nameSv : p.nameEn}
</div>
<div className="text-xs text-ink-500">
{p._count.bookingItems}{' '}
{loc === 'sv' ? 'bokningar' : 'bookings'}
</div>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{formatOre(p.priceOre, loc)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-500">
{(p.vatBp / 100).toFixed(0)}%
</td>
<td className="px-4 py-2.5 text-center">
<form action={toggleProductActive} className="inline">
<input type="hidden" name="id" value={p.id} />
<button
type="submit"
className={`badge ${
p.active
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
: 'bg-ink-200 text-ink-700 hover:bg-ink-300'
}`}
>
{p.active ? t('active') : t('inactive')}
</button>
</form>
</td>
<td className="px-4 py-2.5 text-right">
<Link
href={`/admin/products/${p.id}`}
className="text-brand-600 hover:underline"
>
{t('edit')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { requireAdmin } from '@/lib/requireAdmin';
import { updateSettings } from '@/lib/settings';
export async function saveSettings(formData: FormData) {
await requireAdmin();
const pickupEnabled = formData.get('pickupEnabled') === 'on';
await updateSettings({ pickupEnabled });
// Public booking form depends on this — bust its cache.
revalidatePath('/', 'layout');
revalidatePath('/admin/settings');
redirect('/admin/settings?saved=1');
}

View File

@@ -0,0 +1,58 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { requireAdmin } from '@/lib/requireAdmin';
import { getSettings } from '@/lib/settings';
import { saveSettings } from './actions';
export const dynamic = 'force-dynamic';
export default async function AdminSettingsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ saved?: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.settings');
const { saved } = await searchParams;
const settings = await getSettings();
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
{saved && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{t('saved')}
</div>
)}
<form action={saveSettings} className="card space-y-5 p-5">
<label className="flex items-start gap-3">
<input
type="checkbox"
name="pickupEnabled"
defaultChecked={settings.pickupEnabled}
className="mt-0.5 h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
/>
<span>
<span className="block text-sm font-medium text-ink-900">
{t('pickupEnabled')}
</span>
<span className="mt-0.5 block text-xs text-ink-500">
{t('pickupEnabledHint')}
</span>
</span>
</label>
<div className="flex justify-end border-t border-ink-200 pt-4">
<button type="submit" className="btn-primary">
{t('save')}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import {
AdminUserForm,
PasswordChangeForm,
} from '@/components/admin/AdminUserForm';
import {
updateAdmin,
changeAdminPassword,
deleteAdmin,
} from '../actions';
export const dynamic = 'force-dynamic';
const KNOWN_ERRORS = [
'emailInUse',
'cannotDeleteSelf',
'cannotDeleteLast',
'passwordMismatch',
'passwordTooShort',
] as const;
type ErrorKey = (typeof KNOWN_ERRORS)[number];
const KNOWN_OK = ['passwordChanged'] as const;
type OkKey = (typeof KNOWN_OK)[number];
export default async function EditAdminUserPage({
params,
searchParams,
}: {
params: Promise<{ locale: string; id: string }>;
searchParams: Promise<{ error?: string; ok?: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
const session = await requireAdmin();
const t = await getTranslations('admin.users');
const c = await getTranslations('common');
const { error, ok } = await searchParams;
const admin = await prisma.admin.findUnique({
where: { id },
select: { id: true, name: true, email: true },
});
if (!admin) notFound();
const isMe = session.user?.id === admin.id;
const adminCount = await prisma.admin.count();
const canDelete = !isMe && adminCount > 1;
const errorKey =
error && (KNOWN_ERRORS as readonly string[]).includes(error)
? (error as ErrorKey)
: null;
const okKey =
ok && (KNOWN_OK as readonly string[]).includes(ok) ? (ok as OkKey) : null;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin/users" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">
{t('edit')} ·{' '}
<span className="text-ink-600">{admin.name}</span>
{isMe && (
<span className="ml-2 text-sm font-normal text-accent-600">
{t('you')}
</span>
)}
</h1>
</div>
{errorKey && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{t(errorKey)}
</div>
)}
{okKey && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{t(okKey)}
</div>
)}
<AdminUserForm mode="edit" admin={admin} action={updateAdmin} />
<PasswordChangeForm adminId={admin.id} action={changeAdminPassword} />
<form
action={deleteAdmin}
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
>
<input type="hidden" name="id" value={admin.id} />
<div className="text-red-700">
{isMe
? t('cannotDeleteSelf')
: adminCount <= 1
? t('cannotDeleteLast')
: t('deleteConfirm')}
</div>
<button
type="submit"
disabled={!canDelete}
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('delete')}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,147 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import type { Prisma } from '@prisma/client';
const baseFields = z.object({
name: z.string().trim().min(1).max(200),
email: z.string().trim().toLowerCase().email().max(200),
});
const passwordField = z
.string()
.min(8, 'passwordTooShort')
.max(200);
const createSchema = baseFields.extend({
password: passwordField,
passwordConfirm: z.string(),
}).refine((d) => d.password === d.passwordConfirm, {
path: ['passwordConfirm'],
message: 'passwordMismatch',
});
const updateSchema = baseFields;
const passwordChangeSchema = z
.object({
password: passwordField,
passwordConfirm: z.string(),
})
.refine((d) => d.password === d.passwordConfirm, {
path: ['passwordConfirm'],
message: 'passwordMismatch',
});
function fail(id: string, code: string): never {
redirect(`/admin/users/${id}?error=${encodeURIComponent(code)}`);
}
function failNew(code: string): never {
redirect(`/admin/users/new?error=${encodeURIComponent(code)}`);
}
export async function createAdmin(formData: FormData) {
await requireAdmin();
const parsed = createSchema.safeParse({
name: formData.get('name') ?? '',
email: formData.get('email') ?? '',
password: formData.get('password') ?? '',
passwordConfirm: formData.get('passwordConfirm') ?? '',
});
if (!parsed.success) {
const code = parsed.error.errors[0]?.message ?? 'invalid';
failNew(code);
}
const { name, email, password } = parsed.data;
try {
const hash = await bcrypt.hash(password, 12);
await prisma.admin.create({
data: { name, email, passwordHash: hash },
});
} catch (e) {
const err = e as Prisma.PrismaClientKnownRequestError;
if (err.code === 'P2002') failNew('emailInUse');
throw e;
}
revalidatePath('/admin/users');
redirect('/admin/users');
}
export async function updateAdmin(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const parsed = updateSchema.safeParse({
name: formData.get('name') ?? '',
email: formData.get('email') ?? '',
});
if (!parsed.success) {
fail(id, parsed.error.errors[0]?.message ?? 'invalid');
}
try {
await prisma.admin.update({
where: { id },
data: parsed.data,
});
} catch (e) {
const err = e as Prisma.PrismaClientKnownRequestError;
if (err.code === 'P2002') fail(id, 'emailInUse');
throw e;
}
revalidatePath('/admin/users');
revalidatePath(`/admin/users/${id}`);
redirect('/admin/users');
}
export async function changeAdminPassword(formData: FormData) {
await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
const parsed = passwordChangeSchema.safeParse({
password: formData.get('password') ?? '',
passwordConfirm: formData.get('passwordConfirm') ?? '',
});
if (!parsed.success) {
fail(id, parsed.error.errors[0]?.message ?? 'invalid');
}
const hash = await bcrypt.hash(parsed.data.password, 12);
await prisma.admin.update({
where: { id },
data: { passwordHash: hash },
});
revalidatePath(`/admin/users/${id}`);
redirect(`/admin/users/${id}?ok=passwordChanged`);
}
export async function deleteAdmin(formData: FormData) {
const session = await requireAdmin();
const id = String(formData.get('id') ?? '');
if (!id) return;
// Two safeguards: you can't lock yourself out, and you can't empty the table.
if (session.user?.id === id) {
fail(id, 'cannotDeleteSelf');
}
const count = await prisma.admin.count();
if (count <= 1) {
fail(id, 'cannotDeleteLast');
}
await prisma.admin.delete({ where: { id } });
revalidatePath('/admin/users');
redirect('/admin/users');
}

View File

@@ -0,0 +1,46 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
import { AdminUserForm } from '@/components/admin/AdminUserForm';
import { createAdmin } from '../actions';
export default async function NewAdminUserPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const t = await getTranslations('admin.users');
const c = await getTranslations('common');
const { error } = await searchParams;
const errorKey = error as
| 'emailInUse'
| 'passwordMismatch'
| 'passwordTooShort'
| undefined;
const errorMsg = errorKey ? t(errorKey) : null;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link href="/admin/users" className="btn-ghost text-sm">
{c('back')}
</Link>
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
</div>
{errorMsg && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{errorMsg}
</div>
)}
<AdminUserForm mode="create" action={createAdmin} />
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/requireAdmin';
import { Link } from '@/i18n/routing';
export const dynamic = 'force-dynamic';
export default async function AdminUsersPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const session = await requireAdmin();
const t = await getTranslations('admin.users');
const loc = locale as 'sv' | 'en';
const currentUserId = session.user?.id;
const admins = await prisma.admin.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, name: true, email: true, createdAt: true },
});
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<Link href="/admin/users/new" className="btn-primary text-sm">
+ {t('new')}
</Link>
</div>
<div className="card overflow-hidden">
{admins.length === 0 ? (
<div className="p-8 text-center text-sm text-ink-500">
{t('empty')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wide text-ink-500">
<tr>
<th className="px-4 py-2.5">{t('columns.name')}</th>
<th className="px-4 py-2.5">{t('columns.email')}</th>
<th className="px-4 py-2.5">{t('columns.created')}</th>
<th className="px-4 py-2.5 text-right">
{t('columns.actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{admins.map((a) => {
const isMe = a.id === currentUserId;
return (
<tr key={a.id} className="hover:bg-ink-50/50">
<td className="px-4 py-2.5 font-medium text-ink-900">
{a.name}
{isMe && (
<span className="ml-2 text-xs font-normal text-accent-600">
{t('you')}
</span>
)}
</td>
<td className="px-4 py-2.5 text-ink-700">{a.email}</td>
<td className="px-4 py-2.5 text-ink-500">
{a.createdAt.toLocaleDateString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
)}
</td>
<td className="px-4 py-2.5 text-right">
<Link
href={`/admin/users/${a.id}`}
className="text-brand-600 hover:underline"
>
{t('edit')}
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { Header } from '@/components/Header';
import { formatOre } from '@/lib/money';
import { Link } from '@/i18n/routing';
export default async function BookingConfirmedPage({
params,
}: {
params: Promise<{ locale: string; number: string }>;
}) {
const { locale, number } = await params;
setRequestLocale(locale);
const t = await getTranslations();
const booking = await prisma.booking.findUnique({
where: { bookingNumber: number },
include: { items: true, pickupSlot: true },
});
if (!booking) notFound();
const loc = locale as 'sv' | 'en';
return (
<div className="min-h-screen bg-ink-50">
<Header />
<main className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<div className="card overflow-hidden">
<div className="bg-brand-600 px-6 py-5 text-white">
<div className="flex items-center gap-2 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm4.7-12.3a1 1 0 0 0-1.4-1.4L11 12.6 8.7 10.3a1 1 0 1 0-1.4 1.4l3 3a1 1 0 0 0 1.4 0l5-5z"
clipRule="evenodd"
/>
</svg>
<span>{t('booking.success.title')}</span>
</div>
<div className="mt-3 text-2xl font-semibold">
{t('booking.success.bookingNumber', { number: booking.bookingNumber })}
</div>
<div className="mt-1 text-sm text-white/80">
{t('booking.success.subtitle', { email: booking.email })}
</div>
</div>
<div className="space-y-6 p-6">
<div>
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{t('email.orderSummary')}
</h2>
<table className="mt-2 w-full text-sm">
<tbody className="divide-y divide-ink-100">
{booking.items.map((it) => (
<tr key={it.id}>
<td className="py-2">
{loc === 'sv' ? it.nameSv : it.nameEn}
<span className="ml-2 text-ink-400">×{it.quantity}</span>
</td>
<td className="py-2 text-right tabular-nums">
{formatOre(it.lineTotalOre, loc)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-ink-200">
<td className="pt-2 text-ink-600">
{t('common.subtotal')}
</td>
<td className="pt-2 text-right tabular-nums">
{formatOre(booking.subtotalOre, loc)}
</td>
</tr>
<tr>
<td className="text-ink-500">{t('common.ofWhichVat')}</td>
<td className="text-right tabular-nums text-ink-500">
{formatOre(booking.vatOre, loc)}
</td>
</tr>
<tr>
<td className="pt-1 font-semibold">{t('common.total')}</td>
<td className="pt-1 text-right font-semibold tabular-nums">
{formatOre(booking.totalOre, loc)}
</td>
</tr>
</tfoot>
</table>
</div>
{booking.pickupSlot && (
<div>
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{t('email.pickup')}
</h2>
<p className="mt-1 text-sm">
{loc === 'sv'
? booking.pickupSlot.labelSv
: booking.pickupSlot.labelEn}{' '}
·{' '}
{booking.pickupSlot.startsAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)}
</p>
</div>
)}
<div className="rounded-lg bg-ink-50 p-4 text-sm text-ink-600">
{t('email.invoiceInfo')}
</div>
<Link href="/" className="btn-secondary w-full justify-center">
{t('booking.success.newOrder')}
</Link>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as 'sv' | 'en')) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

78
src/app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { prisma } from '@/lib/prisma';
import { getSettings } from '@/lib/settings';
import { Header } from '@/components/Header';
import { BookingForm } from '@/components/BookingForm';
// Render fresh so toggling settings, products or slots in admin reflects
// immediately on the public form. revalidatePath() in admin actions covers
// most cases, but force-dynamic removes any cache surprise.
export const dynamic = 'force-dynamic';
export default async function BookingPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('booking');
const [products, slotsRaw, settings] = await Promise.all([
prisma.product.findMany({
where: { active: true },
orderBy: { sortOrder: 'asc' },
}),
prisma.pickupSlot.findMany({
where: { active: true, endsAt: { gte: new Date() } },
orderBy: { startsAt: 'asc' },
include: { _count: { select: { bookings: true } } },
}),
getSettings(),
]);
const slots = slotsRaw.map((s) => ({
id: s.id,
labelSv: s.labelSv,
labelEn: s.labelEn,
startsAt: s.startsAt.toISOString(),
endsAt: s.endsAt.toISOString(),
capacityLeft: Math.max(0, s.capacity - s._count.bookings),
}));
return (
<div className="min-h-screen bg-ink-50">
<Header />
<main className="mx-auto max-w-5xl px-4 py-6 sm:py-10">
<div className="mb-6">
<h1 className="text-2xl font-bold text-ink-900 sm:text-3xl">
{t('title')}
</h1>
<p className="mt-1 max-w-2xl text-sm text-ink-600">{t('intro')}</p>
</div>
<BookingForm
products={products.map((p) => ({
id: p.id,
sku: p.sku,
nameSv: p.nameSv,
nameEn: p.nameEn,
descriptionSv: p.descriptionSv,
descriptionEn: p.descriptionEn,
priceOre: p.priceOre,
vatBp: p.vatBp,
}))}
pickupSlots={settings.pickupEnabled ? slots : []}
pickupEnabled={settings.pickupEnabled}
/>
</main>
<footer className="border-t border-ink-200 bg-white">
<div className="mx-auto max-w-5xl px-4 py-6 text-xs text-ink-500">
© {new Date().getFullYear()} Gasol247 ·{' '}
<a href="https://www.gasol247.com" className="hover:underline">
gasol247.com
</a>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { NextResponse } from 'next/server';
import { getSafeSession } from '@/lib/safeAuth';
import { prisma } from '@/lib/prisma';
// CSV export of bookings for invoicing. One row per booking line item so the
// receiver can sum across SKUs per customer in their accounting software.
// Excel-friendly: UTF-8 BOM + ; separator + Windows line endings.
const SEP = ';';
const EOL = '\r\n';
function csvCell(v: unknown): string {
if (v === null || v === undefined) return '';
const s = String(v);
if (/[";\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function row(cells: unknown[]): string {
return cells.map(csvCell).join(SEP);
}
export async function GET(req: Request) {
const session = await getSafeSession();
if (!session?.user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const status = url.searchParams.get('status') ?? undefined;
const q = url.searchParams.get('q') ?? undefined;
const fromStr = url.searchParams.get('from');
const toStr = url.searchParams.get('to');
const bookings = await prisma.booking.findMany({
where: {
...(status && status !== 'all' ? { status } : {}),
...(q
? {
OR: [
{ bookingNumber: { contains: q } },
{ orgName: { contains: q } },
{ email: { contains: q } },
{ contactName: { contains: q } },
],
}
: {}),
...(fromStr || toStr
? {
createdAt: {
...(fromStr ? { gte: new Date(fromStr) } : {}),
...(toStr ? { lte: new Date(toStr) } : {}),
},
}
: {}),
},
orderBy: { createdAt: 'asc' },
include: { items: true, pickupSlot: true },
});
const lines: string[] = [];
lines.push(
row([
'BookingNumber',
'BookingDate',
'Status',
'OrgName',
'OrgNumber',
'ContactName',
'Email',
'Phone',
'Address',
'PostalCode',
'City',
'Country',
'SKU',
'Product',
'Quantity',
'DeliveredQuantity',
'ReturnedQuantity',
'BillableQuantity',
'UnitPriceSEK',
'BillableNetSEK',
'VATPct',
'BillableVatSEK',
'BillableTotalSEK',
'OrderedNetSEK',
'OrderedTotalSEK',
'PickupLabel',
'PickupStart',
'Notes',
]),
);
for (const b of bookings) {
for (const it of b.items) {
// Billable = handed out returned. That's what the customer pays for.
const billableQty = Math.max(
0,
it.deliveredQuantity - it.returnedQuantity,
);
const unitSek = it.unitPriceOre / 100;
const billableNet = unitSek * billableQty;
const vatPct = it.vatBp / 100;
const billableVat = (billableNet * it.vatBp) / 10000;
const billableTotal = billableNet + billableVat;
const orderedNet = unitSek * it.quantity;
const orderedTotal = it.lineTotalOre / 100;
const dec = (n: number) => n.toFixed(2).replace('.', ',');
lines.push(
row([
b.bookingNumber,
b.createdAt.toISOString(),
b.status,
b.orgName,
b.orgNumber,
b.contactName,
b.email,
b.phone,
b.address,
b.postalCode,
b.city,
b.country,
it.sku,
it.nameSv,
it.quantity,
it.deliveredQuantity,
it.returnedQuantity,
billableQty,
dec(unitSek),
dec(billableNet),
dec(vatPct),
dec(billableVat),
dec(billableTotal),
dec(orderedNet),
dec(orderedTotal),
b.pickupSlot?.labelSv ?? '',
b.pickupSlot?.startsAt.toISOString() ?? '',
b.notes ?? '',
]),
);
}
}
const body = '' + lines.join(EOL) + EOL;
const date = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="bookings-${date}.csv"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { bookingSubmitSchema } from '@/lib/validation';
import { generateBookingNumber } from '@/lib/bookingNumber';
import { computeTotals, vatAmountOre } from '@/lib/money';
import { sendBookingConfirmation } from '@/lib/mailjet';
import { getSettings } from '@/lib/settings';
import type { Prisma } from '@prisma/client';
export async function POST(req: Request) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'invalid-json' }, { status: 400 });
}
const parsed = bookingSubmitSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'validation', issues: parsed.error.flatten() },
{ status: 400 },
);
}
const input = parsed.data;
// Pull live products to snapshot prices/names (don't trust client prices).
const productIds = input.items.map((i) => i.productId);
const products = await prisma.product.findMany({
where: { id: { in: productIds }, active: true },
});
if (products.length !== productIds.length) {
return NextResponse.json({ error: 'product-not-found' }, { status: 400 });
}
const productById = new Map(products.map((p) => [p.id, p]));
// If admin has globally disabled the pickup step, drop any slot id the
// client sent so we don't tie this booking to a possibly-stale slot.
const settings = await getSettings();
if (!settings.pickupEnabled) {
input.pickupSlotId = null;
} else if (input.pickupSlotId) {
// Validate pickup slot if given
const slot = await prisma.pickupSlot.findUnique({
where: { id: input.pickupSlotId },
});
if (!slot || !slot.active) {
return NextResponse.json({ error: 'pickup-slot-invalid' }, { status: 400 });
}
}
const itemSnapshots = input.items.map((i) => {
const p = productById.get(i.productId)!;
const lineNet = p.priceOre * i.quantity;
const lineVat = vatAmountOre(lineNet, p.vatBp);
return {
productId: p.id,
sku: p.sku,
nameSv: p.nameSv,
nameEn: p.nameEn,
unitPriceOre: p.priceOre,
vatBp: p.vatBp,
quantity: i.quantity,
lineTotalOre: lineNet + lineVat,
};
});
const totals = computeTotals(
itemSnapshots.map((i) => ({
unitPriceOre: i.unitPriceOre,
quantity: i.quantity,
vatBp: i.vatBp,
})),
);
// Insert with retry on unique-number collision (very unlikely with our format).
let booking;
for (let attempt = 0; attempt < 5; attempt++) {
const bookingNumber = generateBookingNumber();
try {
booking = await prisma.booking.create({
data: {
bookingNumber,
status: 'CONFIRMED',
contactName: input.contactName,
email: input.email.toLowerCase(),
phone: input.phone,
orgName: input.orgName,
orgNumber: input.orgNumber,
address: input.address,
postalCode: input.postalCode,
city: input.city,
country: input.country,
pickupSlotId: input.pickupSlotId ?? null,
notes: input.notes ?? null,
locale: input.locale,
subtotalOre: totals.subtotalOre,
vatOre: totals.vatOre,
totalOre: totals.totalOre,
items: { create: itemSnapshots },
},
include: { items: true, pickupSlot: true },
});
break;
} catch (e) {
const err = e as Prisma.PrismaClientKnownRequestError;
if (err.code === 'P2002') continue; // unique violation — retry with new number
throw e;
}
}
if (!booking) {
return NextResponse.json(
{ error: 'could-not-generate-booking-number' },
{ status: 500 },
);
}
// Send confirmation email (best-effort — don't fail the booking if email fails).
const mail = await sendBookingConfirmation(booking);
if (!mail.ok) {
console.warn('[bookings] confirmation email not sent:', mail.error);
}
return NextResponse.json({
bookingNumber: booking.bookingNumber,
emailSent: mail.ok,
});
}

57
src/app/globals.css Normal file
View File

@@ -0,0 +1,57 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
html,
body {
background: #fffaf5;
color: theme('colors.ink.900');
-webkit-font-smoothing: antialiased;
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800
focus:outline-none focus:ring-2 focus:ring-accent-300 focus:ring-offset-2;
}
.btn-secondary {
@apply btn border border-ink-200 bg-white text-ink-800 hover:bg-ink-50
focus:outline-none focus:ring-2 focus:ring-accent-300;
}
.btn-ghost {
@apply btn text-ink-700 hover:bg-ink-100
focus:outline-none focus:ring-2 focus:ring-accent-300;
}
.input {
@apply w-full rounded-lg border border-ink-200 bg-white px-3 py-2.5 text-sm text-ink-900 placeholder:text-ink-400
focus:border-accent-400 focus:outline-none focus:ring-2 focus:ring-accent-300/40;
}
.label {
@apply block text-sm font-medium text-ink-700;
}
.card {
@apply rounded-xl border border-ink-200 bg-white shadow-card;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
/* Numeric input with no spinner — used for quantity boxes. */
.num-input {
@apply w-14 rounded-md border border-ink-200 bg-white py-1.5 text-center text-sm font-medium tabular-nums
focus:border-accent-400 focus:outline-none focus:ring-2 focus:ring-accent-300/40;
appearance: textfield;
-moz-appearance: textfield;
}
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}

14
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'Gasol247 Bokning',
template: '%s — Gasol247 Bokning',
},
description: 'Boka gasoltuber till Jamboree-lägret.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return children;
}

28
src/auth.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { NextAuthConfig } from 'next-auth';
// Edge-compatible part of the auth config. No DB calls here — those go in auth.ts.
export const authConfig = {
pages: {
signIn: '/admin/login',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.email = user.email;
token.name = user.name;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.email = token.email as string;
session.user.name = token.name as string;
}
return session;
},
},
providers: [],
session: { strategy: 'jwt' },
} satisfies NextAuthConfig;

42
src/auth.ts Normal file
View File

@@ -0,0 +1,42 @@
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { authConfig } from './auth.config';
import { prisma } from './lib/prisma';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: 'E-post', type: 'email' },
password: { label: 'Lösenord', type: 'password' },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const admin = await prisma.admin.findUnique({
where: { email: email.toLowerCase() },
});
if (!admin) return null;
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) return null;
return {
id: admin.id,
email: admin.email,
name: admin.name,
};
},
}),
],
});

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>
);
}

13
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,13 @@
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as 'sv' | 'en')) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

13
src/i18n/routing.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
export const routing = defineRouting({
locales: ['sv', 'en'],
defaultLocale: 'sv',
localePrefix: 'as-needed',
});
export type Locale = (typeof routing.locales)[number];
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

14
src/lib/bookingNumber.ts Normal file
View File

@@ -0,0 +1,14 @@
// Booking number format: JAM-YY-XXXX
// Two-letter event code + 2-digit year + 4 random base32 chars.
// Easy to read over phone, hard to guess.
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // skip I,O,0,1 to avoid confusion
export function generateBookingNumber(prefix = 'JAM'): string {
const yy = String(new Date().getFullYear()).slice(2);
let suffix = '';
for (let i = 0; i < 4; i++) {
suffix += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
return `${prefix}-${yy}-${suffix}`;
}

73
src/lib/bookingStatus.ts Normal file
View File

@@ -0,0 +1,73 @@
// Booking lifecycle helpers.
export const BOOKING_STATUSES = [
'PENDING',
'CONFIRMED',
'DELIVERED_PARTIAL',
'DELIVERED',
'RETURNED_PARTIAL',
'RETURNED',
'INVOICED',
'CANCELLED',
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
// Statuses where deriving from item counters is appropriate. INVOICED and
// CANCELLED are sticky — admin sets them explicitly, we don't overwrite.
const DERIVABLE: ReadonlySet<string> = new Set([
'PENDING',
'CONFIRMED',
'DELIVERED_PARTIAL',
'DELIVERED',
'RETURNED_PARTIAL',
'RETURNED',
]);
type ItemCounts = { quantity: number; deliveredQuantity: number; returnedQuantity: number };
/**
* Compute the lifecycle status from per-line delivery/return counters.
* Does not touch INVOICED or CANCELLED — those are terminal admin choices.
*/
export function deriveBookingStatus(
current: string,
items: ItemCounts[],
): BookingStatus {
if (current === 'INVOICED' || current === 'CANCELLED') {
return current as BookingStatus;
}
if (!DERIVABLE.has(current) && current !== '') {
// Unknown status — leave as-is, just narrow the type.
return (current as BookingStatus) ?? 'CONFIRMED';
}
const total = items.reduce((a, i) => a + i.quantity, 0);
const delivered = items.reduce((a, i) => a + i.deliveredQuantity, 0);
const returned = items.reduce((a, i) => a + i.returnedQuantity, 0);
if (delivered === 0) {
// Nothing handed out yet — booking is just confirmed.
return 'CONFIRMED';
}
if (returned === 0) {
return delivered < total ? 'DELIVERED_PARTIAL' : 'DELIVERED';
}
// Some returns started.
if (returned < delivered) {
return 'RETURNED_PARTIAL';
}
// All delivered items have come back.
return 'RETURNED';
}
/** Clamp delivered/returned to legal ranges relative to quantity. */
export function clampFulfillment(
quantity: number,
delivered: number,
returned: number,
): { delivered: number; returned: number } {
const d = Math.max(0, Math.min(quantity, Math.floor(delivered)));
const r = Math.max(0, Math.min(d, Math.floor(returned)));
return { delivered: d, returned: r };
}

102
src/lib/bookingStore.ts Normal file
View File

@@ -0,0 +1,102 @@
'use client';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
// Persist booking form state across page navigations (e.g. when the user
// switches language mid-flow). sessionStorage so it's per-tab and cleared
// when the tab closes. Bumping STORE_VERSION invalidates old persisted state.
const STORE_VERSION = 1;
export type Step = 'products' | 'details' | 'pickup' | 'review';
export type BookingFormState = {
step: Step;
quantities: Record<string, number>;
contactName: string;
email: string;
phone: string;
orgName: string;
orgNumber: string;
address: string;
postalCode: string;
city: string;
country: string;
pickupSlotId: string | null;
notes: string;
confirm: boolean;
};
type BookingFormActions = {
setStep: (step: Step) => void;
setQuantity: (productId: string, qty: number) => void;
patch: (partial: Partial<BookingFormState>) => void;
reset: () => void;
};
const initial: BookingFormState = {
step: 'products',
quantities: {},
contactName: '',
email: '',
phone: '',
orgName: '',
orgNumber: '',
address: '',
postalCode: '',
city: '',
country: 'SE',
pickupSlotId: null,
notes: '',
confirm: false,
};
export const useBookingStore = create<BookingFormState & BookingFormActions>()(
persist(
(set) => ({
...initial,
setStep: (step) => set({ step }),
setQuantity: (productId, qty) =>
set((s) => {
const q = { ...s.quantities };
if (qty <= 0) delete q[productId];
else q[productId] = Math.min(999, Math.floor(qty));
return { quantities: q };
}),
patch: (partial) => set(partial as BookingFormState),
reset: () => set({ ...initial }),
}),
{
name: 'boka-gasol247:booking-form',
version: STORE_VERSION,
storage: createJSONStorage(() =>
typeof window === 'undefined'
? // SSR no-op
{
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
: window.sessionStorage,
),
// Don't persist `confirm` (re-check each session) or `step` if user
// hasn't started filling anything. Keeps the experience clean if they
// close and reopen a tab with stale state.
partialize: (s) => ({
step: s.step,
quantities: s.quantities,
contactName: s.contactName,
email: s.email,
phone: s.phone,
orgName: s.orgName,
orgNumber: s.orgNumber,
address: s.address,
postalCode: s.postalCode,
city: s.city,
country: s.country,
pickupSlotId: s.pickupSlotId,
notes: s.notes,
}),
},
),
);

253
src/lib/mailjet.ts Normal file
View File

@@ -0,0 +1,253 @@
import Mailjet from 'node-mailjet';
import { formatOre } from './money';
import type { Booking, BookingItem, PickupSlot } from '@prisma/client';
let client: Mailjet | null = null;
function getClient(): Mailjet | null {
if (!process.env.MAILJET_API_KEY || !process.env.MAILJET_API_SECRET) {
return null;
}
if (!client) {
client = new Mailjet({
apiKey: process.env.MAILJET_API_KEY,
apiSecret: process.env.MAILJET_API_SECRET,
});
}
return client;
}
type BookingWithRels = Booking & {
items: BookingItem[];
pickupSlot: PickupSlot | null;
};
type EmailStrings = {
subject: string;
greeting: string;
intro: string;
bookingNumber: string;
orderSummary: string;
pickup: string;
invoiceInfo: string;
questions: string;
footer: string;
subtotal: string;
ofWhichVat: string;
total: string;
eventName: string;
};
function getStrings(locale: 'sv' | 'en', event: string): EmailStrings {
if (locale === 'sv') {
return {
subject: `Bokningsbekräftelse {number} — ${event}`,
greeting: 'Hej {name},',
intro: `Tack för din beställning av gasoltuber till ${event}. Här är din bokningsbekräftelse.`,
bookingNumber: 'Bokningsnummer',
orderSummary: 'Beställning',
pickup: 'Upphämtning',
invoiceInfo: 'Faktura skickas till organisationen efter eventet.',
questions: 'Har du frågor? Svara på detta mejl så hjälper vi dig.',
footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
subtotal: 'Delsumma',
ofWhichVat: 'varav moms',
total: 'Totalt',
eventName: event,
};
}
return {
subject: `Booking confirmation {number} — ${event}`,
greeting: 'Hi {name},',
intro: `Thank you for ordering LPG cylinders for ${event}. Here is your booking confirmation.`,
bookingNumber: 'Booking number',
orderSummary: 'Order',
pickup: 'Pickup',
invoiceInfo: 'An invoice will be sent to your organization after the event.',
questions: 'Questions? Reply to this email and we will help you.',
footer: 'This is an automated confirmation from Gasol247.',
subtotal: 'Subtotal',
ofWhichVat: 'of which VAT',
total: 'Total',
eventName: event,
};
}
export function renderBookingEmail(
booking: BookingWithRels,
locale: 'sv' | 'en' = 'sv',
): { subject: string; html: string; text: string } {
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
const s = getStrings(locale, event);
const fmt = (ore: number) => formatOre(ore, locale);
const itemName = (it: BookingItem) =>
locale === 'sv' ? it.nameSv : it.nameEn;
const slotLabel = booking.pickupSlot
? locale === 'sv'
? booking.pickupSlot.labelSv
: booking.pickupSlot.labelEn
: null;
const slotTime = booking.pickupSlot
? booking.pickupSlot.startsAt.toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)
: null;
const subject = s.subject.replace('{number}', booking.bookingNumber);
const greeting = s.greeting.replace('{name}', booking.contactName);
const itemRowsHtml = booking.items
.map(
(it) => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;">
${escapeHtml(itemName(it))} <span style="color:#94a3b8;">× ${it.quantity}</span>
</td>
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
${fmt(it.lineTotalOre)}
</td>
</tr>`,
)
.join('');
const pickupHtml =
slotLabel && slotTime
? `
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${s.pickup}</h2>
<p style="font-size:14px;color:#0f172a;margin:0;">${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}</p>`
: '';
const html = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(subject)}</title>
</head>
<body style="margin:0;background:#f8fafc;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<tr>
<td style="background:#ea580c;color:#ffffff;padding:24px;">
<div style="font-size:12px;opacity:0.9;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(s.bookingNumber)}</div>
<div style="font-size:24px;font-weight:600;margin-top:4px;">${escapeHtml(booking.bookingNumber)}</div>
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(s.eventName)}</div>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(greeting)}</p>
<p style="font-size:14px;color:#475569;margin:0 0 16px;">${escapeHtml(s.intro)}</p>
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:16px 0 8px;">${s.orderSummary}</h2>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
${itemRowsHtml}
<tr>
<td style="padding-top:12px;font-size:14px;color:#475569;">${s.subtotal}</td>
<td style="padding-top:12px;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">${fmt(booking.subtotalOre)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;">${s.ofWhichVat}</td>
<td style="font-size:13px;color:#64748b;text-align:right;white-space:nowrap;">${fmt(booking.vatOre)}</td>
</tr>
<tr>
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;border-top:1px solid #e2e8f0;">${s.total}</td>
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">${fmt(booking.totalOre)}</td>
</tr>
</table>
${pickupHtml}
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
${escapeHtml(s.invoiceInfo)}
</div>
<p style="font-size:13px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.questions)}</p>
</td>
</tr>
<tr>
<td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center;">
${escapeHtml(s.footer)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [
greeting,
'',
s.intro,
'',
`${s.bookingNumber}: ${booking.bookingNumber}`,
'',
`${s.orderSummary}:`,
...booking.items.map(
(it) => ` ${itemName(it)} × ${it.quantity} ${fmt(it.lineTotalOre)}`,
),
'',
`${s.subtotal}: ${fmt(booking.subtotalOre)}`,
`${s.ofWhichVat}: ${fmt(booking.vatOre)}`,
`${s.total}: ${fmt(booking.totalOre)}`,
...(slotLabel ? ['', `${s.pickup}: ${slotLabel} ${slotTime}`] : []),
'',
s.invoiceInfo,
'',
s.questions,
'',
'— ' + s.footer,
].join('\n');
return { subject, html, text };
}
export async function sendBookingConfirmation(
booking: BookingWithRels,
): Promise<{ ok: boolean; error?: string }> {
const c = getClient();
if (!c) {
console.warn('[mailjet] not configured — skipping email send');
return { ok: false, error: 'mailjet-not-configured' };
}
const locale = (booking.locale as 'sv' | 'en') ?? 'sv';
const { subject, html, text } = renderBookingEmail(booking, locale);
const fromEmail = process.env.MAIL_FROM_EMAIL ?? 'no-reply@example.com';
const fromName = process.env.MAIL_FROM_NAME ?? 'Gasol247 Bokning';
try {
await c.post('send', { version: 'v3.1' }).request({
Messages: [
{
From: { Email: fromEmail, Name: fromName },
To: [{ Email: booking.email, Name: booking.contactName }],
Subject: subject,
HTMLPart: html,
TextPart: text,
CustomID: booking.bookingNumber,
},
],
});
return { ok: true };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[mailjet] send failed', msg);
return { ok: false, error: msg };
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

33
src/lib/money.ts Normal file
View File

@@ -0,0 +1,33 @@
// Price math in öre (1 SEK = 100 öre) to avoid float drift.
export function formatOre(ore: number, locale: 'sv' | 'en' = 'sv'): string {
const sek = ore / 100;
return new Intl.NumberFormat(locale === 'sv' ? 'sv-SE' : 'en-SE', {
style: 'currency',
currency: 'SEK',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(sek);
}
export function vatAmountOre(netOre: number, vatBp: number): number {
return Math.round((netOre * vatBp) / 10000);
}
/** Net price + VAT, rounded the same way as billing. */
export function priceInclVatOre(netOre: number, vatBp: number): number {
return netOre + vatAmountOre(netOre, vatBp);
}
export function computeTotals(
items: Array<{ unitPriceOre: number; quantity: number; vatBp: number }>,
) {
let subtotal = 0;
let vat = 0;
for (const i of items) {
const line = i.unitPriceOre * i.quantity;
subtotal += line;
vat += vatAmountOre(line, i.vatBp);
}
return { subtotalOre: subtotal, vatOre: vat, totalOre: subtotal + vat };
}

29
src/lib/orgNumber.ts Normal file
View File

@@ -0,0 +1,29 @@
// Swedish organization number validation (Luhn over 10 digits).
// Accepts "XXXXXX-XXXX" or "XXXXXXXXXX". Some non-SE orgs may have different formats;
// we keep validation strict for SE and permit a relaxed form for orgs with country != SE.
export function normalizeOrgNumber(input: string): string {
return input.replace(/[\s-]/g, '');
}
export function isValidSeOrgNumber(input: string): boolean {
const n = normalizeOrgNumber(input);
if (!/^\d{10}$/.test(n)) return false;
// Luhn
let sum = 0;
for (let i = 0; i < 10; i++) {
let d = parseInt(n[i], 10);
if (i % 2 === 0) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return sum % 10 === 0;
}
export function formatOrgNumber(input: string): string {
const n = normalizeOrgNumber(input);
if (n.length !== 10) return input;
return `${n.slice(0, 6)}-${n.slice(6)}`;
}

13
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

10
src/lib/requireAdmin.ts Normal file
View File

@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation';
import { getSafeSession } from '@/lib/safeAuth';
export async function requireAdmin() {
const session = await getSafeSession();
if (!session?.user) {
redirect('/admin/login');
}
return session;
}

24
src/lib/safeAuth.ts Normal file
View File

@@ -0,0 +1,24 @@
import { auth } from '@/auth';
import type { Session } from 'next-auth';
// Wrap `auth()` so stale session cookies (e.g. AUTH_SECRET rotated, or default
// dev secret swapped out) don't surface as scary JWTSessionError stacks in
// every server log. We treat any decode failure as "not logged in" and let the
// next request rewrite the cookie at next login.
export async function getSafeSession(): Promise<Session | null> {
try {
return await auth();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (
msg.includes('decryption') ||
msg.includes('JWTSessionError') ||
msg.includes('JWEDecryptionFailed')
) {
// Stale or unreadable session cookie — treat as logged out silently.
return null;
}
// Re-throw anything unexpected so it's visible.
throw err;
}
}

32
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,32 @@
import { prisma } from './prisma';
export type AppSettings = {
pickupEnabled: boolean;
};
const DEFAULTS: AppSettings = {
pickupEnabled: true,
};
const SINGLETON_ID = 'singleton';
/** Returns the singleton settings row, creating it with defaults on first call. */
export async function getSettings(): Promise<AppSettings> {
const row = await prisma.settings.upsert({
where: { id: SINGLETON_ID },
update: {},
create: { id: SINGLETON_ID, ...DEFAULTS },
});
return { pickupEnabled: row.pickupEnabled };
}
export async function updateSettings(
patch: Partial<AppSettings>,
): Promise<AppSettings> {
const row = await prisma.settings.upsert({
where: { id: SINGLETON_ID },
update: patch,
create: { id: SINGLETON_ID, ...DEFAULTS, ...patch },
});
return { pickupEnabled: row.pickupEnabled };
}

33
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { isValidSeOrgNumber, normalizeOrgNumber } from './orgNumber';
export const bookingItemSchema = z.object({
productId: z.string().min(1),
quantity: z.number().int().min(1).max(999),
});
export const bookingSubmitSchema = z.object({
contactName: z.string().trim().min(2).max(120),
email: z.string().trim().email().max(200),
phone: z.string().trim().min(5).max(40),
orgName: z.string().trim().min(2).max(200),
orgNumber: z
.string()
.trim()
.min(10)
.max(13)
.refine((v) => isValidSeOrgNumber(v), {
message: 'invalidOrgNumber',
})
.transform((v) => normalizeOrgNumber(v)),
address: z.string().trim().min(2).max(200),
postalCode: z.string().trim().min(3).max(20),
city: z.string().trim().min(1).max(100),
country: z.string().trim().min(2).max(2).default('SE'),
pickupSlotId: z.string().min(1).nullable().optional(),
notes: z.string().trim().max(2000).optional().nullable(),
locale: z.enum(['sv', 'en']).default('sv'),
items: z.array(bookingItemSchema).min(1, 'selectProduct'),
});
export type BookingSubmitInput = z.infer<typeof bookingSubmitSchema>;

9
src/middleware.ts Normal file
View File

@@ -0,0 +1,9 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match all paths except next internals, api, and static assets.
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};

1
src/types/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.css';

9
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
}
}