Compare commits

...

8 Commits

Author SHA1 Message Date
Ola Malmgren
50f28e2a29 fix back to home
All checks were successful
Build and deploy / build-deploy (push) Successful in 1m17s
2026-05-22 23:41:49 +02:00
Ola Malmgren
79616c8fdc fix state
All checks were successful
Build and deploy / build-deploy (push) Successful in 1m16s
2026-05-22 23:27:45 +02:00
Ola Malmgren
287e9a2a0e fix my page header
All checks were successful
Build and deploy / build-deploy (push) Successful in 1m17s
2026-05-22 23:20:56 +02:00
Ola Malmgren
a03d1e8541 fix magic link 2026-05-22 23:20:45 +02:00
Ola Malmgren
23fd84d85c fiix: fullfillment 2026-05-22 23:20:34 +02:00
Ola
98e05193ec build: force-include node-mailjet in standalone output
All checks were successful
Build and deploy / build-deploy (push) Successful in 1m18s
2026-05-22 22:16:07 +02:00
Ola Malmgren
12cfa790ad fix: remove mandatory
All checks were successful
Build and deploy / build-deploy (push) Successful in 1m51s
2026-05-22 22:07:22 +02:00
Ola Malmgren
7e78ace956 fix: add disclaimer 2026-05-22 22:05:16 +02:00
13 changed files with 108 additions and 26 deletions

View File

@@ -27,6 +27,10 @@
"booking": { "booking": {
"title": "LPG cylinder order", "title": "LPG cylinder order",
"intro": "Reserve LPG cylinders for your contingent at the camp. A confirmation will be sent to the email you provide.", "intro": "Reserve LPG cylinders for your contingent at the camp. A confirmation will be sent to the email you provide.",
"responsibility": {
"title": "Your responsibility",
"body": "As the orderer you are responsible for ensuring your village/contingent has permission for the quantity of LPG ordered. Guideline: 60 litres per 40 participants."
},
"stepProducts": "Choose products", "stepProducts": "Choose products",
"stepDetails": "Your details", "stepDetails": "Your details",
"stepPickup": "Pickup", "stepPickup": "Pickup",

View File

@@ -27,6 +27,10 @@
"booking": { "booking": {
"title": "Beställning av gasoltuber", "title": "Beställning av gasoltuber",
"intro": "Reservera gasoltuber till ert kontingent på lägret. Bekräftelse skickas till angiven e-postadress.", "intro": "Reservera gasoltuber till ert kontingent på lägret. Bekräftelse skickas till angiven e-postadress.",
"responsibility": {
"title": "Beställarens ansvar",
"body": "Som beställare ansvarar du för att din by/kontingent har tillstånd för den mängd gasol som beställs. Riktlinje: 60 liter per 40 deltagare."
},
"stepProducts": "Välj produkter", "stepProducts": "Välj produkter",
"stepDetails": "Era uppgifter", "stepDetails": "Era uppgifter",
"stepPickup": "Upphämtning", "stepPickup": "Upphämtning",

View File

@@ -5,6 +5,11 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
// node-mailjet uses dynamic requires that Next's static tracer misses, so
// it never lands in .next/standalone/node_modules. Force-include it.
outputFileTracingIncludes: {
'**': ['./node_modules/node-mailjet/**/*'],
},
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '2mb', bodySizeLimit: '2mb',

View File

@@ -80,7 +80,7 @@ model Booking {
// Organization // Organization
orgName String orgName String
orgNumber String orgNumber String?
// Invoice address // Invoice address
address String address String

View File

@@ -26,47 +26,47 @@ export default async function AdminLayout({
return ( return (
<div className="min-h-screen bg-ink-50"> <div className="min-h-screen bg-ink-50">
<header className="border-b border-ink-200 bg-white"> <header className="border-b border-brand-700 bg-brand-600 text-white">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3"> <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"> <div className="flex items-center gap-3">
<Link href="/admin" className="text-base font-semibold text-ink-900"> <Link href="/admin" className="text-base font-semibold">
{t('title')} {t('title')}
</Link> </Link>
{session?.user && ( {session?.user && (
<nav className="hidden gap-1 sm:flex"> <nav className="hidden gap-1 sm:flex">
<Link <Link
href="/admin" href="/admin"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100" className="rounded-md px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 hover:text-white"
> >
{t('nav.bookings')} {t('nav.bookings')}
</Link> </Link>
<Link <Link
href="/admin/products" href="/admin/products"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100" className="rounded-md px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 hover:text-white"
> >
{t('nav.products')} {t('nav.products')}
</Link> </Link>
<Link <Link
href="/admin/pickup-slots" href="/admin/pickup-slots"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100" className="rounded-md px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 hover:text-white"
> >
{t('nav.pickupSlots')} {t('nav.pickupSlots')}
</Link> </Link>
<Link <Link
href="/admin/replacements" href="/admin/replacements"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100" className="rounded-md px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 hover:text-white"
> >
{tr('navTitle')} {tr('navTitle')}
</Link> </Link>
<Link <Link
href="/admin/settings" href="/admin/settings"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100" className="rounded-md px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 hover:text-white"
> >
{t('nav.settings')} {t('nav.settings')}
</Link> </Link>
<Link <Link
href="/admin/users" href="/admin/users"
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100" className="rounded-md px-3 py-1.5 text-sm text-white/80 hover:bg-white/10 hover:text-white"
> >
{t('nav.users')} {t('nav.users')}
</Link> </Link>
@@ -82,7 +82,10 @@ export default async function AdminLayout({
await signOut({ redirectTo: '/admin/login' }); await signOut({ redirectTo: '/admin/login' });
}} }}
> >
<button type="submit" className="btn-ghost text-xs"> <button
type="submit"
className="rounded-md px-3 py-1.5 text-xs text-white/80 hover:bg-white/10 hover:text-white"
>
{t('nav.signOut')} {t('nav.signOut')}
</button> </button>
</form> </form>

View File

@@ -114,6 +114,15 @@ export default async function BookingConfirmedPage({
</div> </div>
)} )}
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<div className="font-semibold text-amber-900">
{t('booking.responsibility.title')}
</div>
<p className="mt-1 text-amber-800">
{t('booking.responsibility.body')}
</p>
</div>
<div className="rounded-lg bg-ink-50 p-4 text-sm text-ink-600"> <div className="rounded-lg bg-ink-50 p-4 text-sm text-ink-600">
{t('email.invoiceInfo')} {t('email.invoiceInfo')}
</div> </div>

View File

@@ -50,6 +50,27 @@ export default async function BookingPage({
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-ink-600">{t('intro')}</p> <p className="mt-1 max-w-2xl text-sm text-ink-600">{t('intro')}</p>
</div> </div>
<div className="mb-6 flex max-w-2xl gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clipRule="evenodd"
/>
</svg>
<div className="text-sm">
<div className="font-semibold text-amber-900">
{t('responsibility.title')}
</div>
<p className="mt-1 text-amber-800">{t('responsibility.body')}</p>
</div>
</div>
<BookingForm <BookingForm
products={products.map((p) => ({ products={products.map((p) => ({
id: p.id, id: p.id,

View File

@@ -11,5 +11,9 @@ export async function GET(req: Request) {
const target = email const target = email
? `${prefix}/min-sida/oversikt` ? `${prefix}/min-sida/oversikt`
: `${prefix}/min-sida/verifiera`; : `${prefix}/min-sida/verifiera`;
return NextResponse.redirect(new URL(target, url.origin)); // Use the configured public origin — req.url.origin is the internal
// container host (e.g. http://<hash>:3000) when behind Traefik.
const base =
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.AUTH_URL ?? url.origin;
return NextResponse.redirect(new URL(target, base));
} }

View File

@@ -126,7 +126,7 @@ export function BookingForm({
/^\S+@\S+\.\S+$/.test(email) && /^\S+@\S+\.\S+$/.test(email) &&
phone.trim().length >= 5 && phone.trim().length >= 5 &&
orgName.trim().length >= 2 && orgName.trim().length >= 2 &&
isValidSeOrgNumber(orgNumber) && (orgNumber.trim() === '' || isValidSeOrgNumber(orgNumber)) &&
address.trim().length >= 2 && address.trim().length >= 2 &&
postalCode.trim().length >= 3 && postalCode.trim().length >= 3 &&
city.trim().length >= 1 city.trim().length >= 1
@@ -347,7 +347,6 @@ export function BookingForm({
/> />
<Field <Field
label={t('details.orgNumber')} label={t('details.orgNumber')}
required
value={orgNumber} value={orgNumber}
onChange={(v) => patch({ orgNumber: v })} onChange={(v) => patch({ orgNumber: v })}
placeholder={t('details.orgNumberPlaceholder')} placeholder={t('details.orgNumberPlaceholder')}

View File

@@ -1,14 +1,20 @@
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
import { LanguageSwitcher } from './LanguageSwitcher'; import { LanguageSwitcher } from './LanguageSwitcher';
export function Header() { export function Header() {
const t = useTranslations('header'); const t = useTranslations('header');
const c = useTranslations('common'); const c = useTranslations('common');
const cu = useTranslations('customer');
return ( return (
<header className="border-b border-brand-700 bg-brand-600 text-white"> <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="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"> <Link
href="/"
className="flex min-w-0 items-center gap-3 rounded-lg outline-none ring-white/40 transition-opacity hover:opacity-90 focus-visible:ring-2"
aria-label={c('siteName')}
>
<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"> <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 <Image
src="/gasol247-logo.png" src="/gasol247-logo.png"
@@ -27,8 +33,16 @@ export function Header() {
{t('tagline')} {t('tagline')}
</div> </div>
</div> </div>
</Link>
<div className="flex items-center gap-2 sm:gap-3">
<Link
href="/min-sida"
className="rounded-md border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-3 sm:py-1.5"
>
{cu('navTitle')}
</Link>
<LanguageSwitcher />
</div> </div>
<LanguageSwitcher />
</div> </div>
</header> </header>
); );

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; import { useEffect, useState, useTransition } from 'react';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { import {
setItemFulfillment, setItemFulfillment,
@@ -36,9 +36,7 @@ export function FulfillmentTable({
{t('title')} {t('title')}
</h3> </h3>
<div className="flex gap-1"> <div className="flex gap-1">
<form <form action={markAllDelivered}>
action={(fd) => startTransition(() => markAllDelivered(fd))}
>
<input type="hidden" name="bookingId" value={bookingId} /> <input type="hidden" name="bookingId" value={bookingId} />
<button <button
type="submit" type="submit"
@@ -48,9 +46,7 @@ export function FulfillmentTable({
{t('deliverAll')} {t('deliverAll')}
</button> </button>
</form> </form>
<form <form action={markAllReturned}>
action={(fd) => startTransition(() => markAllReturned(fd))}
>
<input type="hidden" name="bookingId" value={bookingId} /> <input type="hidden" name="bookingId" value={bookingId} />
<button <button
type="submit" type="submit"
@@ -113,6 +109,13 @@ function FulfillmentRow({
const [delivered, setDelivered] = useState(item.deliveredQuantity); const [delivered, setDelivered] = useState(item.deliveredQuantity);
const [returned, setReturned] = useState(item.returnedQuantity); const [returned, setReturned] = useState(item.returnedQuantity);
// Sync local state when server data refreshes (e.g. after deliver-all /
// return-all bulk actions). Without this, the inputs stay stale until reload.
useEffect(() => {
setDelivered(item.deliveredQuantity);
setReturned(item.returnedQuantity);
}, [item.deliveredQuantity, item.returnedQuantity]);
const outstanding = Math.max(0, delivered - returned); const outstanding = Math.max(0, delivered - returned);
const dirty = const dirty =
delivered !== item.deliveredQuantity || returned !== item.returnedQuantity; delivered !== item.deliveredQuantity || returned !== item.returnedQuantity;

View File

@@ -35,6 +35,8 @@ type EmailStrings = {
orderSummary: string; orderSummary: string;
pickup: string; pickup: string;
invoiceInfo: string; invoiceInfo: string;
responsibilityTitle: string;
responsibilityBody: string;
questions: string; questions: string;
footer: string; footer: string;
subtotal: string; subtotal: string;
@@ -53,6 +55,9 @@ function getStrings(locale: 'sv' | 'en', event: string): EmailStrings {
orderSummary: 'Beställning', orderSummary: 'Beställning',
pickup: 'Upphämtning', pickup: 'Upphämtning',
invoiceInfo: 'Faktura skickas till organisationen efter eventet.', invoiceInfo: 'Faktura skickas till organisationen efter eventet.',
responsibilityTitle: 'Beställarens ansvar',
responsibilityBody:
'Som beställare ansvarar du för att din by/kontingent har tillstånd för den mängd gasol som beställs. Riktlinje: 60 liter per 40 deltagare.',
questions: 'Har du frågor? Svara på detta mejl så hjälper vi dig.', questions: 'Har du frågor? Svara på detta mejl så hjälper vi dig.',
footer: 'Detta är en automatiserad bekräftelse från Gasol247.', footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
subtotal: 'Delsumma', subtotal: 'Delsumma',
@@ -69,6 +74,9 @@ function getStrings(locale: 'sv' | 'en', event: string): EmailStrings {
orderSummary: 'Order', orderSummary: 'Order',
pickup: 'Pickup', pickup: 'Pickup',
invoiceInfo: 'An invoice will be sent to your organization after the event.', invoiceInfo: 'An invoice will be sent to your organization after the event.',
responsibilityTitle: 'Your responsibility',
responsibilityBody:
'As the orderer you are responsible for ensuring your village/contingent has permission for the quantity of LPG ordered. Guideline: 60 litres per 40 participants.',
questions: 'Questions? Reply to this email and we will help you.', questions: 'Questions? Reply to this email and we will help you.',
footer: 'This is an automated confirmation from Gasol247.', footer: 'This is an automated confirmation from Gasol247.',
subtotal: 'Subtotal', subtotal: 'Subtotal',
@@ -166,7 +174,12 @@ export function renderBookingEmail(
${pickupHtml} ${pickupHtml}
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;"> <div style="margin-top:24px;padding:16px;background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;font-size:13px;color:#78350f;">
<div style="font-weight:600;color:#7c2d12;margin-bottom:4px;">${escapeHtml(s.responsibilityTitle)}</div>
${escapeHtml(s.responsibilityBody)}
</div>
<div style="margin-top:16px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
${escapeHtml(s.invoiceInfo)} ${escapeHtml(s.invoiceInfo)}
</div> </div>
@@ -202,6 +215,8 @@ export function renderBookingEmail(
`${s.total}: ${fmt(booking.totalOre)}`, `${s.total}: ${fmt(booking.totalOre)}`,
...(slotLabel ? ['', `${s.pickup}: ${slotLabel} ${slotTime}`] : []), ...(slotLabel ? ['', `${s.pickup}: ${slotLabel} ${slotTime}`] : []),
'', '',
`${s.responsibilityTitle}: ${s.responsibilityBody}`,
'',
s.invoiceInfo, s.invoiceInfo,
'', '',
s.questions, s.questions,

View File

@@ -14,12 +14,13 @@ export const bookingSubmitSchema = z.object({
orgNumber: z orgNumber: z
.string() .string()
.trim() .trim()
.min(10)
.max(13) .max(13)
.refine((v) => isValidSeOrgNumber(v), { .refine((v) => v === '' || isValidSeOrgNumber(v), {
message: 'invalidOrgNumber', message: 'invalidOrgNumber',
}) })
.transform((v) => normalizeOrgNumber(v)), .transform((v) => (v === '' ? null : normalizeOrgNumber(v)))
.nullable()
.optional(),
address: z.string().trim().min(2).max(200), address: z.string().trim().min(2).max(200),
postalCode: z.string().trim().min(3).max(20), postalCode: z.string().trim().min(3).max(20),
city: z.string().trim().min(1).max(100), city: z.string().trim().min(1).max(100),