initial booking
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
data
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="file:./data/app.db"
|
||||||
|
|
||||||
|
# NextAuth — generate with: openssl rand -base64 32
|
||||||
|
AUTH_SECRET="change-me-to-a-long-random-string"
|
||||||
|
AUTH_URL="http://localhost:3000"
|
||||||
|
AUTH_TRUST_HOST="true"
|
||||||
|
|
||||||
|
# Mailjet — https://app.mailjet.com/account/apikeys
|
||||||
|
MAILJET_API_KEY=""
|
||||||
|
MAILJET_API_SECRET=""
|
||||||
|
MAIL_FROM_EMAIL="bokning@example.com"
|
||||||
|
MAIL_FROM_NAME="Gasol247 Bokning"
|
||||||
|
|
||||||
|
# Public site
|
||||||
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_EVENT_NAME="Jamboree 2027"
|
||||||
|
|
||||||
|
# Initial admin (used by seed script). Change before deploy.
|
||||||
|
SEED_ADMIN_EMAIL="admin@example.com"
|
||||||
|
SEED_ADMIN_PASSWORD="change-me"
|
||||||
|
SEED_ADMIN_NAME="Admin"
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# database
|
||||||
|
/prisma/*.db
|
||||||
|
/prisma/*.db-journal
|
||||||
|
/data
|
||||||
57
BACKLOG.md
Normal file
57
BACKLOG.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
Prioriterade förbättringar och kända luckor. Top-down ungefär i prioritetsordning.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [ ] **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.
|
||||||
|
- [ ] **Skarpa produktpriser och upphämtningstider**: ändra i `prisma/seed.ts` eller via Prisma Studio innan lansering.
|
||||||
|
- [ ] **Cookie-banner / GDPR-info**: bokningen samlar PII (namn, e-post, org.nummer, adress). Kort policy-text + länk.
|
||||||
|
- [ ] **Backup-rutin för SQLite-volymen**: cron-jobb på värden som kör `cp` mot en separat disk eller S3.
|
||||||
|
|
||||||
|
## P1 — höjer kvaliteten
|
||||||
|
|
||||||
|
- [ ] **Admin-UI för produkter och upphämtningstider**: idag måste man köra seed-filen eller Prisma Studio. Lägg till `/admin/products` och `/admin/pickup-slots` med CRUD.
|
||||||
|
- [ ] **Admin-UI för att skapa fler administratörer**: tabellen `Admin` stöder flera men det finns ingen invite-flow. Antingen self-serve invite via mejl-länk eller manuell skapa-form i admin-panelen.
|
||||||
|
- [ ] **Kund-avbokning via token-länk**: lägg till `cancelToken` på `Booking`, inkludera unik avbokningslänk i bekräftelsemejlet. Trigger på `GET /booking/cancel/{token}` med bekräftelsesida.
|
||||||
|
- [ ] **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.
|
||||||
|
- [ ] **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.
|
||||||
|
- [ ] **Healthcheck-endpoint**: `GET /api/health` returnerar 200 + `{db: 'ok'}` för proxy/uptime-monitor.
|
||||||
|
|
||||||
|
## P2 — bra att ha
|
||||||
|
|
||||||
|
- [ ] **Prisma migrate istället för db push**: när schemat är stabilt, kör `prisma migrate dev` initialt och commita migrations-mappen. Byt entrypoint till `migrate deploy`.
|
||||||
|
- [ ] **Multi-event-stöd**: idag är allt knutet till ett event via `NEXT_PUBLIC_EVENT_NAME`. Lägg till `Event`-tabell med egen produkt- och slot-koppling om systemet ska användas till fler läger.
|
||||||
|
- [ ] **E-mail verification innan bokning**: skicka kod eller bekräfta via dubbelt opt-in. Idag litar vi på att kunden anger korrekt mejl.
|
||||||
|
- [ ] **Slot-omval via avbokningslänk**: kunden kan byta upphämtningstid utan att kontakta admin.
|
||||||
|
- [ ] **Skicka påminnelse-mejl** dagen innan upphämtning (cron + Mailjet).
|
||||||
|
- [ ] **Status-mejl vid statusbyte**: kunden får mejl när bokningen markeras `INVOICED` eller `CANCELLED`.
|
||||||
|
- [ ] **Inbjudnings-mejl till nya admins** (kopplat till P1 admin-UI för admins).
|
||||||
|
- [ ] **Tester**: minst en smoke-test för `POST /api/bookings` (Playwright eller Vitest + Supertest).
|
||||||
|
- [ ] **CI**: GitHub Actions som kör `npm run build` på PR.
|
||||||
|
- [ ] **Telemetri / felrapportering**: Sentry eller liknande.
|
||||||
|
|
||||||
|
## P3 — framtida överväganden
|
||||||
|
|
||||||
|
- [ ] **Postgres istället för SQLite**: när volymen växer eller om vi vill ha managed backups. Prisma byter provider med en rad + ny `DATABASE_URL`.
|
||||||
|
- [ ] **Prisma 7-uppgradering**: ny `prisma.config.ts` med driver adapter (t.ex. `@prisma/adapter-better-sqlite3`), uppdaterad `PrismaClient`-init. Inte värt det förrän Prisma 7 är stabilare och vi har annat skäl att uppgradera.
|
||||||
|
- [ ] **Server-side e-postvalidering**: t.ex. DNS/MX-koll eller integration mot Mailjet Address Validation.
|
||||||
|
- [ ] **Bilder på produkterna i formuläret**: laddas från egen `/public/products/*.webp` eller via en CDN.
|
||||||
|
- [ ] **Skip-link och bättre a11y-audit**: fokushantering i stegen, ARIA-live för felmeddelanden.
|
||||||
|
- [ ] **Webhooks från Mailjet** (bounce/spam-rapport) — markerar bokningar med ogiltig e-post.
|
||||||
|
|
||||||
|
## Beslutslogg
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
- **2026-05-22**: Egen inline HTML-mall i `src/lib/mailjet.ts` istället för Mailjet Templates — färre externa beroenden för MVP, men ändras inte utan deploy.
|
||||||
|
- **2026-05-22**: NextAuth Credentials + lokal Admin-tabell (inte magic link) — användaren valde detta för flera admins.
|
||||||
|
- **2026-05-22**: next-intl med `localePrefix: "as-needed"` — `/` är svenska, `/en/*` engelska. Switch i header behåller path.
|
||||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# -------- deps --------
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
|
# -------- build --------
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Generate prisma client; skip migrate during build (no DB yet).
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# -------- runner --------
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs \
|
||||||
|
&& mkdir -p /app/data \
|
||||||
|
&& chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
# Standalone output bundles only what's needed.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
# Prisma needs the schema + engines + CLI for migrate deploy at startup.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/tsx ./node_modules/tsx
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
200
README.md
Normal file
200
README.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# boka.gasol247
|
||||||
|
|
||||||
|
Beställningsplattform för gasoltuber till ett enskilt scoutläger (Jamboree).
|
||||||
|
Riktar sig till organisationer (scoutkårer, föreningar) som beställer för leverans/upphämtning
|
||||||
|
på plats. Bokningen genererar ett faktureringsunderlag som administratören exporterar
|
||||||
|
till CSV — själva faktureringen sker utanför systemet.
|
||||||
|
|
||||||
|
Tekniskt frikopplad från [gasol247.com](https://www.gasol247.com/) men ärver dess
|
||||||
|
färgspråk. Tänkt att deployas på egen subdomän.
|
||||||
|
|
||||||
|
## Funktioner i MVP
|
||||||
|
|
||||||
|
- **Publikt bokningsformulär** med fyra steg: produkter → uppgifter → upphämtning → granska
|
||||||
|
- **Språkstöd**: svenska (default) och engelska, växlas i header
|
||||||
|
- **Organisationsbeställning** med Luhn-validering av svenskt organisationsnummer
|
||||||
|
- **Upphämtningstider** (slots) — kunden väljer tid, kapacitet per slot
|
||||||
|
- **Bekräftelse-mejl** via Mailjet med responsiv HTML-template (mobil + desktop)
|
||||||
|
- **Admin-panel** bakom lösen: lista bokningar, filter, status, detaljvy, ändra status
|
||||||
|
- **CSV-export** av faktureringsunderlag (Excel-vänlig, semikolon + UTF-8 BOM)
|
||||||
|
- **Prissnapshot**: priser & namn fryses i bokningen när den skapas
|
||||||
|
- **Bokningsnummer** i formatet `JAM-YY-XXXX` (läsbart över telefon)
|
||||||
|
|
||||||
|
## Teknisk stack
|
||||||
|
|
||||||
|
| Lager | Val | Varför |
|
||||||
|
|---|---|---|
|
||||||
|
| Frontend + Backend | Next.js 15 (App Router) | En process, enkel deploy |
|
||||||
|
| Språk | TypeScript | Typsäkerhet över hela kodbasen |
|
||||||
|
| Styling | Tailwind CSS | Snabb iteration, små bundles |
|
||||||
|
| Databas | SQLite via Prisma 5 | En fil, ingen separat container för MVP |
|
||||||
|
| Auth | Auth.js v5 (NextAuth) Credentials + bcrypt | Lokal admin-tabell, ingen extern beroende |
|
||||||
|
| i18n | next-intl | Server- + client-translations, prefix-routing |
|
||||||
|
| Mejl | Mailjet (`node-mailjet`) | Bra leverans, designat HTML-mejl |
|
||||||
|
| Deploy | Docker (Next standalone) | Körs bakom egen reverse proxy |
|
||||||
|
|
||||||
|
## Snabbstart – utveckling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Generera AUTH_SECRET:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npx prisma db push # skapa SQLite-schemat
|
||||||
|
npm run db:seed # admin + produkter + upphämtningstider
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Default-admin (från `.env`): `admin@example.com` / `change-me` — ändra direkt i `.env` innan deploy.
|
||||||
|
|
||||||
|
### Användbara kommandon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # dev-server med hot reload
|
||||||
|
npm run build # produktionsbygge
|
||||||
|
npm run start # produktionsserver (kräver byggd app)
|
||||||
|
npm run db:studio # Prisma Studio — visuell DB-editor
|
||||||
|
npm run db:seed # idempotent seed (kör om för uppdaterade priser)
|
||||||
|
npx prisma db push # synka schemat till SQLite efter ändring i prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
boka.gasol247/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma Admin, Product, PickupSlot, Booking, BookingItem
|
||||||
|
│ └── seed.ts Första admin + P6/P11/P19 + exempel-slots
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── [locale]/ sv default, /en/* för engelska
|
||||||
|
│ │ │ ├── page.tsx Publikt bokningsformulär
|
||||||
|
│ │ │ ├── booking/[number]/ Bekräftelsesida
|
||||||
|
│ │ │ └── admin/ Login + dashboard + detaljvy
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ ├── bookings/ POST: skapa bokning + skicka mejl
|
||||||
|
│ │ ├── admin/export/ GET: CSV-faktureringsunderlag
|
||||||
|
│ │ └── auth/[...nextauth]/
|
||||||
|
│ ├── components/ BookingForm, Header, LanguageSwitcher, StatusBadge
|
||||||
|
│ ├── lib/ prisma, money, mailjet, bookingNumber, orgNumber, validation
|
||||||
|
│ ├── i18n/ next-intl routing + request config
|
||||||
|
│ ├── auth.ts Auth.js v5 setup
|
||||||
|
│ └── middleware.ts Locale-routing
|
||||||
|
├── messages/{sv,en}.json Översättningar (UI + e-post)
|
||||||
|
├── docker/entrypoint.sh Kör prisma db push + ev. seed vid start
|
||||||
|
├── Dockerfile Multi-stage, Next standalone, non-root
|
||||||
|
├── docker-compose.yml Volym för SQLite
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datamodell – snabb översikt
|
||||||
|
|
||||||
|
- **Admin** — `email`, `passwordHash` (bcrypt), `name`. Krediter för login.
|
||||||
|
- **Product** — `sku`, lokaliserade namn/beskrivningar, `priceOre` (öre, inte SEK), `vatBp` (basis-punkter), `active`, `sortOrder`.
|
||||||
|
- **PickupSlot** — `labelSv`/`labelEn`, `startsAt`/`endsAt`, `capacity`, `active`.
|
||||||
|
- **Booking** — bokningsnr, status, kontaktuppgifter, organisationsuppgifter, fakturaadress, snapshot av subtotal/vat/total, locale, valfri `pickupSlotId`.
|
||||||
|
- **BookingItem** — snapshot av `sku`/`nameSv`/`nameEn`/`unitPriceOre`/`vatBp`/`quantity`/`lineTotalOre`. Frysta vid bokningstillfället så priser kan ändras i `Product` utan att gamla bokningar påverkas.
|
||||||
|
|
||||||
|
### Bokningsstatus
|
||||||
|
`PENDING` → `CONFIRMED` → `DELIVERED` → `INVOICED` (eller `CANCELLED` när som helst). Lagras som sträng eftersom SQLite inte stöder enums.
|
||||||
|
|
||||||
|
### Priser i öre
|
||||||
|
All prismatte sker i heltal (öre). Konvertering till SEK vid presentation eller export via [`src/lib/money.ts`](src/lib/money.ts). Undviker float-drift.
|
||||||
|
|
||||||
|
## Produktion – deploy med Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Skapa .env för produktion (på servern)
|
||||||
|
cp .env.example .env
|
||||||
|
# - Sätt AUTH_SECRET (openssl rand -base64 32)
|
||||||
|
# - Sätt MAILJET_API_KEY / SECRET / MAIL_FROM_EMAIL
|
||||||
|
# - Sätt AUTH_URL och NEXT_PUBLIC_SITE_URL till din domän (https://...)
|
||||||
|
# - Sätt SEED_ADMIN_EMAIL / PASSWORD för första admin
|
||||||
|
|
||||||
|
# 2. Bygg och starta
|
||||||
|
docker compose build
|
||||||
|
RUN_SEED=true docker compose up -d # första gången
|
||||||
|
# Vid omstarter: docker compose up -d (RUN_SEED är false som default)
|
||||||
|
|
||||||
|
# 3. Logga in
|
||||||
|
# https://din-domän/admin/login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bakom reverse proxy
|
||||||
|
|
||||||
|
`docker-compose.yml` exponerar port 3000 endast på `127.0.0.1` som standard. Om du
|
||||||
|
kör Caddy/Traefik/nginx som separat stack:
|
||||||
|
|
||||||
|
1. Ta bort `ports:` i `docker-compose.yml`
|
||||||
|
2. Anslut containern till proxyns Docker-nätverk med `networks:` i compose-filen
|
||||||
|
3. Pekande proxy → `boka-gasol247:3000`
|
||||||
|
|
||||||
|
Sätt `AUTH_TRUST_HOST=true` (redan satt) så Auth.js godkänner proxyns host-header.
|
||||||
|
|
||||||
|
### SQLite-volym & backup
|
||||||
|
|
||||||
|
Databasen ligger i Docker-volymen `app-data` (monterad som `/app/data/app.db`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup
|
||||||
|
docker run --rm \
|
||||||
|
-v boka.gasol247_app-data:/data \
|
||||||
|
-v "$PWD":/out alpine \
|
||||||
|
cp /data/app.db /out/app.backup-$(date +%F).db
|
||||||
|
|
||||||
|
# Restore (stoppa appen först)
|
||||||
|
docker compose down
|
||||||
|
docker run --rm \
|
||||||
|
-v boka.gasol247_app-data:/data \
|
||||||
|
-v "$PWD":/in alpine \
|
||||||
|
cp /in/app.backup-2026-05-22.db /data/app.db
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Kör backup minst dagligen vid skarp användning.
|
||||||
|
|
||||||
|
## Konfiguration – miljövariabler
|
||||||
|
|
||||||
|
| Variabel | Krav | Beskrivning |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | ✅ | T.ex. `file:./data/app.db` lokalt, `file:/app/data/app.db` i Docker |
|
||||||
|
| `AUTH_SECRET` | ✅ | `openssl rand -base64 32` |
|
||||||
|
| `AUTH_URL` | ✅ | T.ex. `https://boka.gasol247.com` i produktion |
|
||||||
|
| `AUTH_TRUST_HOST` | ✅ | `true` bakom proxy |
|
||||||
|
| `MAILJET_API_KEY` | ⚠️ | Krävs för att skicka mejl. Utan: bokning skapas, mejl skippas |
|
||||||
|
| `MAILJET_API_SECRET` | ⚠️ | Som ovan |
|
||||||
|
| `MAIL_FROM_EMAIL` | ⚠️ | Verifierad avsändaradress i Mailjet |
|
||||||
|
| `MAIL_FROM_NAME` | – | Visningsnamn |
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` | – | För absoluta länkar i framtida e-post |
|
||||||
|
| `NEXT_PUBLIC_EVENT_NAME` | – | Visas i header och e-postmall |
|
||||||
|
| `SEED_ADMIN_EMAIL/PASSWORD/NAME` | – | Bara för `npm run db:seed` / `RUN_SEED=true` |
|
||||||
|
|
||||||
|
## Aktuell status och kända begränsningar
|
||||||
|
|
||||||
|
> **Verifierat 2026-05-22:** `next build` grönt, `prisma db push` skapar schemat, seed körs, `GET /sv|/en|/admin/login` 200, `POST /api/bookings` validerar och skapar bokning.
|
||||||
|
|
||||||
|
- **Inga migrationer** — vi kör `prisma db push` istället för `prisma migrate dev`. Bra för MVP, men byt till `migrate dev` när schemat är stabilt så historiken finns versionerad.
|
||||||
|
- **Mailjet-mall är inline HTML** i [src/lib/mailjet.ts](src/lib/mailjet.ts). Funkar och är responsiv, men för att kunna ändra design utan deploy bör vi flytta till Mailjet Template-ID.
|
||||||
|
- **Ingen lagerbegränsning** — boka.gasol247 förhindrar inte överbokning av en produkt. Kapacitet hanteras bara per upphämtnings-slot.
|
||||||
|
- **Ingen kund-avbokning** — admin kan markera `CANCELLED` men kunden själv har ingen länk i mejlet.
|
||||||
|
- **Admin-tabellen finns** men det finns inget UI för att skapa nya admins. Lägg till manuellt via Prisma Studio eller kör seed med andra `SEED_ADMIN_*`-variabler.
|
||||||
|
- **Produkter & slots redigeras genom seed-filen** — inget admin-UI för det än.
|
||||||
|
- **Ingen rate-limit eller CAPTCHA** på publika `POST /api/bookings`. Lägg till om eventet annonseras brett.
|
||||||
|
- **Prisma VS Code-tillägget visar Prisma 7-varningar** för `datasource.url` — kosmetiskt; projektet kör Prisma 5.22 där det fortfarande är obligatoriskt.
|
||||||
|
|
||||||
|
Backlog och prioriterade förbättringar finns i [BACKLOG.md](./BACKLOG.md).
|
||||||
|
|
||||||
|
## Felsökning
|
||||||
|
|
||||||
|
**"Cannot find module './globals.css'"** — Cleared via `src/types/css.d.ts`. Om felet kommer tillbaka, kontrollera att filen finns kvar.
|
||||||
|
|
||||||
|
**Mejl skickas inte** — Kolla logs: `docker compose logs app | grep mailjet`. Vanliga orsaker: avsändaradress inte verifierad i Mailjet, fel API-key, eller `MAILJET_API_KEY` saknas (då skippas sändningen tyst och loggas).
|
||||||
|
|
||||||
|
**Bokning skapas inte** — Kolla validation: `POST /api/bookings` returnerar `{error: "validation", issues: {...}}` med fältnivå-detaljer. Org.nummer måste passera Luhn-check.
|
||||||
|
|
||||||
|
**Admin kan inte logga in** — Kör `npm run db:seed` igen, eller skapa admin manuellt via Prisma Studio. Lösenord bcrypt-hashas i seed-scriptet.
|
||||||
|
|
||||||
|
## Licens
|
||||||
|
|
||||||
|
Privat — för intern användning på Jamboree-eventet.
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: boka-gasol247:latest
|
||||||
|
container_name: boka-gasol247
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: "file:/app/data/app.db"
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
AUTH_URL: ${AUTH_URL}
|
||||||
|
AUTH_TRUST_HOST: "true"
|
||||||
|
MAILJET_API_KEY: ${MAILJET_API_KEY}
|
||||||
|
MAILJET_API_SECRET: ${MAILJET_API_SECRET}
|
||||||
|
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL}
|
||||||
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||||
|
NEXT_PUBLIC_EVENT_NAME: ${NEXT_PUBLIC_EVENT_NAME}
|
||||||
|
RUN_SEED: ${RUN_SEED:-false}
|
||||||
|
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL}
|
||||||
|
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD}
|
||||||
|
SEED_ADMIN_NAME: ${SEED_ADMIN_NAME}
|
||||||
|
volumes:
|
||||||
|
- app-data:/app/data
|
||||||
|
# Expose only to a reverse proxy on the same docker network.
|
||||||
|
# If you run a Caddy/nginx/Traefik proxy as a separate stack,
|
||||||
|
# attach this container to that proxy's network and remove the ports below.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
16
docker/entrypoint.sh
Normal file
16
docker/entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Sync schema to the mounted SQLite volume. `db push` is fine for a single-tenant
|
||||||
|
# MVP where we don't need an audit trail of migrations. Switch to `migrate deploy`
|
||||||
|
# once the schema stabilizes and we want versioned migrations.
|
||||||
|
echo "→ Running prisma db push"
|
||||||
|
npx prisma db push --skip-generate --accept-data-loss=false
|
||||||
|
|
||||||
|
# Optionally seed when explicitly requested. Idempotent (upsert).
|
||||||
|
if [ "${RUN_SEED:-false}" = "true" ]; then
|
||||||
|
echo "→ Running seed"
|
||||||
|
node --import tsx ./prisma/seed.ts || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
290
messages/en.json
Normal file
290
messages/en.json
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"siteName": "Gasol247 Booking",
|
||||||
|
"eventName": "Jamboree 2027",
|
||||||
|
"languageSwedish": "Svenska",
|
||||||
|
"languageEnglish": "English",
|
||||||
|
"back": "Back",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"submit": "Submit",
|
||||||
|
"currency": "SEK",
|
||||||
|
"vat": "VAT",
|
||||||
|
"ofWhichVat": "of which VAT",
|
||||||
|
"inclVat": "incl. VAT",
|
||||||
|
"exclVat": "excl. VAT",
|
||||||
|
"total": "Total",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"quantity": "Qty",
|
||||||
|
"price": "Price",
|
||||||
|
"search": "Search"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"tagline": "Book LPG cylinders for the Jamboree camp",
|
||||||
|
"switchLanguage": "Switch language"
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"title": "LPG cylinder order",
|
||||||
|
"intro": "Reserve LPG cylinders for your contingent at the camp. A confirmation will be sent to the email you provide.",
|
||||||
|
"stepProducts": "Choose products",
|
||||||
|
"stepDetails": "Your details",
|
||||||
|
"stepPickup": "Pickup",
|
||||||
|
"stepReview": "Review",
|
||||||
|
"stepProductsShort": "Products",
|
||||||
|
"stepDetailsShort": "Details",
|
||||||
|
"stepPickupShort": "Pickup",
|
||||||
|
"stepReviewShort": "Review",
|
||||||
|
"stepOf": "Step {current} of {total}",
|
||||||
|
"products": {
|
||||||
|
"title": "Choose products",
|
||||||
|
"subtitle": "Adjust the quantity for each cylinder size below.",
|
||||||
|
"addToCart": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"perUnit": "/unit",
|
||||||
|
"noneSelected": "Select at least one product to continue."
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"title": "Your details",
|
||||||
|
"organization": "Organization",
|
||||||
|
"orgName": "Organization name",
|
||||||
|
"orgNamePlaceholder": "Scout group, association, company …",
|
||||||
|
"orgNumber": "Organization number",
|
||||||
|
"orgNumberPlaceholder": "XXXXXX-XXXX",
|
||||||
|
"contact": "Contact person",
|
||||||
|
"contactName": "Name",
|
||||||
|
"contactNamePlaceholder": "First and last name",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "example@group.org",
|
||||||
|
"phone": "Phone",
|
||||||
|
"phonePlaceholder": "+46 …",
|
||||||
|
"invoiceAddress": "Invoice address",
|
||||||
|
"address": "Street address",
|
||||||
|
"postalCode": "Postal code",
|
||||||
|
"city": "City",
|
||||||
|
"country": "Country"
|
||||||
|
},
|
||||||
|
"pickup": {
|
||||||
|
"title": "Choose a pickup time",
|
||||||
|
"subtitle": "Pick a time when you can collect the cylinders at the camp site.",
|
||||||
|
"noSlots": "No pickup times are configured yet.",
|
||||||
|
"capacity": "{count} slots left"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"label": "Additional notes (optional)",
|
||||||
|
"placeholder": "Any preferences or information we should know."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "Review your order",
|
||||||
|
"confirm": "I confirm that the details are correct and accept that a booking is created.",
|
||||||
|
"submitting": "Submitting booking…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"invalidEmail": "Enter a valid email address",
|
||||||
|
"invalidOrgNumber": "Enter a valid organization number (XXXXXX-XXXX)",
|
||||||
|
"selectProduct": "Select at least one product",
|
||||||
|
"submitFailed": "Something went wrong. Try again or contact us."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Thank you! Your booking has been received.",
|
||||||
|
"subtitle": "A confirmation has been sent to {email}.",
|
||||||
|
"bookingNumber": "Booking number: {number}",
|
||||||
|
"newOrder": "Make another booking"
|
||||||
|
},
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Administration",
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"error": "Invalid email or password."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"bookings": "Bookings",
|
||||||
|
"products": "Products",
|
||||||
|
"pickupSlots": "Pickup slots",
|
||||||
|
"settings": "Settings",
|
||||||
|
"users": "Users",
|
||||||
|
"admins": "Admins",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Administrators",
|
||||||
|
"empty": "No administrators.",
|
||||||
|
"new": "New administrator",
|
||||||
|
"edit": "Edit",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this administrator?",
|
||||||
|
"cannotDeleteSelf": "You can't delete your own account.",
|
||||||
|
"cannotDeleteLast": "There must be at least one administrator.",
|
||||||
|
"emailInUse": "Email is already used by another administrator.",
|
||||||
|
"passwordMismatch": "Passwords don't match.",
|
||||||
|
"passwordTooShort": "Password must be at least 8 characters.",
|
||||||
|
"passwordChanged": "Password updated.",
|
||||||
|
"you": "(you)",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"created": "Created",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordConfirm": "Confirm password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"newPasswordHint": "Leave empty to keep the current password",
|
||||||
|
"passwordHint": "Minimum 8 characters"
|
||||||
|
},
|
||||||
|
"changePassword": "Change password"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"saved": "Settings saved.",
|
||||||
|
"save": "Save",
|
||||||
|
"pickupEnabled": "Show pickup step in the booking form",
|
||||||
|
"pickupEnabledHint": "When off, the entire step is removed from the form and no pickup slot is selected. Existing bookings and pickup slots are not affected."
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"title": "Products",
|
||||||
|
"empty": "No products yet.",
|
||||||
|
"new": "New product",
|
||||||
|
"edit": "Edit",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this product?",
|
||||||
|
"cannotDelete": "Cannot delete — used in {count} booking(s). Deactivate it instead.",
|
||||||
|
"activate": "Activate",
|
||||||
|
"deactivate": "Deactivate",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"columns": {
|
||||||
|
"sku": "SKU",
|
||||||
|
"name": "Name",
|
||||||
|
"price": "Price",
|
||||||
|
"vat": "VAT",
|
||||||
|
"active": "Active",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"sku": "SKU",
|
||||||
|
"skuHint": "Unique code, e.g. P11",
|
||||||
|
"nameSv": "Name (Swedish)",
|
||||||
|
"nameEn": "Name (English)",
|
||||||
|
"descriptionSv": "Description (Swedish)",
|
||||||
|
"descriptionEn": "Description (English)",
|
||||||
|
"priceSek": "Price (SEK ex VAT)",
|
||||||
|
"vatPct": "VAT (%)",
|
||||||
|
"sortOrder": "Sort order",
|
||||||
|
"sortOrderHint": "Lower values appear first",
|
||||||
|
"active": "Active (shown in booking form)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pickupSlots": {
|
||||||
|
"title": "Pickup slots",
|
||||||
|
"empty": "No pickup slots yet.",
|
||||||
|
"new": "New slot",
|
||||||
|
"edit": "Edit",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this slot?",
|
||||||
|
"cannotDelete": "Cannot delete — chosen by {count} booking(s). Deactivate it instead.",
|
||||||
|
"activate": "Activate",
|
||||||
|
"deactivate": "Deactivate",
|
||||||
|
"columns": {
|
||||||
|
"label": "Label",
|
||||||
|
"when": "When",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"bookings": "Bookings",
|
||||||
|
"active": "Active",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"labelSv": "Label (Swedish)",
|
||||||
|
"labelEn": "Label (English)",
|
||||||
|
"labelHint": "e.g. \"Day 1 — Morning\"",
|
||||||
|
"startsAt": "Starts",
|
||||||
|
"endsAt": "Ends",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"capacityHint": "Maximum bookings that can choose this slot",
|
||||||
|
"active": "Active (shown in booking form)"
|
||||||
|
},
|
||||||
|
"invalidTime": "End time must be after start time."
|
||||||
|
},
|
||||||
|
"bookings": {
|
||||||
|
"title": "Bookings",
|
||||||
|
"empty": "No bookings yet.",
|
||||||
|
"columns": {
|
||||||
|
"number": "Booking #",
|
||||||
|
"date": "Date",
|
||||||
|
"org": "Organization",
|
||||||
|
"contact": "Contact",
|
||||||
|
"total": "Amount",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"search": "Search booking #, org or email"
|
||||||
|
},
|
||||||
|
"export": "Export CSV",
|
||||||
|
"view": "View",
|
||||||
|
"detail": {
|
||||||
|
"title": "Booking {number}",
|
||||||
|
"items": "Products",
|
||||||
|
"customer": "Customer",
|
||||||
|
"pickup": "Pickup",
|
||||||
|
"totals": "Totals",
|
||||||
|
"markInvoiced": "Mark invoiced",
|
||||||
|
"markDelivered": "Mark delivered",
|
||||||
|
"markCancelled": "Cancel booking",
|
||||||
|
"resendEmail": "Resend confirmation"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"PENDING": "Pending",
|
||||||
|
"CONFIRMED": "Confirmed",
|
||||||
|
"DELIVERED_PARTIAL": "Partly handed out",
|
||||||
|
"DELIVERED": "Handed out",
|
||||||
|
"RETURNED_PARTIAL": "Partly returned",
|
||||||
|
"RETURNED": "Returned",
|
||||||
|
"INVOICED": "Invoiced",
|
||||||
|
"CANCELLED": "Cancelled"
|
||||||
|
},
|
||||||
|
"fulfillment": {
|
||||||
|
"title": "Handout & return",
|
||||||
|
"ordered": "Ordered",
|
||||||
|
"delivered": "Handed out",
|
||||||
|
"returned": "Returned",
|
||||||
|
"outstanding": "Out",
|
||||||
|
"deliverAll": "Hand out all",
|
||||||
|
"returnAll": "Return all",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved",
|
||||||
|
"deliveredHint": "Cylinders the customer has collected",
|
||||||
|
"returnedHint": "Cylinders that have come back",
|
||||||
|
"invalidValue": "Value out of allowed range"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"subject": "Booking confirmation {number} — {event}",
|
||||||
|
"greeting": "Hi {name},",
|
||||||
|
"intro": "Thank you for ordering LPG cylinders for {event}. Here is your booking confirmation.",
|
||||||
|
"bookingNumber": "Booking number",
|
||||||
|
"orderSummary": "Order",
|
||||||
|
"pickup": "Pickup",
|
||||||
|
"invoiceInfo": "An invoice will be sent to your organization after the event.",
|
||||||
|
"questions": "Questions? Reply to this email and we will help you.",
|
||||||
|
"footer": "This is an automated confirmation from Gasol247."
|
||||||
|
}
|
||||||
|
}
|
||||||
290
messages/sv.json
Normal file
290
messages/sv.json
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"siteName": "Gasol247 Bokning",
|
||||||
|
"eventName": "Jamboree 2027",
|
||||||
|
"languageSwedish": "Svenska",
|
||||||
|
"languageEnglish": "English",
|
||||||
|
"back": "Tillbaka",
|
||||||
|
"save": "Spara",
|
||||||
|
"cancel": "Avbryt",
|
||||||
|
"loading": "Laddar…",
|
||||||
|
"submit": "Skicka in",
|
||||||
|
"currency": "kr",
|
||||||
|
"vat": "moms",
|
||||||
|
"ofWhichVat": "varav moms",
|
||||||
|
"inclVat": "inkl. moms",
|
||||||
|
"exclVat": "exkl. moms",
|
||||||
|
"total": "Totalt",
|
||||||
|
"subtotal": "Delsumma",
|
||||||
|
"quantity": "Antal",
|
||||||
|
"price": "Pris",
|
||||||
|
"search": "Sök"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"tagline": "Boka gasoltuber till Jamboree-lägret",
|
||||||
|
"switchLanguage": "Byt språk"
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"title": "Beställning av gasoltuber",
|
||||||
|
"intro": "Reservera gasoltuber till ert kontingent på lägret. Bekräftelse skickas till angiven e-postadress.",
|
||||||
|
"stepProducts": "Välj produkter",
|
||||||
|
"stepDetails": "Era uppgifter",
|
||||||
|
"stepPickup": "Upphämtning",
|
||||||
|
"stepReview": "Sammanfattning",
|
||||||
|
"stepProductsShort": "Produkter",
|
||||||
|
"stepDetailsShort": "Uppgifter",
|
||||||
|
"stepPickupShort": "Upphämtning",
|
||||||
|
"stepReviewShort": "Granska",
|
||||||
|
"stepOf": "Steg {current} av {total}",
|
||||||
|
"products": {
|
||||||
|
"title": "Välj produkter",
|
||||||
|
"subtitle": "Justera antalet av varje tubstorlek nedan.",
|
||||||
|
"addToCart": "Lägg till",
|
||||||
|
"remove": "Ta bort",
|
||||||
|
"perUnit": "/st",
|
||||||
|
"noneSelected": "Välj minst en produkt för att fortsätta."
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"title": "Era uppgifter",
|
||||||
|
"organization": "Organisation",
|
||||||
|
"orgName": "Organisationsnamn",
|
||||||
|
"orgNamePlaceholder": "Scoutkår, förening, företag …",
|
||||||
|
"orgNumber": "Organisationsnummer",
|
||||||
|
"orgNumberPlaceholder": "XXXXXX-XXXX",
|
||||||
|
"contact": "Kontaktperson",
|
||||||
|
"contactName": "Namn",
|
||||||
|
"contactNamePlaceholder": "För- och efternamn",
|
||||||
|
"email": "E-post",
|
||||||
|
"emailPlaceholder": "exempel@kar.se",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"phonePlaceholder": "07X-XXX XX XX",
|
||||||
|
"invoiceAddress": "Fakturaadress",
|
||||||
|
"address": "Gatuadress",
|
||||||
|
"postalCode": "Postnummer",
|
||||||
|
"city": "Ort",
|
||||||
|
"country": "Land"
|
||||||
|
},
|
||||||
|
"pickup": {
|
||||||
|
"title": "Välj upphämtningstid",
|
||||||
|
"subtitle": "Välj en tid då ni kan hämta tuberna på lägerplatsen.",
|
||||||
|
"noSlots": "Inga upphämtningstider är konfigurerade ännu.",
|
||||||
|
"capacity": "{count} platser kvar"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"label": "Övriga meddelanden (valfritt)",
|
||||||
|
"placeholder": "Eventuella önskemål eller information vi bör veta om."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "Granska din beställning",
|
||||||
|
"confirm": "Jag bekräftar att uppgifterna stämmer och godkänner att en bokning skapas.",
|
||||||
|
"submitting": "Skickar bokning…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"required": "Detta fält är obligatoriskt",
|
||||||
|
"invalidEmail": "Ange en giltig e-postadress",
|
||||||
|
"invalidOrgNumber": "Ange ett giltigt organisationsnummer (XXXXXX-XXXX)",
|
||||||
|
"selectProduct": "Välj minst en produkt",
|
||||||
|
"submitFailed": "Något gick fel. Försök igen eller kontakta oss."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Tack! Din beställning är mottagen.",
|
||||||
|
"subtitle": "Bekräftelse är skickad till {email}.",
|
||||||
|
"bookingNumber": "Bokningsnummer: {number}",
|
||||||
|
"newOrder": "Gör ny beställning"
|
||||||
|
},
|
||||||
|
"next": "Nästa",
|
||||||
|
"previous": "Föregående"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Administration",
|
||||||
|
"login": {
|
||||||
|
"title": "Logga in",
|
||||||
|
"email": "E-post",
|
||||||
|
"password": "Lösenord",
|
||||||
|
"submit": "Logga in",
|
||||||
|
"error": "Felaktig e-post eller lösenord."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"bookings": "Bokningar",
|
||||||
|
"products": "Produkter",
|
||||||
|
"pickupSlots": "Upphämtningstider",
|
||||||
|
"settings": "Inställningar",
|
||||||
|
"users": "Användare",
|
||||||
|
"admins": "Administratörer",
|
||||||
|
"signOut": "Logga ut"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Administratörer",
|
||||||
|
"empty": "Inga administratörer.",
|
||||||
|
"new": "Ny administratör",
|
||||||
|
"edit": "Redigera",
|
||||||
|
"create": "Skapa",
|
||||||
|
"save": "Spara",
|
||||||
|
"delete": "Ta bort",
|
||||||
|
"deleteConfirm": "Är du säker på att du vill ta bort denna administratör?",
|
||||||
|
"cannotDeleteSelf": "Du kan inte ta bort ditt eget konto.",
|
||||||
|
"cannotDeleteLast": "Det måste finnas minst en administratör.",
|
||||||
|
"emailInUse": "E-postadressen används redan av en annan administratör.",
|
||||||
|
"passwordMismatch": "Lösenorden matchar inte.",
|
||||||
|
"passwordTooShort": "Lösenordet måste vara minst 8 tecken.",
|
||||||
|
"passwordChanged": "Lösenordet har uppdaterats.",
|
||||||
|
"you": "(du)",
|
||||||
|
"columns": {
|
||||||
|
"name": "Namn",
|
||||||
|
"email": "E-post",
|
||||||
|
"created": "Skapad",
|
||||||
|
"actions": "Åtgärder"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Namn",
|
||||||
|
"email": "E-post",
|
||||||
|
"password": "Lösenord",
|
||||||
|
"passwordConfirm": "Bekräfta lösenord",
|
||||||
|
"newPassword": "Nytt lösenord",
|
||||||
|
"newPasswordHint": "Lämna tomt för att behålla nuvarande lösenord",
|
||||||
|
"passwordHint": "Minst 8 tecken"
|
||||||
|
},
|
||||||
|
"changePassword": "Byt lösenord"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Inställningar",
|
||||||
|
"saved": "Inställningarna är sparade.",
|
||||||
|
"save": "Spara",
|
||||||
|
"pickupEnabled": "Visa upphämtningssteget i bokningsformuläret",
|
||||||
|
"pickupEnabledHint": "När detta är av tas hela steget bort från formuläret och inga upphämtningstider väljs. Befintliga bokningar och upphämtningstider påverkas inte."
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"title": "Produkter",
|
||||||
|
"empty": "Inga produkter ännu.",
|
||||||
|
"new": "Ny produkt",
|
||||||
|
"edit": "Redigera",
|
||||||
|
"create": "Skapa",
|
||||||
|
"save": "Spara",
|
||||||
|
"delete": "Ta bort",
|
||||||
|
"deleteConfirm": "Är du säker på att du vill ta bort produkten?",
|
||||||
|
"cannotDelete": "Produkten kan inte tas bort — den används i {count} bokning(ar). Inaktivera den i stället.",
|
||||||
|
"activate": "Aktivera",
|
||||||
|
"deactivate": "Inaktivera",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Inaktiv",
|
||||||
|
"columns": {
|
||||||
|
"sku": "SKU",
|
||||||
|
"name": "Namn",
|
||||||
|
"price": "Pris",
|
||||||
|
"vat": "Moms",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"actions": "Åtgärder"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"sku": "SKU",
|
||||||
|
"skuHint": "Unik kod, t.ex. P11",
|
||||||
|
"nameSv": "Namn (svenska)",
|
||||||
|
"nameEn": "Namn (engelska)",
|
||||||
|
"descriptionSv": "Beskrivning (svenska)",
|
||||||
|
"descriptionEn": "Beskrivning (engelska)",
|
||||||
|
"priceSek": "Pris (kr exkl. moms)",
|
||||||
|
"vatPct": "Moms (%)",
|
||||||
|
"sortOrder": "Sorteringsordning",
|
||||||
|
"sortOrderHint": "Lägre värde visas först",
|
||||||
|
"active": "Aktiv (visas i bokningsformuläret)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pickupSlots": {
|
||||||
|
"title": "Upphämtningstider",
|
||||||
|
"empty": "Inga upphämtningstider ännu.",
|
||||||
|
"new": "Ny tid",
|
||||||
|
"edit": "Redigera",
|
||||||
|
"create": "Skapa",
|
||||||
|
"save": "Spara",
|
||||||
|
"delete": "Ta bort",
|
||||||
|
"deleteConfirm": "Är du säker på att du vill ta bort tiden?",
|
||||||
|
"cannotDelete": "Tiden kan inte tas bort — den är vald i {count} bokning(ar). Inaktivera den i stället.",
|
||||||
|
"activate": "Aktivera",
|
||||||
|
"deactivate": "Inaktivera",
|
||||||
|
"columns": {
|
||||||
|
"label": "Etikett",
|
||||||
|
"when": "När",
|
||||||
|
"capacity": "Kapacitet",
|
||||||
|
"bookings": "Bokningar",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"actions": "Åtgärder"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"labelSv": "Etikett (svenska)",
|
||||||
|
"labelEn": "Etikett (engelska)",
|
||||||
|
"labelHint": "T.ex. \"Dag 1 — Förmiddag\"",
|
||||||
|
"startsAt": "Startar",
|
||||||
|
"endsAt": "Slutar",
|
||||||
|
"capacity": "Kapacitet",
|
||||||
|
"capacityHint": "Max antal bokningar som kan välja denna tid",
|
||||||
|
"active": "Aktiv (visas i bokningsformuläret)"
|
||||||
|
},
|
||||||
|
"invalidTime": "Sluttiden måste vara efter starttiden."
|
||||||
|
},
|
||||||
|
"bookings": {
|
||||||
|
"title": "Bokningar",
|
||||||
|
"empty": "Inga bokningar ännu.",
|
||||||
|
"columns": {
|
||||||
|
"number": "Boknings-nr",
|
||||||
|
"date": "Datum",
|
||||||
|
"org": "Organisation",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"total": "Belopp",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Åtgärder"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "Alla",
|
||||||
|
"search": "Sök boknings-nr, organisation eller e-post"
|
||||||
|
},
|
||||||
|
"export": "Exportera CSV",
|
||||||
|
"view": "Visa",
|
||||||
|
"detail": {
|
||||||
|
"title": "Bokning {number}",
|
||||||
|
"items": "Produkter",
|
||||||
|
"customer": "Kund",
|
||||||
|
"pickup": "Upphämtning",
|
||||||
|
"totals": "Summor",
|
||||||
|
"markInvoiced": "Markera fakturerad",
|
||||||
|
"markDelivered": "Markera levererad",
|
||||||
|
"markCancelled": "Avboka",
|
||||||
|
"resendEmail": "Skicka om bekräftelse"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"PENDING": "Väntar",
|
||||||
|
"CONFIRMED": "Bekräftad",
|
||||||
|
"DELIVERED_PARTIAL": "Delvis utlämnad",
|
||||||
|
"DELIVERED": "Utlämnad",
|
||||||
|
"RETURNED_PARTIAL": "Delvis inlämnad",
|
||||||
|
"RETURNED": "Inlämnad",
|
||||||
|
"INVOICED": "Fakturerad",
|
||||||
|
"CANCELLED": "Avbokad"
|
||||||
|
},
|
||||||
|
"fulfillment": {
|
||||||
|
"title": "Utlämning & retur",
|
||||||
|
"ordered": "Beställt",
|
||||||
|
"delivered": "Utlämnat",
|
||||||
|
"returned": "Inlämnat",
|
||||||
|
"outstanding": "Ute",
|
||||||
|
"deliverAll": "Utlämna allt",
|
||||||
|
"returnAll": "Lämna in allt",
|
||||||
|
"save": "Spara",
|
||||||
|
"saved": "Sparat",
|
||||||
|
"deliveredHint": "Antal tuber som kunden hämtat",
|
||||||
|
"returnedHint": "Antal tuber som kommit tillbaka",
|
||||||
|
"invalidValue": "Värdet ligger utanför tillåtet intervall"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"subject": "Bokningsbekräftelse {number} — {event}",
|
||||||
|
"greeting": "Hej {name},",
|
||||||
|
"intro": "Tack för din beställning av gasoltuber till {event}. Här är din bokningsbekräftelse.",
|
||||||
|
"bookingNumber": "Bokningsnummer",
|
||||||
|
"orderSummary": "Beställning",
|
||||||
|
"pickup": "Upphämtning",
|
||||||
|
"invoiceInfo": "Faktura skickas till organisationen efter eventet.",
|
||||||
|
"questions": "Har du frågor? Svara på detta mejl så hjälper vi dig.",
|
||||||
|
"footer": "Detta är en automatiserad bekräftelse från Gasol247."
|
||||||
|
}
|
||||||
|
}
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
15
next.config.mjs
Normal file
15
next.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
7294
package-lock.json
generated
Normal file
7294
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "boka-gasol247",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:seed": "node --env-file=.env --import tsx prisma/seed.ts",
|
||||||
|
"postinstall": "prisma generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"next": "15.0.3",
|
||||||
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"next-intl": "^3.25.1",
|
||||||
|
"node-mailjet": "^6.0.6",
|
||||||
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.14.0",
|
||||||
|
"eslint-config-next": "15.0.3",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
prisma/data/app.db
Normal file
BIN
prisma/data/app.db
Normal file
Binary file not shown.
135
prisma/schema.prisma
Normal file
135
prisma/schema.prisma
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Boka Gasol247 — Prisma schema
|
||||||
|
// SQLite for MVP. Migrating to Postgres later: change provider + DATABASE_URL.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton settings row. Always accessed with id = "singleton" so we never
|
||||||
|
// have more than one row. Add new fields as feature flags grow.
|
||||||
|
model Settings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
pickupEnabled Boolean @default(true)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sku String @unique
|
||||||
|
nameSv String
|
||||||
|
nameEn String
|
||||||
|
descriptionSv String
|
||||||
|
descriptionEn String
|
||||||
|
// Price in öre (SEK * 100) to avoid float issues.
|
||||||
|
priceOre Int
|
||||||
|
// VAT in basis points (e.g. 2500 = 25.00%).
|
||||||
|
vatBp Int @default(2500)
|
||||||
|
active Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
bookingItems BookingItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PickupSlot {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookingStatus values (string, since SQLite has no enums):
|
||||||
|
// PENDING | CONFIRMED
|
||||||
|
// DELIVERED_PARTIAL | DELIVERED (handout out, none returned)
|
||||||
|
// RETURNED_PARTIAL | RETURNED (everything has come back)
|
||||||
|
// INVOICED (billing done, terminal)
|
||||||
|
// CANCELLED (terminal)
|
||||||
|
// Derived statuses (DELIVERED*/RETURNED*) are normally auto-set from item
|
||||||
|
// counters; INVOICED and CANCELLED are set explicitly by admin.
|
||||||
|
model Booking {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
bookingNumber String @unique
|
||||||
|
status String @default("CONFIRMED")
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
contactName String
|
||||||
|
email String
|
||||||
|
phone String
|
||||||
|
|
||||||
|
// Organization
|
||||||
|
orgName String
|
||||||
|
orgNumber String
|
||||||
|
|
||||||
|
// Invoice address
|
||||||
|
address String
|
||||||
|
postalCode String
|
||||||
|
city String
|
||||||
|
country String @default("SE")
|
||||||
|
|
||||||
|
// Pickup
|
||||||
|
pickupSlotId String?
|
||||||
|
pickupSlot PickupSlot? @relation(fields: [pickupSlotId], references: [id])
|
||||||
|
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
// Snapshots in öre at booking time
|
||||||
|
subtotalOre Int
|
||||||
|
vatOre Int
|
||||||
|
totalOre Int
|
||||||
|
|
||||||
|
locale String @default("sv")
|
||||||
|
|
||||||
|
items BookingItem[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BookingItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
bookingId String
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
|
||||||
|
// Snapshots so historical bookings don't break if a product changes
|
||||||
|
sku String
|
||||||
|
nameSv String
|
||||||
|
nameEn String
|
||||||
|
unitPriceOre Int
|
||||||
|
vatBp Int
|
||||||
|
quantity Int
|
||||||
|
lineTotalOre Int
|
||||||
|
|
||||||
|
// Fulfillment tracking — set by admin as cylinders go out and come back.
|
||||||
|
// Invariants: 0 <= deliveredQuantity <= quantity
|
||||||
|
// 0 <= returnedQuantity <= deliveredQuantity
|
||||||
|
deliveredQuantity Int @default(0)
|
||||||
|
returnedQuantity Int @default(0)
|
||||||
|
|
||||||
|
@@index([bookingId])
|
||||||
|
}
|
||||||
121
prisma/seed.ts
Normal file
121
prisma/seed.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// -- Settings singleton --
|
||||||
|
await prisma.settings.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
update: {},
|
||||||
|
create: { id: 'singleton', pickupEnabled: true },
|
||||||
|
});
|
||||||
|
console.log('✓ Settings singleton ensured');
|
||||||
|
|
||||||
|
// -- Initial admin --
|
||||||
|
const email = (process.env.SEED_ADMIN_EMAIL ?? 'admin@example.com').toLowerCase();
|
||||||
|
const password = process.env.SEED_ADMIN_PASSWORD ?? 'change-me';
|
||||||
|
const name = process.env.SEED_ADMIN_NAME ?? 'Admin';
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
await prisma.admin.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: {},
|
||||||
|
create: { email, name, passwordHash },
|
||||||
|
});
|
||||||
|
console.log(`✓ Admin ensured: ${email}`);
|
||||||
|
|
||||||
|
// -- Products (example LPG cylinders) --
|
||||||
|
const products = [
|
||||||
|
{
|
||||||
|
sku: 'P6',
|
||||||
|
nameSv: 'Gasoltub P6',
|
||||||
|
nameEn: 'LPG cylinder P6',
|
||||||
|
descriptionSv: '6 kg gasol — för mindre kök och campingkök.',
|
||||||
|
descriptionEn: '6 kg LPG — for smaller stoves and camping cooktops.',
|
||||||
|
priceOre: 39900, // 399 kr
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sku: 'P11',
|
||||||
|
nameSv: 'Gasoltub P11',
|
||||||
|
nameEn: 'LPG cylinder P11',
|
||||||
|
descriptionSv: '11 kg gasol — vanligaste storleken för matlagning på läger.',
|
||||||
|
descriptionEn: '11 kg LPG — most common size for cooking at camps.',
|
||||||
|
priceOre: 64900, // 649 kr
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sku: 'P19',
|
||||||
|
nameSv: 'Gasoltub P19',
|
||||||
|
nameEn: 'LPG cylinder P19',
|
||||||
|
descriptionSv: '19 kg gasol — för större kök eller längre vistelse.',
|
||||||
|
descriptionEn: '19 kg LPG — for larger kitchens or longer stays.',
|
||||||
|
priceOre: 99900, // 999 kr
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of products) {
|
||||||
|
await prisma.product.upsert({
|
||||||
|
where: { sku: p.sku },
|
||||||
|
update: {
|
||||||
|
nameSv: p.nameSv,
|
||||||
|
nameEn: p.nameEn,
|
||||||
|
descriptionSv: p.descriptionSv,
|
||||||
|
descriptionEn: p.descriptionEn,
|
||||||
|
priceOre: p.priceOre,
|
||||||
|
sortOrder: p.sortOrder,
|
||||||
|
},
|
||||||
|
create: { ...p, active: true, vatBp: 2500 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`✓ Seeded ${products.length} products`);
|
||||||
|
|
||||||
|
// -- Pickup slots (example: 3 days × 2 slots) --
|
||||||
|
const existing = await prisma.pickupSlot.count();
|
||||||
|
if (existing === 0) {
|
||||||
|
const baseDay = new Date();
|
||||||
|
baseDay.setUTCHours(8, 0, 0, 0);
|
||||||
|
// Place slots ~6 months out so they don't expire immediately.
|
||||||
|
baseDay.setUTCMonth(baseDay.getUTCMonth() + 6);
|
||||||
|
|
||||||
|
const slots = [
|
||||||
|
{ offsetDays: 0, startH: 9, endH: 12, labelSv: 'Dag 1 — Förmiddag', labelEn: 'Day 1 — Morning' },
|
||||||
|
{ offsetDays: 0, startH: 13, endH: 17, labelSv: 'Dag 1 — Eftermiddag', labelEn: 'Day 1 — Afternoon' },
|
||||||
|
{ offsetDays: 1, startH: 9, endH: 12, labelSv: 'Dag 2 — Förmiddag', labelEn: 'Day 2 — Morning' },
|
||||||
|
{ offsetDays: 1, startH: 13, endH: 17, labelSv: 'Dag 2 — Eftermiddag', labelEn: 'Day 2 — Afternoon' },
|
||||||
|
{ offsetDays: 2, startH: 9, endH: 12, labelSv: 'Dag 3 — Förmiddag', labelEn: 'Day 3 — Morning' },
|
||||||
|
];
|
||||||
|
for (const s of slots) {
|
||||||
|
const startsAt = new Date(baseDay);
|
||||||
|
startsAt.setUTCDate(baseDay.getUTCDate() + s.offsetDays);
|
||||||
|
startsAt.setUTCHours(s.startH, 0, 0, 0);
|
||||||
|
const endsAt = new Date(startsAt);
|
||||||
|
endsAt.setUTCHours(s.endH, 0, 0, 0);
|
||||||
|
|
||||||
|
await prisma.pickupSlot.create({
|
||||||
|
data: {
|
||||||
|
labelSv: s.labelSv,
|
||||||
|
labelEn: s.labelEn,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
capacity: 50,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`✓ Seeded ${slots.length} pickup slots`);
|
||||||
|
} else {
|
||||||
|
console.log(`• Pickup slots already exist (${existing}) — skipping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
BIN
public/gasol247-logo.png
Normal file
BIN
public/gasol247-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 581 KiB |
92
src/app/[locale]/admin/bookings/[id]/actions.ts
Normal file
92
src/app/[locale]/admin/bookings/[id]/actions.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import {
|
||||||
|
clampFulfillment,
|
||||||
|
deriveBookingStatus,
|
||||||
|
} from '@/lib/bookingStatus';
|
||||||
|
|
||||||
|
async function recomputeBookingStatus(bookingId: string) {
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
if (!booking) return;
|
||||||
|
const next = deriveBookingStatus(booking.status, booking.items);
|
||||||
|
if (next !== booking.status) {
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { id: bookingId },
|
||||||
|
data: { status: next },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setItemFulfillment(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const bookingId = String(formData.get('bookingId') ?? '');
|
||||||
|
const itemId = String(formData.get('itemId') ?? '');
|
||||||
|
const deliveredRaw = Number(formData.get('delivered') ?? 0);
|
||||||
|
const returnedRaw = Number(formData.get('returned') ?? 0);
|
||||||
|
if (!bookingId || !itemId) return;
|
||||||
|
|
||||||
|
const item = await prisma.bookingItem.findUnique({ where: { id: itemId } });
|
||||||
|
if (!item || item.bookingId !== bookingId) return;
|
||||||
|
|
||||||
|
const { delivered, returned } = clampFulfillment(
|
||||||
|
item.quantity,
|
||||||
|
deliveredRaw,
|
||||||
|
returnedRaw,
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.bookingItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: { deliveredQuantity: delivered, returnedQuantity: returned },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recomputeBookingStatus(bookingId);
|
||||||
|
revalidatePath(`/admin/bookings/${bookingId}`);
|
||||||
|
revalidatePath('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllDelivered(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const bookingId = String(formData.get('bookingId') ?? '');
|
||||||
|
if (!bookingId) return;
|
||||||
|
|
||||||
|
const items = await prisma.bookingItem.findMany({ where: { bookingId } });
|
||||||
|
await prisma.$transaction(
|
||||||
|
items.map((it) =>
|
||||||
|
prisma.bookingItem.update({
|
||||||
|
where: { id: it.id },
|
||||||
|
data: { deliveredQuantity: it.quantity },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await recomputeBookingStatus(bookingId);
|
||||||
|
revalidatePath(`/admin/bookings/${bookingId}`);
|
||||||
|
revalidatePath('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllReturned(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const bookingId = String(formData.get('bookingId') ?? '');
|
||||||
|
if (!bookingId) return;
|
||||||
|
|
||||||
|
const items = await prisma.bookingItem.findMany({ where: { bookingId } });
|
||||||
|
// "Return all" means: everything that was handed out comes back. If nothing
|
||||||
|
// was marked delivered we treat it as a full handout + return cycle.
|
||||||
|
await prisma.$transaction(
|
||||||
|
items.map((it) => {
|
||||||
|
const delivered = it.deliveredQuantity > 0 ? it.deliveredQuantity : it.quantity;
|
||||||
|
return prisma.bookingItem.update({
|
||||||
|
where: { id: it.id },
|
||||||
|
data: { deliveredQuantity: delivered, returnedQuantity: delivered },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await recomputeBookingStatus(bookingId);
|
||||||
|
revalidatePath(`/admin/bookings/${bookingId}`);
|
||||||
|
revalidatePath('/admin');
|
||||||
|
}
|
||||||
288
src/app/[locale]/admin/bookings/[id]/page.tsx
Normal file
288
src/app/[locale]/admin/bookings/[id]/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { formatOre } from '@/lib/money';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
import { FulfillmentTable } from '@/components/admin/FulfillmentTable';
|
||||||
|
import { sendBookingConfirmation } from '@/lib/mailjet';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function setStatus(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
const status = String(formData.get('status') ?? '');
|
||||||
|
if (!id || !status) return;
|
||||||
|
await prisma.booking.update({ where: { id }, data: { status } });
|
||||||
|
revalidatePath(`/admin/bookings/${id}`);
|
||||||
|
revalidatePath('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendEmail(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { items: true, pickupSlot: true },
|
||||||
|
});
|
||||||
|
if (booking) {
|
||||||
|
await sendBookingConfirmation(booking);
|
||||||
|
}
|
||||||
|
revalidatePath(`/admin/bookings/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BookingDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.bookings');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
const loc = locale as 'sv' | 'en';
|
||||||
|
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { items: true, pickupSlot: true },
|
||||||
|
});
|
||||||
|
if (!booking) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">
|
||||||
|
{t('detail.title', { number: booking.bookingNumber })}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge status={booking.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div className="card p-4 lg:col-span-2">
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{t('detail.items')}
|
||||||
|
</h2>
|
||||||
|
<table className="mt-2 w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-ink-500">
|
||||||
|
<th className="py-1">SKU</th>
|
||||||
|
<th className="py-1">{loc === 'sv' ? 'Namn' : 'Name'}</th>
|
||||||
|
<th className="py-1 text-right">{c('quantity')}</th>
|
||||||
|
<th className="py-1 text-right">{c('price')}</th>
|
||||||
|
<th className="py-1 text-right">{c('total')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{booking.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td className="py-2 font-mono text-xs">{it.sku}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{loc === 'sv' ? it.nameSv : it.nameEn}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right tabular-nums">
|
||||||
|
{it.quantity}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right tabular-nums">
|
||||||
|
{formatOre(it.unitPriceOre, loc)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right tabular-nums">
|
||||||
|
{formatOre(it.lineTotalOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t border-ink-200">
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="pt-2 text-ink-600">
|
||||||
|
{c('subtotal')}
|
||||||
|
</td>
|
||||||
|
<td className="pt-2 text-right tabular-nums">
|
||||||
|
{formatOre(booking.subtotalOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-ink-500">
|
||||||
|
{c('ofWhichVat')}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums text-ink-500">
|
||||||
|
{formatOre(booking.vatOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="pt-1 font-semibold">
|
||||||
|
{c('total')}
|
||||||
|
</td>
|
||||||
|
<td className="pt-1 text-right font-semibold tabular-nums">
|
||||||
|
{formatOre(booking.totalOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{booking.notes && (
|
||||||
|
<div className="mt-4 rounded-lg bg-ink-50 p-3 text-sm">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
Notes
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-ink-700">
|
||||||
|
{booking.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<FulfillmentTable
|
||||||
|
bookingId={booking.id}
|
||||||
|
items={booking.items.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
sku: it.sku,
|
||||||
|
nameSv: it.nameSv,
|
||||||
|
nameEn: it.nameEn,
|
||||||
|
quantity: it.quantity,
|
||||||
|
deliveredQuantity: it.deliveredQuantity,
|
||||||
|
returnedQuantity: it.returnedQuantity,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{t('detail.customer')}
|
||||||
|
</h2>
|
||||||
|
<dl className="mt-2 space-y-1 text-sm">
|
||||||
|
<DLRow label={loc === 'sv' ? 'Organisation' : 'Organization'} value={booking.orgName} />
|
||||||
|
<DLRow label="Org.nr" value={booking.orgNumber} />
|
||||||
|
<DLRow label={loc === 'sv' ? 'Kontakt' : 'Contact'} value={booking.contactName} />
|
||||||
|
<DLRow
|
||||||
|
label="E-post"
|
||||||
|
value={
|
||||||
|
<a className="text-brand-600 hover:underline" href={`mailto:${booking.email}`}>
|
||||||
|
{booking.email}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DLRow label={loc === 'sv' ? 'Telefon' : 'Phone'} value={booking.phone} />
|
||||||
|
<DLRow
|
||||||
|
label={loc === 'sv' ? 'Adress' : 'Address'}
|
||||||
|
value={
|
||||||
|
<span>
|
||||||
|
{booking.address}
|
||||||
|
<br />
|
||||||
|
{booking.postalCode} {booking.city}
|
||||||
|
<br />
|
||||||
|
{booking.country}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.pickupSlot && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{t('detail.pickup')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
{loc === 'sv'
|
||||||
|
? booking.pickupSlot.labelSv
|
||||||
|
: booking.pickupSlot.labelEn}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">
|
||||||
|
{booking.pickupSlot.startsAt.toLocaleString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
Actions
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-ink-500">
|
||||||
|
{loc === 'sv'
|
||||||
|
? 'Status uppdateras automatiskt från utlämningsraderna.'
|
||||||
|
: 'Status updates automatically from the handout rows.'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{booking.status !== 'INVOICED' &&
|
||||||
|
booking.status !== 'CANCELLED' && (
|
||||||
|
<StatusButton
|
||||||
|
bookingId={booking.id}
|
||||||
|
status="INVOICED"
|
||||||
|
label={t('detail.markInvoiced')}
|
||||||
|
setStatus={setStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{booking.status !== 'CANCELLED' && (
|
||||||
|
<StatusButton
|
||||||
|
bookingId={booking.id}
|
||||||
|
status="CANCELLED"
|
||||||
|
label={t('detail.markCancelled')}
|
||||||
|
setStatus={setStatus}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<form action={resendEmail}>
|
||||||
|
<input type="hidden" name="id" value={booking.id} />
|
||||||
|
<button className="btn-secondary w-full text-sm" type="submit">
|
||||||
|
{t('detail.resendEmail')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusButton({
|
||||||
|
bookingId,
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
setStatus,
|
||||||
|
destructive,
|
||||||
|
}: {
|
||||||
|
bookingId: string;
|
||||||
|
status: string;
|
||||||
|
label: string;
|
||||||
|
setStatus: (fd: FormData) => Promise<void>;
|
||||||
|
destructive?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form action={setStatus}>
|
||||||
|
<input type="hidden" name="id" value={bookingId} />
|
||||||
|
<input type="hidden" name="status" value={status} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`w-full text-sm ${destructive ? 'btn border border-red-200 bg-white text-red-700 hover:bg-red-50' : 'btn-secondary'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DLRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-ink-500">{label}</dt>
|
||||||
|
<dd className="text-right text-ink-900">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/app/[locale]/admin/layout.tsx
Normal file
90
src/app/[locale]/admin/layout.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { signOut } from '@/auth';
|
||||||
|
import { getSafeSession } from '@/lib/safeAuth';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const session = await getSafeSession();
|
||||||
|
const t = await getTranslations('admin');
|
||||||
|
|
||||||
|
// Public login page is handled in admin/login/page.tsx — but layout still wraps it.
|
||||||
|
// For non-login admin routes we redirect when not signed in via a route segment guard.
|
||||||
|
// Here we expose `session` to the rendered children via a server util; simpler:
|
||||||
|
// we redirect from this layout only when path is NOT /admin/login. Since segment
|
||||||
|
// info isn't easily accessible, we let each page check itself. Login page will not redirect.
|
||||||
|
// We do the protection by rendering the nav only when signed in; pages must call requireAdmin().
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink-50">
|
||||||
|
<header className="border-b border-ink-200 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin" className="text-base font-semibold text-ink-900">
|
||||||
|
{t('title')}
|
||||||
|
</Link>
|
||||||
|
{session?.user && (
|
||||||
|
<nav className="hidden gap-1 sm:flex">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
{t('nav.bookings')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/products"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
{t('nav.products')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/pickup-slots"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
{t('nav.pickupSlots')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/settings"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
{t('nav.users')}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
{session?.user && (
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
'use server';
|
||||||
|
await signOut({ redirectTo: '/admin/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="submit" className="btn-ghost text-xs">
|
||||||
|
{t('nav.signOut')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
98
src/app/[locale]/admin/login/page.tsx
Normal file
98
src/app/[locale]/admin/login/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { signIn } from '@/auth';
|
||||||
|
import { getSafeSession } from '@/lib/safeAuth';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<{ error?: string; from?: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const { error, from } = await searchParams;
|
||||||
|
|
||||||
|
const session = await getSafeSession();
|
||||||
|
if (session?.user) {
|
||||||
|
redirect(`/${locale === 'sv' ? '' : locale + '/'}admin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('admin.login');
|
||||||
|
|
||||||
|
async function login(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const email = String(formData.get('email') ?? '');
|
||||||
|
const password = String(formData.get('password') ?? '');
|
||||||
|
try {
|
||||||
|
await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirectTo: from || '/admin',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Auth.js v5 throws an error to trigger the redirect; rethrow non-redirect ones.
|
||||||
|
// Errors that are not "NEXT_REDIRECT" should be re-thrown so Next.js handles routing.
|
||||||
|
if (
|
||||||
|
e &&
|
||||||
|
typeof e === 'object' &&
|
||||||
|
'digest' in e &&
|
||||||
|
typeof (e as { digest?: unknown }).digest === 'string' &&
|
||||||
|
((e as { digest: string }).digest.startsWith('NEXT_REDIRECT') ||
|
||||||
|
(e as { digest: string }).digest.startsWith('NEXT_HTTP_ERROR'))
|
||||||
|
) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
redirect(`/admin/login?error=1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink-50">
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto flex max-w-md flex-col px-4 py-12">
|
||||||
|
<div className="card p-6">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||||
|
<form action={login} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="email">
|
||||||
|
{t('email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="input mt-1"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="password">
|
||||||
|
{t('password')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="input mt-1"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||||
|
{t('error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn-primary w-full">
|
||||||
|
{t('submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/app/[locale]/admin/page.tsx
Normal file
219
src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { formatOre } from '@/lib/money';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
import { AdminFilters } from '@/components/admin/AdminFilters';
|
||||||
|
import { SortHeader } from '@/components/admin/SortHeader';
|
||||||
|
import { Pagination } from '@/components/admin/Pagination';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
const SORT_WHITELIST: Record<string, Prisma.BookingOrderByWithRelationInput> = {
|
||||||
|
createdAt: { createdAt: 'desc' },
|
||||||
|
bookingNumber: { bookingNumber: 'desc' },
|
||||||
|
orgName: { orgName: 'desc' },
|
||||||
|
contactName: { contactName: 'desc' },
|
||||||
|
totalOre: { totalOre: 'desc' },
|
||||||
|
status: { status: 'desc' },
|
||||||
|
pickupSlot: { pickupSlot: { startsAt: 'desc' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildOrderBy(
|
||||||
|
sort: string,
|
||||||
|
dir: 'asc' | 'desc',
|
||||||
|
): Prisma.BookingOrderByWithRelationInput {
|
||||||
|
const base = SORT_WHITELIST[sort] ?? SORT_WHITELIST.createdAt;
|
||||||
|
// Replace direction
|
||||||
|
if ('pickupSlot' in base) {
|
||||||
|
return { pickupSlot: { startsAt: dir } };
|
||||||
|
}
|
||||||
|
const [key] = Object.keys(base);
|
||||||
|
return { [key]: dir } as Prisma.BookingOrderByWithRelationInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminBookingsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: string;
|
||||||
|
sort?: string;
|
||||||
|
dir?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.bookings');
|
||||||
|
const sp = await searchParams;
|
||||||
|
|
||||||
|
const loc = locale as 'sv' | 'en';
|
||||||
|
const page = Math.max(1, parseInt(sp.page ?? '1', 10) || 1);
|
||||||
|
const sort = sp.sort && SORT_WHITELIST[sp.sort] ? sp.sort : 'createdAt';
|
||||||
|
const dir: 'asc' | 'desc' = sp.dir === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
const where: Prisma.BookingWhereInput = {
|
||||||
|
...(sp.status && sp.status !== 'all' ? { status: sp.status } : {}),
|
||||||
|
...(sp.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ bookingNumber: { contains: sp.q } },
|
||||||
|
{ orgName: { contains: sp.q } },
|
||||||
|
{ email: { contains: sp.q } },
|
||||||
|
{ contactName: { contains: sp.q } },
|
||||||
|
{ orgNumber: { contains: sp.q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, bookings] = await Promise.all([
|
||||||
|
prisma.booking.count({ where }),
|
||||||
|
prisma.booking.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: buildOrderBy(sort, dir),
|
||||||
|
skip: (page - 1) * PAGE_SIZE,
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
include: { pickupSlot: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build export URL preserving current filters (skip page/sort).
|
||||||
|
const exportParams = new URLSearchParams();
|
||||||
|
if (sp.status) exportParams.set('status', sp.status);
|
||||||
|
if (sp.q) exportParams.set('q', sp.q);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||||
|
<a
|
||||||
|
href={`/api/admin/export?${exportParams.toString()}`}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
↓ {t('export')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminFilters />
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-ink-500">{t('empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-ink-50 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5">
|
||||||
|
<SortHeader
|
||||||
|
field="bookingNumber"
|
||||||
|
label={t('columns.number')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5">
|
||||||
|
<SortHeader field="createdAt" label={t('columns.date')} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5">
|
||||||
|
<SortHeader field="orgName" label={t('columns.org')} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5">
|
||||||
|
<SortHeader
|
||||||
|
field="contactName"
|
||||||
|
label={t('columns.contact')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5">
|
||||||
|
<SortHeader
|
||||||
|
field="pickupSlot"
|
||||||
|
label={t('detail.pickup')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
<SortHeader
|
||||||
|
field="totalOre"
|
||||||
|
label={t('columns.total')}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5">
|
||||||
|
<SortHeader field="status" label={t('columns.status')} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<tr key={b.id} className="hover:bg-ink-50/50">
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs">
|
||||||
|
{b.bookingNumber}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink-600">
|
||||||
|
{b.createdAt.toLocaleDateString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="font-medium text-ink-900">
|
||||||
|
{b.orgName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-500">{b.orgNumber}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div>{b.contactName}</div>
|
||||||
|
<div className="text-xs text-ink-500">{b.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{b.pickupSlot ? (
|
||||||
|
<>
|
||||||
|
<div className="text-ink-900">
|
||||||
|
{loc === 'sv'
|
||||||
|
? b.pickupSlot.labelSv
|
||||||
|
: b.pickupSlot.labelEn}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-500">
|
||||||
|
{b.pickupSlot.startsAt.toLocaleString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'short', timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||||
|
{formatOre(b.totalOre, loc)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<StatusBadge status={b.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/bookings/${b.id}`}
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t('view')} →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={PAGE_SIZE} total={total} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/[locale]/admin/pickup-slots/[id]/page.tsx
Normal file
94
src/app/[locale]/admin/pickup-slots/[id]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { PickupSlotForm } from '@/components/admin/PickupSlotForm';
|
||||||
|
import { updatePickupSlot, deletePickupSlot } from '../actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Convert a UTC Date to a "YYYY-MM-DDTHH:mm" string in the server's local
|
||||||
|
// timezone — that's the format datetime-local inputs expect.
|
||||||
|
function toLocalInputValue(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditPickupSlotPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
searchParams: Promise<{ error?: string; count?: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.pickupSlots');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
const { error, count } = await searchParams;
|
||||||
|
|
||||||
|
const slot = await prisma.pickupSlot.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { bookings: true } } },
|
||||||
|
});
|
||||||
|
if (!slot) notFound();
|
||||||
|
|
||||||
|
const inUseCount = slot._count.bookings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/pickup-slots" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">
|
||||||
|
{t('edit')} ·{' '}
|
||||||
|
<span className="text-ink-600">
|
||||||
|
{locale === 'sv' ? slot.labelSv : slot.labelEn}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error === 'in-use' && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
{t('cannotDelete', { count: count ?? '?' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PickupSlotForm
|
||||||
|
mode="edit"
|
||||||
|
slot={{
|
||||||
|
id: slot.id,
|
||||||
|
labelSv: slot.labelSv,
|
||||||
|
labelEn: slot.labelEn,
|
||||||
|
startsAt: toLocalInputValue(slot.startsAt),
|
||||||
|
endsAt: toLocalInputValue(slot.endsAt),
|
||||||
|
capacity: slot.capacity,
|
||||||
|
active: slot.active,
|
||||||
|
}}
|
||||||
|
action={updatePickupSlot}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={deletePickupSlot}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={slot.id} />
|
||||||
|
<div className="text-red-700">
|
||||||
|
{inUseCount > 0
|
||||||
|
? t('cannotDelete', { count: inUseCount })
|
||||||
|
: t('deleteConfirm')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={inUseCount > 0}
|
||||||
|
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/app/[locale]/admin/pickup-slots/actions.ts
Normal file
101
src/app/[locale]/admin/pickup-slots/actions.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
|
||||||
|
// datetime-local inputs return "YYYY-MM-DDTHH:mm" in the browser's local
|
||||||
|
// timezone. We treat them as local time and convert via Date constructor
|
||||||
|
// (which interprets the string in the server's timezone — for a single-host
|
||||||
|
// deploy that's fine; document if you ever shard across timezones).
|
||||||
|
const slotSchema = z
|
||||||
|
.object({
|
||||||
|
labelSv: z.string().trim().min(1).max(200),
|
||||||
|
labelEn: z.string().trim().min(1).max(200),
|
||||||
|
startsAt: z.string().trim().min(1),
|
||||||
|
endsAt: z.string().trim().min(1),
|
||||||
|
capacity: z.coerce.number().int().min(0).max(10000),
|
||||||
|
active: z.preprocess(
|
||||||
|
(v) => v === 'on' || v === 'true' || v === true,
|
||||||
|
z.boolean(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.refine((d) => new Date(d.startsAt) < new Date(d.endsAt), {
|
||||||
|
path: ['endsAt'],
|
||||||
|
message: 'invalidTime',
|
||||||
|
});
|
||||||
|
|
||||||
|
type Parsed = z.infer<typeof slotSchema>;
|
||||||
|
|
||||||
|
function toDbFields(d: Parsed) {
|
||||||
|
return {
|
||||||
|
labelSv: d.labelSv,
|
||||||
|
labelEn: d.labelEn,
|
||||||
|
startsAt: new Date(d.startsAt),
|
||||||
|
endsAt: new Date(d.endsAt),
|
||||||
|
capacity: d.capacity,
|
||||||
|
active: d.active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromFormData(fd: FormData): Parsed {
|
||||||
|
return slotSchema.parse({
|
||||||
|
labelSv: fd.get('labelSv') ?? '',
|
||||||
|
labelEn: fd.get('labelEn') ?? '',
|
||||||
|
startsAt: fd.get('startsAt') ?? '',
|
||||||
|
endsAt: fd.get('endsAt') ?? '',
|
||||||
|
capacity: fd.get('capacity') ?? 0,
|
||||||
|
active: fd.get('active') ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPickupSlot(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const data = fromFormData(formData);
|
||||||
|
await prisma.pickupSlot.create({ data: toDbFields(data) });
|
||||||
|
revalidatePath('/admin/pickup-slots');
|
||||||
|
revalidatePath('/');
|
||||||
|
redirect('/admin/pickup-slots');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePickupSlot(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
const data = fromFormData(formData);
|
||||||
|
await prisma.pickupSlot.update({ where: { id }, data: toDbFields(data) });
|
||||||
|
revalidatePath('/admin/pickup-slots');
|
||||||
|
revalidatePath(`/admin/pickup-slots/${id}`);
|
||||||
|
revalidatePath('/');
|
||||||
|
redirect('/admin/pickup-slots');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function togglePickupSlotActive(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
const cur = await prisma.pickupSlot.findUnique({ where: { id } });
|
||||||
|
if (!cur) return;
|
||||||
|
await prisma.pickupSlot.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: !cur.active },
|
||||||
|
});
|
||||||
|
revalidatePath('/admin/pickup-slots');
|
||||||
|
revalidatePath('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePickupSlot(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
const inUse = await prisma.booking.count({ where: { pickupSlotId: id } });
|
||||||
|
if (inUse > 0) {
|
||||||
|
redirect(`/admin/pickup-slots/${id}?error=in-use&count=${inUse}`);
|
||||||
|
}
|
||||||
|
await prisma.pickupSlot.delete({ where: { id } });
|
||||||
|
revalidatePath('/admin/pickup-slots');
|
||||||
|
revalidatePath('/');
|
||||||
|
redirect('/admin/pickup-slots');
|
||||||
|
}
|
||||||
29
src/app/[locale]/admin/pickup-slots/new/page.tsx
Normal file
29
src/app/[locale]/admin/pickup-slots/new/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { PickupSlotForm } from '@/components/admin/PickupSlotForm';
|
||||||
|
import { createPickupSlot } from '../actions';
|
||||||
|
|
||||||
|
export default async function NewPickupSlotPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.pickupSlots');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/pickup-slots" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
|
||||||
|
</div>
|
||||||
|
<PickupSlotForm mode="create" action={createPickupSlot} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/app/[locale]/admin/pickup-slots/page.tsx
Normal file
137
src/app/[locale]/admin/pickup-slots/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { togglePickupSlotActive } from './actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AdminPickupSlotsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.pickupSlots');
|
||||||
|
const loc = locale as 'sv' | 'en';
|
||||||
|
|
||||||
|
const slots = await prisma.pickupSlot.findMany({
|
||||||
|
orderBy: { startsAt: 'asc' },
|
||||||
|
include: { _count: { select: { bookings: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||||
|
<Link href="/admin/pickup-slots/new" className="btn-primary text-sm">
|
||||||
|
+ {t('new')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{slots.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-ink-500">
|
||||||
|
{t('empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wide text-ink-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.label')}</th>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.when')}</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.capacity')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.bookings')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-center">
|
||||||
|
{t('columns.active')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{slots.map((s) => {
|
||||||
|
const taken = s._count.bookings;
|
||||||
|
const left = Math.max(0, s.capacity - taken);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={s.id}
|
||||||
|
className={`hover:bg-ink-50/50 ${s.active ? '' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="font-medium text-ink-900">
|
||||||
|
{loc === 'sv' ? s.labelSv : s.labelEn}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-500">
|
||||||
|
{loc === 'sv' ? s.labelEn : s.labelSv}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div>
|
||||||
|
{s.startsAt.toLocaleString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-500">
|
||||||
|
→{' '}
|
||||||
|
{s.endsAt.toLocaleString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||||
|
{s.capacity}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||||
|
{taken}
|
||||||
|
<span className="ml-1 text-xs text-ink-400">
|
||||||
|
({left} {loc === 'sv' ? 'kvar' : 'left'})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-center">
|
||||||
|
<form
|
||||||
|
action={togglePickupSlotActive}
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={s.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`badge ${
|
||||||
|
s.active
|
||||||
|
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
|
||||||
|
: 'bg-ink-200 text-ink-700 hover:bg-ink-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.active ? '✓' : '○'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/pickup-slots/${s.id}`}
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t('edit')} →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/app/[locale]/admin/products/[id]/page.tsx
Normal file
87
src/app/[locale]/admin/products/[id]/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { ProductForm } from '@/components/admin/ProductForm';
|
||||||
|
import { updateProduct, deleteProduct } from '../actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function EditProductPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
searchParams: Promise<{ error?: string; count?: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.products');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
const { error, count } = await searchParams;
|
||||||
|
|
||||||
|
const product = await prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { bookingItems: true } } },
|
||||||
|
});
|
||||||
|
if (!product) notFound();
|
||||||
|
|
||||||
|
const inUseCount = product._count.bookingItems;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/products" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">
|
||||||
|
{t('edit')} · <span className="font-mono">{product.sku}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error === 'in-use' && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
{t('cannotDelete', { count: count ?? '?' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductForm
|
||||||
|
mode="edit"
|
||||||
|
product={{
|
||||||
|
id: product.id,
|
||||||
|
sku: product.sku,
|
||||||
|
nameSv: product.nameSv,
|
||||||
|
nameEn: product.nameEn,
|
||||||
|
descriptionSv: product.descriptionSv,
|
||||||
|
descriptionEn: product.descriptionEn,
|
||||||
|
priceOre: product.priceOre,
|
||||||
|
vatBp: product.vatBp,
|
||||||
|
sortOrder: product.sortOrder,
|
||||||
|
active: product.active,
|
||||||
|
}}
|
||||||
|
action={updateProduct}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={deleteProduct}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={product.id} />
|
||||||
|
<div className="text-red-700">
|
||||||
|
{inUseCount > 0
|
||||||
|
? t('cannotDelete', { count: inUseCount })
|
||||||
|
: t('deleteConfirm')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={inUseCount > 0}
|
||||||
|
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/[locale]/admin/products/actions.ts
Normal file
106
src/app/[locale]/admin/products/actions.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
|
||||||
|
// Form coercion: HTML number inputs send strings; we sanitize to int/float.
|
||||||
|
const productSchema = z.object({
|
||||||
|
sku: z.string().trim().min(1).max(50),
|
||||||
|
nameSv: z.string().trim().min(1).max(200),
|
||||||
|
nameEn: z.string().trim().min(1).max(200),
|
||||||
|
descriptionSv: z.string().trim().max(2000),
|
||||||
|
descriptionEn: z.string().trim().max(2000),
|
||||||
|
priceSek: z.coerce.number().min(0).max(1_000_000),
|
||||||
|
vatPct: z.coerce.number().min(0).max(100),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(10000),
|
||||||
|
active: z.preprocess(
|
||||||
|
(v) => v === 'on' || v === 'true' || v === true,
|
||||||
|
z.boolean(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Parsed = z.infer<typeof productSchema>;
|
||||||
|
|
||||||
|
function toDbFields(d: Parsed) {
|
||||||
|
return {
|
||||||
|
sku: d.sku,
|
||||||
|
nameSv: d.nameSv,
|
||||||
|
nameEn: d.nameEn,
|
||||||
|
descriptionSv: d.descriptionSv,
|
||||||
|
descriptionEn: d.descriptionEn,
|
||||||
|
priceOre: Math.round(d.priceSek * 100),
|
||||||
|
vatBp: Math.round(d.vatPct * 100),
|
||||||
|
sortOrder: d.sortOrder,
|
||||||
|
active: d.active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromFormData(fd: FormData): Parsed {
|
||||||
|
const obj = {
|
||||||
|
sku: fd.get('sku') ?? '',
|
||||||
|
nameSv: fd.get('nameSv') ?? '',
|
||||||
|
nameEn: fd.get('nameEn') ?? '',
|
||||||
|
descriptionSv: fd.get('descriptionSv') ?? '',
|
||||||
|
descriptionEn: fd.get('descriptionEn') ?? '',
|
||||||
|
priceSek: fd.get('priceSek') ?? 0,
|
||||||
|
vatPct: fd.get('vatPct') ?? 25,
|
||||||
|
sortOrder: fd.get('sortOrder') ?? 0,
|
||||||
|
active: fd.get('active') ?? false,
|
||||||
|
};
|
||||||
|
return productSchema.parse(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProduct(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const data = fromFormData(formData);
|
||||||
|
await prisma.product.create({ data: toDbFields(data) });
|
||||||
|
revalidatePath('/admin/products');
|
||||||
|
revalidatePath('/');
|
||||||
|
redirect('/admin/products');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
const data = fromFormData(formData);
|
||||||
|
await prisma.product.update({ where: { id }, data: toDbFields(data) });
|
||||||
|
revalidatePath('/admin/products');
|
||||||
|
revalidatePath(`/admin/products/${id}`);
|
||||||
|
revalidatePath('/');
|
||||||
|
redirect('/admin/products');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleProductActive(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
const cur = await prisma.product.findUnique({ where: { id } });
|
||||||
|
if (!cur) return;
|
||||||
|
await prisma.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: !cur.active },
|
||||||
|
});
|
||||||
|
revalidatePath('/admin/products');
|
||||||
|
revalidatePath('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProduct(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
// Guard: never delete products that have booking history — would break
|
||||||
|
// invoicing/reporting. Deactivate is the right move there.
|
||||||
|
const inUse = await prisma.bookingItem.count({ where: { productId: id } });
|
||||||
|
if (inUse > 0) {
|
||||||
|
// Soft-redirect with error hint in query.
|
||||||
|
redirect(`/admin/products/${id}?error=in-use&count=${inUse}`);
|
||||||
|
}
|
||||||
|
await prisma.product.delete({ where: { id } });
|
||||||
|
revalidatePath('/admin/products');
|
||||||
|
revalidatePath('/');
|
||||||
|
redirect('/admin/products');
|
||||||
|
}
|
||||||
29
src/app/[locale]/admin/products/new/page.tsx
Normal file
29
src/app/[locale]/admin/products/new/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { ProductForm } from '@/components/admin/ProductForm';
|
||||||
|
import { createProduct } from '../actions';
|
||||||
|
|
||||||
|
export default async function NewProductPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.products');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/products" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
|
||||||
|
</div>
|
||||||
|
<ProductForm mode="create" action={createProduct} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/[locale]/admin/products/page.tsx
Normal file
115
src/app/[locale]/admin/products/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { formatOre } from '@/lib/money';
|
||||||
|
import { toggleProductActive } from './actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AdminProductsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.products');
|
||||||
|
const loc = locale as 'sv' | 'en';
|
||||||
|
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
orderBy: [{ active: 'desc' }, { sortOrder: 'asc' }],
|
||||||
|
include: { _count: { select: { bookingItems: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||||
|
<Link href="/admin/products/new" className="btn-primary text-sm">
|
||||||
|
+ {t('new')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-ink-500">
|
||||||
|
{t('empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wide text-ink-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.sku')}</th>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.name')}</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.price')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.vat')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-center">
|
||||||
|
{t('columns.active')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{products.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
className={`hover:bg-ink-50/50 ${p.active ? '' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs">{p.sku}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="font-medium text-ink-900">
|
||||||
|
{loc === 'sv' ? p.nameSv : p.nameEn}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-500">
|
||||||
|
{p._count.bookingItems}{' '}
|
||||||
|
{loc === 'sv' ? 'bokningar' : 'bookings'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||||
|
{formatOre(p.priceOre, loc)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-ink-500">
|
||||||
|
{(p.vatBp / 100).toFixed(0)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-center">
|
||||||
|
<form action={toggleProductActive} className="inline">
|
||||||
|
<input type="hidden" name="id" value={p.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`badge ${
|
||||||
|
p.active
|
||||||
|
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
|
||||||
|
: 'bg-ink-200 text-ink-700 hover:bg-ink-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.active ? t('active') : t('inactive')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/products/${p.id}`}
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t('edit')} →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/[locale]/admin/settings/actions.ts
Normal file
17
src/app/[locale]/admin/settings/actions.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { updateSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export async function saveSettings(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const pickupEnabled = formData.get('pickupEnabled') === 'on';
|
||||||
|
await updateSettings({ pickupEnabled });
|
||||||
|
|
||||||
|
// Public booking form depends on this — bust its cache.
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
revalidatePath('/admin/settings');
|
||||||
|
redirect('/admin/settings?saved=1');
|
||||||
|
}
|
||||||
58
src/app/[locale]/admin/settings/page.tsx
Normal file
58
src/app/[locale]/admin/settings/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
import { saveSettings } from './actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AdminSettingsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<{ saved?: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.settings');
|
||||||
|
const { saved } = await searchParams;
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||||
|
|
||||||
|
{saved && (
|
||||||
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
|
{t('saved')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={saveSettings} className="card space-y-5 p-5">
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="pickupEnabled"
|
||||||
|
defaultChecked={settings.pickupEnabled}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-ink-900">
|
||||||
|
{t('pickupEnabled')}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-ink-500">
|
||||||
|
{t('pickupEnabledHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-ink-200 pt-4">
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/[locale]/admin/users/[id]/page.tsx
Normal file
115
src/app/[locale]/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import {
|
||||||
|
AdminUserForm,
|
||||||
|
PasswordChangeForm,
|
||||||
|
} from '@/components/admin/AdminUserForm';
|
||||||
|
import {
|
||||||
|
updateAdmin,
|
||||||
|
changeAdminPassword,
|
||||||
|
deleteAdmin,
|
||||||
|
} from '../actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const KNOWN_ERRORS = [
|
||||||
|
'emailInUse',
|
||||||
|
'cannotDeleteSelf',
|
||||||
|
'cannotDeleteLast',
|
||||||
|
'passwordMismatch',
|
||||||
|
'passwordTooShort',
|
||||||
|
] as const;
|
||||||
|
type ErrorKey = (typeof KNOWN_ERRORS)[number];
|
||||||
|
|
||||||
|
const KNOWN_OK = ['passwordChanged'] as const;
|
||||||
|
type OkKey = (typeof KNOWN_OK)[number];
|
||||||
|
|
||||||
|
export default async function EditAdminUserPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
searchParams: Promise<{ error?: string; ok?: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.users');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
const { error, ok } = await searchParams;
|
||||||
|
|
||||||
|
const admin = await prisma.admin.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
});
|
||||||
|
if (!admin) notFound();
|
||||||
|
|
||||||
|
const isMe = session.user?.id === admin.id;
|
||||||
|
const adminCount = await prisma.admin.count();
|
||||||
|
const canDelete = !isMe && adminCount > 1;
|
||||||
|
|
||||||
|
const errorKey =
|
||||||
|
error && (KNOWN_ERRORS as readonly string[]).includes(error)
|
||||||
|
? (error as ErrorKey)
|
||||||
|
: null;
|
||||||
|
const okKey =
|
||||||
|
ok && (KNOWN_OK as readonly string[]).includes(ok) ? (ok as OkKey) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/users" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">
|
||||||
|
{t('edit')} ·{' '}
|
||||||
|
<span className="text-ink-600">{admin.name}</span>
|
||||||
|
{isMe && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-accent-600">
|
||||||
|
{t('you')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorKey && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{t(errorKey)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{okKey && (
|
||||||
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
|
{t(okKey)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminUserForm mode="edit" admin={admin} action={updateAdmin} />
|
||||||
|
|
||||||
|
<PasswordChangeForm adminId={admin.id} action={changeAdminPassword} />
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={deleteAdmin}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={admin.id} />
|
||||||
|
<div className="text-red-700">
|
||||||
|
{isMe
|
||||||
|
? t('cannotDeleteSelf')
|
||||||
|
: adminCount <= 1
|
||||||
|
? t('cannotDeleteLast')
|
||||||
|
: t('deleteConfirm')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canDelete}
|
||||||
|
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/app/[locale]/admin/users/actions.ts
Normal file
147
src/app/[locale]/admin/users/actions.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
const baseFields = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
email: z.string().trim().toLowerCase().email().max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordField = z
|
||||||
|
.string()
|
||||||
|
.min(8, 'passwordTooShort')
|
||||||
|
.max(200);
|
||||||
|
|
||||||
|
const createSchema = baseFields.extend({
|
||||||
|
password: passwordField,
|
||||||
|
passwordConfirm: z.string(),
|
||||||
|
}).refine((d) => d.password === d.passwordConfirm, {
|
||||||
|
path: ['passwordConfirm'],
|
||||||
|
message: 'passwordMismatch',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = baseFields;
|
||||||
|
|
||||||
|
const passwordChangeSchema = z
|
||||||
|
.object({
|
||||||
|
password: passwordField,
|
||||||
|
passwordConfirm: z.string(),
|
||||||
|
})
|
||||||
|
.refine((d) => d.password === d.passwordConfirm, {
|
||||||
|
path: ['passwordConfirm'],
|
||||||
|
message: 'passwordMismatch',
|
||||||
|
});
|
||||||
|
|
||||||
|
function fail(id: string, code: string): never {
|
||||||
|
redirect(`/admin/users/${id}?error=${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failNew(code: string): never {
|
||||||
|
redirect(`/admin/users/new?error=${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdmin(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = createSchema.safeParse({
|
||||||
|
name: formData.get('name') ?? '',
|
||||||
|
email: formData.get('email') ?? '',
|
||||||
|
password: formData.get('password') ?? '',
|
||||||
|
passwordConfirm: formData.get('passwordConfirm') ?? '',
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
const code = parsed.error.errors[0]?.message ?? 'invalid';
|
||||||
|
failNew(code);
|
||||||
|
}
|
||||||
|
const { name, email, password } = parsed.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
await prisma.admin.create({
|
||||||
|
data: { name, email, passwordHash: hash },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Prisma.PrismaClientKnownRequestError;
|
||||||
|
if (err.code === 'P2002') failNew('emailInUse');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/users');
|
||||||
|
redirect('/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdmin(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse({
|
||||||
|
name: formData.get('name') ?? '',
|
||||||
|
email: formData.get('email') ?? '',
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
fail(id, parsed.error.errors[0]?.message ?? 'invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.admin.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Prisma.PrismaClientKnownRequestError;
|
||||||
|
if (err.code === 'P2002') fail(id, 'emailInUse');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/users');
|
||||||
|
revalidatePath(`/admin/users/${id}`);
|
||||||
|
redirect('/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeAdminPassword(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const parsed = passwordChangeSchema.safeParse({
|
||||||
|
password: formData.get('password') ?? '',
|
||||||
|
passwordConfirm: formData.get('passwordConfirm') ?? '',
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
fail(id, parsed.error.errors[0]?.message ?? 'invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
await prisma.admin.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwordHash: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/admin/users/${id}`);
|
||||||
|
redirect(`/admin/users/${id}?ok=passwordChanged`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdmin(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const id = String(formData.get('id') ?? '');
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
// Two safeguards: you can't lock yourself out, and you can't empty the table.
|
||||||
|
if (session.user?.id === id) {
|
||||||
|
fail(id, 'cannotDeleteSelf');
|
||||||
|
}
|
||||||
|
const count = await prisma.admin.count();
|
||||||
|
if (count <= 1) {
|
||||||
|
fail(id, 'cannotDeleteLast');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.admin.delete({ where: { id } });
|
||||||
|
revalidatePath('/admin/users');
|
||||||
|
redirect('/admin/users');
|
||||||
|
}
|
||||||
46
src/app/[locale]/admin/users/new/page.tsx
Normal file
46
src/app/[locale]/admin/users/new/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import { AdminUserForm } from '@/components/admin/AdminUserForm';
|
||||||
|
import { createAdmin } from '../actions';
|
||||||
|
|
||||||
|
export default async function NewAdminUserPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.users');
|
||||||
|
const c = await getTranslations('common');
|
||||||
|
const { error } = await searchParams;
|
||||||
|
|
||||||
|
const errorKey = error as
|
||||||
|
| 'emailInUse'
|
||||||
|
| 'passwordMismatch'
|
||||||
|
| 'passwordTooShort'
|
||||||
|
| undefined;
|
||||||
|
const errorMsg = errorKey ? t(errorKey) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/users" className="btn-ghost text-sm">
|
||||||
|
← {c('back')}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminUserForm mode="create" action={createAdmin} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/app/[locale]/admin/users/page.tsx
Normal file
89
src/app/[locale]/admin/users/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { requireAdmin } from '@/lib/requireAdmin';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AdminUsersPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const t = await getTranslations('admin.users');
|
||||||
|
const loc = locale as 'sv' | 'en';
|
||||||
|
const currentUserId = session.user?.id;
|
||||||
|
|
||||||
|
const admins = await prisma.admin.findMany({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, name: true, email: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||||
|
<Link href="/admin/users/new" className="btn-primary text-sm">
|
||||||
|
+ {t('new')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{admins.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-ink-500">
|
||||||
|
{t('empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wide text-ink-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.name')}</th>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.email')}</th>
|
||||||
|
<th className="px-4 py-2.5">{t('columns.created')}</th>
|
||||||
|
<th className="px-4 py-2.5 text-right">
|
||||||
|
{t('columns.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{admins.map((a) => {
|
||||||
|
const isMe = a.id === currentUserId;
|
||||||
|
return (
|
||||||
|
<tr key={a.id} className="hover:bg-ink-50/50">
|
||||||
|
<td className="px-4 py-2.5 font-medium text-ink-900">
|
||||||
|
{a.name}
|
||||||
|
{isMe && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-accent-600">
|
||||||
|
{t('you')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink-700">{a.email}</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink-500">
|
||||||
|
{a.createdAt.toLocaleDateString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${a.id}`}
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t('edit')} →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/[locale]/booking/[number]/page.tsx
Normal file
129
src/app/[locale]/booking/[number]/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { formatOre } from '@/lib/money';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
|
||||||
|
export default async function BookingConfirmedPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; number: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, number } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingNumber: number },
|
||||||
|
include: { items: true, pickupSlot: true },
|
||||||
|
});
|
||||||
|
if (!booking) notFound();
|
||||||
|
|
||||||
|
const loc = locale as 'sv' | 'en';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink-50">
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="bg-brand-600 px-6 py-5 text-white">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm4.7-12.3a1 1 0 0 0-1.4-1.4L11 12.6 8.7 10.3a1 1 0 1 0-1.4 1.4l3 3a1 1 0 0 0 1.4 0l5-5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('booking.success.title')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">
|
||||||
|
{t('booking.success.bookingNumber', { number: booking.bookingNumber })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-white/80">
|
||||||
|
{t('booking.success.subtitle', { email: booking.email })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{t('email.orderSummary')}
|
||||||
|
</h2>
|
||||||
|
<table className="mt-2 w-full text-sm">
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{booking.items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td className="py-2">
|
||||||
|
{loc === 'sv' ? it.nameSv : it.nameEn}
|
||||||
|
<span className="ml-2 text-ink-400">×{it.quantity}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right tabular-nums">
|
||||||
|
{formatOre(it.lineTotalOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-ink-200">
|
||||||
|
<td className="pt-2 text-ink-600">
|
||||||
|
{t('common.subtotal')}
|
||||||
|
</td>
|
||||||
|
<td className="pt-2 text-right tabular-nums">
|
||||||
|
{formatOre(booking.subtotalOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-ink-500">{t('common.ofWhichVat')}</td>
|
||||||
|
<td className="text-right tabular-nums text-ink-500">
|
||||||
|
{formatOre(booking.vatOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="pt-1 font-semibold">{t('common.total')}</td>
|
||||||
|
<td className="pt-1 text-right font-semibold tabular-nums">
|
||||||
|
{formatOre(booking.totalOre, loc)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.pickupSlot && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{t('email.pickup')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
{loc === 'sv'
|
||||||
|
? booking.pickupSlot.labelSv
|
||||||
|
: booking.pickupSlot.labelEn}{' '}
|
||||||
|
·{' '}
|
||||||
|
{booking.pickupSlot.startsAt.toLocaleString(
|
||||||
|
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-ink-50 p-4 text-sm text-ink-600">
|
||||||
|
{t('email.invoiceInfo')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/" className="btn-secondary w-full justify-center">
|
||||||
|
{t('booking.success.newOrder')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/app/[locale]/layout.tsx
Normal file
35
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
if (!routing.locales.includes(locale as 'sv' | 'en')) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/[locale]/page.tsx
Normal file
78
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { BookingForm } from '@/components/BookingForm';
|
||||||
|
|
||||||
|
// Render fresh so toggling settings, products or slots in admin reflects
|
||||||
|
// immediately on the public form. revalidatePath() in admin actions covers
|
||||||
|
// most cases, but force-dynamic removes any cache surprise.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function BookingPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations('booking');
|
||||||
|
|
||||||
|
const [products, slotsRaw, settings] = await Promise.all([
|
||||||
|
prisma.product.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.pickupSlot.findMany({
|
||||||
|
where: { active: true, endsAt: { gte: new Date() } },
|
||||||
|
orderBy: { startsAt: 'asc' },
|
||||||
|
include: { _count: { select: { bookings: true } } },
|
||||||
|
}),
|
||||||
|
getSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const slots = slotsRaw.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
labelSv: s.labelSv,
|
||||||
|
labelEn: s.labelEn,
|
||||||
|
startsAt: s.startsAt.toISOString(),
|
||||||
|
endsAt: s.endsAt.toISOString(),
|
||||||
|
capacityLeft: Math.max(0, s.capacity - s._count.bookings),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink-50">
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-6 sm:py-10">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-ink-900 sm:text-3xl">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-ink-600">{t('intro')}</p>
|
||||||
|
</div>
|
||||||
|
<BookingForm
|
||||||
|
products={products.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
sku: p.sku,
|
||||||
|
nameSv: p.nameSv,
|
||||||
|
nameEn: p.nameEn,
|
||||||
|
descriptionSv: p.descriptionSv,
|
||||||
|
descriptionEn: p.descriptionEn,
|
||||||
|
priceOre: p.priceOre,
|
||||||
|
vatBp: p.vatBp,
|
||||||
|
}))}
|
||||||
|
pickupSlots={settings.pickupEnabled ? slots : []}
|
||||||
|
pickupEnabled={settings.pickupEnabled}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<footer className="border-t border-ink-200 bg-white">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-6 text-xs text-ink-500">
|
||||||
|
© {new Date().getFullYear()} Gasol247 ·{' '}
|
||||||
|
<a href="https://www.gasol247.com" className="hover:underline">
|
||||||
|
gasol247.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/app/api/admin/export/route.ts
Normal file
155
src/app/api/admin/export/route.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSafeSession } from '@/lib/safeAuth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// CSV export of bookings for invoicing. One row per booking line item so the
|
||||||
|
// receiver can sum across SKUs per customer in their accounting software.
|
||||||
|
// Excel-friendly: UTF-8 BOM + ; separator + Windows line endings.
|
||||||
|
|
||||||
|
const SEP = ';';
|
||||||
|
const EOL = '\r\n';
|
||||||
|
|
||||||
|
function csvCell(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
const s = String(v);
|
||||||
|
if (/[";\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(cells: unknown[]): string {
|
||||||
|
return cells.map(csvCell).join(SEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const session = await getSafeSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const status = url.searchParams.get('status') ?? undefined;
|
||||||
|
const q = url.searchParams.get('q') ?? undefined;
|
||||||
|
const fromStr = url.searchParams.get('from');
|
||||||
|
const toStr = url.searchParams.get('to');
|
||||||
|
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
...(status && status !== 'all' ? { status } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ bookingNumber: { contains: q } },
|
||||||
|
{ orgName: { contains: q } },
|
||||||
|
{ email: { contains: q } },
|
||||||
|
{ contactName: { contains: q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(fromStr || toStr
|
||||||
|
? {
|
||||||
|
createdAt: {
|
||||||
|
...(fromStr ? { gte: new Date(fromStr) } : {}),
|
||||||
|
...(toStr ? { lte: new Date(toStr) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: { items: true, pickupSlot: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
row([
|
||||||
|
'BookingNumber',
|
||||||
|
'BookingDate',
|
||||||
|
'Status',
|
||||||
|
'OrgName',
|
||||||
|
'OrgNumber',
|
||||||
|
'ContactName',
|
||||||
|
'Email',
|
||||||
|
'Phone',
|
||||||
|
'Address',
|
||||||
|
'PostalCode',
|
||||||
|
'City',
|
||||||
|
'Country',
|
||||||
|
'SKU',
|
||||||
|
'Product',
|
||||||
|
'Quantity',
|
||||||
|
'DeliveredQuantity',
|
||||||
|
'ReturnedQuantity',
|
||||||
|
'BillableQuantity',
|
||||||
|
'UnitPriceSEK',
|
||||||
|
'BillableNetSEK',
|
||||||
|
'VATPct',
|
||||||
|
'BillableVatSEK',
|
||||||
|
'BillableTotalSEK',
|
||||||
|
'OrderedNetSEK',
|
||||||
|
'OrderedTotalSEK',
|
||||||
|
'PickupLabel',
|
||||||
|
'PickupStart',
|
||||||
|
'Notes',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const b of bookings) {
|
||||||
|
for (const it of b.items) {
|
||||||
|
// Billable = handed out − returned. That's what the customer pays for.
|
||||||
|
const billableQty = Math.max(
|
||||||
|
0,
|
||||||
|
it.deliveredQuantity - it.returnedQuantity,
|
||||||
|
);
|
||||||
|
const unitSek = it.unitPriceOre / 100;
|
||||||
|
const billableNet = unitSek * billableQty;
|
||||||
|
const vatPct = it.vatBp / 100;
|
||||||
|
const billableVat = (billableNet * it.vatBp) / 10000;
|
||||||
|
const billableTotal = billableNet + billableVat;
|
||||||
|
const orderedNet = unitSek * it.quantity;
|
||||||
|
const orderedTotal = it.lineTotalOre / 100;
|
||||||
|
const dec = (n: number) => n.toFixed(2).replace('.', ',');
|
||||||
|
lines.push(
|
||||||
|
row([
|
||||||
|
b.bookingNumber,
|
||||||
|
b.createdAt.toISOString(),
|
||||||
|
b.status,
|
||||||
|
b.orgName,
|
||||||
|
b.orgNumber,
|
||||||
|
b.contactName,
|
||||||
|
b.email,
|
||||||
|
b.phone,
|
||||||
|
b.address,
|
||||||
|
b.postalCode,
|
||||||
|
b.city,
|
||||||
|
b.country,
|
||||||
|
it.sku,
|
||||||
|
it.nameSv,
|
||||||
|
it.quantity,
|
||||||
|
it.deliveredQuantity,
|
||||||
|
it.returnedQuantity,
|
||||||
|
billableQty,
|
||||||
|
dec(unitSek),
|
||||||
|
dec(billableNet),
|
||||||
|
dec(vatPct),
|
||||||
|
dec(billableVat),
|
||||||
|
dec(billableTotal),
|
||||||
|
dec(orderedNet),
|
||||||
|
dec(orderedTotal),
|
||||||
|
b.pickupSlot?.labelSv ?? '',
|
||||||
|
b.pickupSlot?.startsAt.toISOString() ?? '',
|
||||||
|
b.notes ?? '',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = '' + lines.join(EOL) + EOL;
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="bookings-${date}.csv"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from '@/auth';
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
129
src/app/api/bookings/route.ts
Normal file
129
src/app/api/bookings/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { bookingSubmitSchema } from '@/lib/validation';
|
||||||
|
import { generateBookingNumber } from '@/lib/bookingNumber';
|
||||||
|
import { computeTotals, vatAmountOre } from '@/lib/money';
|
||||||
|
import { sendBookingConfirmation } from '@/lib/mailjet';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid-json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = bookingSubmitSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'validation', issues: parsed.error.flatten() },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const input = parsed.data;
|
||||||
|
|
||||||
|
// Pull live products to snapshot prices/names (don't trust client prices).
|
||||||
|
const productIds = input.items.map((i) => i.productId);
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where: { id: { in: productIds }, active: true },
|
||||||
|
});
|
||||||
|
if (products.length !== productIds.length) {
|
||||||
|
return NextResponse.json({ error: 'product-not-found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const productById = new Map(products.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
// If admin has globally disabled the pickup step, drop any slot id the
|
||||||
|
// client sent so we don't tie this booking to a possibly-stale slot.
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.pickupEnabled) {
|
||||||
|
input.pickupSlotId = null;
|
||||||
|
} else if (input.pickupSlotId) {
|
||||||
|
// Validate pickup slot if given
|
||||||
|
const slot = await prisma.pickupSlot.findUnique({
|
||||||
|
where: { id: input.pickupSlotId },
|
||||||
|
});
|
||||||
|
if (!slot || !slot.active) {
|
||||||
|
return NextResponse.json({ error: 'pickup-slot-invalid' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemSnapshots = input.items.map((i) => {
|
||||||
|
const p = productById.get(i.productId)!;
|
||||||
|
const lineNet = p.priceOre * i.quantity;
|
||||||
|
const lineVat = vatAmountOre(lineNet, p.vatBp);
|
||||||
|
return {
|
||||||
|
productId: p.id,
|
||||||
|
sku: p.sku,
|
||||||
|
nameSv: p.nameSv,
|
||||||
|
nameEn: p.nameEn,
|
||||||
|
unitPriceOre: p.priceOre,
|
||||||
|
vatBp: p.vatBp,
|
||||||
|
quantity: i.quantity,
|
||||||
|
lineTotalOre: lineNet + lineVat,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = computeTotals(
|
||||||
|
itemSnapshots.map((i) => ({
|
||||||
|
unitPriceOre: i.unitPriceOre,
|
||||||
|
quantity: i.quantity,
|
||||||
|
vatBp: i.vatBp,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert with retry on unique-number collision (very unlikely with our format).
|
||||||
|
let booking;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const bookingNumber = generateBookingNumber();
|
||||||
|
try {
|
||||||
|
booking = await prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingNumber,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
contactName: input.contactName,
|
||||||
|
email: input.email.toLowerCase(),
|
||||||
|
phone: input.phone,
|
||||||
|
orgName: input.orgName,
|
||||||
|
orgNumber: input.orgNumber,
|
||||||
|
address: input.address,
|
||||||
|
postalCode: input.postalCode,
|
||||||
|
city: input.city,
|
||||||
|
country: input.country,
|
||||||
|
pickupSlotId: input.pickupSlotId ?? null,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
locale: input.locale,
|
||||||
|
subtotalOre: totals.subtotalOre,
|
||||||
|
vatOre: totals.vatOre,
|
||||||
|
totalOre: totals.totalOre,
|
||||||
|
items: { create: itemSnapshots },
|
||||||
|
},
|
||||||
|
include: { items: true, pickupSlot: true },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Prisma.PrismaClientKnownRequestError;
|
||||||
|
if (err.code === 'P2002') continue; // unique violation — retry with new number
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'could-not-generate-booking-number' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send confirmation email (best-effort — don't fail the booking if email fails).
|
||||||
|
const mail = await sendBookingConfirmation(booking);
|
||||||
|
if (!mail.ok) {
|
||||||
|
console.warn('[bookings] confirmation email not sent:', mail.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
bookingNumber: booking.bookingNumber,
|
||||||
|
emailSent: mail.ok,
|
||||||
|
});
|
||||||
|
}
|
||||||
57
src/app/globals.css
Normal file
57
src/app/globals.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: #fffaf5;
|
||||||
|
color: theme('colors.ink.900');
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent-300 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn border border-ink-200 bg-white text-ink-800 hover:bg-ink-50
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent-300;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn text-ink-700 hover:bg-ink-100
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent-300;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-lg border border-ink-200 bg-white px-3 py-2.5 text-sm text-ink-900 placeholder:text-ink-400
|
||||||
|
focus:border-accent-400 focus:outline-none focus:ring-2 focus:ring-accent-300/40;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-ink-700;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl border border-ink-200 bg-white shadow-card;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||||
|
}
|
||||||
|
/* Numeric input with no spinner — used for quantity boxes. */
|
||||||
|
.num-input {
|
||||||
|
@apply w-14 rounded-md border border-ink-200 bg-white py-1.5 text-center text-sm font-medium tabular-nums
|
||||||
|
focus:border-accent-400 focus:outline-none focus:ring-2 focus:ring-accent-300/40;
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.num-input::-webkit-outer-spin-button,
|
||||||
|
.num-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/layout.tsx
Normal file
14
src/app/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import './globals.css';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: 'Gasol247 Bokning',
|
||||||
|
template: '%s — Gasol247 Bokning',
|
||||||
|
},
|
||||||
|
description: 'Boka gasoltuber till Jamboree-lägret.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
28
src/auth.config.ts
Normal file
28
src/auth.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NextAuthConfig } from 'next-auth';
|
||||||
|
|
||||||
|
// Edge-compatible part of the auth config. No DB calls here — those go in auth.ts.
|
||||||
|
export const authConfig = {
|
||||||
|
pages: {
|
||||||
|
signIn: '/admin/login',
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.email = user.email;
|
||||||
|
token.name = user.name;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.email = token.email as string;
|
||||||
|
session.user.name = token.name as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [],
|
||||||
|
session: { strategy: 'jwt' },
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
42
src/auth.ts
Normal file
42
src/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import Credentials from 'next-auth/providers/credentials';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authConfig } from './auth.config';
|
||||||
|
import { prisma } from './lib/prisma';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: 'E-post', type: 'email' },
|
||||||
|
password: { label: 'Lösenord', type: 'password' },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const parsed = loginSchema.safeParse(credentials);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
const admin = await prisma.admin.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
if (!admin) return null;
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
if (!ok) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
name: admin.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
825
src/components/BookingForm.tsx
Normal file
825
src/components/BookingForm.tsx
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { useRouter } from '@/i18n/routing';
|
||||||
|
import { formatOre, computeTotals, priceInclVatOre } from '@/lib/money';
|
||||||
|
import { isValidSeOrgNumber } from '@/lib/orgNumber';
|
||||||
|
import { useBookingStore, type Step } from '@/lib/bookingStore';
|
||||||
|
|
||||||
|
type Product = {
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
nameSv: string;
|
||||||
|
nameEn: string;
|
||||||
|
descriptionSv: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
priceOre: number;
|
||||||
|
vatBp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PickupSlot = {
|
||||||
|
id: string;
|
||||||
|
labelSv: string;
|
||||||
|
labelEn: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
capacityLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_STEPS: Step[] = ['products', 'details', 'pickup', 'review'];
|
||||||
|
|
||||||
|
export function BookingForm({
|
||||||
|
products,
|
||||||
|
pickupSlots,
|
||||||
|
pickupEnabled,
|
||||||
|
}: {
|
||||||
|
products: Product[];
|
||||||
|
pickupSlots: PickupSlot[];
|
||||||
|
pickupEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
// Step list adapts to whether pickup is enabled at all.
|
||||||
|
const STEPS: Step[] = pickupEnabled
|
||||||
|
? ALL_STEPS
|
||||||
|
: ALL_STEPS.filter((s) => s !== 'pickup');
|
||||||
|
const t = useTranslations('booking');
|
||||||
|
const c = useTranslations('common');
|
||||||
|
const locale = useLocale() as 'sv' | 'en';
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Persisted state — survives language switch and tab refresh.
|
||||||
|
const store = useBookingStore();
|
||||||
|
const {
|
||||||
|
step,
|
||||||
|
quantities,
|
||||||
|
contactName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
orgName,
|
||||||
|
orgNumber,
|
||||||
|
address,
|
||||||
|
postalCode,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
pickupSlotId,
|
||||||
|
notes,
|
||||||
|
setStep,
|
||||||
|
setQuantity,
|
||||||
|
patch,
|
||||||
|
reset,
|
||||||
|
} = store;
|
||||||
|
|
||||||
|
// Confirm checkbox is intentionally not persisted; keep local.
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Avoid hydration mismatch: don't render store-backed UI until mounted.
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
useEffect(() => setHydrated(true), []);
|
||||||
|
|
||||||
|
// If the persisted step no longer exists (admin toggled pickup off mid-flow),
|
||||||
|
// bump back to the closest still-valid step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!STEPS.includes(step)) {
|
||||||
|
setStep('products');
|
||||||
|
}
|
||||||
|
}, [step, STEPS, setStep]);
|
||||||
|
|
||||||
|
const selectedItems = useMemo(() => {
|
||||||
|
return products
|
||||||
|
.map((p) => {
|
||||||
|
const qty = quantities[p.id] ?? 0;
|
||||||
|
return qty > 0
|
||||||
|
? {
|
||||||
|
product: p,
|
||||||
|
quantity: qty,
|
||||||
|
unitPriceOre: p.priceOre,
|
||||||
|
vatBp: p.vatBp,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||||
|
}, [products, quantities]);
|
||||||
|
|
||||||
|
const totals = useMemo(
|
||||||
|
() =>
|
||||||
|
computeTotals(
|
||||||
|
selectedItems.map((i) => ({
|
||||||
|
unitPriceOre: i.unitPriceOre,
|
||||||
|
quantity: i.quantity,
|
||||||
|
vatBp: i.vatBp,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
[selectedItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepIndex = STEPS.indexOf(step);
|
||||||
|
|
||||||
|
// Per-step validity. Used both for the Next button and for deciding which
|
||||||
|
// step boxes are clickable.
|
||||||
|
const isStepValid = (s: Step): boolean => {
|
||||||
|
if (s === 'products') return selectedItems.length > 0;
|
||||||
|
if (s === 'details') {
|
||||||
|
return (
|
||||||
|
contactName.trim().length >= 2 &&
|
||||||
|
/^\S+@\S+\.\S+$/.test(email) &&
|
||||||
|
phone.trim().length >= 5 &&
|
||||||
|
orgName.trim().length >= 2 &&
|
||||||
|
isValidSeOrgNumber(orgNumber) &&
|
||||||
|
address.trim().length >= 2 &&
|
||||||
|
postalCode.trim().length >= 3 &&
|
||||||
|
city.trim().length >= 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s === 'pickup') return true; // optional
|
||||||
|
if (s === 'review') return confirm;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canGoNext = (): boolean => isStepValid(step);
|
||||||
|
|
||||||
|
// A target step is reachable if you go backwards, or if all earlier steps
|
||||||
|
// are valid (so you can jump ahead in a wizard).
|
||||||
|
const canReachStep = (target: Step): boolean => {
|
||||||
|
const targetIdx = STEPS.indexOf(target);
|
||||||
|
if (targetIdx <= stepIndex) return true;
|
||||||
|
for (let i = 0; i < targetIdx; i++) {
|
||||||
|
if (!isStepValid(STEPS[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToStep = (target: Step) => {
|
||||||
|
if (canReachStep(target)) setStep(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
const idx = STEPS.indexOf(step);
|
||||||
|
if (idx < STEPS.length - 1) setStep(STEPS[idx + 1]);
|
||||||
|
};
|
||||||
|
const goPrev = () => {
|
||||||
|
const idx = STEPS.indexOf(step);
|
||||||
|
if (idx > 0) setStep(STEPS[idx - 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
contactName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
orgName,
|
||||||
|
orgNumber,
|
||||||
|
address,
|
||||||
|
postalCode,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
// Drop any pickup slot the user might have selected before admin
|
||||||
|
// disabled the step.
|
||||||
|
pickupSlotId: pickupEnabled ? pickupSlotId : null,
|
||||||
|
notes: notes || null,
|
||||||
|
locale,
|
||||||
|
items: selectedItems.map((i) => ({
|
||||||
|
productId: i.product.id,
|
||||||
|
quantity: i.quantity,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const res = await fetch('/api/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error ?? 'submitFailed');
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { bookingNumber: string };
|
||||||
|
reset();
|
||||||
|
router.push(`/booking/${data.bookingNumber}`);
|
||||||
|
} catch {
|
||||||
|
setSubmitError(t('errors.submitFailed'));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a stable placeholder before hydration so SSR markup matches.
|
||||||
|
if (!hydrated) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepIndicator
|
||||||
|
steps={STEPS}
|
||||||
|
step="products"
|
||||||
|
goToStep={() => {}}
|
||||||
|
canReachStep={() => false}
|
||||||
|
/>
|
||||||
|
<div className="card p-6 text-sm text-ink-500">…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepIndicator
|
||||||
|
steps={STEPS}
|
||||||
|
step={step}
|
||||||
|
goToStep={goToStep}
|
||||||
|
canReachStep={canReachStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card p-4 sm:p-6">
|
||||||
|
{step === 'products' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-ink-900">
|
||||||
|
{t('products.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink-500">{t('products.subtitle')}</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{products.map((p) => {
|
||||||
|
const qty = quantities[p.id] ?? 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`flex flex-col gap-3 rounded-lg border p-4 transition-colors sm:flex-row sm:items-center sm:justify-between ${
|
||||||
|
qty > 0
|
||||||
|
? 'border-accent-300 bg-accent-50/40'
|
||||||
|
: 'border-ink-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium text-ink-900">
|
||||||
|
{locale === 'sv' ? p.nameSv : p.nameEn}
|
||||||
|
</div>
|
||||||
|
<span className="badge bg-ink-100 text-ink-600">
|
||||||
|
{p.sku}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-ink-500">
|
||||||
|
{locale === 'sv' ? p.descriptionSv : p.descriptionEn}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<div className="text-sm font-medium text-ink-700">
|
||||||
|
{formatOre(priceInclVatOre(p.priceOre, p.vatBp), locale)}
|
||||||
|
<span className="text-ink-400">
|
||||||
|
{' '}
|
||||||
|
{t('products.perUnit')}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-xs text-ink-400">
|
||||||
|
({c('inclVat')})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{qty > 0 && (
|
||||||
|
<div className="text-sm font-semibold text-brand-700 tabular-nums">
|
||||||
|
={' '}
|
||||||
|
{formatOre(
|
||||||
|
priceInclVatOre(p.priceOre, p.vatBp) * qty,
|
||||||
|
locale,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuantity(p.id, qty - 1)}
|
||||||
|
disabled={qty === 0}
|
||||||
|
className="btn-secondary !px-3 !py-2"
|
||||||
|
aria-label={`-1 ${p.sku}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={999}
|
||||||
|
value={qty}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setQuantity(p.id, Number.isFinite(n) ? n : 0);
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="num-input"
|
||||||
|
aria-label={`${c('quantity')} ${p.sku}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuantity(p.id, qty + 1)}
|
||||||
|
className="btn-primary !px-3 !py-2"
|
||||||
|
aria-label={`+1 ${p.sku}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{selectedItems.length === 0 && (
|
||||||
|
<p className="mt-3 text-sm text-ink-500">
|
||||||
|
{t('products.noneSelected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-ink-900">
|
||||||
|
{t('details.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-ink-700">
|
||||||
|
{t('details.organization')}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
label={t('details.orgName')}
|
||||||
|
required
|
||||||
|
value={orgName}
|
||||||
|
onChange={(v) => patch({ orgName: v })}
|
||||||
|
placeholder={t('details.orgNamePlaceholder')}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('details.orgNumber')}
|
||||||
|
required
|
||||||
|
value={orgNumber}
|
||||||
|
onChange={(v) => patch({ orgNumber: v })}
|
||||||
|
placeholder={t('details.orgNumberPlaceholder')}
|
||||||
|
error={
|
||||||
|
orgNumber.length > 0 && !isValidSeOrgNumber(orgNumber)
|
||||||
|
? t('errors.invalidOrgNumber')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-ink-700">
|
||||||
|
{t('details.contact')}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
label={t('details.contactName')}
|
||||||
|
required
|
||||||
|
value={contactName}
|
||||||
|
onChange={(v) => patch({ contactName: v })}
|
||||||
|
placeholder={t('details.contactNamePlaceholder')}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('details.email')}
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
placeholder={t('details.emailPlaceholder')}
|
||||||
|
error={
|
||||||
|
email.length > 0 && !/^\S+@\S+\.\S+$/.test(email)
|
||||||
|
? t('errors.invalidEmail')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('details.phone')}
|
||||||
|
required
|
||||||
|
value={phone}
|
||||||
|
onChange={(v) => patch({ phone: v })}
|
||||||
|
placeholder={t('details.phonePlaceholder')}
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-ink-700">
|
||||||
|
{t('details.invoiceAddress')}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Field
|
||||||
|
label={t('details.address')}
|
||||||
|
required
|
||||||
|
value={address}
|
||||||
|
onChange={(v) => patch({ address: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label={t('details.postalCode')}
|
||||||
|
required
|
||||||
|
value={postalCode}
|
||||||
|
onChange={(v) => patch({ postalCode: v })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('details.city')}
|
||||||
|
required
|
||||||
|
value={city}
|
||||||
|
onChange={(v) => patch({ city: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'pickup' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-ink-900">
|
||||||
|
{t('pickup.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink-500">{t('pickup.subtitle')}</p>
|
||||||
|
|
||||||
|
{pickupSlots.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-ink-500">{t('pickup.noSlots')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{pickupSlots.map((s) => {
|
||||||
|
const selected = pickupSlotId === s.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
patch({ pickupSlotId: selected ? null : s.id })
|
||||||
|
}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'border-accent-400 bg-accent-50/40 ring-2 ring-accent-300/40'
|
||||||
|
: 'border-ink-200 bg-white hover:bg-ink-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-ink-900">
|
||||||
|
{locale === 'sv' ? s.labelSv : s.labelEn}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-ink-500">
|
||||||
|
{new Date(s.startsAt).toLocaleString(
|
||||||
|
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
{' – '}
|
||||||
|
{new Date(s.endsAt).toLocaleString(
|
||||||
|
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ timeStyle: 'short' },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-ink-500">
|
||||||
|
{t('pickup.capacity', { count: s.capacityLeft })}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<label className="label" htmlFor="notes">
|
||||||
|
{t('notes.label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
className="input mt-1 min-h-24"
|
||||||
|
placeholder={t('notes.placeholder')}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => patch({ notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'review' && (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-ink-900">
|
||||||
|
{t('review.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ReviewSection
|
||||||
|
title={c('subtotal')}
|
||||||
|
rows={selectedItems.map((i) => ({
|
||||||
|
left: `${i.quantity} × ${locale === 'sv' ? i.product.nameSv : i.product.nameEn}`,
|
||||||
|
right: formatOre(
|
||||||
|
priceInclVatOre(i.unitPriceOre, i.vatBp) * i.quantity,
|
||||||
|
locale,
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Row
|
||||||
|
label={`${c('subtotal')} (${c('exclVat')})`}
|
||||||
|
value={formatOre(totals.subtotalOre, locale)}
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={c('ofWhichVat')}
|
||||||
|
value={formatOre(totals.vatOre, locale)}
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={`${c('total')} (${c('inclVat')})`}
|
||||||
|
value={formatOre(totals.totalOre, locale)}
|
||||||
|
bold
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReviewSection
|
||||||
|
title={t('details.organization')}
|
||||||
|
rows={[
|
||||||
|
{ left: t('details.orgName'), right: orgName },
|
||||||
|
{ left: t('details.orgNumber'), right: orgNumber },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ReviewSection
|
||||||
|
title={t('details.contact')}
|
||||||
|
rows={[
|
||||||
|
{ left: t('details.contactName'), right: contactName },
|
||||||
|
{ left: t('details.email'), right: email },
|
||||||
|
{ left: t('details.phone'), right: phone },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ReviewSection
|
||||||
|
title={t('details.invoiceAddress')}
|
||||||
|
rows={[
|
||||||
|
{ left: t('details.address'), right: address },
|
||||||
|
{
|
||||||
|
left: `${t('details.postalCode')} / ${t('details.city')}`,
|
||||||
|
right: `${postalCode} ${city}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pickupEnabled && pickupSlotId && (
|
||||||
|
<ReviewSection
|
||||||
|
title={t('pickup.title')}
|
||||||
|
rows={(() => {
|
||||||
|
const slot = pickupSlots.find((s) => s.id === pickupSlotId);
|
||||||
|
if (!slot) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
left: locale === 'sv' ? slot.labelSv : slot.labelEn,
|
||||||
|
right: new Date(slot.startsAt).toLocaleString(
|
||||||
|
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border border-ink-200 bg-ink-50/50 p-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||||
|
/>
|
||||||
|
<span>{t('review.confirm')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SummaryBar
|
||||||
|
totals={totals}
|
||||||
|
itemCount={selectedItems.reduce((acc, i) => acc + i.quantity, 0)}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={stepIndex === 0 || submitting}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
← {t('previous')}
|
||||||
|
</button>
|
||||||
|
{step !== 'review' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!canGoNext()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{t('next')} →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!canGoNext() || submitting}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{submitting ? t('review.submitting') : c('submit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({
|
||||||
|
steps,
|
||||||
|
step,
|
||||||
|
goToStep,
|
||||||
|
canReachStep,
|
||||||
|
}: {
|
||||||
|
steps: Step[];
|
||||||
|
step: Step;
|
||||||
|
goToStep: (s: Step) => void;
|
||||||
|
canReachStep: (s: Step) => boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('booking');
|
||||||
|
const longLabels: Record<Step, string> = {
|
||||||
|
products: t('stepProducts'),
|
||||||
|
details: t('stepDetails'),
|
||||||
|
pickup: t('stepPickup'),
|
||||||
|
review: t('stepReview'),
|
||||||
|
};
|
||||||
|
const currentIdx = steps.indexOf(step);
|
||||||
|
// Mobile: 2-up grid (with 3 steps, last cell wraps to its own row — that's
|
||||||
|
// intentional, it puts emphasis on the final step). sm+: one row.
|
||||||
|
const desktopCols = steps.length === 4 ? 'sm:grid-cols-4' : 'sm:grid-cols-3';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ol className={`grid grid-cols-2 gap-1.5 ${desktopCols} sm:gap-2`}>
|
||||||
|
{steps.map((s, i) => {
|
||||||
|
const active = s === step;
|
||||||
|
const done = i < currentIdx;
|
||||||
|
const reachable = canReachStep(s);
|
||||||
|
return (
|
||||||
|
<li key={s}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToStep(s)}
|
||||||
|
disabled={!reachable}
|
||||||
|
aria-current={active ? 'step' : undefined}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg border p-2 text-left transition-colors sm:gap-3 ${
|
||||||
|
active
|
||||||
|
? 'border-accent-400 bg-accent-50/60 text-accent-700'
|
||||||
|
: done
|
||||||
|
? 'border-brand-300 bg-brand-50/40 text-brand-700 hover:bg-brand-50'
|
||||||
|
: reachable
|
||||||
|
? 'border-ink-200 bg-white text-ink-600 hover:bg-ink-50'
|
||||||
|
: 'cursor-not-allowed border-ink-200 bg-white text-ink-300'
|
||||||
|
} ${reachable ? '' : 'opacity-70'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold sm:h-7 sm:w-7 ${
|
||||||
|
active
|
||||||
|
? 'bg-accent-400 text-white'
|
||||||
|
: done
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'bg-ink-100 text-ink-500'
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{done ? '✓' : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium leading-tight">
|
||||||
|
{longLabels[s]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<p className="text-center text-xs text-ink-500 sm:hidden">
|
||||||
|
{t('stepOf', { current: currentIdx + 1, total: steps.length })} ·{' '}
|
||||||
|
<span className="font-medium text-ink-700">{longLabels[step]}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
type = 'text',
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: string;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className="input mt-1"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewSection({
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
rows: { left: string; right: string }[];
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-ink-200">
|
||||||
|
<div className="border-b border-ink-200 bg-ink-50/50 px-4 py-2 text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-ink-100">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-ink-600">{r.left}</span>
|
||||||
|
<span className="text-ink-900">{r.right}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{footer && <div className="bg-ink-50/30 px-4 py-2">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
muted,
|
||||||
|
bold,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
muted?: boolean;
|
||||||
|
bold?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex justify-between text-sm ${muted ? 'text-ink-500' : 'text-ink-900'} ${bold ? 'font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryBar({
|
||||||
|
totals,
|
||||||
|
itemCount,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
totals: { subtotalOre: number; vatOre: number; totalOre: number };
|
||||||
|
itemCount: number;
|
||||||
|
locale: 'sv' | 'en';
|
||||||
|
}) {
|
||||||
|
const c = useTranslations('common');
|
||||||
|
if (itemCount === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-brand-600 bg-white px-4 py-3 text-sm shadow-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-ink-600">
|
||||||
|
{itemCount} {c('quantity').toLowerCase()}
|
||||||
|
</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-semibold tabular-nums text-brand-700">
|
||||||
|
{formatOre(totals.totalOre, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-ink-400">
|
||||||
|
{c('inclVat')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-400">
|
||||||
|
{c('ofWhichVat')} {formatOre(totals.vatOre, locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/Header.tsx
Normal file
35
src/components/Header.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const t = useTranslations('header');
|
||||||
|
const c = useTranslations('common');
|
||||||
|
return (
|
||||||
|
<header className="border-b border-brand-700 bg-brand-600 text-white">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:py-5">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white p-1 shadow-sm sm:h-12 sm:w-12 sm:p-1.5">
|
||||||
|
<Image
|
||||||
|
src="/gasol247-logo.png"
|
||||||
|
alt="Gasol247"
|
||||||
|
width={48}
|
||||||
|
height={62}
|
||||||
|
priority
|
||||||
|
className="h-full w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold sm:text-base">
|
||||||
|
{c('siteName')}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-white/80">
|
||||||
|
{t('tagline')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/LanguageSwitcher.tsx
Normal file
46
src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, usePathname } from '@/i18n/routing';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
const current = (params?.locale as string) ?? 'sv';
|
||||||
|
|
||||||
|
const switchTo = (next: 'sv' | 'en') => {
|
||||||
|
router.replace(pathname, { locale: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex shrink-0 overflow-hidden rounded-lg border border-white/20 bg-white/10 text-xs font-medium backdrop-blur-sm"
|
||||||
|
role="group"
|
||||||
|
aria-label="Language switcher"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => switchTo('sv')}
|
||||||
|
className={`px-3 py-1.5 transition-colors ${
|
||||||
|
current === 'sv'
|
||||||
|
? 'bg-accent-300 text-brand-900'
|
||||||
|
: 'text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
SV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => switchTo('en')}
|
||||||
|
className={`px-3 py-1.5 transition-colors ${
|
||||||
|
current === 'en'
|
||||||
|
? 'bg-accent-300 text-brand-900'
|
||||||
|
: 'text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/StatusBadge.tsx
Normal file
32
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
const COLORS: Record<string, string> = {
|
||||||
|
PENDING: 'bg-amber-100 text-amber-800',
|
||||||
|
CONFIRMED: 'bg-sky-100 text-sky-800',
|
||||||
|
DELIVERED_PARTIAL: 'bg-blue-100 text-blue-800',
|
||||||
|
DELIVERED: 'bg-emerald-100 text-emerald-800',
|
||||||
|
RETURNED_PARTIAL: 'bg-teal-100 text-teal-800',
|
||||||
|
RETURNED: 'bg-slate-200 text-slate-800',
|
||||||
|
INVOICED: 'bg-violet-100 text-violet-800',
|
||||||
|
CANCELLED: 'bg-ink-200 text-ink-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN = new Set([
|
||||||
|
'PENDING',
|
||||||
|
'CONFIRMED',
|
||||||
|
'DELIVERED_PARTIAL',
|
||||||
|
'DELIVERED',
|
||||||
|
'RETURNED_PARTIAL',
|
||||||
|
'RETURNED',
|
||||||
|
'INVOICED',
|
||||||
|
'CANCELLED',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
type Known = Parameters<typeof KNOWN.has>[0];
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const t = useTranslations('admin.bookings.status');
|
||||||
|
const cls = COLORS[status] ?? 'bg-ink-100 text-ink-700';
|
||||||
|
const label = KNOWN.has(status as Known) ? t(status as Known) : status;
|
||||||
|
return <span className={`badge ${cls}`}>{label}</span>;
|
||||||
|
}
|
||||||
79
src/components/admin/AdminFilters.tsx
Normal file
79
src/components/admin/AdminFilters.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
// Debounced filter bar — writes search and status to the URL, which the
|
||||||
|
// server component re-reads. No submit button.
|
||||||
|
export function AdminFilters() {
|
||||||
|
const t = useTranslations('admin.bookings');
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const initialQ = searchParams.get('q') ?? '';
|
||||||
|
const initialStatus = searchParams.get('status') ?? 'all';
|
||||||
|
|
||||||
|
const [q, setQ] = useState(initialQ);
|
||||||
|
const [status, setStatus] = useState(initialStatus);
|
||||||
|
|
||||||
|
// Debounced URL push when q changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
|
if (q) next.set('q', q);
|
||||||
|
else next.delete('q');
|
||||||
|
// Reset to page 1 when filter changes.
|
||||||
|
next.delete('page');
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const onStatusChange = (next: string) => {
|
||||||
|
setStatus(next);
|
||||||
|
const sp = new URLSearchParams(searchParams.toString());
|
||||||
|
if (next === 'all') sp.delete('status');
|
||||||
|
else sp.set('status', next);
|
||||||
|
sp.delete('page');
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace(`${pathname}?${sp.toString()}`, { scroll: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t('filters.search')}
|
||||||
|
className="input max-w-sm"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value)}
|
||||||
|
className="input max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="all">{t('filters.all')}</option>
|
||||||
|
<option value="CONFIRMED">{t('status.CONFIRMED')}</option>
|
||||||
|
<option value="DELIVERED_PARTIAL">
|
||||||
|
{t('status.DELIVERED_PARTIAL')}
|
||||||
|
</option>
|
||||||
|
<option value="DELIVERED">{t('status.DELIVERED')}</option>
|
||||||
|
<option value="RETURNED_PARTIAL">
|
||||||
|
{t('status.RETURNED_PARTIAL')}
|
||||||
|
</option>
|
||||||
|
<option value="RETURNED">{t('status.RETURNED')}</option>
|
||||||
|
<option value="INVOICED">{t('status.INVOICED')}</option>
|
||||||
|
<option value="CANCELLED">{t('status.CANCELLED')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/admin/AdminUserForm.tsx
Normal file
158
src/components/admin/AdminUserForm.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
|
||||||
|
type Admin = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminUserForm({
|
||||||
|
mode,
|
||||||
|
admin,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
admin?: Admin;
|
||||||
|
action: (fd: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin.users');
|
||||||
|
const c = useTranslations('common');
|
||||||
|
|
||||||
|
const a: Admin = admin ?? { name: '', email: '' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="card space-y-5 p-5">
|
||||||
|
{a.id && <input type="hidden" name="id" value={a.id} />}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
name="name"
|
||||||
|
label={t('fields.name')}
|
||||||
|
defaultValue={a.name}
|
||||||
|
required
|
||||||
|
autoFocus={mode === 'create'}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="email"
|
||||||
|
label={t('fields.email')}
|
||||||
|
type="email"
|
||||||
|
defaultValue={a.email}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{mode === 'create' && (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
name="password"
|
||||||
|
label={t('fields.password')}
|
||||||
|
hint={t('fields.passwordHint')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="passwordConfirm"
|
||||||
|
label={t('fields.passwordConfirm')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
|
||||||
|
<Link href="/admin/users" className="btn-secondary">
|
||||||
|
{c('cancel')}
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{mode === 'create' ? t('create') : t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordChangeForm({
|
||||||
|
adminId,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
adminId: string;
|
||||||
|
action: (fd: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin.users');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="card space-y-5 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-ink-900">
|
||||||
|
{t('changePassword')}
|
||||||
|
</h2>
|
||||||
|
<input type="hidden" name="id" value={adminId} />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
name="password"
|
||||||
|
label={t('fields.newPassword')}
|
||||||
|
hint={t('fields.passwordHint')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="passwordConfirm"
|
||||||
|
label={t('fields.passwordConfirm')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end border-t border-ink-200 pt-4">
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{t('changePassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
type = 'text',
|
||||||
|
defaultValue,
|
||||||
|
required,
|
||||||
|
autoFocus,
|
||||||
|
autoComplete,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
autoComplete?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor={name}>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
className="input mt-1"
|
||||||
|
/>
|
||||||
|
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/components/admin/FulfillmentTable.tsx
Normal file
198
src/components/admin/FulfillmentTable.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import {
|
||||||
|
setItemFulfillment,
|
||||||
|
markAllDelivered,
|
||||||
|
markAllReturned,
|
||||||
|
} from '@/app/[locale]/admin/bookings/[id]/actions';
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
nameSv: string;
|
||||||
|
nameEn: string;
|
||||||
|
quantity: number;
|
||||||
|
deliveredQuantity: number;
|
||||||
|
returnedQuantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FulfillmentTable({
|
||||||
|
bookingId,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
bookingId: string;
|
||||||
|
items: Item[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin.bookings.fulfillment');
|
||||||
|
const locale = useLocale() as 'sv' | 'en';
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-ink-200">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50/50 px-4 py-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||||
|
{t('title')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<form
|
||||||
|
action={(fd) => startTransition(() => markAllDelivered(fd))}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="bookingId" value={bookingId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="btn-secondary !px-2.5 !py-1 text-xs"
|
||||||
|
>
|
||||||
|
↑ {t('deliverAll')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
action={(fd) => startTransition(() => markAllReturned(fd))}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="bookingId" value={bookingId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="btn-secondary !px-2.5 !py-1 text-xs"
|
||||||
|
>
|
||||||
|
↓ {t('returnAll')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-ink-500">
|
||||||
|
<th className="px-4 py-2">SKU</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
{locale === 'sv' ? 'Namn' : 'Name'}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right">{t('ordered')}</th>
|
||||||
|
<th className="px-4 py-2 text-right">{t('delivered')}</th>
|
||||||
|
<th className="px-4 py-2 text-right">{t('returned')}</th>
|
||||||
|
<th className="px-4 py-2 text-right">{t('outstanding')}</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-100">
|
||||||
|
{items.map((it) => (
|
||||||
|
<FulfillmentRow
|
||||||
|
key={it.id}
|
||||||
|
bookingId={bookingId}
|
||||||
|
item={it}
|
||||||
|
locale={locale}
|
||||||
|
pending={pending}
|
||||||
|
startTransition={startTransition}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FulfillmentRow({
|
||||||
|
bookingId,
|
||||||
|
item,
|
||||||
|
locale,
|
||||||
|
pending,
|
||||||
|
startTransition,
|
||||||
|
}: {
|
||||||
|
bookingId: string;
|
||||||
|
item: Item;
|
||||||
|
locale: 'sv' | 'en';
|
||||||
|
pending: boolean;
|
||||||
|
startTransition: React.TransitionStartFunction;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin.bookings.fulfillment');
|
||||||
|
const [delivered, setDelivered] = useState(item.deliveredQuantity);
|
||||||
|
const [returned, setReturned] = useState(item.returnedQuantity);
|
||||||
|
|
||||||
|
const outstanding = Math.max(0, delivered - returned);
|
||||||
|
const dirty =
|
||||||
|
delivered !== item.deliveredQuantity || returned !== item.returnedQuantity;
|
||||||
|
const validDelivered =
|
||||||
|
Number.isFinite(delivered) && delivered >= 0 && delivered <= item.quantity;
|
||||||
|
const validReturned =
|
||||||
|
Number.isFinite(returned) && returned >= 0 && returned <= delivered;
|
||||||
|
const canSave = dirty && validDelivered && validReturned && !pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{item.sku}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{locale === 'sv' ? item.nameSv : item.nameEn}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-ink-600">
|
||||||
|
{item.quantity}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={item.quantity}
|
||||||
|
value={delivered}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setDelivered(Number.isFinite(n) ? n : 0);
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className={`num-input ${validDelivered ? '' : 'border-red-400'}`}
|
||||||
|
aria-label={t('delivered')}
|
||||||
|
title={t('deliveredHint')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={delivered}
|
||||||
|
value={returned}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setReturned(Number.isFinite(n) ? n : 0);
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className={`num-input ${validReturned ? '' : 'border-red-400'}`}
|
||||||
|
aria-label={t('returned')}
|
||||||
|
title={t('returnedHint')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
outstanding > 0 ? 'font-semibold text-brand-700' : 'text-ink-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{outstanding}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
fd.set('delivered', String(delivered));
|
||||||
|
fd.set('returned', String(returned));
|
||||||
|
startTransition(() => setItemFulfillment(fd));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="bookingId" value={bookingId} />
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSave}
|
||||||
|
className="btn-primary !px-3 !py-1 text-xs"
|
||||||
|
>
|
||||||
|
{t('save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/admin/Pagination.tsx
Normal file
55
src/components/admin/Pagination.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Pagination({ page, pageSize, total }: Props) {
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
|
const to = Math.min(total, page * pageSize);
|
||||||
|
|
||||||
|
const link = (p: number) => {
|
||||||
|
const next = new URLSearchParams(sp.toString());
|
||||||
|
if (p <= 1) next.delete('page');
|
||||||
|
else next.set('page', String(p));
|
||||||
|
return `${pathname}?${next.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm text-ink-600">
|
||||||
|
<div>
|
||||||
|
{from}–{to} / {total}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={link(Math.max(1, page - 1))}
|
||||||
|
aria-disabled={page <= 1}
|
||||||
|
tabIndex={page <= 1 ? -1 : undefined}
|
||||||
|
className={`btn-secondary !px-3 !py-1.5 text-xs ${page <= 1 ? 'pointer-events-none opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Link>
|
||||||
|
<span className="px-2 tabular-nums">
|
||||||
|
{page} / {pages}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={link(Math.min(pages, page + 1))}
|
||||||
|
aria-disabled={page >= pages}
|
||||||
|
tabIndex={page >= pages ? -1 : undefined}
|
||||||
|
className={`btn-secondary !px-3 !py-1.5 text-xs ${page >= pages ? 'pointer-events-none opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/components/admin/PickupSlotForm.tsx
Normal file
149
src/components/admin/PickupSlotForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
|
||||||
|
type Slot = {
|
||||||
|
id?: string;
|
||||||
|
labelSv: string;
|
||||||
|
labelEn: string;
|
||||||
|
startsAt: string; // "YYYY-MM-DDTHH:mm" local
|
||||||
|
endsAt: string;
|
||||||
|
capacity: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PickupSlotForm({
|
||||||
|
mode,
|
||||||
|
slot,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
slot?: Slot;
|
||||||
|
action: (fd: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin.pickupSlots');
|
||||||
|
const c = useTranslations('common');
|
||||||
|
|
||||||
|
const s: Slot = slot ?? {
|
||||||
|
labelSv: '',
|
||||||
|
labelEn: '',
|
||||||
|
startsAt: defaultDateString(0, 9),
|
||||||
|
endsAt: defaultDateString(0, 12),
|
||||||
|
capacity: 50,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="card space-y-5 p-5">
|
||||||
|
{s.id && <input type="hidden" name="id" value={s.id} />}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
name="labelSv"
|
||||||
|
label={t('fields.labelSv')}
|
||||||
|
hint={t('fields.labelHint')}
|
||||||
|
defaultValue={s.labelSv}
|
||||||
|
required
|
||||||
|
autoFocus={mode === 'create'}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="labelEn"
|
||||||
|
label={t('fields.labelEn')}
|
||||||
|
defaultValue={s.labelEn}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="startsAt"
|
||||||
|
label={t('fields.startsAt')}
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={s.startsAt}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="endsAt"
|
||||||
|
label={t('fields.endsAt')}
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={s.endsAt}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="capacity"
|
||||||
|
label={t('fields.capacity')}
|
||||||
|
hint={t('fields.capacityHint')}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={String(s.capacity)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="active"
|
||||||
|
defaultChecked={s.active}
|
||||||
|
className="h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||||
|
/>
|
||||||
|
<span>{t('fields.active')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
|
||||||
|
<Link href="/admin/pickup-slots" className="btn-secondary">
|
||||||
|
{c('cancel')}
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{mode === 'create' ? t('create') : t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDateString(daysAhead: number, hour: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + daysAhead);
|
||||||
|
d.setHours(hour, 0, 0, 0);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
type = 'text',
|
||||||
|
defaultValue,
|
||||||
|
required,
|
||||||
|
min,
|
||||||
|
autoFocus,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
min?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor={name}>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
min={min}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="input mt-1"
|
||||||
|
/>
|
||||||
|
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/components/admin/ProductForm.tsx
Normal file
198
src/components/admin/ProductForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
|
||||||
|
type Product = {
|
||||||
|
id?: string;
|
||||||
|
sku: string;
|
||||||
|
nameSv: string;
|
||||||
|
nameEn: string;
|
||||||
|
descriptionSv: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
priceOre: number;
|
||||||
|
vatBp: number;
|
||||||
|
sortOrder: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductForm({
|
||||||
|
mode,
|
||||||
|
product,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
product?: Product;
|
||||||
|
action: (fd: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin.products');
|
||||||
|
const c = useTranslations('common');
|
||||||
|
|
||||||
|
const p: Product = product ?? {
|
||||||
|
sku: '',
|
||||||
|
nameSv: '',
|
||||||
|
nameEn: '',
|
||||||
|
descriptionSv: '',
|
||||||
|
descriptionEn: '',
|
||||||
|
priceOre: 0,
|
||||||
|
vatBp: 2500,
|
||||||
|
sortOrder: 100,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="card space-y-5 p-5">
|
||||||
|
{p.id && <input type="hidden" name="id" value={p.id} />}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
name="sku"
|
||||||
|
label={t('fields.sku')}
|
||||||
|
hint={t('fields.skuHint')}
|
||||||
|
defaultValue={p.sku}
|
||||||
|
required
|
||||||
|
autoFocus={mode === 'create'}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="sortOrder"
|
||||||
|
label={t('fields.sortOrder')}
|
||||||
|
hint={t('fields.sortOrderHint')}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={String(p.sortOrder)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="nameSv"
|
||||||
|
label={t('fields.nameSv')}
|
||||||
|
defaultValue={p.nameSv}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="nameEn"
|
||||||
|
label={t('fields.nameEn')}
|
||||||
|
defaultValue={p.nameEn}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
name="descriptionSv"
|
||||||
|
label={t('fields.descriptionSv')}
|
||||||
|
defaultValue={p.descriptionSv}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
name="descriptionEn"
|
||||||
|
label={t('fields.descriptionEn')}
|
||||||
|
defaultValue={p.descriptionEn}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="priceSek"
|
||||||
|
label={t('fields.priceSek')}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
defaultValue={(p.priceOre / 100).toFixed(2)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="vatPct"
|
||||||
|
label={t('fields.vatPct')}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
defaultValue={(p.vatBp / 100).toFixed(2)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="active"
|
||||||
|
defaultChecked={p.active}
|
||||||
|
className="h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||||
|
/>
|
||||||
|
<span>{t('fields.active')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
|
||||||
|
<Link href="/admin/products" className="btn-secondary">
|
||||||
|
{c('cancel')}
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{mode === 'create' ? t('create') : t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
type = 'text',
|
||||||
|
defaultValue,
|
||||||
|
required,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
autoFocus,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
step?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor={name}>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="input mt-1"
|
||||||
|
/>
|
||||||
|
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextArea({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
defaultValue,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label" htmlFor={name}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
rows={2}
|
||||||
|
className="input mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/admin/SortHeader.tsx
Normal file
42
src/components/admin/SortHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SortHeader({ field, label, align = 'left' }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const currentSort = sp.get('sort') ?? 'createdAt';
|
||||||
|
const currentDir = sp.get('dir') === 'asc' ? 'asc' : 'desc';
|
||||||
|
const active = currentSort === field;
|
||||||
|
|
||||||
|
const next = new URLSearchParams(sp.toString());
|
||||||
|
next.set('sort', field);
|
||||||
|
next.set('dir', active && currentDir === 'desc' ? 'asc' : 'desc');
|
||||||
|
// Keep page on column toggle — feels less jumpy than resetting.
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center gap-1 text-xs font-medium uppercase tracking-wide ${
|
||||||
|
active ? 'text-brand-700' : 'text-ink-500 hover:text-ink-700'
|
||||||
|
} ${align === 'right' ? 'ml-auto flex-row-reverse' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span aria-hidden className="inline-block w-2 text-[10px] leading-none">
|
||||||
|
{active ? (currentDir === 'asc' ? '▲' : '▼') : '↕'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/i18n/request.ts
Normal file
13
src/i18n/request.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
let locale = await requestLocale;
|
||||||
|
if (!locale || !routing.locales.includes(locale as 'sv' | 'en')) {
|
||||||
|
locale = routing.defaultLocale;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
||||||
13
src/i18n/routing.ts
Normal file
13
src/i18n/routing.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ['sv', 'en'],
|
||||||
|
defaultLocale: 'sv',
|
||||||
|
localePrefix: 'as-needed',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Locale = (typeof routing.locales)[number];
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||||
|
createNavigation(routing);
|
||||||
14
src/lib/bookingNumber.ts
Normal file
14
src/lib/bookingNumber.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Booking number format: JAM-YY-XXXX
|
||||||
|
// Two-letter event code + 2-digit year + 4 random base32 chars.
|
||||||
|
// Easy to read over phone, hard to guess.
|
||||||
|
|
||||||
|
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // skip I,O,0,1 to avoid confusion
|
||||||
|
|
||||||
|
export function generateBookingNumber(prefix = 'JAM'): string {
|
||||||
|
const yy = String(new Date().getFullYear()).slice(2);
|
||||||
|
let suffix = '';
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
suffix += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return `${prefix}-${yy}-${suffix}`;
|
||||||
|
}
|
||||||
73
src/lib/bookingStatus.ts
Normal file
73
src/lib/bookingStatus.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Booking lifecycle helpers.
|
||||||
|
|
||||||
|
export const BOOKING_STATUSES = [
|
||||||
|
'PENDING',
|
||||||
|
'CONFIRMED',
|
||||||
|
'DELIVERED_PARTIAL',
|
||||||
|
'DELIVERED',
|
||||||
|
'RETURNED_PARTIAL',
|
||||||
|
'RETURNED',
|
||||||
|
'INVOICED',
|
||||||
|
'CANCELLED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||||
|
|
||||||
|
// Statuses where deriving from item counters is appropriate. INVOICED and
|
||||||
|
// CANCELLED are sticky — admin sets them explicitly, we don't overwrite.
|
||||||
|
const DERIVABLE: ReadonlySet<string> = new Set([
|
||||||
|
'PENDING',
|
||||||
|
'CONFIRMED',
|
||||||
|
'DELIVERED_PARTIAL',
|
||||||
|
'DELIVERED',
|
||||||
|
'RETURNED_PARTIAL',
|
||||||
|
'RETURNED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
type ItemCounts = { quantity: number; deliveredQuantity: number; returnedQuantity: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the lifecycle status from per-line delivery/return counters.
|
||||||
|
* Does not touch INVOICED or CANCELLED — those are terminal admin choices.
|
||||||
|
*/
|
||||||
|
export function deriveBookingStatus(
|
||||||
|
current: string,
|
||||||
|
items: ItemCounts[],
|
||||||
|
): BookingStatus {
|
||||||
|
if (current === 'INVOICED' || current === 'CANCELLED') {
|
||||||
|
return current as BookingStatus;
|
||||||
|
}
|
||||||
|
if (!DERIVABLE.has(current) && current !== '') {
|
||||||
|
// Unknown status — leave as-is, just narrow the type.
|
||||||
|
return (current as BookingStatus) ?? 'CONFIRMED';
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = items.reduce((a, i) => a + i.quantity, 0);
|
||||||
|
const delivered = items.reduce((a, i) => a + i.deliveredQuantity, 0);
|
||||||
|
const returned = items.reduce((a, i) => a + i.returnedQuantity, 0);
|
||||||
|
|
||||||
|
if (delivered === 0) {
|
||||||
|
// Nothing handed out yet — booking is just confirmed.
|
||||||
|
return 'CONFIRMED';
|
||||||
|
}
|
||||||
|
if (returned === 0) {
|
||||||
|
return delivered < total ? 'DELIVERED_PARTIAL' : 'DELIVERED';
|
||||||
|
}
|
||||||
|
// Some returns started.
|
||||||
|
if (returned < delivered) {
|
||||||
|
return 'RETURNED_PARTIAL';
|
||||||
|
}
|
||||||
|
// All delivered items have come back.
|
||||||
|
return 'RETURNED';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp delivered/returned to legal ranges relative to quantity. */
|
||||||
|
export function clampFulfillment(
|
||||||
|
quantity: number,
|
||||||
|
delivered: number,
|
||||||
|
returned: number,
|
||||||
|
): { delivered: number; returned: number } {
|
||||||
|
const d = Math.max(0, Math.min(quantity, Math.floor(delivered)));
|
||||||
|
const r = Math.max(0, Math.min(d, Math.floor(returned)));
|
||||||
|
return { delivered: d, returned: r };
|
||||||
|
}
|
||||||
102
src/lib/bookingStore.ts
Normal file
102
src/lib/bookingStore.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
|
// Persist booking form state across page navigations (e.g. when the user
|
||||||
|
// switches language mid-flow). sessionStorage so it's per-tab and cleared
|
||||||
|
// when the tab closes. Bumping STORE_VERSION invalidates old persisted state.
|
||||||
|
const STORE_VERSION = 1;
|
||||||
|
|
||||||
|
export type Step = 'products' | 'details' | 'pickup' | 'review';
|
||||||
|
|
||||||
|
export type BookingFormState = {
|
||||||
|
step: Step;
|
||||||
|
quantities: Record<string, number>;
|
||||||
|
contactName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
orgName: string;
|
||||||
|
orgNumber: string;
|
||||||
|
address: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
pickupSlotId: string | null;
|
||||||
|
notes: string;
|
||||||
|
confirm: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BookingFormActions = {
|
||||||
|
setStep: (step: Step) => void;
|
||||||
|
setQuantity: (productId: string, qty: number) => void;
|
||||||
|
patch: (partial: Partial<BookingFormState>) => void;
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial: BookingFormState = {
|
||||||
|
step: 'products',
|
||||||
|
quantities: {},
|
||||||
|
contactName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
orgName: '',
|
||||||
|
orgNumber: '',
|
||||||
|
address: '',
|
||||||
|
postalCode: '',
|
||||||
|
city: '',
|
||||||
|
country: 'SE',
|
||||||
|
pickupSlotId: null,
|
||||||
|
notes: '',
|
||||||
|
confirm: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBookingStore = create<BookingFormState & BookingFormActions>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...initial,
|
||||||
|
setStep: (step) => set({ step }),
|
||||||
|
setQuantity: (productId, qty) =>
|
||||||
|
set((s) => {
|
||||||
|
const q = { ...s.quantities };
|
||||||
|
if (qty <= 0) delete q[productId];
|
||||||
|
else q[productId] = Math.min(999, Math.floor(qty));
|
||||||
|
return { quantities: q };
|
||||||
|
}),
|
||||||
|
patch: (partial) => set(partial as BookingFormState),
|
||||||
|
reset: () => set({ ...initial }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'boka-gasol247:booking-form',
|
||||||
|
version: STORE_VERSION,
|
||||||
|
storage: createJSONStorage(() =>
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? // SSR no-op
|
||||||
|
{
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
}
|
||||||
|
: window.sessionStorage,
|
||||||
|
),
|
||||||
|
// Don't persist `confirm` (re-check each session) or `step` if user
|
||||||
|
// hasn't started filling anything. Keeps the experience clean if they
|
||||||
|
// close and reopen a tab with stale state.
|
||||||
|
partialize: (s) => ({
|
||||||
|
step: s.step,
|
||||||
|
quantities: s.quantities,
|
||||||
|
contactName: s.contactName,
|
||||||
|
email: s.email,
|
||||||
|
phone: s.phone,
|
||||||
|
orgName: s.orgName,
|
||||||
|
orgNumber: s.orgNumber,
|
||||||
|
address: s.address,
|
||||||
|
postalCode: s.postalCode,
|
||||||
|
city: s.city,
|
||||||
|
country: s.country,
|
||||||
|
pickupSlotId: s.pickupSlotId,
|
||||||
|
notes: s.notes,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
253
src/lib/mailjet.ts
Normal file
253
src/lib/mailjet.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import Mailjet from 'node-mailjet';
|
||||||
|
import { formatOre } from './money';
|
||||||
|
import type { Booking, BookingItem, PickupSlot } from '@prisma/client';
|
||||||
|
|
||||||
|
let client: Mailjet | null = null;
|
||||||
|
|
||||||
|
function getClient(): Mailjet | null {
|
||||||
|
if (!process.env.MAILJET_API_KEY || !process.env.MAILJET_API_SECRET) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!client) {
|
||||||
|
client = new Mailjet({
|
||||||
|
apiKey: process.env.MAILJET_API_KEY,
|
||||||
|
apiSecret: process.env.MAILJET_API_SECRET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookingWithRels = Booking & {
|
||||||
|
items: BookingItem[];
|
||||||
|
pickupSlot: PickupSlot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailStrings = {
|
||||||
|
subject: string;
|
||||||
|
greeting: string;
|
||||||
|
intro: string;
|
||||||
|
bookingNumber: string;
|
||||||
|
orderSummary: string;
|
||||||
|
pickup: string;
|
||||||
|
invoiceInfo: string;
|
||||||
|
questions: string;
|
||||||
|
footer: string;
|
||||||
|
subtotal: string;
|
||||||
|
ofWhichVat: string;
|
||||||
|
total: string;
|
||||||
|
eventName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStrings(locale: 'sv' | 'en', event: string): EmailStrings {
|
||||||
|
if (locale === 'sv') {
|
||||||
|
return {
|
||||||
|
subject: `Bokningsbekräftelse {number} — ${event}`,
|
||||||
|
greeting: 'Hej {name},',
|
||||||
|
intro: `Tack för din beställning av gasoltuber till ${event}. Här är din bokningsbekräftelse.`,
|
||||||
|
bookingNumber: 'Bokningsnummer',
|
||||||
|
orderSummary: 'Beställning',
|
||||||
|
pickup: 'Upphämtning',
|
||||||
|
invoiceInfo: 'Faktura skickas till organisationen efter eventet.',
|
||||||
|
questions: 'Har du frågor? Svara på detta mejl så hjälper vi dig.',
|
||||||
|
footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
|
||||||
|
subtotal: 'Delsumma',
|
||||||
|
ofWhichVat: 'varav moms',
|
||||||
|
total: 'Totalt',
|
||||||
|
eventName: event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject: `Booking confirmation {number} — ${event}`,
|
||||||
|
greeting: 'Hi {name},',
|
||||||
|
intro: `Thank you for ordering LPG cylinders for ${event}. Here is your booking confirmation.`,
|
||||||
|
bookingNumber: 'Booking number',
|
||||||
|
orderSummary: 'Order',
|
||||||
|
pickup: 'Pickup',
|
||||||
|
invoiceInfo: 'An invoice will be sent to your organization after the event.',
|
||||||
|
questions: 'Questions? Reply to this email and we will help you.',
|
||||||
|
footer: 'This is an automated confirmation from Gasol247.',
|
||||||
|
subtotal: 'Subtotal',
|
||||||
|
ofWhichVat: 'of which VAT',
|
||||||
|
total: 'Total',
|
||||||
|
eventName: event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBookingEmail(
|
||||||
|
booking: BookingWithRels,
|
||||||
|
locale: 'sv' | 'en' = 'sv',
|
||||||
|
): { subject: string; html: string; text: string } {
|
||||||
|
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
|
||||||
|
const s = getStrings(locale, event);
|
||||||
|
const fmt = (ore: number) => formatOre(ore, locale);
|
||||||
|
const itemName = (it: BookingItem) =>
|
||||||
|
locale === 'sv' ? it.nameSv : it.nameEn;
|
||||||
|
const slotLabel = booking.pickupSlot
|
||||||
|
? locale === 'sv'
|
||||||
|
? booking.pickupSlot.labelSv
|
||||||
|
: booking.pickupSlot.labelEn
|
||||||
|
: null;
|
||||||
|
const slotTime = booking.pickupSlot
|
||||||
|
? booking.pickupSlot.startsAt.toLocaleString(
|
||||||
|
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||||
|
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const subject = s.subject.replace('{number}', booking.bookingNumber);
|
||||||
|
const greeting = s.greeting.replace('{name}', booking.contactName);
|
||||||
|
|
||||||
|
const itemRowsHtml = booking.items
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;">
|
||||||
|
${escapeHtml(itemName(it))} <span style="color:#94a3b8;">× ${it.quantity}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
|
||||||
|
${fmt(it.lineTotalOre)}
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const pickupHtml =
|
||||||
|
slotLabel && slotTime
|
||||||
|
? `
|
||||||
|
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${s.pickup}</h2>
|
||||||
|
<p style="font-size:14px;color:#0f172a;margin:0;">${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html lang="${locale}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${escapeHtml(subject)}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#f8fafc;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;padding:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ea580c;color:#ffffff;padding:24px;">
|
||||||
|
<div style="font-size:12px;opacity:0.9;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(s.bookingNumber)}</div>
|
||||||
|
<div style="font-size:24px;font-weight:600;margin-top:4px;">${escapeHtml(booking.bookingNumber)}</div>
|
||||||
|
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(s.eventName)}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px;">
|
||||||
|
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(greeting)}</p>
|
||||||
|
<p style="font-size:14px;color:#475569;margin:0 0 16px;">${escapeHtml(s.intro)}</p>
|
||||||
|
|
||||||
|
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:16px 0 8px;">${s.orderSummary}</h2>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
${itemRowsHtml}
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:12px;font-size:14px;color:#475569;">${s.subtotal}</td>
|
||||||
|
<td style="padding-top:12px;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">${fmt(booking.subtotalOre)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;">${s.ofWhichVat}</td>
|
||||||
|
<td style="font-size:13px;color:#64748b;text-align:right;white-space:nowrap;">${fmt(booking.vatOre)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;border-top:1px solid #e2e8f0;">${s.total}</td>
|
||||||
|
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">${fmt(booking.totalOre)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${pickupHtml}
|
||||||
|
|
||||||
|
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
|
||||||
|
${escapeHtml(s.invoiceInfo)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:13px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.questions)}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center;">
|
||||||
|
${escapeHtml(s.footer)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
greeting,
|
||||||
|
'',
|
||||||
|
s.intro,
|
||||||
|
'',
|
||||||
|
`${s.bookingNumber}: ${booking.bookingNumber}`,
|
||||||
|
'',
|
||||||
|
`${s.orderSummary}:`,
|
||||||
|
...booking.items.map(
|
||||||
|
(it) => ` ${itemName(it)} × ${it.quantity} ${fmt(it.lineTotalOre)}`,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
`${s.subtotal}: ${fmt(booking.subtotalOre)}`,
|
||||||
|
`${s.ofWhichVat}: ${fmt(booking.vatOre)}`,
|
||||||
|
`${s.total}: ${fmt(booking.totalOre)}`,
|
||||||
|
...(slotLabel ? ['', `${s.pickup}: ${slotLabel} – ${slotTime}`] : []),
|
||||||
|
'',
|
||||||
|
s.invoiceInfo,
|
||||||
|
'',
|
||||||
|
s.questions,
|
||||||
|
'',
|
||||||
|
'— ' + s.footer,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return { subject, html, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBookingConfirmation(
|
||||||
|
booking: BookingWithRels,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const c = getClient();
|
||||||
|
if (!c) {
|
||||||
|
console.warn('[mailjet] not configured — skipping email send');
|
||||||
|
return { ok: false, error: 'mailjet-not-configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = (booking.locale as 'sv' | 'en') ?? 'sv';
|
||||||
|
const { subject, html, text } = renderBookingEmail(booking, locale);
|
||||||
|
|
||||||
|
const fromEmail = process.env.MAIL_FROM_EMAIL ?? 'no-reply@example.com';
|
||||||
|
const fromName = process.env.MAIL_FROM_NAME ?? 'Gasol247 Bokning';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await c.post('send', { version: 'v3.1' }).request({
|
||||||
|
Messages: [
|
||||||
|
{
|
||||||
|
From: { Email: fromEmail, Name: fromName },
|
||||||
|
To: [{ Email: booking.email, Name: booking.contactName }],
|
||||||
|
Subject: subject,
|
||||||
|
HTMLPart: html,
|
||||||
|
TextPart: text,
|
||||||
|
CustomID: booking.bookingNumber,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[mailjet] send failed', msg);
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
33
src/lib/money.ts
Normal file
33
src/lib/money.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Price math in öre (1 SEK = 100 öre) to avoid float drift.
|
||||||
|
|
||||||
|
export function formatOre(ore: number, locale: 'sv' | 'en' = 'sv'): string {
|
||||||
|
const sek = ore / 100;
|
||||||
|
return new Intl.NumberFormat(locale === 'sv' ? 'sv-SE' : 'en-SE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'SEK',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(sek);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vatAmountOre(netOre: number, vatBp: number): number {
|
||||||
|
return Math.round((netOre * vatBp) / 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Net price + VAT, rounded the same way as billing. */
|
||||||
|
export function priceInclVatOre(netOre: number, vatBp: number): number {
|
||||||
|
return netOre + vatAmountOre(netOre, vatBp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTotals(
|
||||||
|
items: Array<{ unitPriceOre: number; quantity: number; vatBp: number }>,
|
||||||
|
) {
|
||||||
|
let subtotal = 0;
|
||||||
|
let vat = 0;
|
||||||
|
for (const i of items) {
|
||||||
|
const line = i.unitPriceOre * i.quantity;
|
||||||
|
subtotal += line;
|
||||||
|
vat += vatAmountOre(line, i.vatBp);
|
||||||
|
}
|
||||||
|
return { subtotalOre: subtotal, vatOre: vat, totalOre: subtotal + vat };
|
||||||
|
}
|
||||||
29
src/lib/orgNumber.ts
Normal file
29
src/lib/orgNumber.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Swedish organization number validation (Luhn over 10 digits).
|
||||||
|
// Accepts "XXXXXX-XXXX" or "XXXXXXXXXX". Some non-SE orgs may have different formats;
|
||||||
|
// we keep validation strict for SE and permit a relaxed form for orgs with country != SE.
|
||||||
|
|
||||||
|
export function normalizeOrgNumber(input: string): string {
|
||||||
|
return input.replace(/[\s-]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSeOrgNumber(input: string): boolean {
|
||||||
|
const n = normalizeOrgNumber(input);
|
||||||
|
if (!/^\d{10}$/.test(n)) return false;
|
||||||
|
// Luhn
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
let d = parseInt(n[i], 10);
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) d -= 9;
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
}
|
||||||
|
return sum % 10 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOrgNumber(input: string): string {
|
||||||
|
const n = normalizeOrgNumber(input);
|
||||||
|
if (n.length !== 10) return input;
|
||||||
|
return `${n.slice(0, 6)}-${n.slice(6)}`;
|
||||||
|
}
|
||||||
13
src/lib/prisma.ts
Normal file
13
src/lib/prisma.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
10
src/lib/requireAdmin.ts
Normal file
10
src/lib/requireAdmin.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getSafeSession } from '@/lib/safeAuth';
|
||||||
|
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const session = await getSafeSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/admin/login');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
24
src/lib/safeAuth.ts
Normal file
24
src/lib/safeAuth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import type { Session } from 'next-auth';
|
||||||
|
|
||||||
|
// Wrap `auth()` so stale session cookies (e.g. AUTH_SECRET rotated, or default
|
||||||
|
// dev secret swapped out) don't surface as scary JWTSessionError stacks in
|
||||||
|
// every server log. We treat any decode failure as "not logged in" and let the
|
||||||
|
// next request rewrite the cookie at next login.
|
||||||
|
export async function getSafeSession(): Promise<Session | null> {
|
||||||
|
try {
|
||||||
|
return await auth();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (
|
||||||
|
msg.includes('decryption') ||
|
||||||
|
msg.includes('JWTSessionError') ||
|
||||||
|
msg.includes('JWEDecryptionFailed')
|
||||||
|
) {
|
||||||
|
// Stale or unreadable session cookie — treat as logged out silently.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Re-throw anything unexpected so it's visible.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/lib/settings.ts
Normal file
32
src/lib/settings.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
export type AppSettings = {
|
||||||
|
pickupEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: AppSettings = {
|
||||||
|
pickupEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SINGLETON_ID = 'singleton';
|
||||||
|
|
||||||
|
/** Returns the singleton settings row, creating it with defaults on first call. */
|
||||||
|
export async function getSettings(): Promise<AppSettings> {
|
||||||
|
const row = await prisma.settings.upsert({
|
||||||
|
where: { id: SINGLETON_ID },
|
||||||
|
update: {},
|
||||||
|
create: { id: SINGLETON_ID, ...DEFAULTS },
|
||||||
|
});
|
||||||
|
return { pickupEnabled: row.pickupEnabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(
|
||||||
|
patch: Partial<AppSettings>,
|
||||||
|
): Promise<AppSettings> {
|
||||||
|
const row = await prisma.settings.upsert({
|
||||||
|
where: { id: SINGLETON_ID },
|
||||||
|
update: patch,
|
||||||
|
create: { id: SINGLETON_ID, ...DEFAULTS, ...patch },
|
||||||
|
});
|
||||||
|
return { pickupEnabled: row.pickupEnabled };
|
||||||
|
}
|
||||||
33
src/lib/validation.ts
Normal file
33
src/lib/validation.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { isValidSeOrgNumber, normalizeOrgNumber } from './orgNumber';
|
||||||
|
|
||||||
|
export const bookingItemSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
quantity: z.number().int().min(1).max(999),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bookingSubmitSchema = z.object({
|
||||||
|
contactName: z.string().trim().min(2).max(120),
|
||||||
|
email: z.string().trim().email().max(200),
|
||||||
|
phone: z.string().trim().min(5).max(40),
|
||||||
|
orgName: z.string().trim().min(2).max(200),
|
||||||
|
orgNumber: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(10)
|
||||||
|
.max(13)
|
||||||
|
.refine((v) => isValidSeOrgNumber(v), {
|
||||||
|
message: 'invalidOrgNumber',
|
||||||
|
})
|
||||||
|
.transform((v) => normalizeOrgNumber(v)),
|
||||||
|
address: z.string().trim().min(2).max(200),
|
||||||
|
postalCode: z.string().trim().min(3).max(20),
|
||||||
|
city: z.string().trim().min(1).max(100),
|
||||||
|
country: z.string().trim().min(2).max(2).default('SE'),
|
||||||
|
pickupSlotId: z.string().min(1).nullable().optional(),
|
||||||
|
notes: z.string().trim().max(2000).optional().nullable(),
|
||||||
|
locale: z.enum(['sv', 'en']).default('sv'),
|
||||||
|
items: z.array(bookingItemSchema).min(1, 'selectProduct'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BookingSubmitInput = z.infer<typeof bookingSubmitSchema>;
|
||||||
9
src/middleware.ts
Normal file
9
src/middleware.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { routing } from './i18n/routing';
|
||||||
|
|
||||||
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match all paths except next internals, api, and static assets.
|
||||||
|
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
|
||||||
|
};
|
||||||
1
src/types/css.d.ts
vendored
Normal file
1
src/types/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||
9
src/types/next-auth.d.ts
vendored
Normal file
9
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { DefaultSession } from 'next-auth';
|
||||||
|
|
||||||
|
declare module 'next-auth' {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
} & DefaultSession['user'];
|
||||||
|
}
|
||||||
|
}
|
||||||
65
tailwind.config.ts
Normal file
65
tailwind.config.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// gasol247 palette — primary blue with peach accent.
|
||||||
|
// Pulled from gasol247.com CSS: navy #26476c, light blue #7fcbf1, peach #f7a872.
|
||||||
|
brand: {
|
||||||
|
50: '#eef5fb',
|
||||||
|
100: '#d6e6f3',
|
||||||
|
200: '#aecde7',
|
||||||
|
300: '#7fcbf1',
|
||||||
|
400: '#5293c4',
|
||||||
|
500: '#3a749f',
|
||||||
|
600: '#26476c',
|
||||||
|
700: '#1f3a59',
|
||||||
|
800: '#172c43',
|
||||||
|
900: '#101f30',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
50: '#fff7ed',
|
||||||
|
100: '#ffead5',
|
||||||
|
200: '#fed3a7',
|
||||||
|
300: '#f7a872',
|
||||||
|
400: '#f4904f',
|
||||||
|
500: '#ea7a33',
|
||||||
|
600: '#c45f23',
|
||||||
|
700: '#9a4a1b',
|
||||||
|
800: '#723814',
|
||||||
|
900: '#4e260d',
|
||||||
|
},
|
||||||
|
cream: '#fffaf5',
|
||||||
|
ink: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: '0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05)',
|
||||||
|
elevated: '0 10px 25px -5px rgb(0 0 0 / 0.08), 0 8px 10px -6px rgb(0 0 0 / 0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user