From f19e2d4e0d39f241cce0d47ab5e827dcfea232ed Mon Sep 17 00:00:00 2001 From: Ola Malmgren Date: Fri, 22 May 2026 20:33:21 +0200 Subject: [PATCH] feat: replacement --- BACKLOG.md | 11 +- messages/en.json | 87 +++++ messages/sv.json | 87 +++++ package.json | 4 + prisma/data/app.db | Bin 86016 -> 155648 bytes prisma/schema.prisma | 90 ++++- src/app/[locale]/admin/bookings/[id]/page.tsx | 68 +++- src/app/[locale]/admin/layout.tsx | 7 + src/app/[locale]/admin/page.tsx | 39 +- src/app/[locale]/admin/replacements/page.tsx | 224 +++++++++++ src/app/[locale]/booking/[number]/page.tsx | 14 +- src/app/[locale]/min-sida/layout.tsx | 32 ++ .../oversikt/[bookingId]/byte/page.tsx | 127 +++++++ src/app/[locale]/min-sida/oversikt/page.tsx | 163 ++++++++ src/app/[locale]/min-sida/page.tsx | 32 ++ src/app/[locale]/min-sida/verifiera/page.tsx | 26 ++ src/app/api/admin/export/route.ts | 63 +++- src/app/api/customer/magic-link/route.ts | 50 +++ src/app/api/customer/replacement/route.ts | 90 +++++ src/app/api/customer/verify/route.ts | 15 + .../customer/CustomerSignOutForm.tsx | 21 ++ src/components/customer/MagicLinkForm.tsx | 62 +++ src/components/customer/ReplacementForm.tsx | 190 ++++++++++ src/lib/customerAuth.ts | 98 +++++ src/lib/mailjet.ts | 354 +++++++++++++++++- src/lib/validation.ts | 17 + 26 files changed, 1951 insertions(+), 20 deletions(-) create mode 100644 src/app/[locale]/admin/replacements/page.tsx create mode 100644 src/app/[locale]/min-sida/layout.tsx create mode 100644 src/app/[locale]/min-sida/oversikt/[bookingId]/byte/page.tsx create mode 100644 src/app/[locale]/min-sida/oversikt/page.tsx create mode 100644 src/app/[locale]/min-sida/page.tsx create mode 100644 src/app/[locale]/min-sida/verifiera/page.tsx create mode 100644 src/app/api/customer/magic-link/route.ts create mode 100644 src/app/api/customer/replacement/route.ts create mode 100644 src/app/api/customer/verify/route.ts create mode 100644 src/components/customer/CustomerSignOutForm.tsx create mode 100644 src/components/customer/MagicLinkForm.tsx create mode 100644 src/components/customer/ReplacementForm.tsx create mode 100644 src/lib/customerAuth.ts diff --git a/BACKLOG.md b/BACKLOG.md index a16fa8c..206ca9f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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` på `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). diff --git a/messages/en.json b/messages/en.json index df1cf82..dfc8c08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." + } } } diff --git a/messages/sv.json b/messages/sv.json index 587cedf..a843889 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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." + } } } diff --git a/package.json b/package.json index 2a69cf1..a585aaf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/data/app.db b/prisma/data/app.db index 0892c7456ae0a1e4be0cc856b7840587ae459dd8..1b01b72a76abeaa1a2042c241a57f8fb9f689fdd 100644 GIT binary patch delta 5326 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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91cc1a1..07c25bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/[locale]/admin/bookings/[id]/page.tsx b/src/app/[locale]/admin/bookings/[id]/page.tsx index 0c6725d..5511970 100644 --- a/src/app/[locale]/admin/bookings/[id]/page.tsx +++ b/src/app/[locale]/admin/bookings/[id]/page.tsx @@ -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 (
@@ -155,6 +163,64 @@ export default async function BookingDetailPage({ }))} />
+ +
+

+ {tr('bookingSection.title')} +

+ {booking.replacements.length === 0 ? ( +

+ {tr('bookingSection.empty')} +

+ ) : ( +
    + {booking.replacements.map((r) => ( +
  • +
    +
    + {loc === 'sv' ? r.nameSv : r.nameEn} × {r.quantity} +
    +
    + {r.createdAt.toLocaleString( + loc === 'sv' ? 'sv-SE' : 'en-SE', + { dateStyle: 'short', timeStyle: 'short' }, + )} + {r.pickupSlot && ( + <> + {' · '} + {loc === 'sv' + ? r.pickupSlot.labelSv + : r.pickupSlot.labelEn} + + )} +
    +
    +
    + + {formatOre(r.lineTotalOre, loc)} + + + {tr( + `status.${r.status as 'REQUESTED' | 'SCHEDULED' | 'DELIVERED' | 'CANCELLED'}`, + )} + +
    +
  • + ))} +
+ )} +
+ + {tr('title')} → + +
+
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')} + + {tr('navTitle')} + ( - {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')}

+ +
+ +
+ +
+ {rows.length === 0 ? ( +
+ {t('empty')} +
+ ) : ( +
+ + + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + + + ))} + +
{t('columns.created')}{t('columns.booking')}{t('columns.org')}{t('columns.item')}{t('columns.quantity')}{t('columns.total')}{t('columns.pickup')}{t('columns.status')}
+ {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')}

+ ) : ( +
    + {booking.replacements.map((r) => ( +
  • +
    +
    + {loc === 'sv' ? r.nameSv : r.nameEn} × {r.quantity} +
    +
    + {r.createdAt.toLocaleString( + loc === 'sv' ? 'sv-SE' : 'en-SE', + { dateStyle: 'short', timeStyle: 'short' }, + )} +
    +
    +
    + + {formatOre(r.lineTotalOre, loc)} + + + {t( + `status.${r.status as 'REQUESTED' | 'SCHEDULED' | 'DELIVERED' | 'CANCELLED'}`, + )} + +
    +
  • + ))} +
+ )} +
+
+ ); +} 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')} +
+
    + {b.replacements.map((r) => ( +
  • +
    +
    + {loc === 'sv' ? r.nameSv : r.nameEn} × {r.quantity} +
    +
    + {r.createdAt.toLocaleString( + loc === 'sv' ? 'sv-SE' : 'en-SE', + { dateStyle: 'short', timeStyle: 'short' }, + )} + {r.pickupSlot && ( + <> + {' · '} + {loc === 'sv' + ? r.pickupSlot.labelSv + : r.pickupSlot.labelEn} + + )} +
    +
    +
    + + {formatOre(r.lineTotalOre, loc)} + + + {tr( + `status.${r.status as 'REQUESTED' | 'SCHEDULED' | 'DELIVERED' | 'CANCELLED'}`, + )} + +
    +
  • + ))} +
+
+ )} + +
+ + {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 ( +
{ + 'use server'; + await destroyCustomerSession(); + redirect(target); + }} + > + +
+ ); +} 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 ( +
+ + + + + {pickupSlots.length > 0 && ( + + )} + +