feat: replacement
This commit is contained in:
11
BACKLOG.md
11
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).
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
224
src/app/[locale]/admin/replacements/page.tsx
Normal file
224
src/app/[locale]/admin/replacements/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
32
src/app/[locale]/min-sida/layout.tsx
Normal file
32
src/app/[locale]/min-sida/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/app/[locale]/min-sida/oversikt/[bookingId]/byte/page.tsx
Normal file
127
src/app/[locale]/min-sida/oversikt/[bookingId]/byte/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/app/[locale]/min-sida/oversikt/page.tsx
Normal file
163
src/app/[locale]/min-sida/oversikt/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/[locale]/min-sida/page.tsx
Normal file
32
src/app/[locale]/min-sida/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/[locale]/min-sida/verifiera/page.tsx
Normal file
26
src/app/[locale]/min-sida/verifiera/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
50
src/app/api/customer/magic-link/route.ts
Normal file
50
src/app/api/customer/magic-link/route.ts
Normal 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;
|
||||
}
|
||||
90
src/app/api/customer/replacement/route.ts
Normal file
90
src/app/api/customer/replacement/route.ts
Normal 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 });
|
||||
}
|
||||
15
src/app/api/customer/verify/route.ts
Normal file
15
src/app/api/customer/verify/route.ts
Normal 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));
|
||||
}
|
||||
21
src/components/customer/CustomerSignOutForm.tsx
Normal file
21
src/components/customer/CustomerSignOutForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/customer/MagicLinkForm.tsx
Normal file
62
src/components/customer/MagicLinkForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/components/customer/ReplacementForm.tsx
Normal file
190
src/components/customer/ReplacementForm.tsx
Normal 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
98
src/lib/customerAuth.ts
Normal 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}`;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import Mailjet from 'node-mailjet';
|
||||
import { formatOre } from './money';
|
||||
import type { Booking, BookingItem, PickupSlot } from '@prisma/client';
|
||||
import type {
|
||||
Booking,
|
||||
BookingItem,
|
||||
PickupSlot,
|
||||
ReplacementRequest,
|
||||
} from '@prisma/client';
|
||||
|
||||
let client: Mailjet | null = null;
|
||||
|
||||
@@ -251,3 +256,350 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
type MagicLinkStrings = {
|
||||
subject: string;
|
||||
greeting: string;
|
||||
intro: string;
|
||||
cta: string;
|
||||
expiry: string;
|
||||
fallback: string;
|
||||
ignore: string;
|
||||
footer: string;
|
||||
};
|
||||
|
||||
function magicLinkStrings(locale: 'sv' | 'en', event: string): MagicLinkStrings {
|
||||
if (locale === 'sv') {
|
||||
return {
|
||||
subject: `Inloggningslänk — ${event}`,
|
||||
greeting: 'Hej,',
|
||||
intro: 'Klicka på knappen nedan för att se dina bokningar och beställa byte av tomma gasolflaskor.',
|
||||
cta: 'Öppna min sida',
|
||||
expiry: 'Länken gäller i 1 timme och kan användas en gång.',
|
||||
fallback: 'Fungerar inte knappen? Klistra in denna länk i webbläsaren:',
|
||||
ignore: 'Om du inte begärt detta mejl kan du ignorera det.',
|
||||
footer: 'Detta är ett automatiskt mejl från Gasol247.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
subject: `Sign-in link — ${event}`,
|
||||
greeting: 'Hi,',
|
||||
intro: 'Click the button below to view your bookings and request empty cylinder swaps.',
|
||||
cta: 'Open my page',
|
||||
expiry: 'This link is valid for 1 hour and can be used once.',
|
||||
fallback: 'Button not working? Paste this link into your browser:',
|
||||
ignore: "If you didn't request this email you can ignore it.",
|
||||
footer: 'This is an automated email from Gasol247.',
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMagicLinkEmail(
|
||||
link: string,
|
||||
locale: 'sv' | 'en' = 'sv',
|
||||
): { subject: string; html: string; text: string } {
|
||||
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
|
||||
const s = magicLinkStrings(locale, event);
|
||||
|
||||
const html = `<!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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user