diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx
index 15875cf..a3baaaf 100644
--- a/src/app/[locale]/admin/layout.tsx
+++ b/src/app/[locale]/admin/layout.tsx
@@ -15,6 +15,7 @@ export default async function AdminLayout({
setRequestLocale(locale);
const session = await getSafeSession();
const t = await getTranslations('admin');
+ const tr = await getTranslations('adminReplacements');
// 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.
@@ -51,6 +52,12 @@ export default async function AdminLayout({
>
{t('nav.pickupSlots')}
+
- {b.bookingNumber}
+
+
{b.bookingNumber}
+ {b._count.replacements > 0 && (
+
+
+
+
+ {b._count.replacements}
+
+ )}
+
{b.createdAt.toLocaleDateString(
diff --git a/src/app/[locale]/admin/replacements/page.tsx b/src/app/[locale]/admin/replacements/page.tsx
new file mode 100644
index 0000000..6b129f1
--- /dev/null
+++ b/src/app/[locale]/admin/replacements/page.tsx
@@ -0,0 +1,224 @@
+import { setRequestLocale, getTranslations } from 'next-intl/server';
+import { revalidatePath } from 'next/cache';
+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';
+
+export const dynamic = 'force-dynamic';
+
+const STATUSES = ['REQUESTED', 'SCHEDULED', 'DELIVERED', 'CANCELLED'] as const;
+type ReplacementStatus = (typeof STATUSES)[number];
+
+async function setReplacementStatus(formData: FormData) {
+ 'use server';
+ await requireAdmin();
+ const id = String(formData.get('id') ?? '');
+ const status = String(formData.get('status') ?? '');
+ if (!id || !STATUSES.includes(status as ReplacementStatus)) return;
+ await prisma.replacementRequest.update({
+ where: { id },
+ data: { status },
+ });
+ revalidatePath('/admin/replacements');
+ // Find bookingId to refresh detail view too.
+ const rr = await prisma.replacementRequest.findUnique({
+ where: { id },
+ select: { bookingId: true },
+ });
+ if (rr) revalidatePath(`/admin/bookings/${rr.bookingId}`);
+}
+
+export default async function AdminReplacementsPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>;
+ searchParams: Promise<{ status?: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ await requireAdmin();
+ const sp = await searchParams;
+ const loc = locale as 'sv' | 'en';
+ const t = await getTranslations('adminReplacements');
+
+ const where: Prisma.ReplacementRequestWhereInput =
+ sp.status && STATUSES.includes(sp.status as ReplacementStatus)
+ ? { status: sp.status }
+ : {};
+
+ const rows = await prisma.replacementRequest.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ include: { booking: true, bookingItem: true, pickupSlot: true },
+ take: 200,
+ });
+
+ return (
+
+
{t('title')}
+
+
+
+
+ {t('filters.all')}
+
+ {STATUSES.map((s) => (
+
+ {t(`status.${s}`)}
+
+ ))}
+
+
+
+
+ {rows.length === 0 ? (
+
+ {t('empty')}
+
+ ) : (
+
+
+
+
+ {t('columns.created')}
+ {t('columns.booking')}
+ {t('columns.org')}
+ {t('columns.item')}
+ {t('columns.quantity')}
+ {t('columns.total')}
+ {t('columns.pickup')}
+ {t('columns.status')}
+
+
+
+
+ {rows.map((r) => (
+
+
+ {r.createdAt.toLocaleString(
+ loc === 'sv' ? 'sv-SE' : 'en-SE',
+ { dateStyle: 'short', timeStyle: 'short' },
+ )}
+
+
+
+ {r.booking.bookingNumber}
+
+
+
+ {r.booking.orgName}
+
+
+ {loc === 'sv' ? r.nameSv : r.nameEn}
+
+
+ {r.quantity}
+
+
+ {formatOre(r.lineTotalOre, loc)}
+
+
+ {r.pickupSlot ? (
+ <>
+
+ {loc === 'sv'
+ ? r.pickupSlot.labelSv
+ : r.pickupSlot.labelEn}
+
+
+ {r.pickupSlot.startsAt.toLocaleString(
+ loc === 'sv' ? 'sv-SE' : 'en-SE',
+ { dateStyle: 'short', timeStyle: 'short' },
+ )}
+
+ >
+ ) : (
+ —
+ )}
+
+
+
+ {t(`status.${r.status as ReplacementStatus}`)}
+
+
+
+
+ {r.status === 'REQUESTED' && (
+
+ )}
+ {(r.status === 'REQUESTED' ||
+ r.status === 'SCHEDULED') && (
+
+ )}
+ {r.status !== 'CANCELLED' &&
+ r.status !== 'DELIVERED' && (
+
+ )}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+function StatusAction({
+ id,
+ target,
+ label,
+ action,
+ destructive,
+}: {
+ id: string;
+ target: ReplacementStatus;
+ label: string;
+ action: (fd: FormData) => Promise;
+ destructive?: boolean;
+}) {
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/booking/[number]/page.tsx b/src/app/[locale]/booking/[number]/page.tsx
index 3135e85..c974595 100644
--- a/src/app/[locale]/booking/[number]/page.tsx
+++ b/src/app/[locale]/booking/[number]/page.tsx
@@ -118,9 +118,17 @@ export default async function BookingConfirmedPage({
{t('email.invoiceInfo')}
-
- {t('booking.success.newOrder')}
-
+
+
+ {t('customer.navTitle')}
+
+
+ {t('booking.success.newOrder')}
+
+
diff --git a/src/app/[locale]/min-sida/layout.tsx b/src/app/[locale]/min-sida/layout.tsx
new file mode 100644
index 0000000..cb645ad
--- /dev/null
+++ b/src/app/[locale]/min-sida/layout.tsx
@@ -0,0 +1,32 @@
+import { setRequestLocale } from 'next-intl/server';
+import { Header } from '@/components/Header';
+import { CustomerSignOutForm } from '@/components/customer/CustomerSignOutForm';
+import { getCustomerEmail } from '@/lib/customerAuth';
+
+export default async function MinSidaLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const loc = locale as 'sv' | 'en';
+ const email = await getCustomerEmail();
+
+ return (
+
+
+
+ {email && (
+
+ {email}
+
+
+ )}
+ {children}
+
+
+ );
+}
diff --git a/src/app/[locale]/min-sida/oversikt/[bookingId]/byte/page.tsx b/src/app/[locale]/min-sida/oversikt/[bookingId]/byte/page.tsx
new file mode 100644
index 0000000..230f6d3
--- /dev/null
+++ b/src/app/[locale]/min-sida/oversikt/[bookingId]/byte/page.tsx
@@ -0,0 +1,127 @@
+import { setRequestLocale, getTranslations } from 'next-intl/server';
+import { redirect, notFound } from 'next/navigation';
+import { Link } from '@/i18n/routing';
+import { prisma } from '@/lib/prisma';
+import { getCustomerEmail } from '@/lib/customerAuth';
+import { getSettings } from '@/lib/settings';
+import { formatOre } from '@/lib/money';
+import { ReplacementForm } from '@/components/customer/ReplacementForm';
+
+export const dynamic = 'force-dynamic';
+
+export default async function ReplacementPage({
+ params,
+}: {
+ params: Promise<{ locale: string; bookingId: string }>;
+}) {
+ const { locale, bookingId } = await params;
+ setRequestLocale(locale);
+ const loc = locale as 'sv' | 'en';
+
+ const email = await getCustomerEmail();
+ if (!email) {
+ redirect(locale === 'en' ? '/en/min-sida' : '/min-sida');
+ }
+
+ const [booking, slotsRaw, settings] = await Promise.all([
+ prisma.booking.findUnique({
+ where: { id: bookingId },
+ include: {
+ items: true,
+ replacements: { orderBy: { createdAt: 'desc' } },
+ },
+ }),
+ prisma.pickupSlot.findMany({
+ where: { active: true, endsAt: { gte: new Date() } },
+ orderBy: { startsAt: 'asc' },
+ include: { _count: { select: { bookings: true } } },
+ }),
+ getSettings(),
+ ]);
+
+ if (!booking || booking.email !== email) notFound();
+
+ const slots = slotsRaw.map((s) => ({
+ id: s.id,
+ labelSv: s.labelSv,
+ labelEn: s.labelEn,
+ startsAt: s.startsAt.toISOString(),
+ capacityLeft: Math.max(0, s.capacity - s._count.bookings),
+ }));
+
+ const t = await getTranslations('customer.replacement');
+
+ return (
+
+
+ ← {t('back')}
+
+
+
+
{t('title')}
+
{t('intro')}
+
+ {booking.bookingNumber} · {booking.orgName}
+
+
+
+ ({
+ id: it.id,
+ name: loc === 'sv' ? it.nameSv : it.nameEn,
+ ordered: it.quantity,
+ unitPriceOre: it.unitPriceOre,
+ vatBp: it.vatBp,
+ }))}
+ pickupSlots={settings.pickupEnabled ? slots : []}
+ />
+
+
+
+
+
+ {t('existing')}
+
+ {booking.replacements.length === 0 ? (
+
{t('noExisting')}
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/[locale]/min-sida/oversikt/page.tsx b/src/app/[locale]/min-sida/oversikt/page.tsx
new file mode 100644
index 0000000..d9ebc81
--- /dev/null
+++ b/src/app/[locale]/min-sida/oversikt/page.tsx
@@ -0,0 +1,163 @@
+import { setRequestLocale, getTranslations } from 'next-intl/server';
+import { redirect } from 'next/navigation';
+import { Link } from '@/i18n/routing';
+import { prisma } from '@/lib/prisma';
+import { getCustomerEmail } from '@/lib/customerAuth';
+import { formatOre } from '@/lib/money';
+import { StatusBadge } from '@/components/StatusBadge';
+
+export const dynamic = 'force-dynamic';
+
+export default async function OverviewPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const loc = locale as 'sv' | 'en';
+
+ const email = await getCustomerEmail();
+ if (!email) {
+ redirect(locale === 'en' ? '/en/min-sida' : '/min-sida');
+ }
+
+ const bookings = await prisma.booking.findMany({
+ where: { email, status: { not: 'CANCELLED' } },
+ orderBy: { createdAt: 'desc' },
+ include: {
+ items: true,
+ pickupSlot: true,
+ replacements: {
+ orderBy: { createdAt: 'desc' },
+ include: { pickupSlot: true },
+ },
+ },
+ });
+
+ const t = await getTranslations('customer.overview');
+ const tr = await getTranslations('customer.replacement');
+
+ return (
+
+
+
{t('title')}
+
+ {t('signedInAs', { email })}
+
+
+
+ {bookings.length === 0 ? (
+
+ {t('empty')}
+
+ ) : (
+
+ {bookings.map((b) => (
+
+
+
+
+ {t('bookingNumber')}
+
+
+ {b.bookingNumber}
+
+
+
+
+
+
+
+
{t('items')}
+
+ {b.items
+ .map(
+ (it) =>
+ `${loc === 'sv' ? it.nameSv : it.nameEn} × ${it.quantity}`,
+ )
+ .join(', ')}
+
+
+ {b.pickupSlot && (
+
+
{t('pickup')}
+
+ {loc === 'sv' ? b.pickupSlot.labelSv : b.pickupSlot.labelEn}
+ {' · '}
+ {b.pickupSlot.startsAt.toLocaleString(
+ loc === 'sv' ? 'sv-SE' : 'en-SE',
+ { dateStyle: 'short', timeStyle: 'short' },
+ )}
+
+
+ )}
+
+
Total
+
+ {formatOre(b.totalOre, loc)}
+
+
+
+
+ {b.replacements.length > 0 && (
+
+
+ {tr('existing')}
+
+
+
+ )}
+
+
+
+ {t('requestReplacement')}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/[locale]/min-sida/page.tsx b/src/app/[locale]/min-sida/page.tsx
new file mode 100644
index 0000000..ce5338d
--- /dev/null
+++ b/src/app/[locale]/min-sida/page.tsx
@@ -0,0 +1,32 @@
+import { setRequestLocale, getTranslations } from 'next-intl/server';
+import { redirect } from 'next/navigation';
+import { getCustomerEmail } from '@/lib/customerAuth';
+import { MagicLinkForm } from '@/components/customer/MagicLinkForm';
+
+export const dynamic = 'force-dynamic';
+
+export default async function MinSidaPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+
+ // If already signed in, jump straight to overview.
+ const email = await getCustomerEmail();
+ if (email) {
+ redirect(locale === 'en' ? '/en/min-sida/oversikt' : '/min-sida/oversikt');
+ }
+
+ const t = await getTranslations('customer.request');
+ return (
+
+
{t('title')}
+
{t('intro')}
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/min-sida/verifiera/page.tsx b/src/app/[locale]/min-sida/verifiera/page.tsx
new file mode 100644
index 0000000..c8e393a
--- /dev/null
+++ b/src/app/[locale]/min-sida/verifiera/page.tsx
@@ -0,0 +1,26 @@
+import { setRequestLocale, getTranslations } from 'next-intl/server';
+import { Link } from '@/i18n/routing';
+
+export const dynamic = 'force-dynamic';
+
+export default async function VerifyErrorPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const t = await getTranslations('customer.verify');
+
+ return (
+
+
{t('invalidTitle')}
+
{t('invalidBody')}
+
+
+ {t('tryAgain')}
+
+
+
+ );
+}
diff --git a/src/app/api/admin/export/route.ts b/src/app/api/admin/export/route.ts
index 5746d6b..a9bc2fe 100644
--- a/src/app/api/admin/export/route.ts
+++ b/src/app/api/admin/export/route.ts
@@ -55,12 +55,20 @@ export async function GET(req: Request) {
: {}),
},
orderBy: { createdAt: 'asc' },
- include: { items: true, pickupSlot: true },
+ include: {
+ items: true,
+ pickupSlot: true,
+ replacements: {
+ orderBy: { createdAt: 'asc' },
+ include: { pickupSlot: true },
+ },
+ },
});
const lines: string[] = [];
lines.push(
row([
+ 'LineType',
'BookingNumber',
'BookingDate',
'Status',
@@ -92,6 +100,8 @@ export async function GET(req: Request) {
]),
);
+ const dec = (n: number) => n.toFixed(2).replace('.', ',');
+
for (const b of bookings) {
for (const it of b.items) {
// Billable = handed out − returned. That's what the customer pays for.
@@ -106,9 +116,9 @@ export async function GET(req: Request) {
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([
+ 'Booking',
b.bookingNumber,
b.createdAt.toISOString(),
b.status,
@@ -140,6 +150,55 @@ export async function GET(req: Request) {
]),
);
}
+
+ // Replacement rows immediately after their parent booking. CANCELLED
+ // swaps are excluded — they weren't charged. REQUESTED/SCHEDULED appear
+ // so admin can see pipeline; DELIVERED is what's actually billable.
+ for (const r of b.replacements) {
+ if (r.status === 'CANCELLED') continue;
+ const isBillable = r.status === 'DELIVERED';
+ const billableQty = isBillable ? r.quantity : 0;
+ const unitSek = r.unitPriceOre / 100;
+ const billableNet = unitSek * billableQty;
+ const vatPct = r.vatBp / 100;
+ const billableVat = (billableNet * r.vatBp) / 10000;
+ const billableTotal = billableNet + billableVat;
+ const orderedNet = unitSek * r.quantity;
+ const orderedTotal = r.lineTotalOre / 100;
+ lines.push(
+ row([
+ 'Replacement',
+ b.bookingNumber,
+ r.createdAt.toISOString(),
+ r.status,
+ b.orgName,
+ b.orgNumber,
+ b.contactName,
+ b.email,
+ b.phone,
+ b.address,
+ b.postalCode,
+ b.city,
+ b.country,
+ r.sku,
+ r.nameSv,
+ r.quantity,
+ isBillable ? r.quantity : 0,
+ 0,
+ billableQty,
+ dec(unitSek),
+ dec(billableNet),
+ dec(vatPct),
+ dec(billableVat),
+ dec(billableTotal),
+ dec(orderedNet),
+ dec(orderedTotal),
+ r.pickupSlot?.labelSv ?? '',
+ r.pickupSlot?.startsAt.toISOString() ?? '',
+ r.notes ?? '',
+ ]),
+ );
+ }
}
const body = '' + lines.join(EOL) + EOL;
diff --git a/src/app/api/customer/magic-link/route.ts b/src/app/api/customer/magic-link/route.ts
new file mode 100644
index 0000000..0a7f19c
--- /dev/null
+++ b/src/app/api/customer/magic-link/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { magicLinkRequestSchema } from '@/lib/validation';
+import {
+ buildMagicLinkUrl,
+ issueMagicLink,
+ normalizeEmail,
+} from '@/lib/customerAuth';
+import { sendMagicLinkEmail } from '@/lib/mailjet';
+
+// Always answer the same way — don't leak whether the email exists.
+const OK = NextResponse.json({ ok: true });
+
+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 = magicLinkRequestSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'validation', issues: parsed.error.flatten() },
+ { status: 400 },
+ );
+ }
+
+ const email = normalizeEmail(parsed.data.email);
+ const locale = parsed.data.locale;
+
+ const hasBooking = await prisma.booking.findFirst({
+ where: { email },
+ select: { id: true },
+ });
+ if (!hasBooking) {
+ return OK;
+ }
+
+ const token = await issueMagicLink(email);
+ const link = buildMagicLinkUrl(token, locale);
+
+ const mail = await sendMagicLinkEmail(email, link, locale);
+ if (!mail.ok) {
+ console.warn('[customer/magic-link] email not sent:', mail.error);
+ }
+
+ return OK;
+}
diff --git a/src/app/api/customer/replacement/route.ts b/src/app/api/customer/replacement/route.ts
new file mode 100644
index 0000000..bd7132c
--- /dev/null
+++ b/src/app/api/customer/replacement/route.ts
@@ -0,0 +1,90 @@
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { replacementSubmitSchema } from '@/lib/validation';
+import { getCustomerEmail } from '@/lib/customerAuth';
+import { vatAmountOre } from '@/lib/money';
+import { sendReplacementConfirmation } from '@/lib/mailjet';
+
+export async function POST(req: Request) {
+ const email = await getCustomerEmail();
+ if (!email) {
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
+ }
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: 'invalid-json' }, { status: 400 });
+ }
+
+ const parsed = replacementSubmitSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'validation', issues: parsed.error.flatten() },
+ { status: 400 },
+ );
+ }
+ const input = parsed.data;
+
+ const booking = await prisma.booking.findUnique({
+ where: { id: input.bookingId },
+ include: { items: true },
+ });
+ if (!booking || booking.email !== email) {
+ return NextResponse.json({ error: 'booking-not-found' }, { status: 404 });
+ }
+
+ const item = booking.items.find((it) => it.id === input.bookingItemId);
+ if (!item) {
+ return NextResponse.json({ error: 'item-not-found' }, { status: 400 });
+ }
+
+ // Snapshot current product price — a swap is its own sale at full price.
+ const product = await prisma.product.findUnique({
+ where: { id: item.productId },
+ });
+ if (!product) {
+ return NextResponse.json({ error: 'product-not-found' }, { status: 400 });
+ }
+
+ if (input.pickupSlotId) {
+ const slot = await prisma.pickupSlot.findUnique({
+ where: { id: input.pickupSlotId },
+ });
+ if (!slot || !slot.active) {
+ return NextResponse.json(
+ { error: 'pickup-slot-invalid' },
+ { status: 400 },
+ );
+ }
+ }
+
+ const lineNet = product.priceOre * input.quantity;
+ const lineVat = vatAmountOre(lineNet, product.vatBp);
+
+ const rr = await prisma.replacementRequest.create({
+ data: {
+ bookingId: booking.id,
+ bookingItemId: item.id,
+ quantity: input.quantity,
+ sku: product.sku,
+ nameSv: product.nameSv,
+ nameEn: product.nameEn,
+ unitPriceOre: product.priceOre,
+ vatBp: product.vatBp,
+ lineTotalOre: lineNet + lineVat,
+ pickupSlotId: input.pickupSlotId ?? null,
+ notes: input.notes ?? null,
+ status: 'REQUESTED',
+ },
+ include: { booking: true, bookingItem: true, pickupSlot: true },
+ });
+
+ const mail = await sendReplacementConfirmation(rr);
+ if (!mail.ok) {
+ console.warn('[customer/replacement] confirmation email not sent:', mail.error);
+ }
+
+ return NextResponse.json({ id: rr.id, emailSent: mail.ok });
+}
diff --git a/src/app/api/customer/verify/route.ts b/src/app/api/customer/verify/route.ts
new file mode 100644
index 0000000..4132afd
--- /dev/null
+++ b/src/app/api/customer/verify/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from 'next/server';
+import { consumeMagicLink } from '@/lib/customerAuth';
+
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const token = url.searchParams.get('token') ?? '';
+ const locale = url.searchParams.get('locale') === 'en' ? 'en' : 'sv';
+ const prefix = locale === 'en' ? '/en' : '';
+
+ const email = await consumeMagicLink(token);
+ const target = email
+ ? `${prefix}/min-sida/oversikt`
+ : `${prefix}/min-sida/verifiera`;
+ return NextResponse.redirect(new URL(target, url.origin));
+}
diff --git a/src/components/customer/CustomerSignOutForm.tsx b/src/components/customer/CustomerSignOutForm.tsx
new file mode 100644
index 0000000..6fd9a93
--- /dev/null
+++ b/src/components/customer/CustomerSignOutForm.tsx
@@ -0,0 +1,21 @@
+import { getTranslations } from 'next-intl/server';
+import { redirect } from 'next/navigation';
+import { destroyCustomerSession } from '@/lib/customerAuth';
+
+export async function CustomerSignOutForm({ locale }: { locale: 'sv' | 'en' }) {
+ const t = await getTranslations('customer');
+ const target = locale === 'en' ? '/en/min-sida' : '/min-sida';
+ return (
+
+ );
+}
diff --git a/src/components/customer/MagicLinkForm.tsx b/src/components/customer/MagicLinkForm.tsx
new file mode 100644
index 0000000..2831736
--- /dev/null
+++ b/src/components/customer/MagicLinkForm.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useState } from 'react';
+import { useTranslations } from 'next-intl';
+
+export function MagicLinkForm({ locale }: { locale: 'sv' | 'en' }) {
+ const t = useTranslations('customer.request');
+ const [email, setEmail] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!email.trim()) return;
+ setSubmitting(true);
+ try {
+ await fetch('/api/customer/magic-link', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: email.trim(), locale }),
+ });
+ } finally {
+ setSubmitting(false);
+ setSubmitted(true);
+ }
+ }
+
+ if (submitted) {
+ return (
+
+
{t('successTitle')}
+
+ {t('successBody', { email })}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/customer/ReplacementForm.tsx b/src/components/customer/ReplacementForm.tsx
new file mode 100644
index 0000000..88bb6dd
--- /dev/null
+++ b/src/components/customer/ReplacementForm.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { useRouter } from '@/i18n/routing';
+import { formatOre, priceInclVatOre } from '@/lib/money';
+
+type Item = {
+ id: string;
+ name: string;
+ ordered: number;
+ unitPriceOre: number;
+ vatBp: number;
+};
+type Slot = {
+ id: string;
+ labelSv: string;
+ labelEn: string;
+ startsAt: string;
+ capacityLeft: number;
+};
+
+export function ReplacementForm({
+ locale,
+ bookingId,
+ items,
+ pickupSlots,
+}: {
+ locale: 'sv' | 'en';
+ bookingId: string;
+ items: Item[];
+ pickupSlots: Slot[];
+}) {
+ const t = useTranslations('customer.replacement');
+ const c = useTranslations('common');
+ const router = useRouter();
+
+ const [bookingItemId, setBookingItemId] = useState(items[0]?.id ?? '');
+ const [quantity, setQuantity] = useState(1);
+
+ const selectedItem = items.find((i) => i.id === bookingItemId);
+ const estTotalOre = useMemo(() => {
+ if (!selectedItem) return 0;
+ return priceInclVatOre(
+ selectedItem.unitPriceOre * Math.max(1, quantity),
+ selectedItem.vatBp,
+ );
+ }, [selectedItem, quantity]);
+ const [pickupSlotId, setPickupSlotId] = useState('');
+ const [notes, setNotes] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!bookingItemId || quantity < 1) return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/customer/replacement', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ bookingId,
+ bookingItemId,
+ quantity,
+ pickupSlotId: pickupSlotId || null,
+ notes: notes.trim() || null,
+ }),
+ });
+ if (!res.ok) {
+ setError(t('submitFailed'));
+ return;
+ }
+ setSuccess(true);
+ router.refresh();
+ } catch {
+ setError(t('submitFailed'));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ if (success) {
+ return (
+
+
{t('successTitle')}
+
{t('successBody')}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/customerAuth.ts b/src/lib/customerAuth.ts
new file mode 100644
index 0000000..e1e8c90
--- /dev/null
+++ b/src/lib/customerAuth.ts
@@ -0,0 +1,98 @@
+import crypto from 'crypto';
+import { cookies } from 'next/headers';
+import { prisma } from './prisma';
+
+const MAGIC_LINK_TTL_MS = 60 * 60 * 1000; // 1 hour
+const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
+const COOKIE_NAME = 'gasol_customer_session';
+
+function hash(token: string): string {
+ return crypto.createHash('sha256').update(token).digest('hex');
+}
+
+function genToken(): string {
+ return crypto.randomBytes(32).toString('base64url');
+}
+
+export function normalizeEmail(raw: string): string {
+ return raw.trim().toLowerCase();
+}
+
+export async function issueMagicLink(emailRaw: string): Promise {
+ const email = normalizeEmail(emailRaw);
+ const token = genToken();
+ await prisma.customerMagicLink.create({
+ data: {
+ tokenHash: hash(token),
+ email,
+ expiresAt: new Date(Date.now() + MAGIC_LINK_TTL_MS),
+ },
+ });
+ return token;
+}
+
+export async function consumeMagicLink(
+ rawToken: string,
+): Promise {
+ if (!rawToken) return null;
+ const link = await prisma.customerMagicLink.findUnique({
+ where: { tokenHash: hash(rawToken) },
+ });
+ if (!link || link.usedAt || link.expiresAt < new Date()) return null;
+
+ await prisma.customerMagicLink.update({
+ where: { id: link.id },
+ data: { usedAt: new Date() },
+ });
+
+ const sessionToken = genToken();
+ const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
+ await prisma.customerSession.create({
+ data: { tokenHash: hash(sessionToken), email: link.email, expiresAt },
+ });
+
+ const jar = await cookies();
+ jar.set(COOKIE_NAME, sessionToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ expires: expiresAt,
+ });
+
+ return link.email;
+}
+
+export async function getCustomerEmail(): Promise {
+ const jar = await cookies();
+ const raw = jar.get(COOKIE_NAME)?.value;
+ if (!raw) return null;
+ const session = await prisma.customerSession.findUnique({
+ where: { tokenHash: hash(raw) },
+ });
+ if (!session) return null;
+ if (session.expiresAt < new Date()) {
+ await prisma.customerSession
+ .delete({ where: { id: session.id } })
+ .catch(() => {});
+ return null;
+ }
+ return session.email;
+}
+
+export async function destroyCustomerSession(): Promise {
+ const jar = await cookies();
+ const raw = jar.get(COOKIE_NAME)?.value;
+ if (raw) {
+ await prisma.customerSession.deleteMany({ where: { tokenHash: hash(raw) } });
+ }
+ jar.delete(COOKIE_NAME);
+}
+
+export function buildMagicLinkUrl(token: string, locale: 'sv' | 'en'): string {
+ const base =
+ process.env.NEXT_PUBLIC_SITE_URL ??
+ process.env.AUTH_URL ??
+ 'http://localhost:3000';
+ return `${base.replace(/\/$/, '')}/api/customer/verify?token=${encodeURIComponent(token)}&locale=${locale}`;
+}
diff --git a/src/lib/mailjet.ts b/src/lib/mailjet.ts
index 85a6263..46396bc 100644
--- a/src/lib/mailjet.ts
+++ b/src/lib/mailjet.ts
@@ -1,6 +1,11 @@
import Mailjet from 'node-mailjet';
import { formatOre } from './money';
-import type { Booking, BookingItem, PickupSlot } from '@prisma/client';
+import type {
+ Booking,
+ BookingItem,
+ PickupSlot,
+ ReplacementRequest,
+} from '@prisma/client';
let client: Mailjet | null = null;
@@ -251,3 +256,350 @@ function escapeHtml(s: string): string {
.replace(/"/g, '"')
.replace(/'/g, ''');
}
+
+type MagicLinkStrings = {
+ subject: string;
+ greeting: string;
+ intro: string;
+ cta: string;
+ expiry: string;
+ fallback: string;
+ ignore: string;
+ footer: string;
+};
+
+function magicLinkStrings(locale: 'sv' | 'en', event: string): MagicLinkStrings {
+ if (locale === 'sv') {
+ return {
+ subject: `Inloggningslänk — ${event}`,
+ greeting: 'Hej,',
+ intro: 'Klicka på knappen nedan för att se dina bokningar och beställa byte av tomma gasolflaskor.',
+ cta: 'Öppna min sida',
+ expiry: 'Länken gäller i 1 timme och kan användas en gång.',
+ fallback: 'Fungerar inte knappen? Klistra in denna länk i webbläsaren:',
+ ignore: 'Om du inte begärt detta mejl kan du ignorera det.',
+ footer: 'Detta är ett automatiskt mejl från Gasol247.',
+ };
+ }
+ return {
+ subject: `Sign-in link — ${event}`,
+ greeting: 'Hi,',
+ intro: 'Click the button below to view your bookings and request empty cylinder swaps.',
+ cta: 'Open my page',
+ expiry: 'This link is valid for 1 hour and can be used once.',
+ fallback: 'Button not working? Paste this link into your browser:',
+ ignore: "If you didn't request this email you can ignore it.",
+ footer: 'This is an automated email from Gasol247.',
+ };
+}
+
+export function renderMagicLinkEmail(
+ link: string,
+ locale: 'sv' | 'en' = 'sv',
+): { subject: string; html: string; text: string } {
+ const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
+ const s = magicLinkStrings(locale, event);
+
+ const html = `
+
+
+
+
+ ${escapeHtml(s.subject)}
+
+
+
+
+
+
+
+
+ ${escapeHtml(event)}
+ ${escapeHtml(s.subject)}
+
+
+
+
+ ${escapeHtml(s.greeting)}
+ ${escapeHtml(s.intro)}
+
+ ${escapeHtml(s.cta)}
+
+ ${escapeHtml(s.expiry)}
+ ${escapeHtml(s.fallback)}
+ ${escapeHtml(link)}
+ ${escapeHtml(s.ignore)}
+
+
+
+
+ ${escapeHtml(s.footer)}
+
+
+
+
+
+
+
+`;
+
+ const text = [
+ s.greeting,
+ '',
+ s.intro,
+ '',
+ link,
+ '',
+ s.expiry,
+ '',
+ s.ignore,
+ '',
+ '— ' + s.footer,
+ ].join('\n');
+
+ return { subject: s.subject, html, text };
+}
+
+export async function sendMagicLinkEmail(
+ email: string,
+ link: string,
+ locale: 'sv' | 'en' = 'sv',
+): Promise<{ ok: boolean; error?: string }> {
+ const c = getClient();
+ if (!c) {
+ // Dev fallback: print the link so the flow is testable without Mailjet.
+ console.warn(
+ `\n[magic-link] Mailjet not configured. Login link for ${email}:\n ${link}\n`,
+ );
+ return { ok: false, error: 'mailjet-not-configured' };
+ }
+ const { subject, html, text } = renderMagicLinkEmail(link, 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: email }],
+ Subject: subject,
+ HTMLPart: html,
+ TextPart: text,
+ },
+ ],
+ });
+ return { ok: true };
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error('[mailjet] magic-link send failed', msg);
+ return { ok: false, error: msg };
+ }
+}
+
+type ReplacementWithRels = ReplacementRequest & {
+ booking: Booking;
+ bookingItem: BookingItem;
+ pickupSlot: PickupSlot | null;
+};
+
+type ReplacementStrings = {
+ subject: string;
+ greeting: string;
+ intro: string;
+ what: string;
+ total: string;
+ inclVat: string;
+ invoiceInfo: string;
+ bookingNumber: string;
+ pickup: string;
+ noPickup: string;
+ status: string;
+ nextSteps: string;
+ footer: string;
+};
+
+function replacementStrings(
+ locale: 'sv' | 'en',
+ event: string,
+): ReplacementStrings {
+ if (locale === 'sv') {
+ return {
+ subject: `Bytesärende mottaget — ${event}`,
+ greeting: 'Hej {name},',
+ intro: 'Vi har tagit emot din begäran om byte av tomma gasolflaskor.',
+ what: 'Begärt byte',
+ total: 'Belopp',
+ inclVat: 'inkl. moms',
+ invoiceInfo:
+ 'Bytet faktureras tillsammans med er övriga bokning efter eventet.',
+ bookingNumber: 'Bokningsnummer',
+ pickup: 'Önskad upphämtningstid',
+ noPickup: 'Ingen tid vald — vi återkommer med förslag.',
+ status: 'Status',
+ nextSteps: 'Vi schemalägger bytet och återkommer med bekräftelse.',
+ footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
+ };
+ }
+ return {
+ subject: `Replacement request received — ${event}`,
+ greeting: 'Hi {name},',
+ intro: 'We have received your request to swap empty gas cylinders.',
+ what: 'Requested swap',
+ total: 'Amount',
+ inclVat: 'incl. VAT',
+ invoiceInfo:
+ 'The swap will be invoiced together with your other booking after the event.',
+ bookingNumber: 'Booking number',
+ pickup: 'Preferred pickup time',
+ noPickup: 'No time selected — we will get back to you.',
+ status: 'Status',
+ nextSteps: 'We will schedule the swap and get back to you.',
+ footer: 'This is an automated confirmation from Gasol247.',
+ };
+}
+
+export function renderReplacementEmail(
+ rr: ReplacementWithRels,
+ locale: 'sv' | 'en' = 'sv',
+): { subject: string; html: string; text: string } {
+ const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
+ const s = replacementStrings(locale, event);
+ const fmt = (ore: number) => formatOre(ore, locale);
+ const greeting = s.greeting.replace('{name}', rr.booking.contactName);
+ const itemName = locale === 'sv' ? rr.nameSv : rr.nameEn;
+ const slotLabel = rr.pickupSlot
+ ? locale === 'sv'
+ ? rr.pickupSlot.labelSv
+ : rr.pickupSlot.labelEn
+ : null;
+ const slotTime = rr.pickupSlot
+ ? rr.pickupSlot.startsAt.toLocaleString(
+ locale === 'sv' ? 'sv-SE' : 'en-SE',
+ { dateStyle: 'medium', timeStyle: 'short' },
+ )
+ : null;
+
+ const html = `
+
+
+
+
+ ${escapeHtml(s.subject)}
+
+
+
+
+
+
+
+ ${escapeHtml(s.bookingNumber)}
+ ${escapeHtml(rr.booking.bookingNumber)}
+ ${escapeHtml(event)}
+
+
+
+
+ ${escapeHtml(greeting)}
+ ${escapeHtml(s.intro)}
+
+ ${escapeHtml(s.what)}
+
+
+
+ ${escapeHtml(itemName)} × ${rr.quantity}
+
+
+ ${escapeHtml(fmt(rr.lineTotalOre))}
+
+
+
+ ${escapeHtml(s.total)}
+
+ ${escapeHtml(fmt(rr.lineTotalOre))}
+ ${escapeHtml(s.inclVat)}
+
+
+
+
+ ${escapeHtml(s.pickup)}
+ ${
+ slotLabel && slotTime
+ ? `${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}`
+ : `${escapeHtml(s.noPickup)} `
+ }
+
+ ${
+ rr.notes
+ ? `Notes ${escapeHtml(rr.notes)}
`
+ : ''
+ }
+
+
+ ${escapeHtml(s.nextSteps)}
+ ${escapeHtml(s.invoiceInfo)}
+
+
+
+
+
+ ${escapeHtml(s.footer)}
+
+
+
+
+
+
+`;
+
+ const text = [
+ greeting,
+ '',
+ s.intro,
+ '',
+ `${s.bookingNumber}: ${rr.booking.bookingNumber}`,
+ `${s.what}: ${itemName} × ${rr.quantity}`,
+ `${s.total}: ${fmt(rr.lineTotalOre)} (${s.inclVat})`,
+ `${s.pickup}: ${slotLabel ? `${slotLabel} – ${slotTime}` : s.noPickup}`,
+ ...(rr.notes ? ['', `Notes: ${rr.notes}`] : []),
+ '',
+ s.nextSteps,
+ s.invoiceInfo,
+ '',
+ '— ' + s.footer,
+ ].join('\n');
+
+ return { subject: s.subject, html, text };
+}
+
+export async function sendReplacementConfirmation(
+ rr: ReplacementWithRels,
+): Promise<{ ok: boolean; error?: string }> {
+ const c = getClient();
+ if (!c) {
+ console.warn('[mailjet] not configured — skipping replacement send');
+ return { ok: false, error: 'mailjet-not-configured' };
+ }
+ const locale = (rr.booking.locale as 'sv' | 'en') ?? 'sv';
+ const { subject, html, text } = renderReplacementEmail(rr, 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: rr.booking.email, Name: rr.booking.contactName }],
+ Subject: subject,
+ HTMLPart: html,
+ TextPart: text,
+ CustomID: `replacement-${rr.id}`,
+ },
+ ],
+ });
+ return { ok: true };
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error('[mailjet] replacement send failed', msg);
+ return { ok: false, error: msg };
+ }
+}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index e44e2d0..a7b8908 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -31,3 +31,20 @@ export const bookingSubmitSchema = z.object({
});
export type BookingSubmitInput = z.infer;
+
+export const magicLinkRequestSchema = z.object({
+ email: z.string().trim().email().max(200),
+ locale: z.enum(['sv', 'en']).default('sv'),
+});
+
+export type MagicLinkRequestInput = z.infer;
+
+export const replacementSubmitSchema = z.object({
+ bookingId: z.string().min(1),
+ bookingItemId: z.string().min(1),
+ quantity: z.number().int().min(1).max(999),
+ pickupSlotId: z.string().min(1).nullable().optional(),
+ notes: z.string().trim().max(2000).optional().nullable(),
+});
+
+export type ReplacementSubmitInput = z.infer;