Просмотр исходного кода

Force workshop language on customer-facing pages (#125)

* customer pages force languages

* customer portal logo
Bernt Christian Egeland 5 месяцев назад
Родитель
Сommit
11d2474783

+ 2 - 0
messages/de/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Sprache",
     "languageDescription": "Wählen Sie die Anzeigesprache der Anwendung.",
     "language": "Sprache",
+    "forceCustomerLocale": "Diese Sprache für kundenseitige Seiten erzwingen",
+    "forceCustomerLocaleHint": "Wenn aktiviert, sehen Kunden geteilte Rechnungen, Angebote und Inspektionen in der Sprache Ihrer Werkstatt, unabhängig von ihren Browsereinstellungen. Wenn deaktiviert, sehen Kunden die Seiten in der in ihrem Browser konfigurierten Sprache.",
     "saved": "Lokalisierungseinstellungen gespeichert",
     "saveSettings": "Einstellungen speichern"
   },

+ 2 - 0
messages/en/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Language",
     "languageDescription": "Choose the display language for the application.",
     "language": "Language",
+    "forceCustomerLocale": "Force this language on customer-facing pages",
+    "forceCustomerLocaleHint": "When enabled, customers viewing shared invoices, quotes and inspections will see them in your workshop's language regardless of their browser settings. When disabled, customers see pages in the language configured in their browser.",
     "saved": "Localization settings saved",
     "saveSettings": "Save Settings"
   },

+ 2 - 0
messages/es/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Idioma",
     "languageDescription": "Elija el idioma de visualización de la aplicación.",
     "language": "Idioma",
+    "forceCustomerLocale": "Forzar este idioma en las páginas dirigidas al cliente",
+    "forceCustomerLocaleHint": "Cuando está activado, los clientes que vean facturas, presupuestos e inspecciones compartidas los verán en el idioma de su taller, independientemente de la configuración de su navegador. Cuando está desactivado, los clientes ven las páginas en el idioma configurado en su navegador.",
     "saved": "Configuración de localización guardada",
     "saveSettings": "Guardar configuración"
   },

+ 2 - 0
messages/fr/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Langue",
     "languageDescription": "Choisissez la langue d'affichage de l'application.",
     "language": "Langue",
+    "forceCustomerLocale": "Forcer cette langue sur les pages destinées aux clients",
+    "forceCustomerLocaleHint": "Lorsqu'il est activé, les clients consultant les factures, devis et inspections partagés les verront dans la langue de votre atelier, quels que soient les paramètres de leur navigateur. Lorsqu'il est désactivé, les clients voient les pages dans la langue configurée dans leur navigateur.",
     "saved": "Paramètres de localisation enregistrés",
     "saveSettings": "Enregistrer les paramètres"
   },

+ 2 - 0
messages/it/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Lingua",
     "languageDescription": "Scegli la lingua di visualizzazione dell'applicazione.",
     "language": "Lingua",
+    "forceCustomerLocale": "Forza questa lingua sulle pagine rivolte al cliente",
+    "forceCustomerLocaleHint": "Quando attivato, i clienti che visualizzano fatture, preventivi e ispezioni condivise li vedranno nella lingua della tua officina, indipendentemente dalle impostazioni del browser. Quando disattivato, i clienti vedono le pagine nella lingua configurata nel loro browser.",
     "saved": "Impostazioni di localizzazione salvate",
     "saveSettings": "Salva impostazioni"
   },

+ 2 - 0
messages/nb/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Språk",
     "languageDescription": "Velg visningsspråk for applikasjonen.",
     "language": "Språk",
+    "forceCustomerLocale": "Tving dette språket på kundesider",
+    "forceCustomerLocaleHint": "Når aktivert vil kunder som ser delte fakturaer, tilbud og inspeksjoner se dem på verkstedets språk uavhengig av nettleserinnstillingene deres. Når deaktivert, ser kundene sidene på språket som er konfigurert i nettleseren deres.",
     "saved": "Lokaliseringsinnstillinger lagret",
     "saveSettings": "Lagre innstillinger"
   },

+ 2 - 0
messages/nl/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Taal",
     "languageDescription": "Kies de weergavetaal van de applicatie.",
     "language": "Taal",
+    "forceCustomerLocale": "Forceer deze taal op klantgerichte pagina's",
+    "forceCustomerLocaleHint": "Wanneer ingeschakeld, zien klanten die gedeelde facturen, offertes en inspecties bekijken deze in de taal van uw werkplaats, ongeacht hun browserinstellingen. Wanneer uitgeschakeld, zien klanten de pagina's in de taal die in hun browser is ingesteld.",
     "saved": "Lokalisatie-instellingen opgeslagen",
     "saveSettings": "Instellingen opslaan"
   },

+ 2 - 0
messages/pl/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Język",
     "languageDescription": "Wybierz język wyświetlania aplikacji.",
     "language": "Język",
+    "forceCustomerLocale": "Wymuś ten język na stronach dla klientów",
+    "forceCustomerLocaleHint": "Po włączeniu klienci przeglądający udostępnione faktury, oferty i inspekcje zobaczą je w języku Twojego warsztatu, niezależnie od ustawień przeglądarki. Gdy wyłączone, klienci widzą strony w języku skonfigurowanym w ich przeglądarce.",
     "saved": "Ustawienia lokalizacji zapisane",
     "saveSettings": "Zapisz ustawienia"
   },

+ 2 - 0
messages/pt-BR/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Idioma",
     "languageDescription": "Escolha o idioma de exibição do aplicativo.",
     "language": "Idioma",
+    "forceCustomerLocale": "Forçar este idioma nas páginas voltadas ao cliente",
+    "forceCustomerLocaleHint": "Quando ativado, os clientes que visualizam faturas, orçamentos e inspeções compartilhadas os verão no idioma da sua oficina, independentemente das configurações do navegador. Quando desativado, os clientes veem as páginas no idioma configurado no navegador.",
     "saved": "Configurações de localização salvas",
     "saveSettings": "Salvar configurações"
   },

+ 2 - 0
messages/ru/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Язык",
     "languageDescription": "Выберите язык приложения.",
     "language": "Язык",
+    "forceCustomerLocale": "Применять этот язык на страницах для клиентов",
+    "forceCustomerLocaleHint": "Когда включено, клиенты, просматривающие общие счета, предложения и инспекции, будут видеть их на языке вашей мастерской, независимо от настроек их браузера. Когда отключено, клиенты видят страницы на языке, настроенном в их браузере.",
     "saved": "Настройки локализации сохранены",
     "saveSettings": "Сохранить настройки"
   },

+ 2 - 0
messages/tr/settings.json

@@ -626,6 +626,8 @@
     "languageTitle": "Dil",
     "languageDescription": "Uygulamanın görüntüleme dilini seçin.",
     "language": "Dil",
+    "forceCustomerLocale": "Bu dili müşteriye yönelik sayfalarda zorla",
+    "forceCustomerLocaleHint": "Etkinleştirildiğinde, paylaşılan faturaları, teklifleri ve incelemeleri görüntüleyen müşteriler bunları, tarayıcı ayarlarından bağımsız olarak atölyenizin dilinde görür. Devre dışı bırakıldığında, müşteriler sayfaları tarayıcılarında yapılandırılan dilde görür.",
     "saved": "Yerelleştirme ayarları kaydedildi",
     "saveSettings": "Ayarları kaydet"
   },

+ 47 - 10
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { useRouter } from "next/navigation";
 import { useLocale, useTranslations } from "next-intl";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,7 +9,7 @@ import { Label } from "@/components/ui/label";
 import { Button } from "@/components/ui/button";
 import { Separator } from "@/components/ui/separator";
 import { toast } from "sonner";
-import { setSettings } from "@/features/settings/Actions/settingsActions";
+import { setSetting, setSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import {
   Select,
@@ -143,6 +143,15 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
   const [timeFormat, setTimeFormat] = useState(settings[SETTING_KEYS.TIME_FORMAT] || "12h");
   const [timezone, setTimezone] = useState(settings[SETTING_KEYS.TIMEZONE] || "");
 
+  const [forceCustomerLocale, setForceCustomerLocale] = useState(settings[SETTING_KEYS.FORCE_CUSTOMER_LOCALE] === "true");
+
+  // Theme (localStorage) and new Date() (runtime TZ) are client-only;
+  // defer rendering until after hydration to avoid SSR/client mismatches.
+  const [mounted, setMounted] = useState(false);
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
   const handleSave = async () => {
     setSaving(true);
     await setSettings({
@@ -158,6 +167,25 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
     toast.success(t("localization.saved"));
   };
 
+  const handleLanguageChange = async (value: string) => {
+    await Promise.all([
+      setLocale(value),
+      setSetting(SETTING_KEYS.WORKSHOP_LOCALE, value),
+    ]);
+    router.refresh();
+  };
+
+  const handleForceCustomerLocaleChange = async (value: boolean) => {
+    setForceCustomerLocale(value);
+    // Snapshot the admin's current locale so force mode always has a target,
+    // even on existing installs where workshop.locale was never written.
+    await setSettings({
+      [SETTING_KEYS.FORCE_CUSTOMER_LOCALE]: String(value),
+      [SETTING_KEYS.WORKSHOP_LOCALE]: currentLocale,
+    });
+    router.refresh();
+  };
+
   return (
     <div className="space-y-6">
       <ReadOnlyBanner />
@@ -176,10 +204,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
             <Label>{t("localization.language")}</Label>
             <Select
               value={currentLocale}
-              onValueChange={async (value) => {
-                await setLocale(value);
-                router.refresh();
-              }}
+              onValueChange={handleLanguageChange}
             >
               <SelectTrigger className="w-64">
                 <SelectValue />
@@ -193,6 +218,18 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
               </SelectContent>
             </Select>
           </div>
+
+          <Separator />
+
+          <div className="flex items-center justify-between">
+            <div className="pr-4">
+              <Label>{t("localization.forceCustomerLocale")}</Label>
+              <p className="text-xs text-muted-foreground">
+                {t("localization.forceCustomerLocaleHint")}
+              </p>
+            </div>
+            <Switch checked={forceCustomerLocale} onCheckedChange={handleForceCustomerLocaleChange} />
+          </div>
         </CardContent>
       </Card>
 
@@ -368,8 +405,8 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
                   <Clock className="h-3.5 w-3.5" />
                   <span className="font-medium text-foreground">{t("appearance.previewLabel")}</span>
                 </div>
-                <p>{t("appearance.dateLabel")}: <span className="font-medium text-foreground">{formatDate(new Date(), dateFormat, timezone || undefined)}</span></p>
-                <p>{t("appearance.dateTimeLabel")}: <span className="font-medium text-foreground">{formatDateTime(new Date(), dateFormat, timeFormat as "12h" | "24h", timezone || undefined)}</span></p>
+                <p>{t("appearance.dateLabel")}: <span className="font-medium text-foreground">{mounted ? formatDate(new Date(), dateFormat, timezone || undefined) : ""}</span></p>
+                <p>{t("appearance.dateTimeLabel")}: <span className="font-medium text-foreground">{mounted ? formatDateTime(new Date(), dateFormat, timeFormat as "12h" | "24h", timezone || undefined) : ""}</span></p>
               </div>
             </div>
           </ReadOnlyWrapper>
@@ -385,7 +422,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
         <CardContent className="space-y-6">
           <div className="flex items-center justify-between rounded-lg border p-4">
             <div className="flex items-center gap-3">
-              {theme === "dark" ? (
+              {mounted && theme === "dark" ? (
                 <Moon className="h-5 w-5 text-muted-foreground" />
               ) : (
                 <Sun className="h-5 w-5 text-muted-foreground" />
@@ -398,7 +435,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
               </div>
             </div>
             <Switch
-              checked={theme === "dark"}
+              checked={mounted && theme === "dark"}
               onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
             />
           </div>

+ 3 - 2
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -16,7 +16,7 @@ import {
 import { QRCodeSVG } from 'qrcode.react'
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
 import { sanitizeHtml } from '@/lib/sanitize-html'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
 import { isCustomFieldId, fromCustomFieldId, groupSectionsForRendering, getDefaultInvoiceLayout, getOrderedFieldIds, getVisibleFieldsForSection } from '@/features/settings/Schema/invoiceLayoutSchema'
 
 interface InvoiceRecord {
@@ -225,6 +225,7 @@ export function InvoiceView({
 }) {
   const t = useTranslations('share.invoice')
   const tc = useTranslations('share.common')
+  const locale = useLocale()
   const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
   const [paymentAmount, setPaymentAmount] = useState('')
   const [paymentLoading, setPaymentLoading] = useState<string | null>(null)
@@ -743,7 +744,7 @@ export function InvoiceView({
                       case 'vehicle_name': return <p key={fid} className="font-semibold">{vehicleName}</p>
                       case 'vin': return record.vehicle.vin ? <p key={fid} className="text-sm text-gray-500">{serviceType === 'marine' ? `HIN: ${record.vehicle.vin}` : t('vin', { vin: record.vehicle.vin })}</p> : null
                       case 'license_plate': return record.vehicle.licensePlate ? <p key={fid} className="text-sm text-gray-500">{serviceType === 'marine' ? `Reg: ${record.vehicle.licensePlate}` : t('plate', { plate: record.vehicle.licensePlate })}</p> : null
-                      case 'mileage': return record.vehicle.mileage > 0 ? <p key={fid} className="text-sm text-gray-500">{serviceType === 'marine' ? `Engine Hours: ${record.vehicle.mileage.toLocaleString()}` : record.vehicle.mileage.toLocaleString()}</p> : null
+                      case 'mileage': return record.vehicle.mileage > 0 ? <p key={fid} className="text-sm text-gray-500">{serviceType === 'marine' ? `Engine Hours: ${record.vehicle.mileage.toLocaleString(locale)}` : record.vehicle.mileage.toLocaleString(locale)}</p> : null
                       default: return null
                     }
                   }

+ 67 - 0
src/app/api/public/logo/[orgId]/route.ts

@@ -0,0 +1,67 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { readFile, stat } from "fs/promises";
+import path from "path";
+import { resolvePortalOrg } from "@/lib/portal-slug";
+
+const MIME_TYPES: Record<string, string> = {
+  jpg: "image/jpeg",
+  jpeg: "image/jpeg",
+  png: "image/png",
+  webp: "image/webp",
+  avif: "image/avif",
+  svg: "image/svg+xml",
+};
+
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ orgId: string }> }
+) {
+  const { orgId: orgParam } = await params;
+
+  // Accept either slug or UUID
+  const resolvedOrg = await resolvePortalOrg(orgParam);
+  const orgId = resolvedOrg?.id ?? orgParam;
+
+  const logoSetting = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId: orgId, key: "workshop.logo" } },
+    select: { value: true },
+  });
+
+  if (!logoSetting?.value) {
+    return NextResponse.json({ error: "No logo" }, { status: 404 });
+  }
+
+  const urlParts = logoSetting.value.split("/");
+  const filename = urlParts[urlParts.length - 1];
+
+  if (!filename || filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
+    return NextResponse.json({ error: "Invalid" }, { status: 400 });
+  }
+
+  const filePath = path.join(
+    process.cwd(),
+    "data",
+    "uploads",
+    orgId,
+    "logos",
+    filename,
+  );
+
+  try {
+    await stat(filePath);
+  } catch {
+    return NextResponse.json({ error: "Not found" }, { status: 404 });
+  }
+
+  const buffer = await readFile(filePath);
+  const ext = filename.split(".").pop()?.toLowerCase() || "";
+  const contentType = MIME_TYPES[ext] || "image/png";
+
+  return new NextResponse(buffer, {
+    headers: {
+      "Content-Type": contentType,
+      "Cache-Control": "public, max-age=3600",
+    },
+  });
+}

+ 10 - 9
src/app/api/public/share/inspection/[orgId]/[token]/pdf/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from "next/server";
 import { renderToBuffer } from "@react-pdf/renderer";
 import "@/features/vehicles/Components/invoice-pdf/fonts";
-import { cookies } from "next/headers";
+import { headers } from "next/headers";
 import { db } from "@/lib/db";
 import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
 import React from "react";
@@ -10,15 +10,22 @@ import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { resolvePortalOrg } from "@/lib/portal-slug";
+import { resolveCustomerLocale } from "@/i18n/locale-from-request";
 
 export async function GET(
   _request: Request,
   { params }: { params: Promise<{ orgId: string; token: string }> }
 ) {
   try {
+    const { orgId: orgParam, token } = await params;
+
+    // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
+    const resolvedOrg = await resolvePortalOrg(orgParam);
+    const orgId = resolvedOrg?.id ?? orgParam;
+
     // Load locale-based PDF translations
-    const cookieStore = await cookies();
-    const locale = cookieStore.get("locale")?.value || "en";
+    const headerStore = await headers();
+    const locale = await resolveCustomerLocale(orgId, headerStore.get("accept-language"));
     let pdfMessages: Record<string, Record<string, string>>;
     try {
       pdfMessages = (await import(`../../../../../../../../../messages/${locale}/pdf.json`)).default;
@@ -30,12 +37,6 @@ export async function GET(
       ...pdfMessages.common,
     };
 
-    const { orgId: orgParam, token } = await params;
-
-    // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-    const resolvedOrg = await resolvePortalOrg(orgParam);
-    const orgId = resolvedOrg?.id ?? orgParam;
-
     const inspection = await db.inspection.findFirst({
       where: { publicToken: token, organizationId: orgId },
       include: {

+ 10 - 8
src/app/api/public/share/invoice/[orgId]/[token]/pdf/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from "next/server";
 import { renderToBuffer } from "@react-pdf/renderer";
 import "@/features/vehicles/Components/invoice-pdf/fonts";
-import { cookies } from "next/headers";
+import { headers } from "next/headers";
 import { db } from "@/lib/db";
 import { InvoicePDF } from "@/features/vehicles/Components/InvoicePDF";
 import React from "react";
@@ -13,6 +13,7 @@ import { rateLimit } from "@/lib/rate-limit";
 import { formatDateForPdf } from "@/lib/format";
 import { resolvePortalOrg } from "@/lib/portal-slug";
 import { mergeWithDefaults } from "@/features/settings/Schema/invoiceLayoutSchema";
+import { resolveCustomerLocale } from "@/i18n/locale-from-request";
 
 export async function GET(
   _request: Request,
@@ -22,9 +23,15 @@ export async function GET(
   if (limited) return limited;
 
   try {
+    const { orgId: orgParam, token } = await params;
+
+    // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
+    const resolvedOrg = await resolvePortalOrg(orgParam);
+    const orgId = resolvedOrg?.id ?? orgParam;
+
     // Load locale-based PDF translations
-    const cookieStore = await cookies();
-    const locale = cookieStore.get("locale")?.value || "en";
+    const headerStore = await headers();
+    const locale = await resolveCustomerLocale(orgId, headerStore.get("accept-language"));
     let pdfMessages: Record<string, Record<string, string>>;
     try {
       pdfMessages = (await import(`../../../../../../../../../messages/${locale}/pdf.json`)).default;
@@ -35,11 +42,6 @@ export async function GET(
       ...pdfMessages.invoice,
       ...pdfMessages.common,
     };
-    const { orgId: orgParam, token } = await params;
-
-    // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-    const resolvedOrg = await resolvePortalOrg(orgParam);
-    const orgId = resolvedOrg?.id ?? orgParam;
 
     const record = await db.serviceRecord.findUnique({
       where: { publicToken: token },

+ 10 - 9
src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from "next/server";
 import { renderToBuffer } from "@react-pdf/renderer";
 import "@/features/vehicles/Components/invoice-pdf/fonts";
-import { cookies } from "next/headers";
+import { headers } from "next/headers";
 import { db } from "@/lib/db";
 import { QuotePDF } from "@/features/quotes/Components/QuotePDF";
 import React from "react";
@@ -12,15 +12,22 @@ import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { resolvePortalOrg } from "@/lib/portal-slug";
 import { mergeWithDefaults } from "@/features/settings/Schema/invoiceLayoutSchema";
+import { resolveCustomerLocale } from "@/i18n/locale-from-request";
 
 export async function GET(
   _request: Request,
   { params }: { params: Promise<{ orgId: string; token: string }> }
 ) {
   try {
+    const { orgId: orgParam, token } = await params;
+
+    // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
+    const resolvedOrg = await resolvePortalOrg(orgParam);
+    const orgId = resolvedOrg?.id ?? orgParam;
+
     // Load locale-based PDF translations
-    const cookieStore = await cookies();
-    const locale = cookieStore.get("locale")?.value || "en";
+    const headerStore = await headers();
+    const locale = await resolveCustomerLocale(orgId, headerStore.get("accept-language"));
     let pdfMessages: Record<string, Record<string, string>>;
     try {
       pdfMessages = (await import(`../../../../../../../../../messages/${locale}/pdf.json`)).default;
@@ -32,12 +39,6 @@ export async function GET(
       ...pdfMessages.common,
     };
 
-    const { orgId: orgParam, token } = await params;
-
-    // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-    const resolvedOrg = await resolvePortalOrg(orgParam);
-    const orgId = resolvedOrg?.id ?? orgParam;
-
     const quote = await db.quote.findFirst({
       where: { publicToken: token, organizationId: orgId },
       include: {

+ 1 - 1
src/features/portal/Components/PortalShell.tsx

@@ -89,7 +89,7 @@ export async function PortalShell({
       <PortalHeader
         orgId={orgParam}
         orgName={org.name}
-        orgLogo={logoSetting?.value}
+        orgLogo={logoSetting?.value ? `/api/public/logo/${orgParam}` : undefined}
         customerName={customer.name}
       />
       <div className="flex min-h-0 flex-1">

+ 2 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -89,6 +89,8 @@ export const SETTING_KEYS = {
   AI_MODEL: "ai.model",
   AI_ENABLED: "ai.enabled",
   SERVICE_TYPE: "workshop.serviceType",
+  WORKSHOP_LOCALE: "workshop.locale",
+  FORCE_CUSTOMER_LOCALE: "workshop.forceCustomerLocale",
 } as const;
 
 export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];

+ 82 - 0
src/i18n/locale-from-request.ts

@@ -0,0 +1,82 @@
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { type Locale, defaultLocale, locales } from './config'
+
+/**
+ * Extracts the org URL param (slug or UUID) from a customer-facing pathname.
+ * Returns null if the path is not a customer-facing route.
+ */
+export function extractCustomerOrgParam(pathname: string | null | undefined): string | null {
+  if (!pathname) return null
+
+  // /portal/[orgId]/...
+  const portalMatch = pathname.match(/^\/portal\/([^/]+)/)
+  if (portalMatch) return portalMatch[1]
+
+  // /share/{invoice,quote,inspection,status-report,terms}/[orgId]/...
+  const shareMatch = pathname.match(/^\/share\/(?:invoice|quote|inspection|status-report|terms)\/([^/]+)/)
+  if (shareMatch) return shareMatch[1]
+
+  return null
+}
+
+export function getBestLocaleFromHeader(acceptLanguage: string | null): Locale | undefined {
+  if (!acceptLanguage) return undefined
+
+  const entries = acceptLanguage
+    .split(',')
+    .map((part) => {
+      const [lang, q] = part.trim().split(';q=')
+      return { lang: lang.trim(), q: q ? parseFloat(q) : 1 }
+    })
+    .sort((a, b) => b.q - a.q)
+
+  for (const { lang } of entries) {
+    // Exact match (e.g. "pt-BR")
+    if (locales.includes(lang as Locale)) return lang as Locale
+
+    // Prefix match: "de-AT" → "de"
+    const prefix = lang.split('-')[0]
+    if (locales.includes(prefix as Locale)) return prefix as Locale
+
+    // Reverse prefix: "pt" → "pt-BR"
+    const match = locales.find((l) => l.startsWith(prefix + '-'))
+    if (match) return match
+  }
+
+  return undefined
+}
+
+/**
+ * Resolves the locale to use for customer-facing share PDFs.
+ *
+ * Order:
+ *   1. If the workshop has `workshop.forceCustomerLocale = true`, use `workshop.locale`.
+ *   2. Otherwise, the customer's browser `Accept-Language` header.
+ *   3. Otherwise, the default locale.
+ *
+ * No cookie is read — these routes are downloaded by customers who won't have
+ * a workshop-scoped `locale` cookie.
+ */
+export async function resolveCustomerLocale(
+  organizationId: string,
+  acceptLanguageHeader: string | null,
+): Promise<Locale> {
+  const settings = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: { in: [SETTING_KEYS.FORCE_CUSTOMER_LOCALE, SETTING_KEYS.WORKSHOP_LOCALE] },
+    },
+    select: { key: true, value: true },
+  })
+
+  const forced = settings.find((s) => s.key === SETTING_KEYS.FORCE_CUSTOMER_LOCALE)?.value === 'true'
+  if (forced) {
+    const workshopLocale = settings.find((s) => s.key === SETTING_KEYS.WORKSHOP_LOCALE)?.value
+    if (workshopLocale && locales.includes(workshopLocale as Locale)) {
+      return workshopLocale as Locale
+    }
+  }
+
+  return getBestLocaleFromHeader(acceptLanguageHeader) ?? defaultLocale
+}

+ 22 - 34
src/i18n/request.ts

@@ -1,45 +1,33 @@
 import { getRequestConfig } from 'next-intl/server'
 import { cookies, headers } from 'next/headers'
 import { type Locale, defaultLocale, locales } from './config'
-
-function getBestLocaleFromHeader(acceptLanguage: string | null): Locale | undefined {
-  if (!acceptLanguage) return undefined
-
-  const entries = acceptLanguage
-    .split(',')
-    .map((part) => {
-      const [lang, q] = part.trim().split(';q=')
-      return { lang: lang.trim(), q: q ? parseFloat(q) : 1 }
-    })
-    .sort((a, b) => b.q - a.q)
-
-  for (const { lang } of entries) {
-    // Exact match (e.g. "pt-BR")
-    if (locales.includes(lang as Locale)) return lang as Locale
-
-    // Prefix match: "de-AT" → "de"
-    const prefix = lang.split('-')[0]
-    if (locales.includes(prefix as Locale)) return prefix as Locale
-
-    // Reverse prefix: "pt" → "pt-BR"
-    const match = locales.find((l) => l.startsWith(prefix + '-'))
-    if (match) return match
-  }
-
-  return undefined
-}
+import { extractCustomerOrgParam, getBestLocaleFromHeader, resolveCustomerLocale } from './locale-from-request'
+import { resolvePortalOrg } from '@/lib/portal-slug'
 
 export default getRequestConfig(async () => {
-  const cookieStore = await cookies()
-  const cookieLocale = cookieStore.get('locale')?.value
+  const headerStore = await headers()
+  const acceptLanguage = headerStore.get('accept-language')
+  const pathname = headerStore.get('x-pathname')
 
   let locale: Locale
-  if (locales.includes(cookieLocale as Locale)) {
-    locale = cookieLocale as Locale
+
+  // Customer-facing routes (share/* and portal/*) apply the workshop's
+  // forced locale when enabled, otherwise fall back to the customer's
+  // Accept-Language — never the viewer's cookie (which may belong to
+  // a team member testing the page).
+  const customerOrgParam = extractCustomerOrgParam(pathname)
+  if (customerOrgParam) {
+    const org = await resolvePortalOrg(customerOrgParam)
+    const orgId = org?.id ?? customerOrgParam
+    locale = await resolveCustomerLocale(orgId, acceptLanguage)
   } else {
-    const headerStore = await headers()
-    const acceptLanguage = headerStore.get('accept-language')
-    locale = getBestLocaleFromHeader(acceptLanguage) ?? defaultLocale
+    const cookieStore = await cookies()
+    const cookieLocale = cookieStore.get('locale')?.value
+    if (locales.includes(cookieLocale as Locale)) {
+      locale = cookieLocale as Locale
+    } else {
+      locale = getBestLocaleFromHeader(acceptLanguage) ?? defaultLocale
+    }
   }
 
   const common = (await import(`../../messages/${locale}/common.json`)).default

+ 15 - 24
src/proxy.ts

@@ -1,28 +1,19 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getSessionCookie } from "better-auth/cookies";
+import { NextResponse, type NextRequest } from 'next/server'
 
-const publicPaths = ["/auth", "/api/v1", "/api/public", "/api/internal", "/api/webhooks", "/share", "/terms"];
-
-export async function proxy(request: NextRequest) {
-  const { pathname } = request.nextUrl;
-
-  // Allow public paths
-  if (publicPaths.some((path) => pathname.startsWith(path))) {
-    return NextResponse.next();
-  }
-
-  // Check for session cookie (handles both secure and non-secure prefixes)
-  const sessionCookie = getSessionCookie(request);
-
-  if (!pathname.startsWith("/auth") && !sessionCookie) {
-    const signInUrl = new URL("/auth/sign-in", request.url);
-    signInUrl.searchParams.set("redirect", pathname);
-    return NextResponse.redirect(signInUrl);
-  }
-
-  return NextResponse.next();
+// Expose the request pathname to server components and route handlers
+// via an internal `x-pathname` header. next-intl's request config uses
+// this to detect customer-facing routes and apply forced locales.
+export function proxy(request: NextRequest) {
+  const requestHeaders = new Headers(request.headers)
+  requestHeaders.set('x-pathname', request.nextUrl.pathname)
+  return NextResponse.next({
+    request: { headers: requestHeaders },
+  })
 }
 
 export const config = {
-  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"],
-};
+  matcher: [
+    // Everything except static files, images, and Next internals
+    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|woff|woff2|ttf|eot)$).*)',
+  ],
+}