feat: replacement

This commit is contained in:
Ola Malmgren
2026-05-22 20:33:21 +02:00
parent 4d705a1005
commit f19e2d4e0d
26 changed files with 1951 additions and 20 deletions

View File

@@ -2,9 +2,14 @@
Prioriterade förbättringar och kända luckor. Top-down ungefär i prioritetsordning.
## Bäställningsdisclaimer
- det är bäställarens ansvar att veta hur mycket dom får lov i sin by.
60 liter per 40 deltagare. 2012 (4,8liter) PC5(12,2 liter) PC10 (23,8 liter)
## P0 — innan skarp lansering
- [ ] **Rate-limit på `POST /api/bookings`**: enkel IP-baserad throttle (t.ex. 5 bokningar/timme/IP) för att stoppa spam/bots. Implementera i `src/app/api/bookings/route.ts` eller via en middleware-wrapper.
- [ ] **Rate-limit på `POST /api/bookings` och `POST /api/customer/magic-link`**: enkel IP-baserad throttle (t.ex. 5 bokningar/timme/IP) för att stoppa spam/bots. Magic-link-endpointen är extra känslig — utan throttling kan någon trigga obegränsat med mejl. Implementera i respektive route eller via en middleware-wrapper.
- [ ] **Honeypot eller hCaptcha på formuläret**: bokningsformuläret är publikt utan skydd. Honeypot är minimalistiskt och räcker ofta.
- [ ] **Generera riktigt `AUTH_SECRET`**: dokumenterat i README men måste göras manuellt vid deploy.
- [ ] **Verifiera Mailjet-avsändare**: `MAIL_FROM_EMAIL` måste vara verifierad i Mailjet-kontot innan mejl kommer fram.
@@ -20,6 +25,8 @@ Prioriterade förbättringar och kända luckor. Top-down ungefär i prioritetsor
- [ ] **Lagerbegränsning per produkt**: idag finns kapacitet bara på upphämtnings-slots. Lägg `totalStock``Product` och kontrollera i POST-routen att `Σ confirmed quantity ≤ stock`.
- [ ] **Mailjet Templates (Template-ID) istället för inline HTML**: gör det möjligt att ändra mejldesign utan redeploy. Inline HTML kvar som fallback om template-id saknas.
- [ ] **Resend-bekräftelse loggar**: idag skickar admin-knappen "Resend confirmation" tyst — visa toast/success vid lyckad sändning.
- [ ] **Statusmejl vid byte SCHEDULED/DELIVERED**: kund får idag bara bekräftelse på att begäran är mottagen — borde få mejl när admin schemalägger eller markerar levererat.
- [ ] **Cleanup-jobb för expired customer-tokens**: `CustomerMagicLink` och `CustomerSession` rensas inte automatiskt. Lägg till en cron eller städa i `getCustomerEmail`/`consumeMagicLink`.
- [ ] **Bookings-vy: paginering**: nuvarande tar `take: 200`. Lägg cursor-paginering vid skarp volym.
- [ ] **PDF-faktureringsunderlag per bokning**: utöver CSV — en PDF som matchar fakturamall, lättare att bifoga.
- [ ] **Audit log för admin-actions**: tabell `AuditEvent` med vem som ändrade status, körde resend, etc.
@@ -49,6 +56,8 @@ Prioriterade förbättringar och kända luckor. Top-down ungefär i prioritetsor
## Beslutslogg
- **2026-05-22**: `ReplacementRequest` snapshottar pris från `Product` (samma som ny flaska) vid request-tid — fält `sku/nameSv/nameEn/unitPriceOre/vatBp/lineTotalOre`. CSV-export utökad med `LineType=Booking|Replacement`-kolumn; CANCELLED-byten exkluderas, `BillableQuantity` = quantity om `status=DELIVERED` annars 0. En faktura per org = original-rader + levererade byten.
- **2026-05-22**: Kund-portal (`/min-sida`) + flaskbyte tillagt som del av MVP. Magic-link på begäran (kund matar in mejl, får länk via mejl). Token-scope = alla bokningar för mejlen. `ReplacementRequest`-modell kopplad till `BookingItem` + valbar `PickupSlot`. Inga antalsgränser — admin avgör per ärende.
- **2026-05-22**: Valt SQLite + Prisma 5 (inte 7) för MVP. SQLite-fil i Docker-volym räcker för ett event. Prisma 5 fungerar utan `prisma.config.ts`.
- **2026-05-22**: `prisma db push` istället för `migrate deploy` i entrypoint — färre artefakter att hålla reda på i MVP. Byt vid stabilt schema.
- **2026-05-22**: Priser i öre (`Int`) snarare än `Decimal` — enklare och säkrare för en valuta (SEK).

View File

@@ -239,6 +239,7 @@
},
"export": "Export CSV",
"view": "View",
"openReplacementsTitle": "{count} open swap requests",
"detail": {
"title": "Booking {number}",
"items": "Products",
@@ -286,5 +287,91 @@
"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."
},
"customer": {
"navTitle": "My page",
"signOut": "Sign out",
"request": {
"title": "My page",
"intro": "Enter the email you used when booking and we will send you a sign-in link.",
"email": "Email",
"emailPlaceholder": "example@group.org",
"submit": "Send sign-in link",
"submitting": "Sending…",
"successTitle": "Check your email",
"successBody": "If the email matches a booking we have sent a sign-in link to {email}. The link is valid for 1 hour."
},
"verify": {
"invalidTitle": "Link is not valid",
"invalidBody": "The link may have expired, already been used, or is incorrect. Request a new sign-in link.",
"tryAgain": "Request new link"
},
"overview": {
"title": "My bookings",
"signedInAs": "Signed in as {email}",
"empty": "No bookings found for this email.",
"view": "View booking",
"requestReplacement": "Request cylinder swap",
"bookingNumber": "Booking #",
"items": "Items",
"pickup": "Pickup"
},
"replacement": {
"title": "Request cylinder swap",
"intro": "Choose product, quantity and preferred pickup time for the swap of empty cylinders.",
"selectItem": "Product",
"quantity": "Number of cylinders",
"pickupSlot": "Preferred pickup time",
"noPickup": "No time selected — admin will suggest a time",
"notes": "Additional notes",
"notesPlaceholder": "Any preferences or info",
"submit": "Send request",
"submitting": "Sending…",
"submitFailed": "Something went wrong. Try again.",
"successTitle": "Swap request received",
"successBody": "We have received your request and will get back to you with a confirmation by email.",
"back": "Back to my bookings",
"existing": "Previous swap requests",
"noExisting": "No previous swap requests for this booking.",
"status": {
"REQUESTED": "Received",
"SCHEDULED": "Scheduled",
"DELIVERED": "Swapped",
"CANCELLED": "Cancelled"
}
}
},
"adminReplacements": {
"navTitle": "Swap requests",
"title": "Swap requests",
"empty": "No swap requests yet.",
"filters": {
"all": "All"
},
"columns": {
"created": "Received",
"booking": "Booking",
"org": "Organization",
"item": "Item",
"quantity": "Qty",
"total": "Amount",
"pickup": "Preferred time",
"status": "Status"
},
"actions": {
"markScheduled": "Mark scheduled",
"markDelivered": "Mark swapped",
"markCancelled": "Cancel"
},
"status": {
"REQUESTED": "Received",
"SCHEDULED": "Scheduled",
"DELIVERED": "Swapped",
"CANCELLED": "Cancelled"
},
"bookingSection": {
"title": "Swap requests",
"empty": "No swap requests for this booking."
}
}
}

View File

@@ -239,6 +239,7 @@
},
"export": "Exportera CSV",
"view": "Visa",
"openReplacementsTitle": "{count} öppna bytesärenden",
"detail": {
"title": "Bokning {number}",
"items": "Produkter",
@@ -286,5 +287,91 @@
"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."
},
"customer": {
"navTitle": "Min sida",
"signOut": "Logga ut",
"request": {
"title": "Min sida",
"intro": "Ange den e-postadress du använde när du beställde, så skickar vi en inloggningslänk.",
"email": "E-post",
"emailPlaceholder": "exempel@kar.se",
"submit": "Skicka inloggningslänk",
"submitting": "Skickar…",
"successTitle": "Kolla din mejl",
"successBody": "Om mejlen finns på en bokning har vi skickat en inloggningslänk till {email}. Länken gäller i 1 timme."
},
"verify": {
"invalidTitle": "Länken är inte giltig",
"invalidBody": "Länken kan ha gått ut, redan använts eller är felaktig. Begär en ny inloggningslänk.",
"tryAgain": "Begär ny länk"
},
"overview": {
"title": "Mina bokningar",
"signedInAs": "Inloggad som {email}",
"empty": "Vi hittade inga bokningar kopplade till denna e-post.",
"view": "Visa bokning",
"requestReplacement": "Beställ flaskbyte",
"bookingNumber": "Boknings-nr",
"items": "Produkter",
"pickup": "Upphämtning"
},
"replacement": {
"title": "Beställ flaskbyte",
"intro": "Välj produkt, antal och önskad upphämtningstid för bytet av tomma flaskor.",
"selectItem": "Produkt",
"quantity": "Antal flaskor",
"pickupSlot": "Önskad upphämtningstid",
"noPickup": "Ingen tid vald — administratören föreslår tid",
"notes": "Övriga meddelanden",
"notesPlaceholder": "Ev. önskemål eller annan info",
"submit": "Skicka begäran",
"submitting": "Skickar…",
"submitFailed": "Något gick fel. Försök igen.",
"successTitle": "Bytesärende mottaget",
"successBody": "Vi har tagit emot din begäran och återkommer med bekräftelse via mejl.",
"back": "Tillbaka till mina bokningar",
"existing": "Tidigare bytesärenden",
"noExisting": "Inga tidigare bytesärenden för denna bokning.",
"status": {
"REQUESTED": "Mottaget",
"SCHEDULED": "Schemalagt",
"DELIVERED": "Bytt",
"CANCELLED": "Avbokat"
}
}
},
"adminReplacements": {
"navTitle": "Bytesärenden",
"title": "Bytesärenden",
"empty": "Inga bytesärenden ännu.",
"filters": {
"all": "Alla"
},
"columns": {
"created": "Mottaget",
"booking": "Bokning",
"org": "Organisation",
"item": "Produkt",
"quantity": "Antal",
"total": "Belopp",
"pickup": "Önskad tid",
"status": "Status"
},
"actions": {
"markScheduled": "Markera schemalagt",
"markDelivered": "Markera bytt",
"markCancelled": "Avboka"
},
"status": {
"REQUESTED": "Mottaget",
"SCHEDULED": "Schemalagt",
"DELIVERED": "Bytt",
"CANCELLED": "Avbokat"
},
"bookingSection": {
"title": "Bytesärenden",
"empty": "Inga bytesärenden för denna bokning."
}
}
}

View File

@@ -2,6 +2,10 @@
"name": "boka-gasol247",
"version": "0.1.0",
"private": true,
"author": {
"name": "Ola Malmgren",
"email": "malmgrenola@gmail.com"
},
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",

Binary file not shown.

View File

@@ -46,17 +46,18 @@ model Product {
}
model PickupSlot {
id String @id @default(cuid())
id String @id @default(cuid())
// Human label fallback (e.g. "Måndag förmiddag").
labelSv String
labelEn String
startsAt DateTime
endsAt DateTime
capacity Int @default(50)
active Boolean @default(true)
bookings Booking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
labelSv String
labelEn String
startsAt DateTime
endsAt DateTime
capacity Int @default(50)
active Boolean @default(true)
bookings Booking[]
replacements ReplacementRequest[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// BookingStatus values (string, since SQLite has no enums):
@@ -91,6 +92,8 @@ model Booking {
pickupSlotId String?
pickupSlot PickupSlot? @relation(fields: [pickupSlotId], references: [id])
replacements ReplacementRequest[]
notes String?
// Snapshots in öre at booking time
@@ -131,5 +134,72 @@ model BookingItem {
deliveredQuantity Int @default(0)
returnedQuantity Int @default(0)
replacements ReplacementRequest[]
@@index([bookingId])
}
// Customer-side magic-link tokens. Raw token lives only in the email;
// DB stores SHA-256 hash. Single-use + 1h expiry.
model CustomerMagicLink {
id String @id @default(cuid())
tokenHash String @unique
email String
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@index([email])
@@index([expiresAt])
}
// Customer-side browser sessions, cookie value is hashed before storage.
// Email is the identity — magic link gives access to all bookings with this email.
model CustomerSession {
id String @id @default(cuid())
tokenHash String @unique
email String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([email])
@@index([expiresAt])
}
// ReplacementRequest values for status (string, SQLite has no enums):
// REQUESTED — customer just asked
// SCHEDULED — admin assigned a slot / confirmed
// DELIVERED — exchange done
// CANCELLED — terminal
model ReplacementRequest {
id String @id @default(cuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id])
bookingItemId String
bookingItem BookingItem @relation(fields: [bookingItemId], references: [id])
quantity Int
notes String?
// Price snapshot at request time — a swap is its own sale (full cylinder
// price). Snapshotted so future Product price changes don't rewrite history.
sku String
nameSv String
nameEn String
unitPriceOre Int
vatBp Int
lineTotalOre Int
// Pickup slot chosen by the customer (optional — admin can override)
pickupSlotId String?
pickupSlot PickupSlot? @relation(fields: [pickupSlotId], references: [id])
status String @default("REQUESTED")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([bookingId])
@@index([status])
@@index([createdAt])
}

View File

@@ -52,9 +52,17 @@ export default async function BookingDetailPage({
const booking = await prisma.booking.findUnique({
where: { id },
include: { items: true, pickupSlot: true },
include: {
items: true,
pickupSlot: true,
replacements: {
orderBy: { createdAt: 'desc' },
include: { bookingItem: true, pickupSlot: true },
},
},
});
if (!booking) notFound();
const tr = await getTranslations('adminReplacements');
return (
<div className="space-y-4">
@@ -155,6 +163,64 @@ export default async function BookingDetailPage({
}))}
/>
</div>
<div className="mt-6">
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
{tr('bookingSection.title')}
</h2>
{booking.replacements.length === 0 ? (
<p className="mt-2 text-sm text-ink-500">
{tr('bookingSection.empty')}
</p>
) : (
<ul className="mt-2 divide-y divide-ink-100">
{booking.replacements.map((r) => (
<li
key={r.id}
className="flex flex-wrap items-center justify-between gap-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="text-ink-900">
{loc === 'sv' ? r.nameSv : r.nameEn} × {r.quantity}
</div>
<div className="text-xs text-ink-500">
{r.createdAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
{r.pickupSlot && (
<>
{' · '}
{loc === 'sv'
? r.pickupSlot.labelSv
: r.pickupSlot.labelEn}
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="tabular-nums text-ink-700">
{formatOre(r.lineTotalOre, loc)}
</span>
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs text-ink-700">
{tr(
`status.${r.status as 'REQUESTED' | 'SCHEDULED' | 'DELIVERED' | 'CANCELLED'}`,
)}
</span>
</div>
</li>
))}
</ul>
)}
<div className="mt-3">
<Link
href="/admin/replacements"
className="text-sm text-brand-600 hover:underline"
>
{tr('title')}
</Link>
</div>
</div>
</div>
<div className="space-y-4">

View File

@@ -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')}
</Link>
<Link
href="/admin/replacements"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
{tr('navTitle')}
</Link>
<Link
href="/admin/settings"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"

View File

@@ -81,7 +81,16 @@ export default async function AdminBookingsPage({
orderBy: buildOrderBy(sort, dir),
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
include: { pickupSlot: true },
include: {
pickupSlot: true,
_count: {
select: {
replacements: {
where: { status: { in: ['REQUESTED', 'SCHEDULED'] } },
},
},
},
},
}),
]);
@@ -153,7 +162,33 @@ export default async function AdminBookingsPage({
{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}
<div className="flex items-center gap-2">
<span>{b.bookingNumber}</span>
{b._count.replacements > 0 && (
<Link
href={`/admin/bookings/${b.id}`}
title={t('openReplacementsTitle', {
count: b._count.replacements,
})}
className="inline-flex items-center gap-1 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-medium text-brand-700 hover:bg-brand-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-3 w-3"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M4.755 10.06a7.5 7.5 0 0 1 12.548-3.364l1.903 1.903h-3.183a.75.75 0 1 0 0 1.5h4.992a.75.75 0 0 0 .75-.75V4.356a.75.75 0 0 0-1.5 0v3.18l-1.9-1.9A9 9 0 0 0 3.302 9.873a.75.75 0 1 0 1.453.187Zm15.99 2.187a.75.75 0 0 0-.78.612 7.5 7.5 0 0 1-12.548 3.364l-1.902-1.903h3.183a.75.75 0 0 0 0-1.5H2.706a.75.75 0 0 0-.75.75v4.993a.75.75 0 0 0 1.5 0v-3.181l1.9 1.9a9 9 0 0 0 15.06-4.043.75.75 0 0 0-.612-.78l-.06-.012Z"
clipRule="evenodd"
/>
</svg>
{b._count.replacements}
</Link>
)}
</div>
</td>
<td className="px-4 py-2.5 text-ink-600">
{b.createdAt.toLocaleDateString(

View File

@@ -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 (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<div className="card p-3">
<nav className="flex flex-wrap gap-2 text-sm">
<Link
href="/admin/replacements"
className={`rounded-md px-3 py-1 ${!sp.status ? 'bg-ink-900 text-white' : 'bg-ink-100 text-ink-700 hover:bg-ink-200'}`}
>
{t('filters.all')}
</Link>
{STATUSES.map((s) => (
<Link
key={s}
href={`/admin/replacements?status=${s}`}
className={`rounded-md px-3 py-1 ${sp.status === s ? 'bg-ink-900 text-white' : 'bg-ink-100 text-ink-700 hover:bg-ink-200'}`}
>
{t(`status.${s}`)}
</Link>
))}
</nav>
</div>
<div className="card overflow-hidden">
{rows.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">{t('columns.created')}</th>
<th className="px-4 py-2.5">{t('columns.booking')}</th>
<th className="px-4 py-2.5">{t('columns.org')}</th>
<th className="px-4 py-2.5">{t('columns.item')}</th>
<th className="px-4 py-2.5 text-right">{t('columns.quantity')}</th>
<th className="px-4 py-2.5 text-right">{t('columns.total')}</th>
<th className="px-4 py-2.5">{t('columns.pickup')}</th>
<th className="px-4 py-2.5">{t('columns.status')}</th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{rows.map((r) => (
<tr key={r.id} className="hover:bg-ink-50/50">
<td className="px-4 py-2.5 text-ink-600">
{r.createdAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
</td>
<td className="px-4 py-2.5">
<Link
href={`/admin/bookings/${r.bookingId}`}
className="font-mono text-xs text-brand-600 hover:underline"
>
{r.booking.bookingNumber}
</Link>
</td>
<td className="px-4 py-2.5 text-ink-900">
{r.booking.orgName}
</td>
<td className="px-4 py-2.5 text-ink-900">
{loc === 'sv' ? r.nameSv : r.nameEn}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{r.quantity}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{formatOre(r.lineTotalOre, loc)}
</td>
<td className="px-4 py-2.5 text-ink-900">
{r.pickupSlot ? (
<>
<div>
{loc === 'sv'
? r.pickupSlot.labelSv
: r.pickupSlot.labelEn}
</div>
<div className="text-xs text-ink-500">
{r.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">
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs text-ink-700">
{t(`status.${r.status as ReplacementStatus}`)}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{r.status === 'REQUESTED' && (
<StatusAction
id={r.id}
target="SCHEDULED"
label={t('actions.markScheduled')}
action={setReplacementStatus}
/>
)}
{(r.status === 'REQUESTED' ||
r.status === 'SCHEDULED') && (
<StatusAction
id={r.id}
target="DELIVERED"
label={t('actions.markDelivered')}
action={setReplacementStatus}
/>
)}
{r.status !== 'CANCELLED' &&
r.status !== 'DELIVERED' && (
<StatusAction
id={r.id}
target="CANCELLED"
label={t('actions.markCancelled')}
action={setReplacementStatus}
destructive
/>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
function StatusAction({
id,
target,
label,
action,
destructive,
}: {
id: string;
target: ReplacementStatus;
label: string;
action: (fd: FormData) => Promise<void>;
destructive?: boolean;
}) {
return (
<form action={action}>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="status" value={target} />
<button
type="submit"
className={`rounded-md px-2 py-1 text-xs ${destructive ? 'border border-red-200 bg-white text-red-700 hover:bg-red-50' : 'bg-ink-100 text-ink-700 hover:bg-ink-200'}`}
>
{label}
</button>
</form>
);
}

View File

@@ -118,9 +118,17 @@ export default async function BookingConfirmedPage({
{t('email.invoiceInfo')}
</div>
<Link href="/" className="btn-secondary w-full justify-center">
{t('booking.success.newOrder')}
</Link>
<div className="flex flex-col gap-2 sm:flex-row">
<Link
href="/min-sida"
className="btn-primary w-full justify-center"
>
{t('customer.navTitle')}
</Link>
<Link href="/" className="btn-secondary w-full justify-center">
{t('booking.success.newOrder')}
</Link>
</div>
</div>
</div>
</main>

View File

@@ -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 (
<div className="min-h-screen bg-ink-50">
<Header />
<main className="mx-auto max-w-3xl px-4 py-6 sm:py-10">
{email && (
<div className="mb-4 flex items-center justify-between text-sm text-ink-600">
<span>{email}</span>
<CustomerSignOutForm locale={loc} />
</div>
)}
{children}
</main>
</div>
);
}

View File

@@ -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 (
<div className="space-y-4">
<Link
href="/min-sida/oversikt"
className="inline-flex text-sm text-ink-600 hover:text-ink-900"
>
{t('back')}
</Link>
<div className="card p-6 sm:p-8">
<h1 className="text-2xl font-bold text-ink-900">{t('title')}</h1>
<p className="mt-1 text-sm text-ink-600">{t('intro')}</p>
<div className="mt-2 text-xs text-ink-500">
{booking.bookingNumber} · {booking.orgName}
</div>
<div className="mt-6">
<ReplacementForm
locale={loc}
bookingId={booking.id}
items={booking.items.map((it) => ({
id: it.id,
name: loc === 'sv' ? it.nameSv : it.nameEn,
ordered: it.quantity,
unitPriceOre: it.unitPriceOre,
vatBp: it.vatBp,
}))}
pickupSlots={settings.pickupEnabled ? slots : []}
/>
</div>
</div>
<div className="card p-4 sm:p-6">
<h2 className="text-sm font-semibold uppercase tracking-wide text-ink-500">
{t('existing')}
</h2>
{booking.replacements.length === 0 ? (
<p className="mt-2 text-sm text-ink-500">{t('noExisting')}</p>
) : (
<ul className="mt-3 divide-y divide-ink-100">
{booking.replacements.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<div>
<div className="text-ink-900">
{loc === 'sv' ? r.nameSv : r.nameEn} × {r.quantity}
</div>
<div className="text-xs text-ink-500">
{r.createdAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="tabular-nums text-ink-700">
{formatOre(r.lineTotalOre, loc)}
</span>
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs text-ink-700">
{t(
`status.${r.status as 'REQUESTED' | 'SCHEDULED' | 'DELIVERED' | 'CANCELLED'}`,
)}
</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-ink-900">{t('title')}</h1>
<p className="mt-1 text-sm text-ink-600">
{t('signedInAs', { email })}
</p>
</div>
{bookings.length === 0 ? (
<div className="card p-6 text-center text-sm text-ink-500">
{t('empty')}
</div>
) : (
<div className="space-y-3">
{bookings.map((b) => (
<div key={b.id} className="card p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-wide text-ink-500">
{t('bookingNumber')}
</div>
<div className="font-mono text-base font-semibold text-ink-900">
{b.bookingNumber}
</div>
</div>
<StatusBadge status={b.status} />
</div>
<dl className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-ink-500">{t('items')}</dt>
<dd className="text-ink-900">
{b.items
.map(
(it) =>
`${loc === 'sv' ? it.nameSv : it.nameEn} × ${it.quantity}`,
)
.join(', ')}
</dd>
</div>
{b.pickupSlot && (
<div>
<dt className="text-ink-500">{t('pickup')}</dt>
<dd className="text-ink-900">
{loc === 'sv' ? b.pickupSlot.labelSv : b.pickupSlot.labelEn}
{' · '}
{b.pickupSlot.startsAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
</dd>
</div>
)}
<div className="sm:col-span-2">
<dt className="text-ink-500">Total</dt>
<dd className="font-semibold tabular-nums text-ink-900">
{formatOre(b.totalOre, loc)}
</dd>
</div>
</dl>
{b.replacements.length > 0 && (
<div className="mt-4 border-t border-ink-100 pt-3">
<div className="text-xs font-medium uppercase tracking-wide text-ink-500">
{tr('existing')}
</div>
<ul className="mt-2 divide-y divide-ink-100">
{b.replacements.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="text-ink-900">
{loc === 'sv' ? r.nameSv : r.nameEn} × {r.quantity}
</div>
<div className="text-xs text-ink-500">
{r.createdAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
{r.pickupSlot && (
<>
{' · '}
{loc === 'sv'
? r.pickupSlot.labelSv
: r.pickupSlot.labelEn}
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="tabular-nums text-ink-700">
{formatOre(r.lineTotalOre, loc)}
</span>
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs text-ink-700">
{tr(
`status.${r.status as 'REQUESTED' | 'SCHEDULED' | 'DELIVERED' | 'CANCELLED'}`,
)}
</span>
</div>
</li>
))}
</ul>
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<Link
href={`/min-sida/oversikt/${b.id}/byte`}
className="btn-primary text-sm"
>
{t('requestReplacement')}
</Link>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="card p-6 sm:p-8">
<h1 className="text-2xl font-bold text-ink-900">{t('title')}</h1>
<p className="mt-2 text-sm text-ink-600">{t('intro')}</p>
<div className="mt-6">
<MagicLinkForm locale={locale as 'sv' | 'en'} />
</div>
</div>
);
}

View File

@@ -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 (
<div className="card p-6 sm:p-8">
<h1 className="text-xl font-semibold text-ink-900">{t('invalidTitle')}</h1>
<p className="mt-2 text-sm text-ink-600">{t('invalidBody')}</p>
<div className="mt-4">
<Link href="/min-sida" className="btn-primary inline-block">
{t('tryAgain')}
</Link>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<form
action={async () => {
'use server';
await destroyCustomerSession();
redirect(target);
}}
>
<button type="submit" className="btn-ghost text-xs">
{t('signOut')}
</button>
</form>
);
}

View File

@@ -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 (
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-900">
<div className="font-semibold">{t('successTitle')}</div>
<p className="mt-1 text-emerald-800">
{t('successBody', { email })}
</p>
</div>
);
}
return (
<form onSubmit={onSubmit} className="space-y-3">
<label className="block">
<span className="text-sm font-medium text-ink-900">{t('email')}</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('emailPlaceholder')}
className="mt-1 w-full rounded-md border border-ink-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</label>
<button
type="submit"
disabled={submitting}
className="btn-primary w-full sm:w-auto"
>
{submitting ? t('submitting') : t('submit')}
</button>
</form>
);
}

View File

@@ -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<string | ''>('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-900">
<div className="font-semibold">{t('successTitle')}</div>
<p className="mt-1 text-emerald-800">{t('successBody')}</p>
</div>
);
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-ink-900">
{t('selectItem')}
</span>
<select
value={bookingItemId}
onChange={(e) => setBookingItemId(e.target.value)}
required
className="mt-1 w-full rounded-md border border-ink-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
{items.map((it) => (
<option key={it.id} value={it.id}>
{it.name} · {formatOre(priceInclVatOre(it.unitPriceOre, it.vatBp), locale)}{' '}
{c('inclVat')}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium text-ink-900">{t('quantity')}</span>
<input
type="number"
min={1}
max={999}
required
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value, 10) || 1)}
className="mt-1 w-32 rounded-md border border-ink-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</label>
{pickupSlots.length > 0 && (
<label className="block">
<span className="text-sm font-medium text-ink-900">
{t('pickupSlot')}
</span>
<select
value={pickupSlotId}
onChange={(e) => setPickupSlotId(e.target.value)}
className="mt-1 w-full rounded-md border border-ink-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">{t('noPickup')}</option>
{pickupSlots.map((s) => (
<option key={s.id} value={s.id}>
{locale === 'sv' ? s.labelSv : s.labelEn} ·{' '}
{new Date(s.startsAt).toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
</option>
))}
</select>
</label>
)}
<label className="block">
<span className="text-sm font-medium text-ink-900">{t('notes')}</span>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('notesPlaceholder')}
className="mt-1 w-full rounded-md border border-ink-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</label>
{selectedItem && (
<div className="flex items-center justify-between rounded-lg bg-ink-100 px-3 py-2 text-sm">
<span className="text-ink-700">{c('total')}</span>
<span className="font-semibold tabular-nums text-ink-900">
{formatOre(estTotalOre, locale)}{' '}
<span className="text-xs font-normal text-ink-500">
{c('inclVat')}
</span>
</span>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<button
type="submit"
disabled={submitting || !bookingItemId}
className="btn-primary"
>
{submitting ? t('submitting') : t('submit')}
</button>
</form>
);
}

98
src/lib/customerAuth.ts Normal file
View File

@@ -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<string> {
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<string | null> {
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<string | null> {
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<void> {
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}`;
}

View File

@@ -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, '&quot;')
.replace(/'/g, '&#39;');
}
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 = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(s.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:#0f172a;color:#ffffff;padding:24px;">
<div style="font-size:12px;opacity:0.8;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(event)}</div>
<div style="font-size:22px;font-weight:600;margin-top:4px;">${escapeHtml(s.subject)}</div>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(s.greeting)}</p>
<p style="font-size:14px;color:#475569;margin:0 0 24px;">${escapeHtml(s.intro)}</p>
<p style="margin:0 0 16px;">
<a href="${escapeHtml(link)}" style="display:inline-block;background:#ea580c;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;font-size:15px;">${escapeHtml(s.cta)}</a>
</p>
<p style="font-size:13px;color:#64748b;margin:16px 0 0;">${escapeHtml(s.expiry)}</p>
<p style="font-size:12px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.fallback)}</p>
<p style="font-size:12px;color:#0f172a;word-break:break-all;margin:4px 0 0;"><a href="${escapeHtml(link)}" style="color:#0f172a;">${escapeHtml(link)}</a></p>
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0;">${escapeHtml(s.ignore)}</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 = [
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 = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(s.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:22px;font-weight:600;margin-top:4px;">${escapeHtml(rr.booking.bookingNumber)}</div>
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(event)}</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;">${escapeHtml(s.what)}</h2>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="padding:4px 0;font-size:14px;color:#0f172a;">
${escapeHtml(itemName)} <span style="color:#94a3b8;">× ${rr.quantity}</span>
</td>
<td style="padding:4px 0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
${escapeHtml(fmt(rr.lineTotalOre))}
</td>
</tr>
<tr>
<td style="padding-top:6px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">${escapeHtml(s.total)}</td>
<td style="padding-top:6px;font-size:14px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">
${escapeHtml(fmt(rr.lineTotalOre))}
<span style="font-size:12px;font-weight:400;color:#64748b;">${escapeHtml(s.inclVat)}</span>
</td>
</tr>
</table>
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${escapeHtml(s.pickup)}</h2>
<p style="font-size:14px;margin:0;">${
slotLabel && slotTime
? `${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}`
: `<span style="color:#64748b;">${escapeHtml(s.noPickup)}</span>`
}</p>
${
rr.notes
? `<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">Notes</h2><p style="font-size:14px;margin:0;white-space:pre-wrap;color:#475569;">${escapeHtml(rr.notes)}</p>`
: ''
}
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
${escapeHtml(s.nextSteps)}<br>
${escapeHtml(s.invoiceInfo)}
</div>
</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}: ${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 };
}
}

View File

@@ -31,3 +31,20 @@ export const bookingSubmitSchema = z.object({
});
export type BookingSubmitInput = z.infer<typeof bookingSubmitSchema>;
export const magicLinkRequestSchema = z.object({
email: z.string().trim().email().max(200),
locale: z.enum(['sv', 'en']).default('sv'),
});
export type MagicLinkRequestInput = z.infer<typeof magicLinkRequestSchema>;
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<typeof replacementSubmitSchema>;