Explorar el Código

Customer portal: directory, landing, downloads, SMS sign-in (#144)

* improved customer portal

* translations

* reqork customer portal

* customer portal auth signing
Bernt Christian Egeland hace 5 meses
padre
commit
4b850c5a75
Se han modificado 59 ficheros con 4567 adiciones y 1720 borrados
  1. 44 3
      messages/de/portal.json
  2. 36 2
      messages/de/settings.json
  3. 44 3
      messages/en/portal.json
  4. 36 2
      messages/en/settings.json
  5. 44 3
      messages/es/portal.json
  6. 36 2
      messages/es/settings.json
  7. 44 3
      messages/fr/portal.json
  8. 36 2
      messages/fr/settings.json
  9. 44 3
      messages/it/portal.json
  10. 36 2
      messages/it/settings.json
  11. 44 3
      messages/lt/portal.json
  12. 36 2
      messages/lt/settings.json
  13. 44 3
      messages/nb/portal.json
  14. 36 2
      messages/nb/settings.json
  15. 44 3
      messages/nl/portal.json
  16. 36 2
      messages/nl/settings.json
  17. 44 3
      messages/pl/portal.json
  18. 36 2
      messages/pl/settings.json
  19. 44 3
      messages/pt-BR/portal.json
  20. 36 2
      messages/pt-BR/settings.json
  21. 44 3
      messages/ru/portal.json
  22. 36 2
      messages/ru/settings.json
  23. 44 3
      messages/tr/portal.json
  24. 36 2
      messages/tr/settings.json
  25. 18 0
      prisma/migrations/20260411182756_sms_customer_login/migration.sql
  26. 14 0
      prisma/schema.prisma
  27. 45 23
      src/app/(authenticated)/settings/customer-portal/page.tsx
  28. 331 205
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  29. 76 45
      src/app/(public)/portal/[orgId]/auth/login/page.tsx
  30. 293 161
      src/app/(public)/portal/[orgId]/dashboard/page.tsx
  31. 89 0
      src/app/(public)/portal/[orgId]/invoices/[invoiceId]/pdf/route.ts
  32. 44 36
      src/app/(public)/portal/[orgId]/invoices/page.tsx
  33. 0 24
      src/app/(public)/portal/[orgId]/layout.tsx
  34. 53 44
      src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx
  35. 20 0
      src/app/(public)/portal/layout.tsx
  36. 57 0
      src/app/(public)/portal/page.tsx
  37. 36 36
      src/app/api/protected/files/[...path]/route.ts
  38. 66 0
      src/app/api/protected/upload/portal-background/route.ts
  39. 63 0
      src/app/api/public/portal-bg/[orgId]/route.ts
  40. 57 56
      src/app/api/public/portal/[orgId]/auth/request/route.ts
  41. 109 0
      src/app/api/public/portal/[orgId]/auth/sms-request/route.ts
  42. 126 0
      src/app/api/public/portal/[orgId]/auth/sms-verify/route.ts
  43. 38 238
      src/app/api/public/share/invoice/[orgId]/[token]/pdf/route.ts
  44. 1 1
      src/app/layout.tsx
  45. 285 0
      src/features/invoices/Pdf/buildInvoicePdfBuffer.ts
  46. 144 110
      src/features/portal/Actions/portalActions.ts
  47. 351 113
      src/features/portal/Components/CustomerPortalSettings.tsx
  48. 101 0
      src/features/portal/Components/PortalDirectory.tsx
  49. 173 40
      src/features/portal/Components/PortalHeader.tsx
  50. 192 0
      src/features/portal/Components/PortalLanding.tsx
  51. 264 105
      src/features/portal/Components/PortalLoginForm.tsx
  52. 0 76
      src/features/portal/Components/PortalNav.tsx
  53. 26 36
      src/features/portal/Components/PortalShell.tsx
  54. 62 0
      src/features/portal/portal-backgrounds.ts
  55. 105 99
      src/features/settings/Schema/settingsSchema.ts
  56. 94 81
      src/features/sms/Actions/smsActions.ts
  57. 31 33
      src/features/sms/Actions/smsSettingsActions.ts
  58. 113 0
      src/lib/portal-phone.ts
  59. 130 98
      src/lib/sms.ts

+ 44 - 3
messages/de/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Finden Sie Ihre Werkstatt",
+    "description": "Wählen Sie unten Ihre Werkstatt, um auf das Kundenportal zuzugreifen.",
+    "searchPlaceholder": "Werkstätten suchen…",
+    "noWorkshops": "Derzeit sind keine Werkstätten verfügbar.",
+    "noResults": "Keine Werkstatt entspricht Ihrer Suche.",
+    "openPortal": "Portal öffnen"
+  },
+  "landing": {
+    "contactTitle": "Kontakt",
+    "aboutTitle": "Über uns",
+    "noContactInfo": "Kontaktdaten sind noch nicht verfügbar.",
+    "address": "Adresse",
+    "phone": "Telefon",
+    "email": "E-Mail",
+    "hours": "Öffnungszeiten",
+    "directions": "Wegbeschreibung"
+  },
   "login": {
   "login": {
-    "signInTitle": "Melden Sie sich bei Ihrem Kundenportal an",
+    "signInTitle": "Kundenanmeldung",
+    "signInSubtitle": "Melden Sie sich an, um Rechnungen, Angebote und Servicehistorie zu sehen.",
     "emailLabel": "E-Mail-Adresse",
     "emailLabel": "E-Mail-Adresse",
     "emailPlaceholder": "sie@beispiel.de",
     "emailPlaceholder": "sie@beispiel.de",
     "sendLink": "Anmeldelink senden",
     "sendLink": "Anmeldelink senden",
     "checkEmail": "Prüfen Sie Ihre E-Mails",
     "checkEmail": "Prüfen Sie Ihre E-Mails",
     "checkEmailDescription": "Falls ein Konto für diese E-Mail-Adresse existiert, haben wir Ihnen einen Anmeldelink gesendet. Der Link läuft in 15 Minuten ab.",
     "checkEmailDescription": "Falls ein Konto für diese E-Mail-Adresse existiert, haben wir Ihnen einen Anmeldelink gesendet. Der Link läuft in 15 Minuten ab.",
     "tryDifferentEmail": "Andere E-Mail-Adresse verwenden",
     "tryDifferentEmail": "Andere E-Mail-Adresse verwenden",
+    "tabEmail": "E-Mail",
+    "tabSms": "SMS",
+    "phoneLabel": "Telefonnummer",
+    "phonePlaceholder": "+49 12345678",
+    "phoneLocalPlaceholder": "12345678",
+    "sendCode": "Code senden",
+    "codeSentTitle": "Geben Sie den Code ein, den wir gesendet haben",
+    "codeSentDescription": "Wir haben einen 6-stelligen Code an {phone} gesendet, falls ein Konto existiert.",
+    "codeLabel": "6-stelliger Code",
+    "codePlaceholder": "123456",
+    "verifyCode": "Anmelden",
+    "useDifferentPhone": "Andere Nummer verwenden",
+    "invalidCode": "Ungültiger oder abgelaufener Code",
     "notFound": "Nicht gefunden",
     "notFound": "Nicht gefunden",
     "portalNotExist": "Dieses Portal existiert nicht.",
     "portalNotExist": "Dieses Portal existiert nicht.",
     "portalNotAvailable": "Portal nicht verfügbar",
     "portalNotAvailable": "Portal nicht verfügbar",
@@ -21,7 +53,8 @@
     "requestService": "Service anfordern"
     "requestService": "Service anfordern"
   },
   },
   "header": {
   "header": {
-    "signOut": "Abmelden"
+    "signOut": "Abmelden",
+    "askShort": "Service"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Nicht gefunden",
     "notFound": "Nicht gefunden",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Noch keine Angebote.",
     "noQuotesYet": "Noch keine Angebote.",
     "view": "Ansehen",
     "view": "Ansehen",
     "pending": "Ausstehend",
     "pending": "Ausstehend",
-    "failedToLoad": "Dashboard konnte nicht geladen werden."
+    "failedToLoad": "Dashboard konnte nicht geladen werden.",
+    "quickRequestService": "Service anfragen",
+    "quickRequestServiceHint": "Reparatur oder Inspektion buchen",
+    "quickViewInvoices": "Ihre Rechnungen",
+    "quickViewInvoicesHint": "Rechnungen und Zahlungsverlauf",
+    "quickViewVehicles": "Ihre Fahrzeuge",
+    "quickViewVehiclesHint": "Servicehistorie und Details",
+    "activeJobs": "Aktive Aufträge"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Rechnungen",
     "title": "Rechnungen",
@@ -57,6 +97,7 @@
     "amount": "Betrag",
     "amount": "Betrag",
     "payment": "Zahlung",
     "payment": "Zahlung",
     "view": "Ansehen",
     "view": "Ansehen",
+    "download": "Herunterladen",
     "paid": "bezahlt",
     "paid": "bezahlt",
     "partial": "teilweise",
     "partial": "teilweise",
     "unpaid": "unbezahlt"
     "unpaid": "unbezahlt"

+ 36 - 2
messages/de/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Wählen Sie die Anzeigesprache der Anwendung.",
     "languageDescription": "Wählen Sie die Anzeigesprache der Anwendung.",
     "language": "Sprache",
     "language": "Sprache",
     "forceCustomerLocale": "Diese Sprache für kundenseitige Seiten erzwingen",
     "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.",
+    "forceCustomerLocaleHint": "Wenn aktiviert, erscheinen das Kundenportal sowie geteilte Rechnungen, Angebote und Inspektionen in der Sprache Ihrer Werkstatt, unabhängig von den Browsereinstellungen des Besuchers. Wenn deaktiviert, folgen kundenseitige Seiten der Browsersprache des Besuchers.",
+    "defaultCountryCode": "Standard-Ländervorwahl",
+    "defaultCountryCodeHint": "Wird verwendet, wenn Kunden sich per SMS am Portal anmelden — ergänzt Telefonnummern ohne Vorwahl (Ihre oder die des Kunden). Format: + gefolgt von Ziffern, z. B. +49.",
+    "defaultCountryCodeInvalid": "Geben Sie eine gültige Ländervorwahl ein, z. B. +49, 49 oder 0049.",
     "saved": "Lokalisierungseinstellungen gespeichert",
     "saved": "Lokalisierungseinstellungen gespeichert",
     "saveSettings": "Einstellungen speichern"
     "saveSettings": "Einstellungen speichern"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche. 3-48 Zeichen. Leer lassen, um die Standard-URL zu verwenden.",
     "slugHint": "Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche. 3-48 Zeichen. Leer lassen, um die Standard-URL zu verwenden.",
     "portalUrl": "Portal-URL",
     "portalUrl": "Portal-URL",
     "urlCopied": "Portal-URL kopiert",
     "urlCopied": "Portal-URL kopiert",
-    "shareHint": "Teilen Sie diesen Link mit Ihren Kunden, damit sie sich in ihr Portal einloggen konnen."
+    "shareHint": "Teilen Sie diesen Link mit Ihren Kunden, damit sie sich in ihr Portal einloggen konnen.",
+    "descriptionLabel": "Über Ihre Werkstatt",
+    "descriptionPlaceholder": "Erzählen Sie Ihren Kunden etwas über Ihre Werkstatt, die angebotenen Leistungen und was Sie auszeichnet.",
+    "descriptionHint": "Wird Besuchern oben auf Ihrer Portal-Startseite angezeigt.",
+    "hoursLabel": "Öffnungszeiten",
+    "hoursPlaceholder": "Mo-Fr: 8:00-17:00\nSa: 9:00-13:00\nSo: Geschlossen",
+    "hoursHint": "Freitext. Verwenden Sie Zeilenumbrüche, um jeden Tag separat aufzulisten.",
+    "contentSaved": "Portalinhalt gespeichert",
+    "background": {
+      "title": "Portal-Hintergrund",
+      "description": "Wählen Sie das Aussehen der Startseite des Kundenportals.",
+      "typeNone": "Keiner",
+      "typeTemplate": "Vorlage",
+      "typeImage": "Eigenes Bild",
+      "chooseTemplate": "Vorlage auswählen",
+      "uploadLabel": "Hintergrundbild",
+      "upload": "Bild hochladen",
+      "replace": "Bild ersetzen",
+      "remove": "Entfernen",
+      "uploadHint": "JPG, PNG oder WebP. Max. 8 MB. Empfohlen mindestens 1600×900.",
+      "uploading": "Hintergrund wird hochgeladen…",
+      "uploaded": "Hintergrund aktualisiert",
+      "uploadFailed": "Hintergrund konnte nicht hochgeladen werden",
+      "templateNames": {
+        "default": "Dezent",
+        "sunrise": "Sonnenaufgang",
+        "ocean": "Ozean",
+        "forest": "Wald",
+        "slate": "Schiefer",
+        "sand": "Sand"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "E-Mail-Anbieter",
     "title": "E-Mail-Anbieter",

+ 44 - 3
messages/en/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Find your workshop",
+    "description": "Choose your workshop below to access the customer portal.",
+    "searchPlaceholder": "Search workshops…",
+    "noWorkshops": "No workshops are currently available.",
+    "noResults": "No workshops match your search.",
+    "openPortal": "Open portal"
+  },
+  "landing": {
+    "contactTitle": "Contact us",
+    "aboutTitle": "About us",
+    "noContactInfo": "Contact details are not yet available.",
+    "address": "Address",
+    "phone": "Phone",
+    "email": "Email",
+    "hours": "Opening hours",
+    "directions": "Get directions"
+  },
   "login": {
   "login": {
-    "signInTitle": "Sign in to your customer portal",
+    "signInTitle": "Customer login",
+    "signInSubtitle": "Sign in to view your invoices, quotes, and service history.",
     "emailLabel": "Email address",
     "emailLabel": "Email address",
     "emailPlaceholder": "you@example.com",
     "emailPlaceholder": "you@example.com",
     "sendLink": "Send sign-in link",
     "sendLink": "Send sign-in link",
     "checkEmail": "Check your email",
     "checkEmail": "Check your email",
     "checkEmailDescription": "If an account exists for that email, we've sent a sign-in link. The link expires in 15 minutes.",
     "checkEmailDescription": "If an account exists for that email, we've sent a sign-in link. The link expires in 15 minutes.",
     "tryDifferentEmail": "Try a different email",
     "tryDifferentEmail": "Try a different email",
+    "tabEmail": "Email",
+    "tabSms": "SMS",
+    "phoneLabel": "Phone number",
+    "phonePlaceholder": "+47 12345678",
+    "phoneLocalPlaceholder": "12345678",
+    "sendCode": "Send code",
+    "codeSentTitle": "Enter the code we sent",
+    "codeSentDescription": "We sent a 6-digit code to {phone} if it matches an account.",
+    "codeLabel": "6-digit code",
+    "codePlaceholder": "123456",
+    "verifyCode": "Sign in",
+    "useDifferentPhone": "Use a different number",
+    "invalidCode": "Invalid or expired code",
     "notFound": "Not Found",
     "notFound": "Not Found",
     "portalNotExist": "This portal does not exist.",
     "portalNotExist": "This portal does not exist.",
     "portalNotAvailable": "Portal Not Available",
     "portalNotAvailable": "Portal Not Available",
@@ -21,7 +53,8 @@
     "requestService": "Request Service"
     "requestService": "Request Service"
   },
   },
   "header": {
   "header": {
-    "signOut": "Sign out"
+    "signOut": "Sign out",
+    "askShort": "Service"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Not Found",
     "notFound": "Not Found",
@@ -44,7 +77,14 @@
     "noQuotesYet": "No quotes yet.",
     "noQuotesYet": "No quotes yet.",
     "view": "View",
     "view": "View",
     "pending": "Pending",
     "pending": "Pending",
-    "failedToLoad": "Failed to load dashboard."
+    "failedToLoad": "Failed to load dashboard.",
+    "quickRequestService": "Request service",
+    "quickRequestServiceHint": "Book a repair or check-up",
+    "quickViewInvoices": "Your invoices",
+    "quickViewInvoicesHint": "Bills and payment history",
+    "quickViewVehicles": "Your vehicles",
+    "quickViewVehiclesHint": "Service history and details",
+    "activeJobs": "Active jobs"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Invoices",
     "title": "Invoices",
@@ -57,6 +97,7 @@
     "amount": "Amount",
     "amount": "Amount",
     "payment": "Payment",
     "payment": "Payment",
     "view": "View",
     "view": "View",
+    "download": "Download",
     "paid": "paid",
     "paid": "paid",
     "partial": "partial",
     "partial": "partial",
     "unpaid": "unpaid"
     "unpaid": "unpaid"

+ 36 - 2
messages/en/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Choose the display language for the application.",
     "languageDescription": "Choose the display language for the application.",
     "language": "Language",
     "language": "Language",
     "forceCustomerLocale": "Force this language on customer-facing pages",
     "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.",
+    "forceCustomerLocaleHint": "When enabled, the customer portal and shared invoices, quotes and inspections will appear in your workshop's language regardless of the visitor's browser settings. When disabled, customer-facing pages follow the visitor's browser language.",
+    "defaultCountryCode": "Default country code",
+    "defaultCountryCodeHint": "Used when customers sign in to the portal by SMS — applied to phone numbers that don't include a country code (yours or theirs). Format: + followed by digits, e.g. +47.",
+    "defaultCountryCodeInvalid": "Enter a valid country code, e.g. +47, 47, or 0047.",
     "saved": "Localization settings saved",
     "saved": "Localization settings saved",
     "saveSettings": "Save Settings"
     "saveSettings": "Save Settings"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Lowercase letters, numbers, hyphens, and underscores. 3-48 characters. Leave blank to use the default URL.",
     "slugHint": "Lowercase letters, numbers, hyphens, and underscores. 3-48 characters. Leave blank to use the default URL.",
     "portalUrl": "Portal URL",
     "portalUrl": "Portal URL",
     "urlCopied": "Portal URL copied",
     "urlCopied": "Portal URL copied",
-    "shareHint": "Share this link with your customers so they can log in to their portal."
+    "shareHint": "Share this link with your customers so they can log in to their portal.",
+    "descriptionLabel": "About your workshop",
+    "descriptionPlaceholder": "Tell customers a bit about your workshop, the services you offer, and what makes you unique.",
+    "descriptionHint": "Shown to visitors at the top of your portal landing page.",
+    "hoursLabel": "Opening hours",
+    "hoursPlaceholder": "Mon-Fri: 8:00-17:00\nSat: 9:00-13:00\nSun: Closed",
+    "hoursHint": "Free-form text. Use line breaks to list each day separately.",
+    "contentSaved": "Portal content saved",
+    "background": {
+      "title": "Portal background",
+      "description": "Choose how the customer portal landing page looks.",
+      "typeNone": "None",
+      "typeTemplate": "Template",
+      "typeImage": "Custom image",
+      "chooseTemplate": "Choose a template",
+      "uploadLabel": "Background image",
+      "upload": "Upload image",
+      "replace": "Replace image",
+      "remove": "Remove",
+      "uploadHint": "JPG, PNG or WebP. Max 8 MB. Recommended at least 1600×900.",
+      "uploading": "Uploading background…",
+      "uploaded": "Background updated",
+      "uploadFailed": "Failed to upload background",
+      "templateNames": {
+        "default": "Subtle",
+        "sunrise": "Sunrise",
+        "ocean": "Ocean",
+        "forest": "Forest",
+        "slate": "Slate",
+        "sand": "Sand"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Email Provider",
     "title": "Email Provider",

+ 44 - 3
messages/es/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Encuentra tu taller",
+    "description": "Elige tu taller a continuación para acceder al portal de clientes.",
+    "searchPlaceholder": "Buscar talleres…",
+    "noWorkshops": "Actualmente no hay talleres disponibles.",
+    "noResults": "Ningún taller coincide con tu búsqueda.",
+    "openPortal": "Abrir portal"
+  },
+  "landing": {
+    "contactTitle": "Contáctanos",
+    "aboutTitle": "Sobre nosotros",
+    "noContactInfo": "Los datos de contacto aún no están disponibles.",
+    "address": "Dirección",
+    "phone": "Teléfono",
+    "email": "Correo electrónico",
+    "hours": "Horario de apertura",
+    "directions": "Cómo llegar"
+  },
   "login": {
   "login": {
-    "signInTitle": "Inicie sesión en su portal de clientes",
+    "signInTitle": "Acceso de clientes",
+    "signInSubtitle": "Inicia sesión para ver tus facturas, presupuestos e historial de servicio.",
     "emailLabel": "Dirección de correo electrónico",
     "emailLabel": "Dirección de correo electrónico",
     "emailPlaceholder": "usted@ejemplo.com",
     "emailPlaceholder": "usted@ejemplo.com",
     "sendLink": "Enviar enlace de acceso",
     "sendLink": "Enviar enlace de acceso",
     "checkEmail": "Revise su correo electrónico",
     "checkEmail": "Revise su correo electrónico",
     "checkEmailDescription": "Si existe una cuenta para ese correo, hemos enviado un enlace de acceso. El enlace expira en 15 minutos.",
     "checkEmailDescription": "Si existe una cuenta para ese correo, hemos enviado un enlace de acceso. El enlace expira en 15 minutos.",
     "tryDifferentEmail": "Probar con otro correo electrónico",
     "tryDifferentEmail": "Probar con otro correo electrónico",
+    "tabEmail": "Correo",
+    "tabSms": "SMS",
+    "phoneLabel": "Número de teléfono",
+    "phonePlaceholder": "+34 612345678",
+    "phoneLocalPlaceholder": "612345678",
+    "sendCode": "Enviar código",
+    "codeSentTitle": "Introduce el código que enviamos",
+    "codeSentDescription": "Enviamos un código de 6 dígitos a {phone} si coincide con una cuenta.",
+    "codeLabel": "Código de 6 dígitos",
+    "codePlaceholder": "123456",
+    "verifyCode": "Iniciar sesión",
+    "useDifferentPhone": "Usar otro número",
+    "invalidCode": "Código no válido o caducado",
     "notFound": "No encontrado",
     "notFound": "No encontrado",
     "portalNotExist": "Este portal no existe.",
     "portalNotExist": "Este portal no existe.",
     "portalNotAvailable": "Portal no disponible",
     "portalNotAvailable": "Portal no disponible",
@@ -21,7 +53,8 @@
     "requestService": "Solicitar servicio"
     "requestService": "Solicitar servicio"
   },
   },
   "header": {
   "header": {
-    "signOut": "Cerrar sesión"
+    "signOut": "Cerrar sesión",
+    "askShort": "Servicio"
   },
   },
   "shell": {
   "shell": {
     "notFound": "No encontrado",
     "notFound": "No encontrado",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Aún no hay presupuestos.",
     "noQuotesYet": "Aún no hay presupuestos.",
     "view": "Ver",
     "view": "Ver",
     "pending": "Pendiente",
     "pending": "Pendiente",
-    "failedToLoad": "Error al cargar el panel."
+    "failedToLoad": "Error al cargar el panel.",
+    "quickRequestService": "Solicitar servicio",
+    "quickRequestServiceHint": "Reservar reparación o revisión",
+    "quickViewInvoices": "Tus facturas",
+    "quickViewInvoicesHint": "Facturas e historial de pagos",
+    "quickViewVehicles": "Tus vehículos",
+    "quickViewVehiclesHint": "Historial de servicio y detalles",
+    "activeJobs": "Trabajos en curso"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Facturas",
     "title": "Facturas",
@@ -57,6 +97,7 @@
     "amount": "Monto",
     "amount": "Monto",
     "payment": "Pago",
     "payment": "Pago",
     "view": "Ver",
     "view": "Ver",
+    "download": "Descargar",
     "paid": "pagado",
     "paid": "pagado",
     "partial": "parcial",
     "partial": "parcial",
     "unpaid": "sin pagar"
     "unpaid": "sin pagar"

+ 36 - 2
messages/es/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Elija el idioma de visualización de la aplicación.",
     "languageDescription": "Elija el idioma de visualización de la aplicación.",
     "language": "Idioma",
     "language": "Idioma",
     "forceCustomerLocale": "Forzar este idioma en las páginas dirigidas al cliente",
     "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.",
+    "forceCustomerLocaleHint": "Cuando está activado, el portal de clientes y las facturas, presupuestos e inspecciones compartidas aparecen en el idioma de su taller, independientemente de la configuración del navegador del visitante. Cuando está desactivado, las páginas para clientes siguen el idioma del navegador del visitante.",
+    "defaultCountryCode": "Código de país predeterminado",
+    "defaultCountryCodeHint": "Se usa cuando los clientes inician sesión en el portal por SMS — se aplica a los números de teléfono que no incluyen el código de país (suyos o del cliente). Formato: + seguido de dígitos, por ejemplo +34.",
+    "defaultCountryCodeInvalid": "Introduce un código de país válido, por ejemplo +34, 34 o 0034.",
     "saved": "Configuración de localización guardada",
     "saved": "Configuración de localización guardada",
     "saveSettings": "Guardar configuración"
     "saveSettings": "Guardar configuración"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Letras minusculas, numeros, guiones y guiones bajos. 3-48 caracteres. Dejar vacio para usar la URL predeterminada.",
     "slugHint": "Letras minusculas, numeros, guiones y guiones bajos. 3-48 caracteres. Dejar vacio para usar la URL predeterminada.",
     "portalUrl": "URL del portal",
     "portalUrl": "URL del portal",
     "urlCopied": "URL del portal copiada",
     "urlCopied": "URL del portal copiada",
-    "shareHint": "Comparta este enlace con sus clientes para que puedan iniciar sesion en su portal."
+    "shareHint": "Comparta este enlace con sus clientes para que puedan iniciar sesion en su portal.",
+    "descriptionLabel": "Sobre tu taller",
+    "descriptionPlaceholder": "Cuenta a tus clientes algo sobre tu taller, los servicios que ofreces y lo que te hace único.",
+    "descriptionHint": "Se muestra a los visitantes en la parte superior de la página de tu portal.",
+    "hoursLabel": "Horario de apertura",
+    "hoursPlaceholder": "Lun-Vie: 8:00-17:00\nSab: 9:00-13:00\nDom: Cerrado",
+    "hoursHint": "Texto libre. Usa saltos de línea para listar cada día por separado.",
+    "contentSaved": "Contenido del portal guardado",
+    "background": {
+      "title": "Fondo del portal",
+      "description": "Elige cómo se ve la página de inicio del portal de clientes.",
+      "typeNone": "Ninguno",
+      "typeTemplate": "Plantilla",
+      "typeImage": "Imagen personalizada",
+      "chooseTemplate": "Elige una plantilla",
+      "uploadLabel": "Imagen de fondo",
+      "upload": "Subir imagen",
+      "replace": "Reemplazar imagen",
+      "remove": "Eliminar",
+      "uploadHint": "JPG, PNG o WebP. Máx. 8 MB. Recomendado al menos 1600×900.",
+      "uploading": "Subiendo fondo…",
+      "uploaded": "Fondo actualizado",
+      "uploadFailed": "Error al subir el fondo",
+      "templateNames": {
+        "default": "Sutil",
+        "sunrise": "Amanecer",
+        "ocean": "Océano",
+        "forest": "Bosque",
+        "slate": "Pizarra",
+        "sand": "Arena"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Proveedor de correo",
     "title": "Proveedor de correo",

+ 44 - 3
messages/fr/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Trouvez votre garage",
+    "description": "Choisissez votre garage ci-dessous pour accéder au portail client.",
+    "searchPlaceholder": "Rechercher un garage…",
+    "noWorkshops": "Aucun garage n'est disponible pour le moment.",
+    "noResults": "Aucun garage ne correspond à votre recherche.",
+    "openPortal": "Ouvrir le portail"
+  },
+  "landing": {
+    "contactTitle": "Nous contacter",
+    "aboutTitle": "À propos",
+    "noContactInfo": "Les coordonnées ne sont pas encore disponibles.",
+    "address": "Adresse",
+    "phone": "Téléphone",
+    "email": "E-mail",
+    "hours": "Horaires d'ouverture",
+    "directions": "Itinéraire"
+  },
   "login": {
   "login": {
-    "signInTitle": "Connexion à votre portail client",
+    "signInTitle": "Connexion client",
+    "signInSubtitle": "Connectez-vous pour consulter vos factures, devis et historique d'entretien.",
     "emailLabel": "Adresse e-mail",
     "emailLabel": "Adresse e-mail",
     "emailPlaceholder": "vous@exemple.com",
     "emailPlaceholder": "vous@exemple.com",
     "sendLink": "Envoyer le lien de connexion",
     "sendLink": "Envoyer le lien de connexion",
     "checkEmail": "Vérifiez votre e-mail",
     "checkEmail": "Vérifiez votre e-mail",
     "checkEmailDescription": "Si un compte existe pour cet e-mail, nous avons envoyé un lien de connexion. Le lien expire dans 15 minutes.",
     "checkEmailDescription": "Si un compte existe pour cet e-mail, nous avons envoyé un lien de connexion. Le lien expire dans 15 minutes.",
     "tryDifferentEmail": "Essayer une autre adresse e-mail",
     "tryDifferentEmail": "Essayer une autre adresse e-mail",
+    "tabEmail": "E-mail",
+    "tabSms": "SMS",
+    "phoneLabel": "Numéro de téléphone",
+    "phonePlaceholder": "+33 612345678",
+    "phoneLocalPlaceholder": "612345678",
+    "sendCode": "Envoyer le code",
+    "codeSentTitle": "Saisissez le code que nous avons envoyé",
+    "codeSentDescription": "Nous avons envoyé un code à 6 chiffres au {phone} s'il correspond à un compte.",
+    "codeLabel": "Code à 6 chiffres",
+    "codePlaceholder": "123456",
+    "verifyCode": "Se connecter",
+    "useDifferentPhone": "Utiliser un autre numéro",
+    "invalidCode": "Code invalide ou expiré",
     "notFound": "Introuvable",
     "notFound": "Introuvable",
     "portalNotExist": "Ce portail n'existe pas.",
     "portalNotExist": "Ce portail n'existe pas.",
     "portalNotAvailable": "Portail indisponible",
     "portalNotAvailable": "Portail indisponible",
@@ -21,7 +53,8 @@
     "requestService": "Demander un service"
     "requestService": "Demander un service"
   },
   },
   "header": {
   "header": {
-    "signOut": "Se déconnecter"
+    "signOut": "Se déconnecter",
+    "askShort": "Service"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Introuvable",
     "notFound": "Introuvable",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Aucun devis pour l'instant.",
     "noQuotesYet": "Aucun devis pour l'instant.",
     "view": "Voir",
     "view": "Voir",
     "pending": "En attente",
     "pending": "En attente",
-    "failedToLoad": "Échec du chargement du tableau de bord."
+    "failedToLoad": "Échec du chargement du tableau de bord.",
+    "quickRequestService": "Demander un service",
+    "quickRequestServiceHint": "Réserver une réparation ou un contrôle",
+    "quickViewInvoices": "Vos factures",
+    "quickViewInvoicesHint": "Factures et historique des paiements",
+    "quickViewVehicles": "Vos véhicules",
+    "quickViewVehiclesHint": "Historique d'entretien et détails",
+    "activeJobs": "Travaux en cours"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Factures",
     "title": "Factures",
@@ -57,6 +97,7 @@
     "amount": "Montant",
     "amount": "Montant",
     "payment": "Paiement",
     "payment": "Paiement",
     "view": "Voir",
     "view": "Voir",
+    "download": "Télécharger",
     "paid": "payé",
     "paid": "payé",
     "partial": "partiel",
     "partial": "partiel",
     "unpaid": "impayé"
     "unpaid": "impayé"

+ 36 - 2
messages/fr/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Choisissez la langue d'affichage de l'application.",
     "languageDescription": "Choisissez la langue d'affichage de l'application.",
     "language": "Langue",
     "language": "Langue",
     "forceCustomerLocale": "Forcer cette langue sur les pages destinées aux clients",
     "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.",
+    "forceCustomerLocaleHint": "Lorsqu'il est activé, le portail client et les factures, devis et inspections partagés s'affichent dans la langue de votre atelier, quels que soient les paramètres du navigateur du visiteur. Lorsqu'il est désactivé, les pages destinées aux clients suivent la langue du navigateur du visiteur.",
+    "defaultCountryCode": "Indicatif pays par défaut",
+    "defaultCountryCodeHint": "Utilisé lorsque les clients se connectent au portail par SMS — appliqué aux numéros de téléphone sans indicatif pays (le vôtre ou celui du client). Format : + suivi de chiffres, par exemple +33.",
+    "defaultCountryCodeInvalid": "Saisissez un indicatif pays valide, par exemple +33, 33 ou 0033.",
     "saved": "Paramètres de localisation enregistrés",
     "saved": "Paramètres de localisation enregistrés",
     "saveSettings": "Enregistrer les paramètres"
     "saveSettings": "Enregistrer les paramètres"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Lettres minuscules, chiffres, tirets et underscores. 3-48 caracteres. Laissez vide pour utiliser l'URL par defaut.",
     "slugHint": "Lettres minuscules, chiffres, tirets et underscores. 3-48 caracteres. Laissez vide pour utiliser l'URL par defaut.",
     "portalUrl": "URL du portail",
     "portalUrl": "URL du portail",
     "urlCopied": "URL du portail copiee",
     "urlCopied": "URL du portail copiee",
-    "shareHint": "Partagez ce lien avec vos clients pour qu'ils puissent se connecter a leur portail."
+    "shareHint": "Partagez ce lien avec vos clients pour qu'ils puissent se connecter a leur portail.",
+    "descriptionLabel": "À propos de votre garage",
+    "descriptionPlaceholder": "Présentez votre garage à vos clients : vos services, votre équipe, ce qui vous rend unique.",
+    "descriptionHint": "Affiché aux visiteurs en haut de la page d'accueil de votre portail.",
+    "hoursLabel": "Horaires d'ouverture",
+    "hoursPlaceholder": "Lun-Ven : 8h00-17h00\nSam : 9h00-13h00\nDim : Fermé",
+    "hoursHint": "Texte libre. Utilisez des sauts de ligne pour lister chaque jour séparément.",
+    "contentSaved": "Contenu du portail enregistré",
+    "background": {
+      "title": "Arrière-plan du portail",
+      "description": "Choisissez l'apparence de la page d'accueil du portail client.",
+      "typeNone": "Aucun",
+      "typeTemplate": "Modèle",
+      "typeImage": "Image personnalisée",
+      "chooseTemplate": "Choisissez un modèle",
+      "uploadLabel": "Image d'arrière-plan",
+      "upload": "Téléverser une image",
+      "replace": "Remplacer l'image",
+      "remove": "Supprimer",
+      "uploadHint": "JPG, PNG ou WebP. Max 8 Mo. Recommandé au moins 1600×900.",
+      "uploading": "Téléversement de l'arrière-plan…",
+      "uploaded": "Arrière-plan mis à jour",
+      "uploadFailed": "Échec du téléversement de l'arrière-plan",
+      "templateNames": {
+        "default": "Subtil",
+        "sunrise": "Lever de soleil",
+        "ocean": "Océan",
+        "forest": "Forêt",
+        "slate": "Ardoise",
+        "sand": "Sable"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Fournisseur de messagerie",
     "title": "Fournisseur de messagerie",

+ 44 - 3
messages/it/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Trova la tua officina",
+    "description": "Scegli la tua officina qui sotto per accedere al portale clienti.",
+    "searchPlaceholder": "Cerca officine…",
+    "noWorkshops": "Nessuna officina è attualmente disponibile.",
+    "noResults": "Nessuna officina corrisponde alla tua ricerca.",
+    "openPortal": "Apri portale"
+  },
+  "landing": {
+    "contactTitle": "Contattaci",
+    "aboutTitle": "Chi siamo",
+    "noContactInfo": "I contatti non sono ancora disponibili.",
+    "address": "Indirizzo",
+    "phone": "Telefono",
+    "email": "Email",
+    "hours": "Orari di apertura",
+    "directions": "Indicazioni stradali"
+  },
   "login": {
   "login": {
-    "signInTitle": "Accedi al tuo portale clienti",
+    "signInTitle": "Accesso clienti",
+    "signInSubtitle": "Accedi per vedere fatture, preventivi e storico dei servizi.",
     "emailLabel": "Indirizzo email",
     "emailLabel": "Indirizzo email",
     "emailPlaceholder": "tu@esempio.com",
     "emailPlaceholder": "tu@esempio.com",
     "sendLink": "Invia link di accesso",
     "sendLink": "Invia link di accesso",
     "checkEmail": "Controlla la tua email",
     "checkEmail": "Controlla la tua email",
     "checkEmailDescription": "Se esiste un account per quell'email, abbiamo inviato un link di accesso. Il link scade in 15 minuti.",
     "checkEmailDescription": "Se esiste un account per quell'email, abbiamo inviato un link di accesso. Il link scade in 15 minuti.",
     "tryDifferentEmail": "Prova con un'altra email",
     "tryDifferentEmail": "Prova con un'altra email",
+    "tabEmail": "Email",
+    "tabSms": "SMS",
+    "phoneLabel": "Numero di telefono",
+    "phonePlaceholder": "+39 3123456789",
+    "phoneLocalPlaceholder": "3123456789",
+    "sendCode": "Invia codice",
+    "codeSentTitle": "Inserisci il codice che abbiamo inviato",
+    "codeSentDescription": "Abbiamo inviato un codice a 6 cifre al {phone} se corrisponde a un account.",
+    "codeLabel": "Codice a 6 cifre",
+    "codePlaceholder": "123456",
+    "verifyCode": "Accedi",
+    "useDifferentPhone": "Usa un altro numero",
+    "invalidCode": "Codice non valido o scaduto",
     "notFound": "Non Trovato",
     "notFound": "Non Trovato",
     "portalNotExist": "Questo portale non esiste.",
     "portalNotExist": "Questo portale non esiste.",
     "portalNotAvailable": "Portale Non Disponibile",
     "portalNotAvailable": "Portale Non Disponibile",
@@ -21,7 +53,8 @@
     "requestService": "Richiedi Servizio"
     "requestService": "Richiedi Servizio"
   },
   },
   "header": {
   "header": {
-    "signOut": "Esci"
+    "signOut": "Esci",
+    "askShort": "Servizio"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Non Trovato",
     "notFound": "Non Trovato",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Nessun preventivo ancora.",
     "noQuotesYet": "Nessun preventivo ancora.",
     "view": "Visualizza",
     "view": "Visualizza",
     "pending": "In attesa",
     "pending": "In attesa",
-    "failedToLoad": "Caricamento della dashboard non riuscito."
+    "failedToLoad": "Caricamento della dashboard non riuscito.",
+    "quickRequestService": "Richiedi assistenza",
+    "quickRequestServiceHint": "Prenota una riparazione o un controllo",
+    "quickViewInvoices": "Le tue fatture",
+    "quickViewInvoicesHint": "Fatture e storico pagamenti",
+    "quickViewVehicles": "I tuoi veicoli",
+    "quickViewVehiclesHint": "Storico assistenza e dettagli",
+    "activeJobs": "Lavori in corso"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Fatture",
     "title": "Fatture",
@@ -57,6 +97,7 @@
     "amount": "Importo",
     "amount": "Importo",
     "payment": "Pagamento",
     "payment": "Pagamento",
     "view": "Visualizza",
     "view": "Visualizza",
+    "download": "Scarica",
     "paid": "pagato",
     "paid": "pagato",
     "partial": "parziale",
     "partial": "parziale",
     "unpaid": "non pagato"
     "unpaid": "non pagato"

+ 36 - 2
messages/it/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Scegli la lingua di visualizzazione dell'applicazione.",
     "languageDescription": "Scegli la lingua di visualizzazione dell'applicazione.",
     "language": "Lingua",
     "language": "Lingua",
     "forceCustomerLocale": "Forza questa lingua sulle pagine rivolte al cliente",
     "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.",
+    "forceCustomerLocaleHint": "Quando attivato, il portale clienti e le fatture, i preventivi e le ispezioni condivise vengono mostrati nella lingua della tua officina, indipendentemente dalle impostazioni del browser del visitatore. Quando disattivato, le pagine rivolte ai clienti seguono la lingua del browser del visitatore.",
+    "defaultCountryCode": "Prefisso paese predefinito",
+    "defaultCountryCodeHint": "Usato quando i clienti accedono al portale tramite SMS — applicato ai numeri di telefono senza prefisso paese (il tuo o quello del cliente). Formato: + seguito da cifre, ad es. +39.",
+    "defaultCountryCodeInvalid": "Inserisci un prefisso paese valido, ad es. +39, 39 o 0039.",
     "saved": "Impostazioni di localizzazione salvate",
     "saved": "Impostazioni di localizzazione salvate",
     "saveSettings": "Salva impostazioni"
     "saveSettings": "Salva impostazioni"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Lettere minuscole, numeri, trattini e underscore. 3-48 caratteri. Lascia vuoto per usare l'URL predefinito.",
     "slugHint": "Lettere minuscole, numeri, trattini e underscore. 3-48 caratteri. Lascia vuoto per usare l'URL predefinito.",
     "portalUrl": "URL del portale",
     "portalUrl": "URL del portale",
     "urlCopied": "URL del portale copiato",
     "urlCopied": "URL del portale copiato",
-    "shareHint": "Condividi questo link con i tuoi clienti per permettere loro di accedere al portale."
+    "shareHint": "Condividi questo link con i tuoi clienti per permettere loro di accedere al portale.",
+    "descriptionLabel": "La tua officina",
+    "descriptionPlaceholder": "Racconta ai clienti qualcosa sulla tua officina, i servizi che offri e ciò che ti rende unico.",
+    "descriptionHint": "Mostrato ai visitatori in cima alla pagina del portale.",
+    "hoursLabel": "Orari di apertura",
+    "hoursPlaceholder": "Lun-Ven: 8:00-17:00\nSab: 9:00-13:00\nDom: Chiuso",
+    "hoursHint": "Testo libero. Usa interruzioni di riga per elencare ogni giorno separatamente.",
+    "contentSaved": "Contenuto del portale salvato",
+    "background": {
+      "title": "Sfondo del portale",
+      "description": "Scegli l'aspetto della pagina di benvenuto del portale clienti.",
+      "typeNone": "Nessuno",
+      "typeTemplate": "Modello",
+      "typeImage": "Immagine personalizzata",
+      "chooseTemplate": "Scegli un modello",
+      "uploadLabel": "Immagine di sfondo",
+      "upload": "Carica immagine",
+      "replace": "Sostituisci immagine",
+      "remove": "Rimuovi",
+      "uploadHint": "JPG, PNG o WebP. Max 8 MB. Consigliato almeno 1600×900.",
+      "uploading": "Caricamento sfondo…",
+      "uploaded": "Sfondo aggiornato",
+      "uploadFailed": "Caricamento sfondo non riuscito",
+      "templateNames": {
+        "default": "Sobrio",
+        "sunrise": "Alba",
+        "ocean": "Oceano",
+        "forest": "Foresta",
+        "slate": "Ardesia",
+        "sand": "Sabbia"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Provider e-mail",
     "title": "Provider e-mail",

+ 44 - 3
messages/lt/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Raskite savo dirbtuves",
+    "description": "Pasirinkite savo dirbtuves žemiau, kad pasiektumėte klientų portalą.",
+    "searchPlaceholder": "Ieškoti dirbtuvių…",
+    "noWorkshops": "Šiuo metu nėra prieinamų dirbtuvių.",
+    "noResults": "Pagal jūsų paiešką nerasta dirbtuvių.",
+    "openPortal": "Atidaryti portalą"
+  },
+  "landing": {
+    "contactTitle": "Susisiekite su mumis",
+    "aboutTitle": "Apie mus",
+    "noContactInfo": "Kontaktiniai duomenys dar nepateikti.",
+    "address": "Adresas",
+    "phone": "Telefonas",
+    "email": "El. paštas",
+    "hours": "Darbo laikas",
+    "directions": "Kelio nuorodos"
+  },
   "login": {
   "login": {
-    "signInTitle": "Prisijunkite prie savo kliento portalo",
+    "signInTitle": "Klientų prisijungimas",
+    "signInSubtitle": "Prisijunkite, kad peržiūrėtumėte sąskaitas, pasiūlymus ir paslaugų istoriją.",
     "emailLabel": "El. pašto adresas",
     "emailLabel": "El. pašto adresas",
     "emailPlaceholder": "you@example.com",
     "emailPlaceholder": "you@example.com",
     "sendLink": "Siųsti prisijungimo nuorodą",
     "sendLink": "Siųsti prisijungimo nuorodą",
     "checkEmail": "Patikrinkite el. paštą",
     "checkEmail": "Patikrinkite el. paštą",
     "checkEmailDescription": "Jei tokia paskyra egzistuoja, išsiuntėme prisijungimo nuorodą. Nuoroda galioja 15 minučių.",
     "checkEmailDescription": "Jei tokia paskyra egzistuoja, išsiuntėme prisijungimo nuorodą. Nuoroda galioja 15 minučių.",
     "tryDifferentEmail": "Pabandyti kitą el. paštą",
     "tryDifferentEmail": "Pabandyti kitą el. paštą",
+    "tabEmail": "El. paštas",
+    "tabSms": "SMS",
+    "phoneLabel": "Telefono numeris",
+    "phonePlaceholder": "+370 61234567",
+    "phoneLocalPlaceholder": "61234567",
+    "sendCode": "Siųsti kodą",
+    "codeSentTitle": "Įveskite mūsų išsiųstą kodą",
+    "codeSentDescription": "Jei egzistuoja paskyra, išsiuntėme 6 skaitmenų kodą į {phone}.",
+    "codeLabel": "6 skaitmenų kodas",
+    "codePlaceholder": "123456",
+    "verifyCode": "Prisijungti",
+    "useDifferentPhone": "Naudoti kitą numerį",
+    "invalidCode": "Neteisingas arba pasibaigęs kodas",
     "notFound": "Nerasta",
     "notFound": "Nerasta",
     "portalNotExist": "Šis portalas neegzistuoja.",
     "portalNotExist": "Šis portalas neegzistuoja.",
     "portalNotAvailable": "Portalas nepasiekiamas",
     "portalNotAvailable": "Portalas nepasiekiamas",
@@ -21,7 +53,8 @@
     "requestService": "Užsakyti paslaugą"
     "requestService": "Užsakyti paslaugą"
   },
   },
   "header": {
   "header": {
-    "signOut": "Atsijungti"
+    "signOut": "Atsijungti",
+    "askShort": "Servisas"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Nerasta",
     "notFound": "Nerasta",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Pasiūlymų dar nėra.",
     "noQuotesYet": "Pasiūlymų dar nėra.",
     "view": "Peržiūrėti",
     "view": "Peržiūrėti",
     "pending": "Laukia",
     "pending": "Laukia",
-    "failedToLoad": "Nepavyko įkelti valdymo skydelio."
+    "failedToLoad": "Nepavyko įkelti valdymo skydelio.",
+    "quickRequestService": "Užsakyti servisą",
+    "quickRequestServiceHint": "Rezervuokite remontą ar patikrą",
+    "quickViewInvoices": "Jūsų sąskaitos",
+    "quickViewInvoicesHint": "Sąskaitos ir mokėjimų istorija",
+    "quickViewVehicles": "Jūsų transporto priemonės",
+    "quickViewVehiclesHint": "Aptarnavimo istorija ir informacija",
+    "activeJobs": "Aktyvūs darbai"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Sąskaitos",
     "title": "Sąskaitos",
@@ -57,6 +97,7 @@
     "amount": "Suma",
     "amount": "Suma",
     "payment": "Mokėjimas",
     "payment": "Mokėjimas",
     "view": "Peržiūrėti",
     "view": "Peržiūrėti",
+    "download": "Atsisiųsti",
     "paid": "apmokėta",
     "paid": "apmokėta",
     "partial": "dalinai",
     "partial": "dalinai",
     "unpaid": "neapmokėta"
     "unpaid": "neapmokėta"

+ 36 - 2
messages/lt/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Pasirinkite programos rodymo kalbą.",
     "languageDescription": "Pasirinkite programos rodymo kalbą.",
     "language": "Kalba",
     "language": "Kalba",
     "forceCustomerLocale": "Priverstinai naudoti šią kalbą klientams matomuose puslapiuose",
     "forceCustomerLocale": "Priverstinai naudoti šią kalbą klientams matomuose puslapiuose",
-    "forceCustomerLocaleHint": "Kai įjungta, klientai, peržiūrintys bendrinas sąskaitas, pasiūlymus ir apžiūras, matys juos jūsų dirbtuvių kalba, nepriklausomai nuo naršyklės nustatymų. Kai išjungta, klientai mato puslapius savo naršyklėje nustatyta kalba.",
+    "forceCustomerLocaleHint": "Kai įjungta, klientų portalas ir bendrinos sąskaitos, pasiūlymai bei apžiūros rodomi jūsų dirbtuvių kalba, nepriklausomai nuo lankytojo naršyklės nustatymų. Kai išjungta, klientams skirti puslapiai naudoja lankytojo naršyklės kalbą.",
+    "defaultCountryCode": "Numatytasis šalies kodas",
+    "defaultCountryCodeHint": "Naudojamas, kai klientai prisijungia prie portalo SMS žinute — pridedamas prie telefono numerių be šalies kodo (jūsų arba kliento). Formatas: + ir skaičiai, pvz. +370.",
+    "defaultCountryCodeInvalid": "Įveskite teisingą šalies kodą, pvz. +370, 370 arba 00370.",
     "saved": "Lokalizacijos nustatymai išsaugoti",
     "saved": "Lokalizacijos nustatymai išsaugoti",
     "saveSettings": "Išsaugoti nustatymus"
     "saveSettings": "Išsaugoti nustatymus"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Mažosios raidės, skaičiai, brūkšneliai ir pabraukimai. 3–48 simboliai. Palikite tuščią, kad naudotumėte numatytąjį URL.",
     "slugHint": "Mažosios raidės, skaičiai, brūkšneliai ir pabraukimai. 3–48 simboliai. Palikite tuščią, kad naudotumėte numatytąjį URL.",
     "portalUrl": "Portalo URL",
     "portalUrl": "Portalo URL",
     "urlCopied": "Portalo URL nukopijuotas",
     "urlCopied": "Portalo URL nukopijuotas",
-    "shareHint": "Pasidalinkite šia nuoroda su klientais, kad jie galėtų prisijungti prie savo portalo."
+    "shareHint": "Pasidalinkite šia nuoroda su klientais, kad jie galėtų prisijungti prie savo portalo.",
+    "descriptionLabel": "Apie jūsų dirbtuves",
+    "descriptionPlaceholder": "Papasakokite klientams apie savo dirbtuves, siūlomas paslaugas ir kuo esate unikalūs.",
+    "descriptionHint": "Rodoma lankytojams jūsų portalo pradžios puslapio viršuje.",
+    "hoursLabel": "Darbo laikas",
+    "hoursPlaceholder": "Pir-Pen: 8:00-17:00\nŠeš: 9:00-13:00\nSek: Uždaryta",
+    "hoursHint": "Laisvas tekstas. Naudokite naujas eilutes, kad nurodytumėte kiekvieną dieną atskirai.",
+    "contentSaved": "Portalo turinys išsaugotas",
+    "background": {
+      "title": "Portalo fonas",
+      "description": "Pasirinkite, kaip atrodo klientų portalo pradžios puslapis.",
+      "typeNone": "Joks",
+      "typeTemplate": "Šablonas",
+      "typeImage": "Pasirinktinė nuotrauka",
+      "chooseTemplate": "Pasirinkite šabloną",
+      "uploadLabel": "Fono nuotrauka",
+      "upload": "Įkelti nuotrauką",
+      "replace": "Pakeisti nuotrauką",
+      "remove": "Pašalinti",
+      "uploadHint": "JPG, PNG arba WebP. Iki 8 MB. Rekomenduojama bent 1600×900.",
+      "uploading": "Įkeliamas fonas…",
+      "uploaded": "Fonas atnaujintas",
+      "uploadFailed": "Nepavyko įkelti fono",
+      "templateNames": {
+        "default": "Subtilus",
+        "sunrise": "Saulėtekis",
+        "ocean": "Vandenynas",
+        "forest": "Miškas",
+        "slate": "Skalūnas",
+        "sand": "Smėlis"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "El. pašto tiekėjas",
     "title": "El. pašto tiekėjas",

+ 44 - 3
messages/nb/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Finn verkstedet ditt",
+    "description": "Velg verkstedet ditt nedenfor for å gå til kundeportalen.",
+    "searchPlaceholder": "Søk etter verksted…",
+    "noWorkshops": "Ingen verksteder er tilgjengelige akkurat nå.",
+    "noResults": "Ingen verksteder samsvarer med søket.",
+    "openPortal": "Åpne portal"
+  },
+  "landing": {
+    "contactTitle": "Kontakt oss",
+    "aboutTitle": "Om oss",
+    "noContactInfo": "Kontaktinformasjon er ikke tilgjengelig ennå.",
+    "address": "Adresse",
+    "phone": "Telefon",
+    "email": "E-post",
+    "hours": "Åpningstider",
+    "directions": "Veibeskrivelse"
+  },
   "login": {
   "login": {
-    "signInTitle": "Logg inn på kundeportalen din",
+    "signInTitle": "Kundeinnlogging",
+    "signInSubtitle": "Logg inn for å se fakturaer, tilbud og servicehistorikk.",
     "emailLabel": "E-postadresse",
     "emailLabel": "E-postadresse",
     "emailPlaceholder": "deg@eksempel.com",
     "emailPlaceholder": "deg@eksempel.com",
     "sendLink": "Send innloggingslenke",
     "sendLink": "Send innloggingslenke",
     "checkEmail": "Sjekk e-posten din",
     "checkEmail": "Sjekk e-posten din",
     "checkEmailDescription": "Hvis det finnes en konto for den e-postadressen, har vi sendt en innloggingslenke. Lenken utløper om 15 minutter.",
     "checkEmailDescription": "Hvis det finnes en konto for den e-postadressen, har vi sendt en innloggingslenke. Lenken utløper om 15 minutter.",
     "tryDifferentEmail": "Prøv en annen e-postadresse",
     "tryDifferentEmail": "Prøv en annen e-postadresse",
+    "tabEmail": "E-post",
+    "tabSms": "SMS",
+    "phoneLabel": "Telefonnummer",
+    "phonePlaceholder": "+47 12345678",
+    "phoneLocalPlaceholder": "12345678",
+    "sendCode": "Send kode",
+    "codeSentTitle": "Skriv inn koden vi sendte",
+    "codeSentDescription": "Vi sendte en 6-sifret kode til {phone} hvis det finnes en konto.",
+    "codeLabel": "6-sifret kode",
+    "codePlaceholder": "123456",
+    "verifyCode": "Logg inn",
+    "useDifferentPhone": "Bruk et annet nummer",
+    "invalidCode": "Ugyldig eller utløpt kode",
     "notFound": "Ikke funnet",
     "notFound": "Ikke funnet",
     "portalNotExist": "Denne portalen finnes ikke.",
     "portalNotExist": "Denne portalen finnes ikke.",
     "portalNotAvailable": "Portal ikke tilgjengelig",
     "portalNotAvailable": "Portal ikke tilgjengelig",
@@ -21,7 +53,8 @@
     "requestService": "Be om service"
     "requestService": "Be om service"
   },
   },
   "header": {
   "header": {
-    "signOut": "Logg ut"
+    "signOut": "Logg ut",
+    "askShort": "Service"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Ikke funnet",
     "notFound": "Ikke funnet",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Ingen tilbud ennå.",
     "noQuotesYet": "Ingen tilbud ennå.",
     "view": "Se",
     "view": "Se",
     "pending": "Ventende",
     "pending": "Ventende",
-    "failedToLoad": "Kunne ikke laste inn oversikten."
+    "failedToLoad": "Kunne ikke laste inn oversikten.",
+    "quickRequestService": "Be om service",
+    "quickRequestServiceHint": "Bestill reparasjon eller kontroll",
+    "quickViewInvoices": "Dine fakturaer",
+    "quickViewInvoicesHint": "Regninger og betalingshistorikk",
+    "quickViewVehicles": "Dine kjøretøy",
+    "quickViewVehiclesHint": "Servicehistorikk og detaljer",
+    "activeJobs": "Pågående arbeid"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Fakturaer",
     "title": "Fakturaer",
@@ -57,6 +97,7 @@
     "amount": "Beløp",
     "amount": "Beløp",
     "payment": "Betaling",
     "payment": "Betaling",
     "view": "Se",
     "view": "Se",
+    "download": "Last ned",
     "paid": "betalt",
     "paid": "betalt",
     "partial": "delbetalt",
     "partial": "delbetalt",
     "unpaid": "ubetalt"
     "unpaid": "ubetalt"

+ 36 - 2
messages/nb/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Velg visningsspråk for applikasjonen.",
     "languageDescription": "Velg visningsspråk for applikasjonen.",
     "language": "Språk",
     "language": "Språk",
     "forceCustomerLocale": "Tving dette språket på kundesider",
     "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.",
+    "forceCustomerLocaleHint": "Når aktivert vises kundeportalen og delte fakturaer, tilbud og inspeksjoner på verkstedets språk uavhengig av besøkendes nettleserinnstillinger. Når deaktivert, følger kundesidene besøkendes nettleserspråk.",
+    "defaultCountryCode": "Standard landskode",
+    "defaultCountryCodeHint": "Brukes når kunder logger inn på portalen via SMS — legges til telefonnummer som mangler landskode (deres eller dine). Format: + etterfulgt av tall, f.eks. +47.",
+    "defaultCountryCodeInvalid": "Skriv inn en gyldig landskode, f.eks. +47, 47 eller 0047.",
     "saved": "Lokaliseringsinnstillinger lagret",
     "saved": "Lokaliseringsinnstillinger lagret",
     "saveSettings": "Lagre innstillinger"
     "saveSettings": "Lagre innstillinger"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Sma bokstaver, tall, bindestreker og understreker. 3-48 tegn. La sta tom for a bruke standard-URL.",
     "slugHint": "Sma bokstaver, tall, bindestreker og understreker. 3-48 tegn. La sta tom for a bruke standard-URL.",
     "portalUrl": "Portal-URL",
     "portalUrl": "Portal-URL",
     "urlCopied": "Portal-URL kopiert",
     "urlCopied": "Portal-URL kopiert",
-    "shareHint": "Del denne lenken med kundene dine slik at de kan logge inn pa portalen sin."
+    "shareHint": "Del denne lenken med kundene dine slik at de kan logge inn pa portalen sin.",
+    "descriptionLabel": "Om verkstedet",
+    "descriptionPlaceholder": "Fortell kundene litt om verkstedet, tjenestene dere tilbyr og hva som gjor dere unike.",
+    "descriptionHint": "Vises til besokende overst pa portalsiden.",
+    "hoursLabel": "Apningstider",
+    "hoursPlaceholder": "Man-fre: 08:00-17:00\nLor: 09:00-13:00\nSon: Stengt",
+    "hoursHint": "Fritekst. Bruk linjeskift for a liste opp dagene hver for seg.",
+    "contentSaved": "Portalinnhold lagret",
+    "background": {
+      "title": "Portalbakgrunn",
+      "description": "Velg hvordan landingssiden for kundeportalen ser ut.",
+      "typeNone": "Ingen",
+      "typeTemplate": "Mal",
+      "typeImage": "Eget bilde",
+      "chooseTemplate": "Velg en mal",
+      "uploadLabel": "Bakgrunnsbilde",
+      "upload": "Last opp bilde",
+      "replace": "Bytt bilde",
+      "remove": "Fjern",
+      "uploadHint": "JPG, PNG eller WebP. Maks 8 MB. Anbefalt minst 1600x900.",
+      "uploading": "Laster opp bakgrunn...",
+      "uploaded": "Bakgrunn oppdatert",
+      "uploadFailed": "Kunne ikke laste opp bakgrunn",
+      "templateNames": {
+        "default": "Diskret",
+        "sunrise": "Soloppgang",
+        "ocean": "Hav",
+        "forest": "Skog",
+        "slate": "Skifer",
+        "sand": "Sand"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "E-postleverandor",
     "title": "E-postleverandor",

+ 44 - 3
messages/nl/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Vind uw werkplaats",
+    "description": "Kies hieronder uw werkplaats om naar het klantenportaal te gaan.",
+    "searchPlaceholder": "Werkplaatsen zoeken…",
+    "noWorkshops": "Er zijn momenteel geen werkplaatsen beschikbaar.",
+    "noResults": "Geen werkplaatsen gevonden voor uw zoekopdracht.",
+    "openPortal": "Portaal openen"
+  },
+  "landing": {
+    "contactTitle": "Neem contact op",
+    "aboutTitle": "Over ons",
+    "noContactInfo": "Contactgegevens zijn nog niet beschikbaar.",
+    "address": "Adres",
+    "phone": "Telefoon",
+    "email": "E-mail",
+    "hours": "Openingstijden",
+    "directions": "Routebeschrijving"
+  },
   "login": {
   "login": {
-    "signInTitle": "Aanmelden bij uw klantenportaal",
+    "signInTitle": "Klantlogin",
+    "signInSubtitle": "Log in om uw facturen, offertes en servicegeschiedenis te bekijken.",
     "emailLabel": "E-mailadres",
     "emailLabel": "E-mailadres",
     "emailPlaceholder": "u@voorbeeld.nl",
     "emailPlaceholder": "u@voorbeeld.nl",
     "sendLink": "Aanmeldlink versturen",
     "sendLink": "Aanmeldlink versturen",
     "checkEmail": "Controleer uw e-mail",
     "checkEmail": "Controleer uw e-mail",
     "checkEmailDescription": "Als er een account bestaat voor dat e-mailadres, hebben we een aanmeldlink verstuurd. De link verloopt na 15 minuten.",
     "checkEmailDescription": "Als er een account bestaat voor dat e-mailadres, hebben we een aanmeldlink verstuurd. De link verloopt na 15 minuten.",
     "tryDifferentEmail": "Probeer een ander e-mailadres",
     "tryDifferentEmail": "Probeer een ander e-mailadres",
+    "tabEmail": "E-mail",
+    "tabSms": "SMS",
+    "phoneLabel": "Telefoonnummer",
+    "phonePlaceholder": "+31 612345678",
+    "phoneLocalPlaceholder": "612345678",
+    "sendCode": "Code versturen",
+    "codeSentTitle": "Voer de code in die we hebben verstuurd",
+    "codeSentDescription": "We hebben een 6-cijferige code naar {phone} gestuurd als deze overeenkomt met een account.",
+    "codeLabel": "6-cijferige code",
+    "codePlaceholder": "123456",
+    "verifyCode": "Aanmelden",
+    "useDifferentPhone": "Een ander nummer gebruiken",
+    "invalidCode": "Ongeldige of verlopen code",
     "notFound": "Niet gevonden",
     "notFound": "Niet gevonden",
     "portalNotExist": "Dit portaal bestaat niet.",
     "portalNotExist": "Dit portaal bestaat niet.",
     "portalNotAvailable": "Portaal niet beschikbaar",
     "portalNotAvailable": "Portaal niet beschikbaar",
@@ -21,7 +53,8 @@
     "requestService": "Service aanvragen"
     "requestService": "Service aanvragen"
   },
   },
   "header": {
   "header": {
-    "signOut": "Afmelden"
+    "signOut": "Afmelden",
+    "askShort": "Service"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Niet gevonden",
     "notFound": "Niet gevonden",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Nog geen offertes.",
     "noQuotesYet": "Nog geen offertes.",
     "view": "Bekijken",
     "view": "Bekijken",
     "pending": "In behandeling",
     "pending": "In behandeling",
-    "failedToLoad": "Dashboard laden mislukt."
+    "failedToLoad": "Dashboard laden mislukt.",
+    "quickRequestService": "Service aanvragen",
+    "quickRequestServiceHint": "Reparatie of controle boeken",
+    "quickViewInvoices": "Uw facturen",
+    "quickViewInvoicesHint": "Facturen en betaalgeschiedenis",
+    "quickViewVehicles": "Uw voertuigen",
+    "quickViewVehiclesHint": "Servicegeschiedenis en details",
+    "activeJobs": "Lopende werkzaamheden"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Facturen",
     "title": "Facturen",
@@ -57,6 +97,7 @@
     "amount": "Bedrag",
     "amount": "Bedrag",
     "payment": "Betaling",
     "payment": "Betaling",
     "view": "Bekijken",
     "view": "Bekijken",
+    "download": "Downloaden",
     "paid": "betaald",
     "paid": "betaald",
     "partial": "gedeeltelijk",
     "partial": "gedeeltelijk",
     "unpaid": "onbetaald"
     "unpaid": "onbetaald"

+ 36 - 2
messages/nl/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Kies de weergavetaal van de applicatie.",
     "languageDescription": "Kies de weergavetaal van de applicatie.",
     "language": "Taal",
     "language": "Taal",
     "forceCustomerLocale": "Forceer deze taal op klantgerichte pagina's",
     "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.",
+    "forceCustomerLocaleHint": "Wanneer ingeschakeld, verschijnen het klantenportaal en gedeelde facturen, offertes en inspecties in de taal van uw werkplaats, ongeacht de browserinstellingen van de bezoeker. Wanneer uitgeschakeld, volgen klantgerichte pagina's de browsertaal van de bezoeker.",
+    "defaultCountryCode": "Standaard landcode",
+    "defaultCountryCodeHint": "Wordt gebruikt wanneer klanten zich via SMS aanmelden bij het portaal — toegepast op telefoonnummers zonder landcode (van u of van de klant). Formaat: + gevolgd door cijfers, bijv. +31.",
+    "defaultCountryCodeInvalid": "Voer een geldige landcode in, bijv. +31, 31 of 0031.",
     "saved": "Lokalisatie-instellingen opgeslagen",
     "saved": "Lokalisatie-instellingen opgeslagen",
     "saveSettings": "Instellingen opslaan"
     "saveSettings": "Instellingen opslaan"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Kleine letters, cijfers, koppeltekens en underscores. 3-48 tekens. Laat leeg om de standaard-URL te gebruiken.",
     "slugHint": "Kleine letters, cijfers, koppeltekens en underscores. 3-48 tekens. Laat leeg om de standaard-URL te gebruiken.",
     "portalUrl": "Portaal-URL",
     "portalUrl": "Portaal-URL",
     "urlCopied": "Portaal-URL gekopieerd",
     "urlCopied": "Portaal-URL gekopieerd",
-    "shareHint": "Deel deze link met uw klanten zodat ze kunnen inloggen op hun portaal."
+    "shareHint": "Deel deze link met uw klanten zodat ze kunnen inloggen op hun portaal.",
+    "descriptionLabel": "Over uw werkplaats",
+    "descriptionPlaceholder": "Vertel klanten iets over uw werkplaats, de diensten die u aanbiedt en wat u uniek maakt.",
+    "descriptionHint": "Wordt aan bezoekers getoond bovenaan uw portaalpagina.",
+    "hoursLabel": "Openingstijden",
+    "hoursPlaceholder": "Ma-Vr: 8:00-17:00\nZa: 9:00-13:00\nZo: Gesloten",
+    "hoursHint": "Vrije tekst. Gebruik regeleinden om elke dag apart te vermelden.",
+    "contentSaved": "Portaalinhoud opgeslagen",
+    "background": {
+      "title": "Portaalachtergrond",
+      "description": "Kies hoe de landingspagina van het klantenportaal eruitziet.",
+      "typeNone": "Geen",
+      "typeTemplate": "Sjabloon",
+      "typeImage": "Eigen afbeelding",
+      "chooseTemplate": "Kies een sjabloon",
+      "uploadLabel": "Achtergrondafbeelding",
+      "upload": "Afbeelding uploaden",
+      "replace": "Afbeelding vervangen",
+      "remove": "Verwijderen",
+      "uploadHint": "JPG, PNG of WebP. Max 8 MB. Aanbevolen minstens 1600×900.",
+      "uploading": "Achtergrond uploaden…",
+      "uploaded": "Achtergrond bijgewerkt",
+      "uploadFailed": "Achtergrond uploaden mislukt",
+      "templateNames": {
+        "default": "Subtiel",
+        "sunrise": "Zonsopgang",
+        "ocean": "Oceaan",
+        "forest": "Bos",
+        "slate": "Leisteen",
+        "sand": "Zand"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "E-mailprovider",
     "title": "E-mailprovider",

+ 44 - 3
messages/pl/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Znajdź swój warsztat",
+    "description": "Wybierz swój warsztat poniżej, aby uzyskać dostęp do portalu klienta.",
+    "searchPlaceholder": "Szukaj warsztatów…",
+    "noWorkshops": "Obecnie nie ma dostępnych warsztatów.",
+    "noResults": "Żaden warsztat nie pasuje do wyszukiwania.",
+    "openPortal": "Otwórz portal"
+  },
+  "landing": {
+    "contactTitle": "Skontaktuj się z nami",
+    "aboutTitle": "O nas",
+    "noContactInfo": "Dane kontaktowe nie są jeszcze dostępne.",
+    "address": "Adres",
+    "phone": "Telefon",
+    "email": "E-mail",
+    "hours": "Godziny otwarcia",
+    "directions": "Wyznacz trasę"
+  },
   "login": {
   "login": {
-    "signInTitle": "Zaloguj się do portalu klienta",
+    "signInTitle": "Logowanie klienta",
+    "signInSubtitle": "Zaloguj się, aby zobaczyć faktury, oferty i historię serwisową.",
     "emailLabel": "Adres e-mail",
     "emailLabel": "Adres e-mail",
     "emailPlaceholder": "ty@przyklad.pl",
     "emailPlaceholder": "ty@przyklad.pl",
     "sendLink": "Wyślij link do logowania",
     "sendLink": "Wyślij link do logowania",
     "checkEmail": "Sprawdź swoją skrzynkę",
     "checkEmail": "Sprawdź swoją skrzynkę",
     "checkEmailDescription": "Jeśli konto dla tego adresu e-mail istnieje, wysłaliśmy link do logowania. Link wygasa po 15 minutach.",
     "checkEmailDescription": "Jeśli konto dla tego adresu e-mail istnieje, wysłaliśmy link do logowania. Link wygasa po 15 minutach.",
     "tryDifferentEmail": "Wypróbuj inny adres e-mail",
     "tryDifferentEmail": "Wypróbuj inny adres e-mail",
+    "tabEmail": "E-mail",
+    "tabSms": "SMS",
+    "phoneLabel": "Numer telefonu",
+    "phonePlaceholder": "+48 512345678",
+    "phoneLocalPlaceholder": "512345678",
+    "sendCode": "Wyślij kod",
+    "codeSentTitle": "Wprowadź kod, który wysłaliśmy",
+    "codeSentDescription": "Wysłaliśmy 6-cyfrowy kod na {phone}, jeśli pasuje do konta.",
+    "codeLabel": "6-cyfrowy kod",
+    "codePlaceholder": "123456",
+    "verifyCode": "Zaloguj się",
+    "useDifferentPhone": "Użyj innego numeru",
+    "invalidCode": "Nieprawidłowy lub wygasły kod",
     "notFound": "Nie znaleziono",
     "notFound": "Nie znaleziono",
     "portalNotExist": "Ten portal nie istnieje.",
     "portalNotExist": "Ten portal nie istnieje.",
     "portalNotAvailable": "Portal niedostępny",
     "portalNotAvailable": "Portal niedostępny",
@@ -21,7 +53,8 @@
     "requestService": "Zamów serwis"
     "requestService": "Zamów serwis"
   },
   },
   "header": {
   "header": {
-    "signOut": "Wyloguj się"
+    "signOut": "Wyloguj się",
+    "askShort": "Serwis"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Nie znaleziono",
     "notFound": "Nie znaleziono",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Brak wycen.",
     "noQuotesYet": "Brak wycen.",
     "view": "Zobacz",
     "view": "Zobacz",
     "pending": "Oczekujące",
     "pending": "Oczekujące",
-    "failedToLoad": "Nie udało się załadować panelu."
+    "failedToLoad": "Nie udało się załadować panelu.",
+    "quickRequestService": "Zamów serwis",
+    "quickRequestServiceHint": "Zarezerwuj naprawę lub przegląd",
+    "quickViewInvoices": "Twoje faktury",
+    "quickViewInvoicesHint": "Faktury i historia płatności",
+    "quickViewVehicles": "Twoje pojazdy",
+    "quickViewVehiclesHint": "Historia serwisowa i szczegóły",
+    "activeJobs": "Bieżące zlecenia"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Faktury",
     "title": "Faktury",
@@ -57,6 +97,7 @@
     "amount": "Kwota",
     "amount": "Kwota",
     "payment": "Płatność",
     "payment": "Płatność",
     "view": "Zobacz",
     "view": "Zobacz",
+    "download": "Pobierz",
     "paid": "zapłacono",
     "paid": "zapłacono",
     "partial": "częściowo",
     "partial": "częściowo",
     "unpaid": "niezapłacono"
     "unpaid": "niezapłacono"

+ 36 - 2
messages/pl/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Wybierz język wyświetlania aplikacji.",
     "languageDescription": "Wybierz język wyświetlania aplikacji.",
     "language": "Język",
     "language": "Język",
     "forceCustomerLocale": "Wymuś ten język na stronach dla klientów",
     "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.",
+    "forceCustomerLocaleHint": "Po włączeniu portal klienta oraz udostępnione faktury, oferty i inspekcje wyświetlają się w języku Twojego warsztatu, niezależnie od ustawień przeglądarki odwiedzającego. Gdy wyłączone, strony dla klientów używają języka przeglądarki odwiedzającego.",
+    "defaultCountryCode": "Domyślny kod kraju",
+    "defaultCountryCodeHint": "Używany, gdy klienci logują się do portalu przez SMS — dodawany do numerów telefonów bez kodu kraju (Twojego lub klienta). Format: + i cyfry, np. +48.",
+    "defaultCountryCodeInvalid": "Wpisz prawidłowy kod kraju, np. +48, 48 lub 0048.",
     "saved": "Ustawienia lokalizacji zapisane",
     "saved": "Ustawienia lokalizacji zapisane",
     "saveSettings": "Zapisz ustawienia"
     "saveSettings": "Zapisz ustawienia"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Male litery, cyfry, myslniki i podkreslenia. 3-48 znakow. Pozostaw puste, aby uzyc domyslnego URL.",
     "slugHint": "Male litery, cyfry, myslniki i podkreslenia. 3-48 znakow. Pozostaw puste, aby uzyc domyslnego URL.",
     "portalUrl": "URL portalu",
     "portalUrl": "URL portalu",
     "urlCopied": "URL portalu skopiowany",
     "urlCopied": "URL portalu skopiowany",
-    "shareHint": "Udostepnij ten link swoim klientom, aby mogli zalogowac sie do swojego portalu."
+    "shareHint": "Udostepnij ten link swoim klientom, aby mogli zalogowac sie do swojego portalu.",
+    "descriptionLabel": "O Twoim warsztacie",
+    "descriptionPlaceholder": "Opowiedz klientom o swoim warsztacie, oferowanych usługach i tym, co Cię wyróżnia.",
+    "descriptionHint": "Wyświetlane odwiedzającym na górze strony portalu.",
+    "hoursLabel": "Godziny otwarcia",
+    "hoursPlaceholder": "Pn-Pt: 8:00-17:00\nSob: 9:00-13:00\nNd: Zamknięte",
+    "hoursHint": "Tekst dowolny. Użyj nowej linii, aby wymienić każdy dzień osobno.",
+    "contentSaved": "Treść portalu zapisana",
+    "background": {
+      "title": "Tło portalu",
+      "description": "Wybierz wygląd strony głównej portalu klienta.",
+      "typeNone": "Brak",
+      "typeTemplate": "Szablon",
+      "typeImage": "Własne zdjęcie",
+      "chooseTemplate": "Wybierz szablon",
+      "uploadLabel": "Zdjęcie tła",
+      "upload": "Prześlij zdjęcie",
+      "replace": "Zamień zdjęcie",
+      "remove": "Usuń",
+      "uploadHint": "JPG, PNG lub WebP. Maks. 8 MB. Zalecane co najmniej 1600×900.",
+      "uploading": "Przesyłanie tła…",
+      "uploaded": "Tło zaktualizowane",
+      "uploadFailed": "Nie udało się przesłać tła",
+      "templateNames": {
+        "default": "Subtelny",
+        "sunrise": "Wschód słońca",
+        "ocean": "Ocean",
+        "forest": "Las",
+        "slate": "Łupek",
+        "sand": "Piasek"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Dostawca poczty e-mail",
     "title": "Dostawca poczty e-mail",

+ 44 - 3
messages/pt-BR/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Encontre sua oficina",
+    "description": "Escolha sua oficina abaixo para acessar o portal do cliente.",
+    "searchPlaceholder": "Buscar oficinas…",
+    "noWorkshops": "Nenhuma oficina está disponível no momento.",
+    "noResults": "Nenhuma oficina corresponde à sua busca.",
+    "openPortal": "Abrir portal"
+  },
+  "landing": {
+    "contactTitle": "Fale conosco",
+    "aboutTitle": "Sobre nós",
+    "noContactInfo": "As informações de contato ainda não estão disponíveis.",
+    "address": "Endereço",
+    "phone": "Telefone",
+    "email": "E-mail",
+    "hours": "Horário de funcionamento",
+    "directions": "Como chegar"
+  },
   "login": {
   "login": {
-    "signInTitle": "Entre no seu portal do cliente",
+    "signInTitle": "Acesso do cliente",
+    "signInSubtitle": "Entre para ver suas faturas, orçamentos e histórico de serviço.",
     "emailLabel": "Endereço de e-mail",
     "emailLabel": "Endereço de e-mail",
     "emailPlaceholder": "voce@exemplo.com",
     "emailPlaceholder": "voce@exemplo.com",
     "sendLink": "Enviar link de acesso",
     "sendLink": "Enviar link de acesso",
     "checkEmail": "Verifique seu e-mail",
     "checkEmail": "Verifique seu e-mail",
     "checkEmailDescription": "Se uma conta existir para esse e-mail, enviamos um link de acesso. O link expira em 15 minutos.",
     "checkEmailDescription": "Se uma conta existir para esse e-mail, enviamos um link de acesso. O link expira em 15 minutos.",
     "tryDifferentEmail": "Tente um e-mail diferente",
     "tryDifferentEmail": "Tente um e-mail diferente",
+    "tabEmail": "E-mail",
+    "tabSms": "SMS",
+    "phoneLabel": "Número de telefone",
+    "phonePlaceholder": "+55 11912345678",
+    "phoneLocalPlaceholder": "11912345678",
+    "sendCode": "Enviar código",
+    "codeSentTitle": "Digite o código que enviamos",
+    "codeSentDescription": "Enviamos um código de 6 dígitos para {phone} se corresponder a uma conta.",
+    "codeLabel": "Código de 6 dígitos",
+    "codePlaceholder": "123456",
+    "verifyCode": "Entrar",
+    "useDifferentPhone": "Usar outro número",
+    "invalidCode": "Código inválido ou expirado",
     "notFound": "Não Encontrado",
     "notFound": "Não Encontrado",
     "portalNotExist": "Este portal não existe.",
     "portalNotExist": "Este portal não existe.",
     "portalNotAvailable": "Portal Indisponível",
     "portalNotAvailable": "Portal Indisponível",
@@ -21,7 +53,8 @@
     "requestService": "Solicitar Serviço"
     "requestService": "Solicitar Serviço"
   },
   },
   "header": {
   "header": {
-    "signOut": "Sair"
+    "signOut": "Sair",
+    "askShort": "Serviço"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Não Encontrado",
     "notFound": "Não Encontrado",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Nenhum orçamento ainda.",
     "noQuotesYet": "Nenhum orçamento ainda.",
     "view": "Ver",
     "view": "Ver",
     "pending": "Pendente",
     "pending": "Pendente",
-    "failedToLoad": "Falha ao carregar o painel."
+    "failedToLoad": "Falha ao carregar o painel.",
+    "quickRequestService": "Solicitar serviço",
+    "quickRequestServiceHint": "Agendar reparo ou revisão",
+    "quickViewInvoices": "Suas faturas",
+    "quickViewInvoicesHint": "Faturas e histórico de pagamentos",
+    "quickViewVehicles": "Seus veículos",
+    "quickViewVehiclesHint": "Histórico de serviços e detalhes",
+    "activeJobs": "Serviços em andamento"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Faturas",
     "title": "Faturas",
@@ -57,6 +97,7 @@
     "amount": "Valor",
     "amount": "Valor",
     "payment": "Pagamento",
     "payment": "Pagamento",
     "view": "Ver",
     "view": "Ver",
+    "download": "Baixar",
     "paid": "pago",
     "paid": "pago",
     "partial": "parcial",
     "partial": "parcial",
     "unpaid": "não pago"
     "unpaid": "não pago"

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

@@ -672,7 +672,10 @@
     "languageDescription": "Escolha o idioma de exibição do aplicativo.",
     "languageDescription": "Escolha o idioma de exibição do aplicativo.",
     "language": "Idioma",
     "language": "Idioma",
     "forceCustomerLocale": "Forçar este idioma nas páginas voltadas ao cliente",
     "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.",
+    "forceCustomerLocaleHint": "Quando ativado, o portal do cliente e as faturas, orçamentos e inspeções compartilhadas aparecem no idioma da sua oficina, independentemente das configurações do navegador do visitante. Quando desativado, as páginas voltadas aos clientes seguem o idioma do navegador do visitante.",
+    "defaultCountryCode": "Código de país padrão",
+    "defaultCountryCodeHint": "Usado quando os clientes fazem login no portal por SMS — aplicado a números de telefone sem código de país (seus ou do cliente). Formato: + seguido de dígitos, ex: +55.",
+    "defaultCountryCodeInvalid": "Digite um código de país válido, ex: +55, 55 ou 0055.",
     "saved": "Configurações de localização salvas",
     "saved": "Configurações de localização salvas",
     "saveSettings": "Salvar configurações"
     "saveSettings": "Salvar configurações"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Letras minusculas, numeros, hifens e underscores. 3-48 caracteres. Deixe em branco para usar a URL padrao.",
     "slugHint": "Letras minusculas, numeros, hifens e underscores. 3-48 caracteres. Deixe em branco para usar a URL padrao.",
     "portalUrl": "URL do portal",
     "portalUrl": "URL do portal",
     "urlCopied": "URL do portal copiada",
     "urlCopied": "URL do portal copiada",
-    "shareHint": "Compartilhe este link com seus clientes para que possam fazer login no portal."
+    "shareHint": "Compartilhe este link com seus clientes para que possam fazer login no portal.",
+    "descriptionLabel": "Sobre sua oficina",
+    "descriptionPlaceholder": "Conte aos clientes sobre sua oficina, os serviços que você oferece e o que torna seu trabalho único.",
+    "descriptionHint": "Mostrado aos visitantes no topo da página inicial do seu portal.",
+    "hoursLabel": "Horário de funcionamento",
+    "hoursPlaceholder": "Seg-Sex: 8:00-17:00\nSáb: 9:00-13:00\nDom: Fechado",
+    "hoursHint": "Texto livre. Use quebras de linha para listar cada dia separadamente.",
+    "contentSaved": "Conteúdo do portal salvo",
+    "background": {
+      "title": "Plano de fundo do portal",
+      "description": "Escolha como a página inicial do portal do cliente fica.",
+      "typeNone": "Nenhum",
+      "typeTemplate": "Modelo",
+      "typeImage": "Imagem personalizada",
+      "chooseTemplate": "Escolha um modelo",
+      "uploadLabel": "Imagem de fundo",
+      "upload": "Enviar imagem",
+      "replace": "Substituir imagem",
+      "remove": "Remover",
+      "uploadHint": "JPG, PNG ou WebP. Máx. 8 MB. Recomendado pelo menos 1600×900.",
+      "uploading": "Enviando plano de fundo…",
+      "uploaded": "Plano de fundo atualizado",
+      "uploadFailed": "Falha ao enviar o plano de fundo",
+      "templateNames": {
+        "default": "Sutil",
+        "sunrise": "Amanhecer",
+        "ocean": "Oceano",
+        "forest": "Floresta",
+        "slate": "Ardósia",
+        "sand": "Areia"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Provedor de e-mail",
     "title": "Provedor de e-mail",

+ 44 - 3
messages/ru/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Найдите вашу мастерскую",
+    "description": "Выберите свою мастерскую ниже, чтобы перейти в портал клиента.",
+    "searchPlaceholder": "Поиск мастерских…",
+    "noWorkshops": "Сейчас нет доступных мастерских.",
+    "noResults": "По вашему запросу мастерские не найдены.",
+    "openPortal": "Открыть портал"
+  },
+  "landing": {
+    "contactTitle": "Связаться с нами",
+    "aboutTitle": "О нас",
+    "noContactInfo": "Контактные данные пока недоступны.",
+    "address": "Адрес",
+    "phone": "Телефон",
+    "email": "Электронная почта",
+    "hours": "Часы работы",
+    "directions": "Маршрут"
+  },
   "login": {
   "login": {
-    "signInTitle": "Войдите в портал клиента",
+    "signInTitle": "Вход для клиентов",
+    "signInSubtitle": "Войдите, чтобы просмотреть счета, расчёты и историю обслуживания.",
     "emailLabel": "Адрес эл. почты",
     "emailLabel": "Адрес эл. почты",
     "emailPlaceholder": "you@example.com",
     "emailPlaceholder": "you@example.com",
     "sendLink": "Отправить ссылку для входа",
     "sendLink": "Отправить ссылку для входа",
     "checkEmail": "Проверьте почту",
     "checkEmail": "Проверьте почту",
     "checkEmailDescription": "Если аккаунт с таким адресом существует, мы отправили ссылку для входа. Ссылка действительна 15 минут.",
     "checkEmailDescription": "Если аккаунт с таким адресом существует, мы отправили ссылку для входа. Ссылка действительна 15 минут.",
     "tryDifferentEmail": "Попробовать другой адрес",
     "tryDifferentEmail": "Попробовать другой адрес",
+    "tabEmail": "Эл. почта",
+    "tabSms": "СМС",
+    "phoneLabel": "Номер телефона",
+    "phonePlaceholder": "+7 9123456789",
+    "phoneLocalPlaceholder": "9123456789",
+    "sendCode": "Отправить код",
+    "codeSentTitle": "Введите отправленный код",
+    "codeSentDescription": "Мы отправили 6-значный код на {phone}, если этот номер привязан к аккаунту.",
+    "codeLabel": "6-значный код",
+    "codePlaceholder": "123456",
+    "verifyCode": "Войти",
+    "useDifferentPhone": "Использовать другой номер",
+    "invalidCode": "Неверный или просроченный код",
     "notFound": "Не найдено",
     "notFound": "Не найдено",
     "portalNotExist": "Этот портал не существует.",
     "portalNotExist": "Этот портал не существует.",
     "portalNotAvailable": "Портал недоступен",
     "portalNotAvailable": "Портал недоступен",
@@ -21,7 +53,8 @@
     "requestService": "Заявка на обслуживание"
     "requestService": "Заявка на обслуживание"
   },
   },
   "header": {
   "header": {
-    "signOut": "Выйти"
+    "signOut": "Выйти",
+    "askShort": "Сервис"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Не найдено",
     "notFound": "Не найдено",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Предложений пока нет.",
     "noQuotesYet": "Предложений пока нет.",
     "view": "Просмотр",
     "view": "Просмотр",
     "pending": "Ожидает",
     "pending": "Ожидает",
-    "failedToLoad": "Не удалось загрузить панель управления."
+    "failedToLoad": "Не удалось загрузить панель управления.",
+    "quickRequestService": "Запросить сервис",
+    "quickRequestServiceHint": "Записаться на ремонт или осмотр",
+    "quickViewInvoices": "Ваши счета",
+    "quickViewInvoicesHint": "Счета и история платежей",
+    "quickViewVehicles": "Ваши автомобили",
+    "quickViewVehiclesHint": "История обслуживания и детали",
+    "activeJobs": "Текущие работы"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Счета",
     "title": "Счета",
@@ -57,6 +97,7 @@
     "amount": "Сумма",
     "amount": "Сумма",
     "payment": "Оплата",
     "payment": "Оплата",
     "view": "Просмотр",
     "view": "Просмотр",
+    "download": "Скачать",
     "paid": "оплачено",
     "paid": "оплачено",
     "partial": "частично",
     "partial": "частично",
     "unpaid": "не оплачено"
     "unpaid": "не оплачено"

+ 36 - 2
messages/ru/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Выберите язык приложения.",
     "languageDescription": "Выберите язык приложения.",
     "language": "Язык",
     "language": "Язык",
     "forceCustomerLocale": "Применять этот язык на страницах для клиентов",
     "forceCustomerLocale": "Применять этот язык на страницах для клиентов",
-    "forceCustomerLocaleHint": "Когда включено, клиенты, просматривающие общие счета, предложения и инспекции, будут видеть их на языке вашей мастерской, независимо от настроек их браузера. Когда отключено, клиенты видят страницы на языке, настроенном в их браузере.",
+    "forceCustomerLocaleHint": "Когда включено, портал клиента и общие счета, предложения и инспекции отображаются на языке вашей мастерской, независимо от настроек браузера посетителя. Когда отключено, клиентские страницы используют язык браузера посетителя.",
+    "defaultCountryCode": "Код страны по умолчанию",
+    "defaultCountryCodeHint": "Используется, когда клиенты входят в портал через SMS — добавляется к номерам телефонов без кода страны (вашего или клиента). Формат: + и цифры, например +7.",
+    "defaultCountryCodeInvalid": "Введите корректный код страны, например +7, 7 или 007.",
     "saved": "Настройки локализации сохранены",
     "saved": "Настройки локализации сохранены",
     "saveSettings": "Сохранить настройки"
     "saveSettings": "Сохранить настройки"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Строчные буквы, цифры, дефисы и символы подчёркивания. 3-48 символов. Оставьте пустым для использования URL по умолчанию.",
     "slugHint": "Строчные буквы, цифры, дефисы и символы подчёркивания. 3-48 символов. Оставьте пустым для использования URL по умолчанию.",
     "portalUrl": "URL портала",
     "portalUrl": "URL портала",
     "urlCopied": "URL портала скопирован",
     "urlCopied": "URL портала скопирован",
-    "shareHint": "Поделитесь этой ссылкой с клиентами, чтобы они могли войти в свой портал."
+    "shareHint": "Поделитесь этой ссылкой с клиентами, чтобы они могли войти в свой портал.",
+    "descriptionLabel": "О вашей мастерской",
+    "descriptionPlaceholder": "Расскажите клиентам о своей мастерской, услугах и о том, что делает вас уникальными.",
+    "descriptionHint": "Отображается посетителям в верхней части главной страницы портала.",
+    "hoursLabel": "Часы работы",
+    "hoursPlaceholder": "Пн-Пт: 8:00-17:00\nСб: 9:00-13:00\nВс: Выходной",
+    "hoursHint": "Свободный текст. Используйте переносы строк, чтобы перечислить каждый день отдельно.",
+    "contentSaved": "Содержимое портала сохранено",
+    "background": {
+      "title": "Фон портала",
+      "description": "Выберите внешний вид главной страницы клиентского портала.",
+      "typeNone": "Нет",
+      "typeTemplate": "Шаблон",
+      "typeImage": "Своё изображение",
+      "chooseTemplate": "Выберите шаблон",
+      "uploadLabel": "Фоновое изображение",
+      "upload": "Загрузить изображение",
+      "replace": "Заменить изображение",
+      "remove": "Удалить",
+      "uploadHint": "JPG, PNG или WebP. Макс. 8 МБ. Рекомендуется минимум 1600×900.",
+      "uploading": "Загрузка фона…",
+      "uploaded": "Фон обновлён",
+      "uploadFailed": "Не удалось загрузить фон",
+      "templateNames": {
+        "default": "Сдержанный",
+        "sunrise": "Рассвет",
+        "ocean": "Океан",
+        "forest": "Лес",
+        "slate": "Сланец",
+        "sand": "Песок"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "Провайдер эл. почты",
     "title": "Провайдер эл. почты",

+ 44 - 3
messages/tr/portal.json

@@ -1,12 +1,44 @@
 {
 {
+  "directory": {
+    "title": "Servisinizi bulun",
+    "description": "Müşteri portalına erişmek için aşağıdan servisinizi seçin.",
+    "searchPlaceholder": "Servis ara…",
+    "noWorkshops": "Şu anda kullanılabilir servis yok.",
+    "noResults": "Aramanızla eşleşen servis bulunamadı.",
+    "openPortal": "Portalı aç"
+  },
+  "landing": {
+    "contactTitle": "Bize ulaşın",
+    "aboutTitle": "Hakkımızda",
+    "noContactInfo": "İletişim bilgileri henüz mevcut değil.",
+    "address": "Adres",
+    "phone": "Telefon",
+    "email": "E-posta",
+    "hours": "Çalışma saatleri",
+    "directions": "Yol tarifi al"
+  },
   "login": {
   "login": {
-    "signInTitle": "Müşteri portalınıza giriş yapın",
+    "signInTitle": "Müşteri girişi",
+    "signInSubtitle": "Faturalarınızı, tekliflerinizi ve servis geçmişinizi görmek için giriş yapın.",
     "emailLabel": "E-posta adresi",
     "emailLabel": "E-posta adresi",
     "emailPlaceholder": "siz@ornek.com",
     "emailPlaceholder": "siz@ornek.com",
     "sendLink": "Giriş bağlantısı gönder",
     "sendLink": "Giriş bağlantısı gönder",
     "checkEmail": "E-postanızı kontrol edin",
     "checkEmail": "E-postanızı kontrol edin",
     "checkEmailDescription": "Bu e-posta için bir hesap mevcutsa, bir giriş bağlantısı gönderdik. Bağlantı 15 dakika içinde sona erer.",
     "checkEmailDescription": "Bu e-posta için bir hesap mevcutsa, bir giriş bağlantısı gönderdik. Bağlantı 15 dakika içinde sona erer.",
     "tryDifferentEmail": "Farklı bir e-posta deneyin",
     "tryDifferentEmail": "Farklı bir e-posta deneyin",
+    "tabEmail": "E-posta",
+    "tabSms": "SMS",
+    "phoneLabel": "Telefon numarası",
+    "phonePlaceholder": "+90 5123456789",
+    "phoneLocalPlaceholder": "5123456789",
+    "sendCode": "Kod gönder",
+    "codeSentTitle": "Gönderdiğimiz kodu girin",
+    "codeSentDescription": "Bir hesapla eşleşiyorsa {phone} numarasına 6 haneli bir kod gönderdik.",
+    "codeLabel": "6 haneli kod",
+    "codePlaceholder": "123456",
+    "verifyCode": "Giriş yap",
+    "useDifferentPhone": "Farklı bir numara kullan",
+    "invalidCode": "Geçersiz veya süresi dolmuş kod",
     "notFound": "Bulunamadı",
     "notFound": "Bulunamadı",
     "portalNotExist": "Bu portal mevcut değil.",
     "portalNotExist": "Bu portal mevcut değil.",
     "portalNotAvailable": "Portal Kullanılamıyor",
     "portalNotAvailable": "Portal Kullanılamıyor",
@@ -21,7 +53,8 @@
     "requestService": "Servis İste"
     "requestService": "Servis İste"
   },
   },
   "header": {
   "header": {
-    "signOut": "Çıkış yap"
+    "signOut": "Çıkış yap",
+    "askShort": "Servis"
   },
   },
   "shell": {
   "shell": {
     "notFound": "Bulunamadı",
     "notFound": "Bulunamadı",
@@ -44,7 +77,14 @@
     "noQuotesYet": "Henüz teklif yok.",
     "noQuotesYet": "Henüz teklif yok.",
     "view": "Görüntüle",
     "view": "Görüntüle",
     "pending": "Beklemede",
     "pending": "Beklemede",
-    "failedToLoad": "Gösterge paneli yüklenemedi."
+    "failedToLoad": "Gösterge paneli yüklenemedi.",
+    "quickRequestService": "Servis talep et",
+    "quickRequestServiceHint": "Onarım veya kontrol rezerve edin",
+    "quickViewInvoices": "Faturalarınız",
+    "quickViewInvoicesHint": "Faturalar ve ödeme geçmişi",
+    "quickViewVehicles": "Araçlarınız",
+    "quickViewVehiclesHint": "Servis geçmişi ve detaylar",
+    "activeJobs": "Devam eden işler"
   },
   },
   "invoices": {
   "invoices": {
     "title": "Faturalar",
     "title": "Faturalar",
@@ -57,6 +97,7 @@
     "amount": "Tutar",
     "amount": "Tutar",
     "payment": "Ödeme",
     "payment": "Ödeme",
     "view": "Görüntüle",
     "view": "Görüntüle",
+    "download": "İndir",
     "paid": "ödendi",
     "paid": "ödendi",
     "partial": "kısmi",
     "partial": "kısmi",
     "unpaid": "ödenmedi"
     "unpaid": "ödenmedi"

+ 36 - 2
messages/tr/settings.json

@@ -672,7 +672,10 @@
     "languageDescription": "Uygulamanın görüntüleme dilini seçin.",
     "languageDescription": "Uygulamanın görüntüleme dilini seçin.",
     "language": "Dil",
     "language": "Dil",
     "forceCustomerLocale": "Bu dili müşteriye yönelik sayfalarda zorla",
     "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.",
+    "forceCustomerLocaleHint": "Etkinleştirildiğinde, müşteri portalı ve paylaşılan faturalar, teklifler ve incelemeler, ziyaretçinin tarayıcı ayarlarından bağımsız olarak atölyenizin dilinde görüntülenir. Devre dışı bırakıldığında, müşteriye yönelik sayfalar ziyaretçinin tarayıcı dilini kullanır.",
+    "defaultCountryCode": "Varsayılan ülke kodu",
+    "defaultCountryCodeHint": "Müşteriler portala SMS ile giriş yaptığında kullanılır — ülke kodu olmayan telefon numaralarına (sizin veya müşterinin) eklenir. Format: + ve rakamlar, örn. +90.",
+    "defaultCountryCodeInvalid": "Geçerli bir ülke kodu girin, örn. +90, 90 veya 0090.",
     "saved": "Yerelleştirme ayarları kaydedildi",
     "saved": "Yerelleştirme ayarları kaydedildi",
     "saveSettings": "Ayarları kaydet"
     "saveSettings": "Ayarları kaydet"
   },
   },
@@ -914,7 +917,38 @@
     "slugHint": "Küçük harfler, sayılar, tireler ve alt çizgiler. 3-48 karakter. Varsayılan URL'yi kullanmak için boş bırakın.",
     "slugHint": "Küçük harfler, sayılar, tireler ve alt çizgiler. 3-48 karakter. Varsayılan URL'yi kullanmak için boş bırakın.",
     "portalUrl": "Portal URL'si",
     "portalUrl": "Portal URL'si",
     "urlCopied": "Portal URL'si kopyalandı",
     "urlCopied": "Portal URL'si kopyalandı",
-    "shareHint": "Bu bağlantıyı müşterilerinizle paylaşarak portallarına giriş yapmalarını sağlayın."
+    "shareHint": "Bu bağlantıyı müşterilerinizle paylaşarak portallarına giriş yapmalarını sağlayın.",
+    "descriptionLabel": "Servisiniz hakkında",
+    "descriptionPlaceholder": "Müşterilerinize servisinizi, sunduğunuz hizmetleri ve sizi özel kılan şeyi anlatın.",
+    "descriptionHint": "Ziyaretçilere portal ana sayfanızın üst kısmında gösterilir.",
+    "hoursLabel": "Çalışma saatleri",
+    "hoursPlaceholder": "Pzt-Cum: 8:00-17:00\nCmt: 9:00-13:00\nPaz: Kapalı",
+    "hoursHint": "Serbest metin. Her günü ayrı listelemek için satır sonu kullanın.",
+    "contentSaved": "Portal içeriği kaydedildi",
+    "background": {
+      "title": "Portal arka planı",
+      "description": "Müşteri portalı ana sayfasının nasıl görüneceğini seçin.",
+      "typeNone": "Yok",
+      "typeTemplate": "Şablon",
+      "typeImage": "Özel görsel",
+      "chooseTemplate": "Bir şablon seçin",
+      "uploadLabel": "Arka plan görseli",
+      "upload": "Görsel yükle",
+      "replace": "Görseli değiştir",
+      "remove": "Kaldır",
+      "uploadHint": "JPG, PNG veya WebP. Maks 8 MB. En az 1600×900 önerilir.",
+      "uploading": "Arka plan yükleniyor…",
+      "uploaded": "Arka plan güncellendi",
+      "uploadFailed": "Arka plan yüklenemedi",
+      "templateNames": {
+        "default": "Sade",
+        "sunrise": "Gün doğumu",
+        "ocean": "Okyanus",
+        "forest": "Orman",
+        "slate": "Arduvaz",
+        "sand": "Kum"
+      }
+    }
   },
   },
   "email": {
   "email": {
     "title": "E-posta Sağlayıcısı",
     "title": "E-posta Sağlayıcısı",

+ 18 - 0
prisma/migrations/20260411182756_sms_customer_login/migration.sql

@@ -0,0 +1,18 @@
+-- CreateTable
+CREATE TABLE "customer_sms_codes" (
+    "id" TEXT NOT NULL,
+    "code" TEXT NOT NULL,
+    "phone" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "expiresAt" TIMESTAMP(3) NOT NULL,
+    "usedAt" TIMESTAMP(3),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "customer_sms_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "customer_sms_codes_phone_organizationId_idx" ON "customer_sms_codes"("phone", "organizationId");
+
+-- CreateIndex
+CREATE INDEX "customer_sms_codes_organizationId_idx" ON "customer_sms_codes"("organizationId");

+ 14 - 0
prisma/schema.prisma

@@ -1079,6 +1079,20 @@ model CustomerMagicLink {
   @@map("customer_magic_links")
   @@map("customer_magic_links")
 }
 }
 
 
+model CustomerSmsCode {
+  id             String    @id @default(cuid())
+  code           String
+  phone          String
+  organizationId String
+  expiresAt      DateTime
+  usedAt         DateTime?
+  createdAt      DateTime  @default(now())
+
+  @@index([phone, organizationId])
+  @@index([organizationId])
+  @@map("customer_sms_codes")
+}
+
 model ServiceRequest {
 model ServiceRequest {
   id            String    @id @default(cuid())
   id            String    @id @default(cuid())
   description   String
   description   String

+ 45 - 23
src/app/(authenticated)/settings/customer-portal/page.tsx

@@ -1,18 +1,22 @@
-import { getLayoutData } from "@/lib/get-layout-data";
-import { getFeatures, isCloudMode } from "@/lib/features";
-import { FeatureLockedMessage } from "../feature-locked-message";
-import { CustomerPortalSettings } from "@/features/portal/Components/CustomerPortalSettings";
-import { redirect } from "next/navigation";
-import { db } from "@/lib/db";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getFeatures, isCloudMode } from '@/lib/features'
+import { FeatureLockedMessage } from '../feature-locked-message'
+import { CustomerPortalSettings } from '@/features/portal/Components/CustomerPortalSettings'
+import { redirect } from 'next/navigation'
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  isValidPortalBackgroundType,
+  type PortalBackgroundType,
+} from '@/features/portal/portal-backgrounds'
 
 
 export default async function CustomerPortalSettingsPage() {
 export default async function CustomerPortalSettingsPage() {
-  const data = await getLayoutData();
+  const data = await getLayoutData()
 
 
-  if (data.status === "unauthenticated") redirect("/auth/sign-in");
-  if (data.status === "no-organization") redirect("/onboarding");
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
 
 
-  const features = await getFeatures(data.organizationId);
+  const features = await getFeatures(data.organizationId)
 
 
   if (!features.customerPortal) {
   if (!features.customerPortal) {
     return (
     return (
@@ -21,36 +25,54 @@ export default async function CustomerPortalSettingsPage() {
         description="Give your customers a self-service portal to view invoices, quotes, inspections, and request service."
         description="Give your customers a self-service portal to view invoices, quotes, inspections, and request service."
         isCloud={isCloudMode()}
         isCloud={isCloudMode()}
       />
       />
-    );
+    )
   }
   }
 
 
-  const [setting, org] = await Promise.all([
-    db.appSetting.findUnique({
+  const [settings, org] = await Promise.all([
+    db.appSetting.findMany({
       where: {
       where: {
-        organizationId_key: {
-          organizationId: data.organizationId,
-          key: SETTING_KEYS.PORTAL_ENABLED,
+        organizationId: data.organizationId,
+        key: {
+          in: [
+            SETTING_KEYS.PORTAL_ENABLED,
+            SETTING_KEYS.PORTAL_DESCRIPTION,
+            SETTING_KEYS.PORTAL_HOURS,
+            SETTING_KEYS.PORTAL_BACKGROUND_TYPE,
+            SETTING_KEYS.PORTAL_BACKGROUND_TEMPLATE,
+            SETTING_KEYS.PORTAL_BACKGROUND_IMAGE,
+          ],
         },
         },
       },
       },
+      select: { key: true, value: true },
     }),
     }),
     db.organization.findUnique({
     db.organization.findUnique({
       where: { id: data.organizationId },
       where: { id: data.organizationId },
       select: { portalSlug: true },
       select: { portalSlug: true },
     }),
     }),
-  ]);
+  ])
+
+  const settingMap = new Map(settings.map((s) => [s.key, s.value]))
 
 
   const appUrl =
   const appUrl =
     process.env.NEXT_PUBLIC_APP_URL ||
     process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL
-      ? `https://${process.env.VERCEL_URL}`
-      : "http://localhost:3000");
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+
+  const rawBgType = settingMap.get(SETTING_KEYS.PORTAL_BACKGROUND_TYPE)
+  const backgroundType: PortalBackgroundType = isValidPortalBackgroundType(rawBgType)
+    ? rawBgType
+    : 'none'
 
 
   return (
   return (
     <CustomerPortalSettings
     <CustomerPortalSettings
-      enabled={setting?.value === "true"}
+      enabled={settingMap.get(SETTING_KEYS.PORTAL_ENABLED) === 'true'}
       orgId={data.organizationId}
       orgId={data.organizationId}
       portalSlug={org?.portalSlug ?? null}
       portalSlug={org?.portalSlug ?? null}
       appUrl={appUrl}
       appUrl={appUrl}
+      description={settingMap.get(SETTING_KEYS.PORTAL_DESCRIPTION) ?? ''}
+      hours={settingMap.get(SETTING_KEYS.PORTAL_HOURS) ?? ''}
+      backgroundType={backgroundType}
+      backgroundTemplate={settingMap.get(SETTING_KEYS.PORTAL_BACKGROUND_TEMPLATE) ?? ''}
+      backgroundImage={settingMap.get(SETTING_KEYS.PORTAL_BACKGROUND_IMAGE) ?? ''}
     />
     />
-  );
+  )
 }
 }

+ 331 - 205
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -1,185 +1,238 @@
-"use client";
+'use client'
 
 
-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";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { toast } from "sonner";
-import { setSetting, setSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+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'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import { toast } from 'sonner'
+import { setSetting, setSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { normalizeCountryCode } from '@/lib/portal-phone'
+import { Input } from '@/components/ui/input'
 import {
 import {
   Select,
   Select,
   SelectContent,
   SelectContent,
   SelectItem,
   SelectItem,
   SelectTrigger,
   SelectTrigger,
   SelectValue,
   SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
-import { Calendar, Check, ChevronDown, Clock, Coins, Globe, Loader2, Moon, Palette, Save, Sun } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { formatCurrency, formatDate, formatDateTime, DEFAULT_DATE_FORMAT } from "@/lib/format";
-import { useTheme } from "@/components/theme-provider";
-import { setLocale } from "@/i18n/actions";
-import { locales, localeNames } from "@/i18n/config";
-import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
+} from '@/components/ui/select'
+import { Switch } from '@/components/ui/switch'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Command,
+  CommandInput,
+  CommandList,
+  CommandEmpty,
+  CommandGroup,
+  CommandItem,
+} from '@/components/ui/command'
+import {
+  Calendar,
+  Check,
+  ChevronDown,
+  Clock,
+  Coins,
+  Globe,
+  Loader2,
+  Moon,
+  Palette,
+  Save,
+  Sun,
+} from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { formatCurrency, formatDate, formatDateTime, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import { useTheme } from '@/components/theme-provider'
+import { setLocale } from '@/i18n/actions'
+import { locales, localeNames } from '@/i18n/config'
+import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 
 
 const CURRENCIES = [
 const CURRENCIES = [
-  { code: "USD", key: "USD" },
-  { code: "EUR", key: "EUR" },
-  { code: "GBP", key: "GBP" },
-  { code: "NOK", key: "NOK" },
-  { code: "SEK", key: "SEK" },
-  { code: "DKK", key: "DKK" },
-  { code: "CHF", key: "CHF" },
-  { code: "CAD", key: "CAD" },
-  { code: "AUD", key: "AUD" },
-  { code: "NZD", key: "NZD" },
-  { code: "JPY", key: "JPY" },
-  { code: "CNY", key: "CNY" },
-  { code: "INR", key: "INR" },
-  { code: "BRL", key: "BRL" },
-  { code: "MXN", key: "MXN" },
-  { code: "PLN", key: "PLN" },
-  { code: "CZK", key: "CZK" },
-  { code: "HUF", key: "HUF" },
-  { code: "TRY", key: "TRY" },
-  { code: "ZAR", key: "ZAR" },
-  { code: "KRW", key: "KRW" },
-  { code: "SGD", key: "SGD" },
-  { code: "HKD", key: "HKD" },
-  { code: "THB", key: "THB" },
-  { code: "ISK", key: "ISK" },
-  { code: "RON", key: "RON" },
-  { code: "ILS", key: "ILS" },
-  { code: "PHP", key: "PHP" },
-  { code: "IDR", key: "IDR" },
-  { code: "MYR", key: "MYR" },
-  { code: "AED", key: "AED" },
-  { code: "SAR", key: "SAR" },
-  { code: "RUB", key: "RUB" },
-];
+  { code: 'USD', key: 'USD' },
+  { code: 'EUR', key: 'EUR' },
+  { code: 'GBP', key: 'GBP' },
+  { code: 'NOK', key: 'NOK' },
+  { code: 'SEK', key: 'SEK' },
+  { code: 'DKK', key: 'DKK' },
+  { code: 'CHF', key: 'CHF' },
+  { code: 'CAD', key: 'CAD' },
+  { code: 'AUD', key: 'AUD' },
+  { code: 'NZD', key: 'NZD' },
+  { code: 'JPY', key: 'JPY' },
+  { code: 'CNY', key: 'CNY' },
+  { code: 'INR', key: 'INR' },
+  { code: 'BRL', key: 'BRL' },
+  { code: 'MXN', key: 'MXN' },
+  { code: 'PLN', key: 'PLN' },
+  { code: 'CZK', key: 'CZK' },
+  { code: 'HUF', key: 'HUF' },
+  { code: 'TRY', key: 'TRY' },
+  { code: 'ZAR', key: 'ZAR' },
+  { code: 'KRW', key: 'KRW' },
+  { code: 'SGD', key: 'SGD' },
+  { code: 'HKD', key: 'HKD' },
+  { code: 'THB', key: 'THB' },
+  { code: 'ISK', key: 'ISK' },
+  { code: 'RON', key: 'RON' },
+  { code: 'ILS', key: 'ILS' },
+  { code: 'PHP', key: 'PHP' },
+  { code: 'IDR', key: 'IDR' },
+  { code: 'MYR', key: 'MYR' },
+  { code: 'AED', key: 'AED' },
+  { code: 'SAR', key: 'SAR' },
+  { code: 'RUB', key: 'RUB' },
+]
 
 
 const DATE_FORMAT_OPTIONS = [
 const DATE_FORMAT_OPTIONS = [
-  { value: "MMM d, yyyy", label: "MMM D, YYYY", example: "Feb 15, 2026" },
-  { value: "dd/MM/yyyy", label: "DD/MM/YYYY", example: "15/02/2026" },
-  { value: "MM/dd/yyyy", label: "MM/DD/YYYY", example: "02/15/2026" },
-  { value: "yyyy-MM-dd", label: "YYYY-MM-DD", example: "2026-02-15" },
-  { value: "d. MMM yyyy", label: "D. MMM YYYY", example: "15. Feb 2026" },
-  { value: "d MMMM yyyy", label: "D MMMM YYYY", example: "15 February 2026" },
-];
+  { value: 'MMM d, yyyy', label: 'MMM D, YYYY', example: 'Feb 15, 2026' },
+  { value: 'dd/MM/yyyy', label: 'DD/MM/YYYY', example: '15/02/2026' },
+  { value: 'MM/dd/yyyy', label: 'MM/DD/YYYY', example: '02/15/2026' },
+  { value: 'yyyy-MM-dd', label: 'YYYY-MM-DD', example: '2026-02-15' },
+  { value: 'd. MMM yyyy', label: 'D. MMM YYYY', example: '15. Feb 2026' },
+  { value: 'd MMMM yyyy', label: 'D MMMM YYYY', example: '15 February 2026' },
+]
 
 
 const TIMEZONE_OPTIONS = [
 const TIMEZONE_OPTIONS = [
-  "UTC",
-  "America/New_York",
-  "America/Chicago",
-  "America/Denver",
-  "America/Los_Angeles",
-  "America/Anchorage",
-  "Pacific/Honolulu",
-  "America/Toronto",
-  "America/Vancouver",
-  "America/Mexico_City",
-  "America/Sao_Paulo",
-  "America/Argentina/Buenos_Aires",
-  "Europe/London",
-  "Europe/Paris",
-  "Europe/Berlin",
-  "Europe/Madrid",
-  "Europe/Rome",
-  "Europe/Amsterdam",
-  "Europe/Brussels",
-  "Europe/Zurich",
-  "Europe/Vienna",
-  "Europe/Stockholm",
-  "Europe/Oslo",
-  "Europe/Copenhagen",
-  "Europe/Helsinki",
-  "Europe/Warsaw",
-  "Europe/Prague",
-  "Europe/Budapest",
-  "Europe/Bucharest",
-  "Europe/Athens",
-  "Europe/Istanbul",
-  "Europe/Moscow",
-  "Asia/Dubai",
-  "Asia/Kolkata",
-  "Asia/Bangkok",
-  "Asia/Singapore",
-  "Asia/Hong_Kong",
-  "Asia/Shanghai",
-  "Asia/Tokyo",
-  "Asia/Seoul",
-  "Australia/Sydney",
-  "Australia/Melbourne",
-  "Australia/Perth",
-  "Pacific/Auckland",
-  "Africa/Johannesburg",
-  "Africa/Cairo",
-  "Africa/Lagos",
-];
+  'UTC',
+  'America/New_York',
+  'America/Chicago',
+  'America/Denver',
+  'America/Los_Angeles',
+  'America/Anchorage',
+  'Pacific/Honolulu',
+  'America/Toronto',
+  'America/Vancouver',
+  'America/Mexico_City',
+  'America/Sao_Paulo',
+  'America/Argentina/Buenos_Aires',
+  'Europe/London',
+  'Europe/Paris',
+  'Europe/Berlin',
+  'Europe/Madrid',
+  'Europe/Rome',
+  'Europe/Amsterdam',
+  'Europe/Brussels',
+  'Europe/Zurich',
+  'Europe/Vienna',
+  'Europe/Stockholm',
+  'Europe/Oslo',
+  'Europe/Copenhagen',
+  'Europe/Helsinki',
+  'Europe/Warsaw',
+  'Europe/Prague',
+  'Europe/Budapest',
+  'Europe/Bucharest',
+  'Europe/Athens',
+  'Europe/Istanbul',
+  'Europe/Moscow',
+  'Asia/Dubai',
+  'Asia/Kolkata',
+  'Asia/Bangkok',
+  'Asia/Singapore',
+  'Asia/Hong_Kong',
+  'Asia/Shanghai',
+  'Asia/Tokyo',
+  'Asia/Seoul',
+  'Australia/Sydney',
+  'Australia/Melbourne',
+  'Australia/Perth',
+  'Pacific/Auckland',
+  'Africa/Johannesburg',
+  'Africa/Cairo',
+  'Africa/Lagos',
+]
 
 
 export function LocalizationSettings({ settings }: { settings: Record<string, string> }) {
 export function LocalizationSettings({ settings }: { settings: Record<string, string> }) {
-  const router = useRouter();
-  const t = useTranslations("settings");
-  const { theme, setTheme } = useTheme();
-  const currentLocale = useLocale();
-  const [saving, setSaving] = useState(false);
-  const [currencyOpen, setCurrencyOpen] = useState(false);
-  const [timezoneOpen, setTimezoneOpen] = useState(false);
+  const router = useRouter()
+  const t = useTranslations('settings')
+  const { theme, setTheme } = useTheme()
+  const currentLocale = useLocale()
+  const [saving, setSaving] = useState(false)
+  const [currencyOpen, setCurrencyOpen] = useState(false)
+  const [timezoneOpen, setTimezoneOpen] = useState(false)
 
 
   // Currency
   // Currency
-  const [currencyCode, setCurrencyCode] = useState(settings[SETTING_KEYS.CURRENCY_CODE] || "USD");
+  const [currencyCode, setCurrencyCode] = useState(settings[SETTING_KEYS.CURRENCY_CODE] || 'USD')
 
 
   // Date & Time
   // Date & Time
-  const [dateFormat, setDateFormat] = useState(settings[SETTING_KEYS.DATE_FORMAT] || DEFAULT_DATE_FORMAT);
-  const [timeFormat, setTimeFormat] = useState(settings[SETTING_KEYS.TIME_FORMAT] || "12h");
-  const [timezone, setTimezone] = useState(settings[SETTING_KEYS.TIMEZONE] || "");
+  const [dateFormat, setDateFormat] = useState(
+    settings[SETTING_KEYS.DATE_FORMAT] || DEFAULT_DATE_FORMAT
+  )
+  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'
+  )
+
+  // Default country code (E.164 prefix like "+47") used when normalizing
+  // customer phone numbers in the portal SMS sign-in flow.
+  const [defaultCountryCode, setDefaultCountryCode] = useState(
+    settings[SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE] || ''
+  )
 
 
-  const [forceCustomerLocale, setForceCustomerLocale] = useState(settings[SETTING_KEYS.FORCE_CUSTOMER_LOCALE] === "true");
+  // The setting is invalid when the user has typed something but it can't
+  // be normalized into a real country code (covers junk like "0", "00",
+  // "abc", "12345", etc.). Empty string is allowed (means "no default").
+  const trimmedCountryCode = defaultCountryCode.trim()
+  const countryCodeInvalid =
+    trimmedCountryCode !== '' && normalizeCountryCode(trimmedCountryCode) === null
+
+  const handleCountryCodeBlur = () => {
+    // Normalize on blur so the user sees the canonical "+47" form right
+    // after typing things like "0047" or "47".
+    const normalized = normalizeCountryCode(defaultCountryCode)
+    if (normalized) setDefaultCountryCode(normalized)
+  }
 
 
   // Theme (localStorage) and new Date() (runtime TZ) are client-only;
   // Theme (localStorage) and new Date() (runtime TZ) are client-only;
   // defer rendering until after hydration to avoid SSR/client mismatches.
   // defer rendering until after hydration to avoid SSR/client mismatches.
-  const [mounted, setMounted] = useState(false);
+  const [mounted, setMounted] = useState(false)
   useEffect(() => {
   useEffect(() => {
-    setMounted(true);
-  }, []);
+    setMounted(true)
+  }, [])
 
 
   const handleSave = async () => {
   const handleSave = async () => {
-    setSaving(true);
+    // Guard against invalid country codes — don't persist garbage that
+    // would silently break the portal SMS flow.
+    const normalizedCountryCode = trimmedCountryCode ? normalizeCountryCode(trimmedCountryCode) : ''
+    if (trimmedCountryCode && normalizedCountryCode === null) {
+      toast.error(t('localization.defaultCountryCodeInvalid'))
+      return
+    }
+
+    setSaving(true)
     await setSettings({
     await setSettings({
       [SETTING_KEYS.CURRENCY_CODE]: currencyCode,
       [SETTING_KEYS.CURRENCY_CODE]: currencyCode,
       [SETTING_KEYS.DATE_FORMAT]: dateFormat,
       [SETTING_KEYS.DATE_FORMAT]: dateFormat,
       [SETTING_KEYS.TIME_FORMAT]: timeFormat,
       [SETTING_KEYS.TIME_FORMAT]: timeFormat,
       [SETTING_KEYS.TIMEZONE]: timezone,
       [SETTING_KEYS.TIMEZONE]: timezone,
-    });
-    setSaving(false);
-    router.refresh();
-    toast.success(t("localization.saved"));
-  };
+      [SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE]: normalizedCountryCode ?? '',
+    })
+    // Reflect the normalized form back in the input.
+    if (normalizedCountryCode) setDefaultCountryCode(normalizedCountryCode)
+    setSaving(false)
+    router.refresh()
+    toast.success(t('localization.saved'))
+  }
 
 
   const handleLanguageChange = async (value: string) => {
   const handleLanguageChange = async (value: string) => {
-    await Promise.all([
-      setLocale(value),
-      setSetting(SETTING_KEYS.WORKSHOP_LOCALE, value),
-    ]);
-    router.refresh();
-  };
+    await Promise.all([setLocale(value), setSetting(SETTING_KEYS.WORKSHOP_LOCALE, value)])
+    router.refresh()
+  }
 
 
   const handleForceCustomerLocaleChange = async (value: boolean) => {
   const handleForceCustomerLocaleChange = async (value: boolean) => {
-    setForceCustomerLocale(value);
+    setForceCustomerLocale(value)
     // Snapshot the admin's current locale so force mode always has a target,
     // Snapshot the admin's current locale so force mode always has a target,
     // even on existing installs where workshop.locale was never written.
     // even on existing installs where workshop.locale was never written.
     await setSettings({
     await setSettings({
       [SETTING_KEYS.FORCE_CUSTOMER_LOCALE]: String(value),
       [SETTING_KEYS.FORCE_CUSTOMER_LOCALE]: String(value),
       [SETTING_KEYS.WORKSHOP_LOCALE]: currentLocale,
       [SETTING_KEYS.WORKSHOP_LOCALE]: currentLocale,
-    });
-    router.refresh();
-  };
+    })
+    router.refresh()
+  }
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
@@ -189,18 +242,13 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       <Card className="border-0 shadow-sm">
       <Card className="border-0 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
           <Globe className="h-5 w-5 text-muted-foreground" />
           <Globe className="h-5 w-5 text-muted-foreground" />
-          <CardTitle className="text-lg">{t("localization.languageTitle")}</CardTitle>
+          <CardTitle className="text-lg">{t('localization.languageTitle')}</CardTitle>
         </CardHeader>
         </CardHeader>
         <CardContent className="space-y-4">
         <CardContent className="space-y-4">
-          <p className="text-sm text-muted-foreground">
-            {t("localization.languageDescription")}
-          </p>
+          <p className="text-sm text-muted-foreground">{t('localization.languageDescription')}</p>
           <div className="space-y-2">
           <div className="space-y-2">
-            <Label>{t("localization.language")}</Label>
-            <Select
-              value={currentLocale}
-              onValueChange={handleLanguageChange}
-            >
+            <Label>{t('localization.language')}</Label>
+            <Select value={currentLocale} onValueChange={handleLanguageChange}>
               <SelectTrigger className="w-64">
               <SelectTrigger className="w-64">
                 <SelectValue />
                 <SelectValue />
               </SelectTrigger>
               </SelectTrigger>
@@ -218,13 +266,46 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
 
 
           <div className="flex items-center justify-between">
           <div className="flex items-center justify-between">
             <div className="pr-4">
             <div className="pr-4">
-              <Label>{t("localization.forceCustomerLocale")}</Label>
+              <Label>{t('localization.forceCustomerLocale')}</Label>
               <p className="text-xs text-muted-foreground">
               <p className="text-xs text-muted-foreground">
-                {t("localization.forceCustomerLocaleHint")}
+                {t('localization.forceCustomerLocaleHint')}
               </p>
               </p>
             </div>
             </div>
-            <Switch checked={forceCustomerLocale} onCheckedChange={handleForceCustomerLocaleChange} />
+            <Switch
+              checked={forceCustomerLocale}
+              onCheckedChange={handleForceCustomerLocaleChange}
+            />
           </div>
           </div>
+
+          <Separator />
+
+          <ReadOnlyWrapper>
+            <div className="space-y-2">
+              <Label htmlFor="default-country-code">{t('localization.defaultCountryCode')}</Label>
+              <Input
+                id="default-country-code"
+                value={defaultCountryCode}
+                onChange={(e) => setDefaultCountryCode(e.target.value)}
+                onBlur={handleCountryCodeBlur}
+                placeholder="+47"
+                inputMode="tel"
+                aria-invalid={countryCodeInvalid || undefined}
+                className={cn(
+                  'w-32 font-mono',
+                  countryCodeInvalid && 'border-destructive focus-visible:ring-destructive'
+                )}
+              />
+              {countryCodeInvalid ? (
+                <p className="text-xs text-destructive">
+                  {t('localization.defaultCountryCodeInvalid')}
+                </p>
+              ) : (
+                <p className="text-xs text-muted-foreground">
+                  {t('localization.defaultCountryCodeHint')}
+                </p>
+              )}
+            </div>
+          </ReadOnlyWrapper>
         </CardContent>
         </CardContent>
       </Card>
       </Card>
 
 
@@ -232,38 +313,49 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       <Card className="border-0 shadow-sm">
       <Card className="border-0 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
           <Coins className="h-5 w-5 text-muted-foreground" />
           <Coins className="h-5 w-5 text-muted-foreground" />
-          <CardTitle className="text-lg">{t("currency.title")}</CardTitle>
+          <CardTitle className="text-lg">{t('currency.title')}</CardTitle>
         </CardHeader>
         </CardHeader>
         <CardContent className="space-y-6">
         <CardContent className="space-y-6">
-          <p className="text-sm text-muted-foreground">
-            {t("currency.description")}
-          </p>
+          <p className="text-sm text-muted-foreground">{t('currency.description')}</p>
 
 
           <ReadOnlyWrapper>
           <ReadOnlyWrapper>
             <div className="space-y-6">
             <div className="space-y-6">
               <div className="space-y-2">
               <div className="space-y-2">
-                <Label>{t("currency.currencyLabel")}</Label>
+                <Label>{t('currency.currencyLabel')}</Label>
                 <Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
                 <Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
                   <PopoverTrigger asChild>
                   <PopoverTrigger asChild>
-                    <Button variant="outline" role="combobox" aria-expanded={currencyOpen} className="w-64 justify-between font-normal">
-                      {currencyCode} &mdash; {t("currency.currencies." + currencyCode)}
+                    <Button
+                      variant="outline"
+                      role="combobox"
+                      aria-expanded={currencyOpen}
+                      className="w-64 justify-between font-normal"
+                    >
+                      {currencyCode} &mdash; {t('currency.currencies.' + currencyCode)}
                       <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                       <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                     </Button>
                     </Button>
                   </PopoverTrigger>
                   </PopoverTrigger>
                   <PopoverContent className="w-64 p-0" align="start">
                   <PopoverContent className="w-64 p-0" align="start">
                     <Command>
                     <Command>
-                      <CommandInput placeholder={t("currency.searchCurrency")} />
+                      <CommandInput placeholder={t('currency.searchCurrency')} />
                       <CommandList>
                       <CommandList>
-                        <CommandEmpty>{t("currency.noCurrencyFound")}</CommandEmpty>
+                        <CommandEmpty>{t('currency.noCurrencyFound')}</CommandEmpty>
                         <CommandGroup>
                         <CommandGroup>
                           {CURRENCIES.map((c) => (
                           {CURRENCIES.map((c) => (
                             <CommandItem
                             <CommandItem
                               key={c.code}
                               key={c.code}
-                              value={`${c.code} ${t("currency.currencies." + c.code)}`}
-                              onSelect={() => { setCurrencyCode(c.code); setCurrencyOpen(false); }}
+                              value={`${c.code} ${t('currency.currencies.' + c.code)}`}
+                              onSelect={() => {
+                                setCurrencyCode(c.code)
+                                setCurrencyOpen(false)
+                              }}
                             >
                             >
-                              <Check className={cn("mr-2 h-4 w-4", currencyCode === c.code ? "opacity-100" : "opacity-0")} />
-                              {c.code} &mdash; {t("currency.currencies." + c.code)}
+                              <Check
+                                className={cn(
+                                  'mr-2 h-4 w-4',
+                                  currencyCode === c.code ? 'opacity-100' : 'opacity-0'
+                                )}
+                              />
+                              {c.code} &mdash; {t('currency.currencies.' + c.code)}
                             </CommandItem>
                             </CommandItem>
                           ))}
                           ))}
                         </CommandGroup>
                         </CommandGroup>
@@ -272,7 +364,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
                   </PopoverContent>
                   </PopoverContent>
                 </Popover>
                 </Popover>
                 <p className="text-xs text-muted-foreground">
                 <p className="text-xs text-muted-foreground">
-                  {t("currency.previewLabel", { value: formatCurrency(1234.56, currencyCode) })}
+                  {t('currency.previewLabel', { value: formatCurrency(1234.56, currencyCode) })}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
@@ -284,17 +376,15 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       <Card className="border-0 shadow-sm">
       <Card className="border-0 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
           <Calendar className="h-5 w-5 text-muted-foreground" />
           <Calendar className="h-5 w-5 text-muted-foreground" />
-          <CardTitle className="text-lg">{t("appearance.dateTimeTitle")}</CardTitle>
+          <CardTitle className="text-lg">{t('appearance.dateTimeTitle')}</CardTitle>
         </CardHeader>
         </CardHeader>
         <CardContent className="space-y-6">
         <CardContent className="space-y-6">
-          <p className="text-sm text-muted-foreground">
-            {t("appearance.dateTimeDescription")}
-          </p>
+          <p className="text-sm text-muted-foreground">{t('appearance.dateTimeDescription')}</p>
 
 
           <ReadOnlyWrapper>
           <ReadOnlyWrapper>
             <div className="space-y-6">
             <div className="space-y-6">
               <div className="space-y-2">
               <div className="space-y-2">
-                <Label>{t("appearance.dateFormat")}</Label>
+                <Label>{t('appearance.dateFormat')}</Label>
                 <Select value={dateFormat} onValueChange={setDateFormat}>
                 <Select value={dateFormat} onValueChange={setDateFormat}>
                   <SelectTrigger className="w-64">
                   <SelectTrigger className="w-64">
                     <SelectValue />
                     <SelectValue />
@@ -310,48 +400,69 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
               </div>
               </div>
 
 
               <div className="space-y-2">
               <div className="space-y-2">
-                <Label>{t("appearance.timeFormat")}</Label>
+                <Label>{t('appearance.timeFormat')}</Label>
                 <Select value={timeFormat} onValueChange={setTimeFormat}>
                 <Select value={timeFormat} onValueChange={setTimeFormat}>
                   <SelectTrigger className="w-48">
                   <SelectTrigger className="w-48">
                     <SelectValue />
                     <SelectValue />
                   </SelectTrigger>
                   </SelectTrigger>
                   <SelectContent>
                   <SelectContent>
-                    <SelectItem value="12h">{t("appearance.time12h")}</SelectItem>
-                    <SelectItem value="24h">{t("appearance.time24h")}</SelectItem>
+                    <SelectItem value="12h">{t('appearance.time12h')}</SelectItem>
+                    <SelectItem value="24h">{t('appearance.time24h')}</SelectItem>
                   </SelectContent>
                   </SelectContent>
                 </Select>
                 </Select>
               </div>
               </div>
 
 
               <div className="space-y-2">
               <div className="space-y-2">
-                <Label>{t("appearance.timezone")}</Label>
+                <Label>{t('appearance.timezone')}</Label>
                 <Popover open={timezoneOpen} onOpenChange={setTimezoneOpen}>
                 <Popover open={timezoneOpen} onOpenChange={setTimezoneOpen}>
                   <PopoverTrigger asChild>
                   <PopoverTrigger asChild>
-                    <Button variant="outline" role="combobox" aria-expanded={timezoneOpen} className="w-72 justify-between font-normal">
-                      {timezone ? timezone.replace(/_/g, " ") : t("appearance.timezoneAuto")}
+                    <Button
+                      variant="outline"
+                      role="combobox"
+                      aria-expanded={timezoneOpen}
+                      className="w-72 justify-between font-normal"
+                    >
+                      {timezone ? timezone.replace(/_/g, ' ') : t('appearance.timezoneAuto')}
                       <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                       <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                     </Button>
                     </Button>
                   </PopoverTrigger>
                   </PopoverTrigger>
                   <PopoverContent className="w-72 p-0" align="start">
                   <PopoverContent className="w-72 p-0" align="start">
                     <Command>
                     <Command>
-                      <CommandInput placeholder={t("appearance.searchTimezone")} />
+                      <CommandInput placeholder={t('appearance.searchTimezone')} />
                       <CommandList>
                       <CommandList>
-                        <CommandEmpty>{t("appearance.noTimezoneFound")}</CommandEmpty>
+                        <CommandEmpty>{t('appearance.noTimezoneFound')}</CommandEmpty>
                         <CommandGroup>
                         <CommandGroup>
                           <CommandItem
                           <CommandItem
-                            value={t("appearance.timezoneAuto")}
-                            onSelect={() => { setTimezone(""); setTimezoneOpen(false); }}
+                            value={t('appearance.timezoneAuto')}
+                            onSelect={() => {
+                              setTimezone('')
+                              setTimezoneOpen(false)
+                            }}
                           >
                           >
-                            <Check className={cn("mr-2 h-4 w-4", !timezone ? "opacity-100" : "opacity-0")} />
-                            {t("appearance.timezoneAuto")}
+                            <Check
+                              className={cn(
+                                'mr-2 h-4 w-4',
+                                !timezone ? 'opacity-100' : 'opacity-0'
+                              )}
+                            />
+                            {t('appearance.timezoneAuto')}
                           </CommandItem>
                           </CommandItem>
                           {TIMEZONE_OPTIONS.map((tz) => (
                           {TIMEZONE_OPTIONS.map((tz) => (
                             <CommandItem
                             <CommandItem
                               key={tz}
                               key={tz}
-                              value={tz.replace(/_/g, " ")}
-                              onSelect={() => { setTimezone(tz); setTimezoneOpen(false); }}
+                              value={tz.replace(/_/g, ' ')}
+                              onSelect={() => {
+                                setTimezone(tz)
+                                setTimezoneOpen(false)
+                              }}
                             >
                             >
-                              <Check className={cn("mr-2 h-4 w-4", timezone === tz ? "opacity-100" : "opacity-0")} />
-                              {tz.replace(/_/g, " ")}
+                              <Check
+                                className={cn(
+                                  'mr-2 h-4 w-4',
+                                  timezone === tz ? 'opacity-100' : 'opacity-0'
+                                )}
+                              />
+                              {tz.replace(/_/g, ' ')}
                             </CommandItem>
                             </CommandItem>
                           ))}
                           ))}
                         </CommandGroup>
                         </CommandGroup>
@@ -359,18 +470,35 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
                     </Command>
                     </Command>
                   </PopoverContent>
                   </PopoverContent>
                 </Popover>
                 </Popover>
-                <p className="text-xs text-muted-foreground">
-                  {t("appearance.timezoneHint")}
-                </p>
+                <p className="text-xs text-muted-foreground">{t('appearance.timezoneHint')}</p>
               </div>
               </div>
 
 
               <div className="rounded-lg border p-3 text-sm text-muted-foreground">
               <div className="rounded-lg border p-3 text-sm text-muted-foreground">
                 <div className="flex items-center gap-2 mb-1">
                 <div className="flex items-center gap-2 mb-1">
                   <Clock className="h-3.5 w-3.5" />
                   <Clock className="h-3.5 w-3.5" />
-                  <span className="font-medium text-foreground">{t("appearance.previewLabel")}</span>
+                  <span className="font-medium text-foreground">
+                    {t('appearance.previewLabel')}
+                  </span>
                 </div>
                 </div>
-                <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>
+                <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>
             </div>
             </div>
           </ReadOnlyWrapper>
           </ReadOnlyWrapper>
@@ -381,26 +509,24 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       <Card className="border-0 shadow-sm">
       <Card className="border-0 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
           <Palette className="h-5 w-5 text-muted-foreground" />
           <Palette className="h-5 w-5 text-muted-foreground" />
-          <CardTitle className="text-lg">{t("appearance.title")}</CardTitle>
+          <CardTitle className="text-lg">{t('appearance.title')}</CardTitle>
         </CardHeader>
         </CardHeader>
         <CardContent className="space-y-6">
         <CardContent className="space-y-6">
           <div className="flex items-center justify-between rounded-lg border p-4">
           <div className="flex items-center justify-between rounded-lg border p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
-              {mounted && theme === "dark" ? (
+              {mounted && theme === 'dark' ? (
                 <Moon className="h-5 w-5 text-muted-foreground" />
                 <Moon className="h-5 w-5 text-muted-foreground" />
               ) : (
               ) : (
                 <Sun className="h-5 w-5 text-muted-foreground" />
                 <Sun className="h-5 w-5 text-muted-foreground" />
               )}
               )}
               <div>
               <div>
-                <Label className="text-sm font-medium">{t("appearance.darkMode")}</Label>
-                <p className="text-xs text-muted-foreground">
-                  {t("appearance.darkModeHint")}
-                </p>
+                <Label className="text-sm font-medium">{t('appearance.darkMode')}</Label>
+                <p className="text-xs text-muted-foreground">{t('appearance.darkModeHint')}</p>
               </div>
               </div>
             </div>
             </div>
             <Switch
             <Switch
-              checked={mounted && theme === "dark"}
-              onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
+              checked={mounted && theme === 'dark'}
+              onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
             />
             />
           </div>
           </div>
         </CardContent>
         </CardContent>
@@ -415,10 +541,10 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
             ) : (
             ) : (
               <Save className="mr-2 h-4 w-4" />
               <Save className="mr-2 h-4 w-4" />
             )}
             )}
-            {t("localization.saveSettings")}
+            {t('localization.saveSettings')}
           </Button>
           </Button>
         </div>
         </div>
       </SaveButton>
       </SaveButton>
     </div>
     </div>
-  );
+  )
 }
 }

+ 76 - 45
src/app/(public)/portal/[orgId]/auth/login/page.tsx

@@ -1,85 +1,116 @@
-import { db } from "@/lib/db";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PortalLoginForm } from "@/features/portal/Components/PortalLoginForm";
-import { getCustomerSession } from "@/lib/customer-session";
-import { redirect } from "next/navigation";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-import { getTranslations } from "next-intl/server";
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PortalLanding } from '@/features/portal/Components/PortalLanding'
+import { getCustomerSession } from '@/lib/customer-session'
+import { redirect } from 'next/navigation'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { getTranslations } from 'next-intl/server'
+import { getOrgSmsProvider } from '@/lib/sms'
+import { normalizeCountryCode } from '@/lib/portal-phone'
+import {
+  isValidPortalBackgroundType,
+  type PortalBackgroundType,
+} from '@/features/portal/portal-backgrounds'
 
 
 export default async function PortalLoginPage({
 export default async function PortalLoginPage({
   params,
   params,
   searchParams,
   searchParams,
 }: {
 }: {
-  params: Promise<{ orgId: string }>;
-  searchParams: Promise<{ error?: string }>;
+  params: Promise<{ orgId: string }>
+  searchParams: Promise<{ error?: string }>
 }) {
 }) {
-  const { orgId: orgParam } = await params;
-  const { error: authError } = await searchParams;
-  const t = await getTranslations('portal.login');
+  const { orgId: orgParam } = await params
+  const { error: authError } = await searchParams
+  const t = await getTranslations('portal.login')
 
 
   // Resolve slug or id to real org
   // Resolve slug or id to real org
-  const org = await resolvePortalOrg(orgParam);
+  const org = await resolvePortalOrg(orgParam)
 
 
   if (!org) {
   if (!org) {
     return (
     return (
       <div className="flex min-h-svh items-center justify-center">
       <div className="flex min-h-svh items-center justify-center">
         <div className="text-center">
         <div className="text-center">
           <h1 className="text-2xl font-bold">{t('notFound')}</h1>
           <h1 className="text-2xl font-bold">{t('notFound')}</h1>
-          <p className="mt-2 text-muted-foreground">
-            {t('portalNotExist')}
-          </p>
+          <p className="mt-2 text-muted-foreground">{t('portalNotExist')}</p>
         </div>
         </div>
       </div>
       </div>
-    );
+    )
   }
   }
 
 
-  const orgId = org.id;
+  const orgId = org.id
 
 
-  // Check portal is enabled
-  const portalSetting = await db.appSetting.findUnique({
+  // Whether this workshop has an SMS provider configured. Controls visibility
+  // of the SMS sign-in tab on the landing page.
+  const smsEnabled = (await getOrgSmsProvider(orgId)) !== null
+
+  // Fetch all the portal-relevant settings in one query.
+  const settings = await db.appSetting.findMany({
     where: {
     where: {
-      organizationId_key: {
-        organizationId: orgId,
-        key: SETTING_KEYS.PORTAL_ENABLED,
+      organizationId: orgId,
+      key: {
+        in: [
+          SETTING_KEYS.PORTAL_ENABLED,
+          SETTING_KEYS.PORTAL_DESCRIPTION,
+          SETTING_KEYS.PORTAL_HOURS,
+          SETTING_KEYS.COMPANY_LOGO,
+          SETTING_KEYS.WORKSHOP_ADDRESS,
+          SETTING_KEYS.WORKSHOP_PHONE,
+          SETTING_KEYS.WORKSHOP_EMAIL,
+          SETTING_KEYS.PORTAL_BACKGROUND_TYPE,
+          SETTING_KEYS.PORTAL_BACKGROUND_TEMPLATE,
+          SETTING_KEYS.PORTAL_BACKGROUND_IMAGE,
+          SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE,
+        ],
       },
       },
     },
     },
-  });
+    select: { key: true, value: true },
+  })
+  const get = (key: string) => settings.find((s) => s.key === key)?.value ?? null
+
+  const countryCode = normalizeCountryCode(get(SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE))
 
 
-  if (portalSetting?.value !== "true") {
+  const rawBgType = get(SETTING_KEYS.PORTAL_BACKGROUND_TYPE)
+  const backgroundType: PortalBackgroundType = isValidPortalBackgroundType(rawBgType)
+    ? rawBgType
+    : 'none'
+
+  if (get(SETTING_KEYS.PORTAL_ENABLED) !== 'true') {
     return (
     return (
       <div className="flex min-h-svh items-center justify-center">
       <div className="flex min-h-svh items-center justify-center">
         <div className="text-center">
         <div className="text-center">
           <h1 className="text-2xl font-bold">{t('portalNotAvailable')}</h1>
           <h1 className="text-2xl font-bold">{t('portalNotAvailable')}</h1>
-          <p className="mt-2 text-muted-foreground">
-            {t('portalNotEnabled')}
-          </p>
+          <p className="mt-2 text-muted-foreground">{t('portalNotEnabled')}</p>
         </div>
         </div>
       </div>
       </div>
-    );
+    )
   }
   }
 
 
   // If already logged in, redirect to dashboard
   // If already logged in, redirect to dashboard
-  const session = await getCustomerSession();
+  const session = await getCustomerSession()
   if (session && session.organizationId === orgId) {
   if (session && session.organizationId === orgId) {
-    redirect(`/portal/${orgParam}/dashboard`);
+    redirect(`/portal/${orgParam}/dashboard`)
   }
   }
 
 
-  // Get org branding
-  const logoSetting = await db.appSetting.findUnique({
-    where: {
-      organizationId_key: {
-        organizationId: orgId,
-        key: SETTING_KEYS.COMPANY_LOGO,
-      },
-    },
-  });
-
   return (
   return (
-    <PortalLoginForm
+    <PortalLanding
       orgId={orgParam}
       orgId={orgParam}
       orgName={org.name}
       orgName={org.name}
-      orgLogo={logoSetting?.value}
-      error={authError}
+      // Use the public logo endpoint so unauthenticated visitors can load it.
+      orgLogo={get(SETTING_KEYS.COMPANY_LOGO) ? `/api/public/logo/${orgParam}` : null}
+      description={get(SETTING_KEYS.PORTAL_DESCRIPTION)}
+      hours={get(SETTING_KEYS.PORTAL_HOURS)}
+      address={get(SETTING_KEYS.WORKSHOP_ADDRESS)}
+      phone={get(SETTING_KEYS.WORKSHOP_PHONE)}
+      email={get(SETTING_KEYS.WORKSHOP_EMAIL)}
+      authError={authError}
+      backgroundType={backgroundType}
+      backgroundTemplateId={get(SETTING_KEYS.PORTAL_BACKGROUND_TEMPLATE)}
+      // Same public-endpoint pattern as the logo.
+      backgroundImageUrl={
+        get(SETTING_KEYS.PORTAL_BACKGROUND_IMAGE) ? `/api/public/portal-bg/${orgParam}` : null
+      }
+      smsEnabled={smsEnabled}
+      defaultCountryCode={countryCode}
     />
     />
-  );
+  )
 }
 }

+ 293 - 161
src/app/(public)/portal/[orgId]/dashboard/page.tsx

@@ -1,223 +1,355 @@
-import { PortalShell } from "@/features/portal/Components/PortalShell";
-import { getPortalDashboard } from "@/features/portal/Actions/portalActions";
+import { PortalShell } from '@/features/portal/Components/PortalShell'
+import { getPortalDashboard } from '@/features/portal/Actions/portalActions'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
 import {
 import {
-  Card,
-  CardContent,
-  CardDescription,
-  CardHeader,
-  CardTitle,
-} from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Car, FileText, FileQuestion, Wrench } from "lucide-react";
-import Link from "next/link";
-import { getTranslations } from "next-intl/server";
+  Activity,
+  ArrowRight,
+  Car,
+  ClipboardCheck,
+  Download,
+  FileQuestion,
+  FileText,
+  Wrench,
+} from 'lucide-react'
+import Link from 'next/link'
+import { getTranslations } from 'next-intl/server'
 
 
 export default async function PortalDashboardPage({
 export default async function PortalDashboardPage({
   params,
   params,
 }: {
 }: {
-  params: Promise<{ orgId: string }>;
+  params: Promise<{ orgId: string }>
 }) {
 }) {
-  const { orgId } = await params;
-  const t = await getTranslations('portal.dashboard');
-  const result = await getPortalDashboard();
+  const { orgId } = await params
+  const t = await getTranslations('portal.dashboard')
+  const tInvoices = await getTranslations('portal.invoices')
+  const result = await getPortalDashboard()
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
     return (
     return (
       <PortalShell orgId={orgId}>
       <PortalShell orgId={orgId}>
         <p className="text-muted-foreground">{t('failedToLoad')}</p>
         <p className="text-muted-foreground">{t('failedToLoad')}</p>
       </PortalShell>
       </PortalShell>
-    );
+    )
   }
   }
 
 
-  const d = result.data;
+  const d = result.data
 
 
   return (
   return (
     <PortalShell orgId={orgId}>
     <PortalShell orgId={orgId}>
       <div className="space-y-6">
       <div className="space-y-6">
-        <div>
-          <h1 className="text-2xl font-bold">
-            {t('welcome', { name: d.customer?.name ?? "Customer" })}
+        {/* Welcome line */}
+        <div className="flex flex-wrap items-baseline justify-between gap-2">
+          <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
+            {t('welcome', { name: d.customer?.name ?? 'Customer' })}
           </h1>
           </h1>
-          <p className="text-muted-foreground">
-            {t('overview')}
-          </p>
+          <p className="text-sm text-muted-foreground">{t('overview')}</p>
         </div>
         </div>
 
 
-        {/* Summary cards */}
-        <div className="grid gap-4 sm:grid-cols-3">
-          <Card>
-            <CardHeader className="flex flex-row items-center justify-between pb-2">
-              <CardDescription>{t('vehicles')}</CardDescription>
-              <Car className="h-4 w-4 text-muted-foreground" />
-            </CardHeader>
-            <CardContent>
-              <p className="text-2xl font-bold">{d.vehicleCount}</p>
-            </CardContent>
-          </Card>
-          <Card>
-            <CardHeader className="flex flex-row items-center justify-between pb-2">
-              <CardDescription>{t('openInvoices')}</CardDescription>
-              <FileText className="h-4 w-4 text-muted-foreground" />
-            </CardHeader>
-            <CardContent>
-              <p className="text-2xl font-bold">{d.openInvoiceCount}</p>
-            </CardContent>
-          </Card>
-          <Card>
-            <CardHeader className="flex flex-row items-center justify-between pb-2">
-              <CardDescription>{t('pendingQuotes')}</CardDescription>
-              <FileQuestion className="h-4 w-4 text-muted-foreground" />
-            </CardHeader>
-            <CardContent>
-              <p className="text-2xl font-bold">{d.pendingQuoteCount}</p>
-            </CardContent>
-          </Card>
+        {/* Quick actions strip */}
+        <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
+          <QuickAction
+            href={`/portal/${orgId}/request-service`}
+            icon={<Wrench className="h-5 w-5" />}
+            label={t('quickRequestService')}
+            sublabel={t('quickRequestServiceHint')}
+            primary
+          />
+          <QuickAction
+            href={`/portal/${orgId}/invoices`}
+            icon={<FileText className="h-5 w-5" />}
+            label={t('quickViewInvoices')}
+            sublabel={t('quickViewInvoicesHint')}
+          />
+          <QuickAction
+            href={`/portal/${orgId}/vehicles`}
+            icon={<Car className="h-5 w-5" />}
+            label={t('quickViewVehicles')}
+            sublabel={t('quickViewVehiclesHint')}
+          />
+        </div>
+
+        {/* KPI tiles */}
+        <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
+          <KpiTile
+            label={t('vehicles')}
+            value={d.vehicleCount}
+            icon={<Car className="h-4 w-4" />}
+            href={`/portal/${orgId}/vehicles`}
+          />
+          <KpiTile
+            label={t('openInvoices')}
+            value={d.openInvoiceCount}
+            icon={<FileText className="h-4 w-4" />}
+            href={`/portal/${orgId}/invoices`}
+          />
+          <KpiTile
+            label={t('pendingQuotes')}
+            value={d.pendingQuoteCount}
+            icon={<FileQuestion className="h-4 w-4" />}
+            href={`/portal/${orgId}/quotes`}
+          />
+          <KpiTile
+            label={t('pendingServiceRequests')}
+            value={d.pendingRequests.length}
+            icon={<ClipboardCheck className="h-4 w-4" />}
+            href={`/portal/${orgId}/request-service`}
+          />
         </div>
         </div>
 
 
-        {/* Recent invoices */}
-        <Card>
-          <CardHeader>
-            <div className="flex items-center justify-between">
+        {/* Two-column main grid */}
+        <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
+          {/* Recent invoices — wider */}
+          <Card className="lg:col-span-2">
+            <CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
               <CardTitle className="text-base">{t('recentInvoices')}</CardTitle>
               <CardTitle className="text-base">{t('recentInvoices')}</CardTitle>
               <Link
               <Link
                 href={`/portal/${orgId}/invoices`}
                 href={`/portal/${orgId}/invoices`}
-                className="text-sm text-primary hover:underline"
+                className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
               >
               >
                 {t('viewAll')}
                 {t('viewAll')}
+                <ArrowRight className="h-3 w-3" />
               </Link>
               </Link>
-            </div>
-          </CardHeader>
-          <CardContent>
-            {d.recentInvoices.length === 0 ? (
-              <p className="text-sm text-muted-foreground">{t('noInvoicesYet')}</p>
-            ) : (
-              <div className="space-y-3">
-                {d.recentInvoices.map((inv) => (
-                  <div
-                    key={inv.id}
-                    className="flex items-center justify-between rounded-lg border p-3"
-                  >
-                    <div className="min-w-0">
-                      <p className="truncate text-sm font-medium">
-                        {inv.invoiceNumber
-                          ? `#${inv.invoiceNumber}`
-                          : inv.title}
-                      </p>
-                      <p className="text-xs text-muted-foreground">
-                        {inv.vehicle?.make} {inv.vehicle?.model} &middot;{" "}
-                        {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString()}
-                      </p>
-                    </div>
-                    <div className="flex items-center gap-2">
-                      <Badge variant="outline">{inv.status}</Badge>
-                      {inv.publicToken && (
-                        <Link
-                          href={`/share/invoice/${orgId}/${inv.publicToken}`}
-                          className="text-xs text-primary hover:underline"
+            </CardHeader>
+            <CardContent className="pt-0">
+              {d.recentInvoices.length === 0 ? (
+                <p className="py-6 text-center text-sm text-muted-foreground">
+                  {t('noInvoicesYet')}
+                </p>
+              ) : (
+                <ul className="divide-y">
+                  {d.recentInvoices.map((inv) => (
+                    <li
+                      key={inv.id}
+                      className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
+                    >
+                      <div className="min-w-0 flex-1">
+                        <p className="truncate text-sm font-medium">
+                          {inv.invoiceNumber ? `#${inv.invoiceNumber}` : inv.title}
+                        </p>
+                        <p className="truncate text-xs text-muted-foreground">
+                          {inv.vehicle?.make} {inv.vehicle?.model} ·{' '}
+                          {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString()}
+                        </p>
+                      </div>
+                      <div className="flex shrink-0 items-center gap-3">
+                        <Badge variant="outline" className="text-xs">
+                          {inv.status}
+                        </Badge>
+                        {inv.publicToken && (
+                          <Link
+                            href={`/share/invoice/${orgId}/${inv.publicToken}`}
+                            className="text-xs font-medium text-primary hover:underline"
+                          >
+                            {t('view')}
+                          </Link>
+                        )}
+                        <a
+                          href={`/portal/${orgId}/invoices/${inv.id}/pdf`}
+                          download
+                          className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
+                          aria-label={tInvoices('download')}
                         >
                         >
-                          {t('view')}
-                        </Link>
-                      )}
-                    </div>
-                  </div>
-                ))}
-              </div>
-            )}
-          </CardContent>
-        </Card>
+                          <Download className="h-3.5 w-3.5" />
+                        </a>
+                      </div>
+                    </li>
+                  ))}
+                </ul>
+              )}
+            </CardContent>
+          </Card>
 
 
-        {/* Recent quotes */}
-        <Card>
-          <CardHeader>
-            <div className="flex items-center justify-between">
+          {/* Recent quotes — narrower */}
+          <Card>
+            <CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
               <CardTitle className="text-base">{t('recentQuotes')}</CardTitle>
               <CardTitle className="text-base">{t('recentQuotes')}</CardTitle>
               <Link
               <Link
                 href={`/portal/${orgId}/quotes`}
                 href={`/portal/${orgId}/quotes`}
-                className="text-sm text-primary hover:underline"
+                className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
               >
               >
                 {t('viewAll')}
                 {t('viewAll')}
+                <ArrowRight className="h-3 w-3" />
               </Link>
               </Link>
-            </div>
-          </CardHeader>
-          <CardContent>
-            {d.recentQuotes.length === 0 ? (
-              <p className="text-sm text-muted-foreground">{t('noQuotesYet')}</p>
-            ) : (
-              <div className="space-y-3">
-                {d.recentQuotes.map((q) => (
-                  <div
-                    key={q.id}
-                    className="flex items-center justify-between rounded-lg border p-3"
+            </CardHeader>
+            <CardContent className="pt-0">
+              {d.recentQuotes.length === 0 ? (
+                <p className="py-6 text-center text-sm text-muted-foreground">{t('noQuotesYet')}</p>
+              ) : (
+                <ul className="divide-y">
+                  {d.recentQuotes.map((q) => (
+                    <li
+                      key={q.id}
+                      className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
+                    >
+                      <div className="min-w-0 flex-1">
+                        <p className="truncate text-sm font-medium">
+                          {q.quoteNumber ? `#${q.quoteNumber}` : q.title}
+                        </p>
+                        <p className="truncate text-xs text-muted-foreground">
+                          {q.vehicle?.make} {q.vehicle?.model}
+                        </p>
+                      </div>
+                      <div className="flex shrink-0 items-center gap-2">
+                        <Badge variant="outline" className="text-xs">
+                          {q.status}
+                        </Badge>
+                        {q.publicToken && (
+                          <Link
+                            href={`/share/quote/${orgId}/${q.publicToken}`}
+                            className="text-xs font-medium text-primary hover:underline"
+                          >
+                            {t('view')}
+                          </Link>
+                        )}
+                      </div>
+                    </li>
+                  ))}
+                </ul>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Active jobs (only if any) */}
+        {d.activeJobs.length > 0 && (
+          <Card>
+            <CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
+              <CardTitle className="flex items-center gap-2 text-base">
+                <Activity className="h-4 w-4 text-primary" />
+                {t('activeJobs')}
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="pt-0">
+              <ul className="divide-y">
+                {d.activeJobs.map((job) => (
+                  <li
+                    key={job.id}
+                    className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
                   >
                   >
-                    <div className="min-w-0">
-                      <p className="truncate text-sm font-medium">
-                        {q.quoteNumber ? `#${q.quoteNumber}` : q.title}
-                      </p>
-                      <p className="text-xs text-muted-foreground">
-                        {q.vehicle?.make} {q.vehicle?.model}
+                    <div className="min-w-0 flex-1">
+                      <p className="truncate text-sm font-medium">{job.title}</p>
+                      <p className="truncate text-xs text-muted-foreground">
+                        {job.vehicle?.make} {job.vehicle?.model} ·{' '}
+                        {new Date(job.startDateTime ?? job.serviceDate).toLocaleDateString()}
                       </p>
                       </p>
                     </div>
                     </div>
-                    <div className="flex items-center gap-2">
-                      <Badge variant="outline">{q.status}</Badge>
-                      {q.publicToken && (
-                        <Link
-                          href={`/share/quote/${orgId}/${q.publicToken}`}
-                          className="text-xs text-primary hover:underline"
-                        >
-                          {t('view')}
-                        </Link>
-                      )}
-                    </div>
-                  </div>
+                    <Badge variant="secondary" className="text-xs">
+                      {job.status}
+                    </Badge>
+                  </li>
                 ))}
                 ))}
-              </div>
-            )}
-          </CardContent>
-        </Card>
+              </ul>
+            </CardContent>
+          </Card>
+        )}
 
 
-        {/* Pending service requests */}
+        {/* Pending requests (only if any) */}
         {d.pendingRequests.length > 0 && (
         {d.pendingRequests.length > 0 && (
           <Card>
           <Card>
-            <CardHeader>
-              <div className="flex items-center justify-between">
-                <CardTitle className="text-base">
-                  {t('pendingServiceRequests')}
-                </CardTitle>
-                <Link
-                  href={`/portal/${orgId}/request-service`}
-                  className="text-sm text-primary hover:underline"
-                >
+            <CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
+              <CardTitle className="text-base">{t('pendingServiceRequests')}</CardTitle>
+              <Button asChild size="sm" variant="outline">
+                <Link href={`/portal/${orgId}/request-service`}>
+                  <Wrench className="mr-1.5 h-3.5 w-3.5" />
                   {t('newRequest')}
                   {t('newRequest')}
                 </Link>
                 </Link>
-              </div>
+              </Button>
             </CardHeader>
             </CardHeader>
-            <CardContent>
-              <div className="space-y-3">
+            <CardContent className="pt-0">
+              <ul className="divide-y">
                 {d.pendingRequests.map((req) => (
                 {d.pendingRequests.map((req) => (
-                  <div
+                  <li
                     key={req.id}
                     key={req.id}
-                    className="flex items-center justify-between rounded-lg border p-3"
+                    className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
                   >
                   >
-                    <div className="min-w-0">
-                      <p className="truncate text-sm font-medium">
-                        {req.description.slice(0, 80)}
-                      </p>
-                      <p className="text-xs text-muted-foreground">
-                        {req.vehicle?.make} {req.vehicle?.model} &middot;{" "}
+                    <div className="min-w-0 flex-1">
+                      <p className="truncate text-sm font-medium">{req.description.slice(0, 80)}</p>
+                      <p className="truncate text-xs text-muted-foreground">
+                        {req.vehicle?.make} {req.vehicle?.model} ·{' '}
                         {new Date(req.createdAt).toLocaleDateString()}
                         {new Date(req.createdAt).toLocaleDateString()}
                       </p>
                       </p>
                     </div>
                     </div>
-                    <Badge variant="secondary">
-                      <Wrench className="mr-1 h-3 w-3" />
+                    <Badge variant="secondary" className="text-xs">
                       {t('pending')}
                       {t('pending')}
                     </Badge>
                     </Badge>
-                  </div>
+                  </li>
                 ))}
                 ))}
-              </div>
+              </ul>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
         )}
         )}
       </div>
       </div>
     </PortalShell>
     </PortalShell>
-  );
+  )
+}
+
+function QuickAction({
+  href,
+  icon,
+  label,
+  sublabel,
+  primary,
+}: {
+  href: string
+  icon: React.ReactNode
+  label: string
+  sublabel: string
+  primary?: boolean
+}) {
+  return (
+    <Link
+      href={href}
+      className={
+        primary
+          ? 'group flex items-center gap-3 rounded-lg border-2 border-primary/40 bg-primary/5 p-4 transition-all hover:border-primary hover:bg-primary/10'
+          : 'group flex items-center gap-3 rounded-lg border bg-card p-4 transition-all hover:border-primary/40 hover:shadow-sm'
+      }
+    >
+      <span
+        className={
+          primary
+            ? 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground'
+            : 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary'
+        }
+      >
+        {icon}
+      </span>
+      <div className="min-w-0 flex-1">
+        <p className="truncate text-sm font-semibold">{label}</p>
+        <p className="truncate text-xs text-muted-foreground">{sublabel}</p>
+      </div>
+      <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
+    </Link>
+  )
+}
+
+function KpiTile({
+  label,
+  value,
+  icon,
+  href,
+}: {
+  label: string
+  value: number
+  icon: React.ReactNode
+  href: string
+}) {
+  return (
+    <Link
+      href={href}
+      className="group flex items-center justify-between gap-3 rounded-lg border bg-card p-3 transition-colors hover:border-primary/40"
+    >
+      <div className="min-w-0">
+        <p className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
+          {label}
+        </p>
+        <p className="text-2xl font-bold tabular-nums">{value}</p>
+      </div>
+      <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
+        {icon}
+      </span>
+    </Link>
+  )
 }
 }

+ 89 - 0
src/app/(public)/portal/[orgId]/invoices/[invoiceId]/pdf/route.ts

@@ -0,0 +1,89 @@
+import { NextResponse } from 'next/server'
+import { headers } from 'next/headers'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { getCustomerSession } from '@/lib/customer-session'
+import { buildInvoicePdfBuffer } from '@/features/invoices/Pdf/buildInvoicePdfBuffer'
+
+// This route lives under /portal/... (not /api/public/...) so the
+// customer-session cookie — which is scoped to path "/portal" — is
+// actually sent by the browser. See verify route's cookie options.
+
+export async function GET(
+  request: Request,
+  { params }: { params: Promise<{ orgId: string; invoiceId: string }> }
+) {
+  const limited = rateLimit(request, { limit: 20, windowMs: 60_000 })
+  if (limited) return limited
+
+  try {
+    const { orgId: orgParam, invoiceId } = await params
+
+    // Customers hit this URL directly via a download link in the portal, so
+    // a redirect to the login page is friendlier than a JSON 401.
+    const session = await getCustomerSession()
+    if (!session) {
+      return NextResponse.redirect(new URL(`/portal/${orgParam}/auth/login`, request.url), 302)
+    }
+
+    const org = await resolvePortalOrg(orgParam)
+    if (!org) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+
+    if (org.id !== session.organizationId) {
+      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+    }
+
+    const record = await db.serviceRecord.findUnique({
+      where: { id: invoiceId },
+      select: {
+        id: true,
+        status: true,
+        vehicle: { select: { customerId: true, organizationId: true } },
+      },
+    })
+
+    if (!record) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+
+    if (
+      record.vehicle.customerId !== session.customerId ||
+      record.vehicle.organizationId !== session.organizationId
+    ) {
+      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+    }
+
+    // Only completed records are downloadable as invoices. Work-in-progress
+    // records aren't real invoices yet (prices may still change).
+    if (record.status !== 'completed') {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+
+    const headerStore = await headers()
+    const result = await buildInvoicePdfBuffer(record.id, headerStore.get('accept-language'))
+
+    if (!result) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+
+    return new NextResponse(
+      result.buffer.buffer.slice(
+        result.buffer.byteOffset,
+        result.buffer.byteOffset + result.buffer.byteLength
+      ) as ArrayBuffer,
+      {
+        headers: {
+          'Content-Type': 'application/pdf',
+          'Content-Disposition': `attachment; filename="${result.filename}"`,
+          'X-Robots-Tag': 'noindex, nofollow',
+        },
+      }
+    )
+  } catch (error) {
+    console.error('[Portal Invoice PDF] Error:', error)
+    return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
+  }
+}

+ 44 - 36
src/app/(public)/portal/[orgId]/invoices/page.tsx

@@ -1,6 +1,6 @@
-import { PortalShell } from "@/features/portal/Components/PortalShell";
-import { getPortalInvoices } from "@/features/portal/Actions/portalActions";
-import { Badge } from "@/components/ui/badge";
+import { PortalShell } from '@/features/portal/Components/PortalShell'
+import { getPortalInvoices } from '@/features/portal/Actions/portalActions'
+import { Badge } from '@/components/ui/badge'
 import {
 import {
   Table,
   Table,
   TableBody,
   TableBody,
@@ -8,36 +8,36 @@ import {
   TableHead,
   TableHead,
   TableHeader,
   TableHeader,
   TableRow,
   TableRow,
-} from "@/components/ui/table";
-import { FileText } from "lucide-react";
-import Link from "next/link";
-import { getTranslations } from "next-intl/server";
+} from '@/components/ui/table'
+import { Download, FileText } from 'lucide-react'
+import Link from 'next/link'
+import { getTranslations } from 'next-intl/server'
 
 
 export default async function PortalInvoicesPage({
 export default async function PortalInvoicesPage({
   params,
   params,
 }: {
 }: {
-  params: Promise<{ orgId: string }>;
+  params: Promise<{ orgId: string }>
 }) {
 }) {
-  const { orgId } = await params;
-  const t = await getTranslations('portal.invoices');
-  const result = await getPortalInvoices();
+  const { orgId } = await params
+  const t = await getTranslations('portal.invoices')
+  const result = await getPortalInvoices()
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
     return (
     return (
       <PortalShell orgId={orgId}>
       <PortalShell orgId={orgId}>
         <p className="text-muted-foreground">{t('failedToLoad')}</p>
         <p className="text-muted-foreground">{t('failedToLoad')}</p>
       </PortalShell>
       </PortalShell>
-    );
+    )
   }
   }
 
 
-  const invoices = result.data;
+  const invoices = result.data
 
 
   function getPaymentStatus(inv: (typeof invoices)[number]) {
   function getPaymentStatus(inv: (typeof invoices)[number]) {
-    if (inv.manuallyPaid) return "paid" as const;
-    const paid = inv.payments.reduce((sum, p) => sum + p.amount, 0);
-    if (paid >= inv.totalAmount) return "paid" as const;
-    if (paid > 0) return "partial" as const;
-    return "unpaid" as const;
+    if (inv.manuallyPaid) return 'paid' as const
+    const paid = inv.payments.reduce((sum, p) => sum + p.amount, 0)
+    if (paid >= inv.totalAmount) return 'paid' as const
+    if (paid > 0) return 'partial' as const
+    return 'unpaid' as const
   }
   }
 
 
   return (
   return (
@@ -68,13 +68,11 @@ export default async function PortalInvoicesPage({
               </TableHeader>
               </TableHeader>
               <TableBody>
               <TableBody>
                 {invoices.map((inv) => {
                 {invoices.map((inv) => {
-                  const payStatus = getPaymentStatus(inv);
+                  const payStatus = getPaymentStatus(inv)
                   return (
                   return (
                     <TableRow key={inv.id}>
                     <TableRow key={inv.id}>
                       <TableCell className="font-medium">
                       <TableCell className="font-medium">
-                        {inv.invoiceNumber
-                          ? `#${inv.invoiceNumber}`
-                          : inv.title}
+                        {inv.invoiceNumber ? `#${inv.invoiceNumber}` : inv.title}
                       </TableCell>
                       </TableCell>
                       <TableCell>
                       <TableCell>
                         {inv.vehicle?.make} {inv.vehicle?.model}
                         {inv.vehicle?.make} {inv.vehicle?.model}
@@ -86,28 +84,38 @@ export default async function PortalInvoicesPage({
                       <TableCell>
                       <TableCell>
                         <Badge
                         <Badge
                           variant={
                           variant={
-                            payStatus === "paid"
-                              ? "default"
-                              : payStatus === "partial"
-                                ? "secondary"
-                                : "outline"
+                            payStatus === 'paid'
+                              ? 'default'
+                              : payStatus === 'partial'
+                                ? 'secondary'
+                                : 'outline'
                           }
                           }
                         >
                         >
                           {t(payStatus)}
                           {t(payStatus)}
                         </Badge>
                         </Badge>
                       </TableCell>
                       </TableCell>
                       <TableCell>
                       <TableCell>
-                        {inv.publicToken && (
-                          <Link
-                            href={`/share/invoice/${orgId}/${inv.publicToken}`}
-                            className="text-sm text-primary hover:underline"
+                        <div className="flex items-center gap-3">
+                          {inv.publicToken && (
+                            <Link
+                              href={`/share/invoice/${orgId}/${inv.publicToken}`}
+                              className="text-sm text-primary hover:underline"
+                            >
+                              {t('view')}
+                            </Link>
+                          )}
+                          <a
+                            href={`/portal/${orgId}/invoices/${inv.id}/pdf`}
+                            download
+                            className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
                           >
                           >
-                            {t('view')}
-                          </Link>
-                        )}
+                            <Download className="h-4 w-4" />
+                            {t('download')}
+                          </a>
+                        </div>
                       </TableCell>
                       </TableCell>
                     </TableRow>
                     </TableRow>
-                  );
+                  )
                 })}
                 })}
               </TableBody>
               </TableBody>
             </Table>
             </Table>
@@ -115,5 +123,5 @@ export default async function PortalInvoicesPage({
         )}
         )}
       </div>
       </div>
     </PortalShell>
     </PortalShell>
-  );
+  )
 }
 }

+ 0 - 24
src/app/(public)/portal/[orgId]/layout.tsx

@@ -1,24 +0,0 @@
-"use client";
-
-import { useLayoutEffect } from "react";
-
-export default function PortalLayout({
-  children,
-}: {
-  children: React.ReactNode;
-}) {
-  useLayoutEffect(() => {
-    const root = document.documentElement;
-    const hadDark = root.classList.contains("dark");
-    root.setAttribute("data-force-theme", "light");
-    root.classList.remove("dark");
-    root.classList.add("light");
-    return () => {
-      root.removeAttribute("data-force-theme");
-      root.classList.remove("light");
-      if (hadDark) root.classList.add("dark");
-    };
-  }, []);
-
-  return <>{children}</>;
-}

+ 53 - 44
src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx

@@ -1,42 +1,39 @@
-import { PortalShell } from "@/features/portal/Components/PortalShell";
-import { getPortalVehicleDetail } from "@/features/portal/Actions/portalActions";
-import {
-  Card,
-  CardContent,
-} from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import Link from "next/link";
-import { getTranslations } from "next-intl/server";
-import { db } from "@/lib/db";
+import { PortalShell } from '@/features/portal/Components/PortalShell'
+import { getPortalVehicleDetail } from '@/features/portal/Actions/portalActions'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Download } from 'lucide-react'
+import Link from 'next/link'
+import { getTranslations } from 'next-intl/server'
+import { db } from '@/lib/db'
 
 
 export default async function PortalVehicleDetailPage({
 export default async function PortalVehicleDetailPage({
   params,
   params,
 }: {
 }: {
-  params: Promise<{ orgId: string; vehicleId: string }>;
+  params: Promise<{ orgId: string; vehicleId: string }>
 }) {
 }) {
-  const { orgId, vehicleId } = await params;
-  const t = await getTranslations('portal.vehicles');
+  const { orgId, vehicleId } = await params
+  const t = await getTranslations('portal.vehicles')
+  const tInvoices = await getTranslations('portal.invoices')
   const [result, serviceTypeSetting] = await Promise.all([
   const [result, serviceTypeSetting] = await Promise.all([
     getPortalVehicleDetail(vehicleId),
     getPortalVehicleDetail(vehicleId),
     db.appSetting.findUnique({
     db.appSetting.findUnique({
-      where: { organizationId_key: { organizationId: orgId, key: "workshop.serviceType" } },
+      where: { organizationId_key: { organizationId: orgId, key: 'workshop.serviceType' } },
       select: { value: true },
       select: { value: true },
     }),
     }),
-  ]);
-  const serviceType = (serviceTypeSetting?.value || "automotive") as "automotive" | "marine";
+  ])
+  const serviceType = (serviceTypeSetting?.value || 'automotive') as 'automotive' | 'marine'
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
     return (
     return (
       <PortalShell orgId={orgId}>
       <PortalShell orgId={orgId}>
-        <p className="text-muted-foreground">
-          {result.error ?? t('vehicleNotFound')}
-        </p>
+        <p className="text-muted-foreground">{result.error ?? t('vehicleNotFound')}</p>
       </PortalShell>
       </PortalShell>
-    );
+    )
   }
   }
 
 
-  const v = result.data;
+  const v = result.data
 
 
   return (
   return (
     <PortalShell orgId={orgId}>
     <PortalShell orgId={orgId}>
@@ -55,10 +52,22 @@ export default async function PortalVehicleDetailPage({
               {v.year} {v.make} {v.model}
               {v.year} {v.make} {v.model}
             </h1>
             </h1>
             <div className="mt-1 flex flex-wrap gap-3 text-sm text-muted-foreground">
             <div className="mt-1 flex flex-wrap gap-3 text-sm text-muted-foreground">
-              {v.licensePlate && <span>{serviceType === 'marine' ? `Reg: ${v.licensePlate}` : t('plate', { plate: v.licensePlate })}</span>}
-              {v.vin && <span>{serviceType === 'marine' ? `HIN: ${v.vin}` : t('vin', { vin: v.vin })}</span>}
+              {v.licensePlate && (
+                <span>
+                  {serviceType === 'marine'
+                    ? `Reg: ${v.licensePlate}`
+                    : t('plate', { plate: v.licensePlate })}
+                </span>
+              )}
+              {v.vin && (
+                <span>{serviceType === 'marine' ? `HIN: ${v.vin}` : t('vin', { vin: v.vin })}</span>
+              )}
               {v.mileage > 0 && (
               {v.mileage > 0 && (
-                <span>{serviceType === 'marine' ? `${v.mileage.toLocaleString()} hrs` : `${v.mileage.toLocaleString()} mi`}</span>
+                <span>
+                  {serviceType === 'marine'
+                    ? `${v.mileage.toLocaleString()} hrs`
+                    : `${v.mileage.toLocaleString()} mi`}
+                </span>
               )}
               )}
               {v.color && <span>{v.color}</span>}
               {v.color && <span>{v.color}</span>}
             </div>
             </div>
@@ -87,20 +96,16 @@ export default async function PortalVehicleDetailPage({
                     <CardContent className="flex items-center justify-between py-4">
                     <CardContent className="flex items-center justify-between py-4">
                       <div className="min-w-0">
                       <div className="min-w-0">
                         <p className="truncate text-sm font-medium">
                         <p className="truncate text-sm font-medium">
-                          {sr.invoiceNumber
-                            ? `#${sr.invoiceNumber} - `
-                            : ""}
+                          {sr.invoiceNumber ? `#${sr.invoiceNumber} - ` : ''}
                           {sr.title}
                           {sr.title}
                         </p>
                         </p>
                         <p className="text-xs text-muted-foreground">
                         <p className="text-xs text-muted-foreground">
                           {new Date(sr.startDateTime ?? sr.serviceDate).toLocaleDateString()}
                           {new Date(sr.startDateTime ?? sr.serviceDate).toLocaleDateString()}
                         </p>
                         </p>
                       </div>
                       </div>
-                      <div className="flex items-center gap-2">
+                      <div className="flex items-center gap-3">
                         <Badge variant="outline">{sr.status}</Badge>
                         <Badge variant="outline">{sr.status}</Badge>
-                        <span className="text-sm font-medium">
-                          ${sr.totalAmount.toFixed(2)}
-                        </span>
+                        <span className="text-sm font-medium">${sr.totalAmount.toFixed(2)}</span>
                         {sr.publicToken && (
                         {sr.publicToken && (
                           <Link
                           <Link
                             href={`/share/invoice/${orgId}/${sr.publicToken}`}
                             href={`/share/invoice/${orgId}/${sr.publicToken}`}
@@ -109,6 +114,14 @@ export default async function PortalVehicleDetailPage({
                             {t('view')}
                             {t('view')}
                           </Link>
                           </Link>
                         )}
                         )}
+                        <a
+                          href={`/portal/${orgId}/invoices/${sr.id}/pdf`}
+                          download
+                          className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
+                        >
+                          <Download className="h-3.5 w-3.5" />
+                          {tInvoices('download')}
+                        </a>
                       </div>
                       </div>
                     </CardContent>
                     </CardContent>
                   </Card>
                   </Card>
@@ -119,27 +132,23 @@ export default async function PortalVehicleDetailPage({
 
 
           <TabsContent value="inspections" className="mt-4">
           <TabsContent value="inspections" className="mt-4">
             {v.inspections.length === 0 ? (
             {v.inspections.length === 0 ? (
-              <p className="py-8 text-center text-sm text-muted-foreground">
-                {t('noInspections')}
-              </p>
+              <p className="py-8 text-center text-sm text-muted-foreground">{t('noInspections')}</p>
             ) : (
             ) : (
               <div className="space-y-3">
               <div className="space-y-3">
                 {v.inspections.map((insp) => {
                 {v.inspections.map((insp) => {
                   const conditions = insp.items.reduce(
                   const conditions = insp.items.reduce(
                     (acc, item) => {
                     (acc, item) => {
-                      acc[item.condition] = (acc[item.condition] || 0) + 1;
-                      return acc;
+                      acc[item.condition] = (acc[item.condition] || 0) + 1
+                      return acc
                     },
                     },
-                    {} as Record<string, number>,
-                  );
+                    {} as Record<string, number>
+                  )
 
 
                   return (
                   return (
                     <Card key={insp.id}>
                     <Card key={insp.id}>
                       <CardContent className="flex items-center justify-between py-4">
                       <CardContent className="flex items-center justify-between py-4">
                         <div className="min-w-0">
                         <div className="min-w-0">
-                          <p className="truncate text-sm font-medium">
-                            {insp.template.name}
-                          </p>
+                          <p className="truncate text-sm font-medium">{insp.template.name}</p>
                           <p className="text-xs text-muted-foreground">
                           <p className="text-xs text-muted-foreground">
                             {new Date(insp.createdAt).toLocaleDateString()}
                             {new Date(insp.createdAt).toLocaleDateString()}
                             {insp.completedAt &&
                             {insp.completedAt &&
@@ -176,7 +185,7 @@ export default async function PortalVehicleDetailPage({
                         </div>
                         </div>
                       </CardContent>
                       </CardContent>
                     </Card>
                     </Card>
-                  );
+                  )
                 })}
                 })}
               </div>
               </div>
             )}
             )}
@@ -184,5 +193,5 @@ export default async function PortalVehicleDetailPage({
         </Tabs>
         </Tabs>
       </div>
       </div>
     </PortalShell>
     </PortalShell>
-  );
+  )
 }
 }

+ 20 - 0
src/app/(public)/portal/layout.tsx

@@ -0,0 +1,20 @@
+'use client'
+
+import { useLayoutEffect } from 'react'
+
+export default function PortalLayout({ children }: { children: React.ReactNode }) {
+  useLayoutEffect(() => {
+    const root = document.documentElement
+    const hadDark = root.classList.contains('dark')
+    root.setAttribute('data-force-theme', 'light')
+    root.classList.remove('dark')
+    root.classList.add('light')
+    return () => {
+      root.removeAttribute('data-force-theme')
+      root.classList.remove('light')
+      if (hadDark) root.classList.add('dark')
+    }
+  }, [])
+
+  return <>{children}</>
+}

+ 57 - 0
src/app/(public)/portal/page.tsx

@@ -0,0 +1,57 @@
+import { redirect } from 'next/navigation'
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  PortalDirectory,
+  type PortalDirectoryEntry,
+} from '@/features/portal/Components/PortalDirectory'
+
+export const dynamic = 'force-dynamic'
+
+export default async function PortalIndexPage() {
+  // Fetch all orgs that have the portal enabled.
+  const enabledOrgs = await db.organization.findMany({
+    where: {
+      settings: {
+        some: {
+          key: SETTING_KEYS.PORTAL_ENABLED,
+          value: 'true',
+        },
+      },
+    },
+    select: {
+      id: true,
+      name: true,
+      portalSlug: true,
+      settings: {
+        where: {
+          key: {
+            in: [SETTING_KEYS.COMPANY_LOGO, SETTING_KEYS.WORKSHOP_ADDRESS],
+          },
+        },
+        select: { key: true, value: true },
+      },
+    },
+    orderBy: { name: 'asc' },
+  })
+
+  const workshops: PortalDirectoryEntry[] = enabledOrgs.map((org) => {
+    const slug = org.portalSlug ?? org.id
+    const hasLogo = org.settings.some((s) => s.key === SETTING_KEYS.COMPANY_LOGO && s.value)
+    const address = org.settings.find((s) => s.key === SETTING_KEYS.WORKSHOP_ADDRESS)?.value ?? null
+    return {
+      slug,
+      name: org.name,
+      // Use the public logo endpoint so unauthenticated visitors can load it.
+      logo: hasLogo ? `/api/public/logo/${slug}` : null,
+      address,
+    }
+  })
+
+  // If there's only one workshop, skip the directory and go straight to its login.
+  if (workshops.length === 1) {
+    redirect(`/portal/${workshops[0].slug}/auth/login`)
+  }
+
+  return <PortalDirectory workshops={workshops} />
+}

+ 36 - 36
src/app/api/protected/files/[...path]/route.ts

@@ -1,71 +1,71 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { readFile, stat } from "fs/promises";
-import path from "path";
+import { NextRequest, NextResponse } from 'next/server'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { readFile, stat } from 'fs/promises'
+import path from 'path'
 
 
 const MIME_TYPES: Record<string, string> = {
 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",
-  pdf: "application/pdf",
-  csv: "text/csv",
-  txt: "text/plain",
-  mp4: "video/mp4",
-  webm: "video/webm",
-  mov: "video/quicktime",
-};
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  png: 'image/png',
+  webp: 'image/webp',
+  avif: 'image/avif',
+  svg: 'image/svg+xml',
+  pdf: 'application/pdf',
+  csv: 'text/csv',
+  txt: 'text/plain',
+  mp4: 'video/mp4',
+  webm: 'video/webm',
+  mov: 'video/quicktime',
+}
 
 
 export async function GET(
 export async function GET(
   _request: NextRequest,
   _request: NextRequest,
   { params }: { params: Promise<{ path: string[] }> }
   { params }: { params: Promise<{ path: string[] }> }
 ) {
 ) {
-  const ctx = await getAuthContext();
+  const ctx = await getAuthContext()
   if (!ctx) {
   if (!ctx) {
-    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
   }
 
 
-  const segments = await params;
+  const segments = await params
   // Expected: [orgId, category, filename]
   // Expected: [orgId, category, filename]
   if (segments.path.length !== 3) {
   if (segments.path.length !== 3) {
-    return NextResponse.json({ error: "Invalid path" }, { status: 400 });
+    return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
   }
   }
 
 
-  const [orgId, category, filename] = segments.path;
+  const [orgId, category, filename] = segments.path
 
 
   // Verify user belongs to the requested org
   // Verify user belongs to the requested org
   if (orgId !== ctx.organizationId) {
   if (orgId !== ctx.organizationId) {
-    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
   }
   }
 
 
-  const allowedCategories = ["vehicles", "inventory", "services", "logos", "quotes"];
+  const allowedCategories = ['vehicles', 'inventory', 'services', 'logos', 'quotes', 'portal']
   if (!allowedCategories.includes(category)) {
   if (!allowedCategories.includes(category)) {
-    return NextResponse.json({ error: "Invalid category" }, { status: 400 });
+    return NextResponse.json({ error: 'Invalid category' }, { status: 400 })
   }
   }
 
 
   // Prevent directory traversal
   // Prevent directory traversal
-  if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
-    return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
+  if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+    return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
   }
   }
 
 
-  const filePath = path.join(process.cwd(), "data", "uploads", orgId, category, filename);
+  const filePath = path.join(process.cwd(), 'data', 'uploads', orgId, category, filename)
 
 
   try {
   try {
-    await stat(filePath);
+    await stat(filePath)
   } catch {
   } catch {
-    return NextResponse.json({ error: "Not found" }, { status: 404 });
+    return NextResponse.json({ error: 'Not found' }, { status: 404 })
   }
   }
 
 
-  const buffer = await readFile(filePath);
-  const ext = filename.split(".").pop()?.toLowerCase() || "";
-  const contentType = MIME_TYPES[ext] || "application/octet-stream";
+  const buffer = await readFile(filePath)
+  const ext = filename.split('.').pop()?.toLowerCase() || ''
+  const contentType = MIME_TYPES[ext] || 'application/octet-stream'
 
 
   return new NextResponse(buffer, {
   return new NextResponse(buffer, {
     headers: {
     headers: {
-      "Content-Type": contentType,
-      "Cache-Control": "private, max-age=31536000, immutable",
+      'Content-Type': contentType,
+      'Cache-Control': 'private, max-age=31536000, immutable',
     },
     },
-  });
+  })
 }
 }

+ 66 - 0
src/app/api/protected/upload/portal-background/route.ts

@@ -0,0 +1,66 @@
+import { NextResponse } from 'next/server'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { writeFile, mkdir, unlink } from 'fs/promises'
+import path from 'path'
+import { randomUUID } from 'crypto'
+import { db } from '@/lib/db'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+export async function POST(request: Request) {
+  try {
+    const ctx = await getAuthContext()
+
+    if (!ctx) {
+      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+    }
+
+    const formData = await request.formData()
+    const file = formData.get('file') as File | null
+
+    if (!file) {
+      return NextResponse.json({ error: 'No file provided' }, { status: 400 })
+    }
+
+    if (!file.type.startsWith('image/')) {
+      return NextResponse.json({ error: 'Only image files are allowed' }, { status: 400 })
+    }
+
+    // Backgrounds are allowed to be larger than logos.
+    if (file.size > 8 * 1024 * 1024) {
+      return NextResponse.json({ error: 'File size must be under 8MB' }, { status: 400 })
+    }
+
+    const ext = file.name.split('.').pop() || 'png'
+    const fileName = `${randomUUID()}.${ext}`
+    const uploadDir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId, 'portal')
+
+    await mkdir(uploadDir, { recursive: true })
+
+    const buffer = Buffer.from(await file.arrayBuffer())
+    await writeFile(path.join(uploadDir, fileName), buffer)
+
+    const url = `/api/protected/files/${ctx.organizationId}/portal/${fileName}`
+
+    // Delete the previous background image if one exists.
+    const previous = await db.appSetting.findFirst({
+      where: {
+        organizationId: ctx.organizationId,
+        key: SETTING_KEYS.PORTAL_BACKGROUND_IMAGE,
+      },
+      select: { value: true },
+    })
+    if (previous?.value) {
+      try {
+        await unlink(resolveUploadPath(previous.value))
+      } catch {
+        // File may already be gone — best effort.
+      }
+    }
+
+    return NextResponse.json({ url, fileName })
+  } catch (error) {
+    console.error('[Portal Background Upload] Error:', error)
+    return NextResponse.json({ error: 'Failed to upload background' }, { status: 500 })
+  }
+}

+ 63 - 0
src/app/api/public/portal-bg/[orgId]/route.ts

@@ -0,0 +1,63 @@
+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'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+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 setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: {
+        organizationId: orgId,
+        key: SETTING_KEYS.PORTAL_BACKGROUND_IMAGE,
+      },
+    },
+    select: { value: true },
+  })
+
+  if (!setting?.value) {
+    return NextResponse.json({ error: 'No background' }, { status: 404 })
+  }
+
+  const urlParts = setting.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, 'portal', 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',
+    },
+  })
+}

+ 57 - 56
src/app/api/public/portal/[orgId]/auth/request/route.ts

@@ -1,41 +1,44 @@
-import { NextResponse } from "next/server";
-import { randomBytes } from "crypto";
-import { db } from "@/lib/db";
-import { rateLimit } from "@/lib/rate-limit";
-import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { MAGIC_LINK_DURATION } from "@/lib/customer-session";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-
-export async function POST(
-  request: Request,
-  { params }: { params: Promise<{ orgId: string }> },
-) {
-  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 });
-  if (rateLimitResponse) return rateLimitResponse;
-
-  const { orgId: orgParam } = await params;
+import { NextResponse } from 'next/server'
+import { randomBytes } from 'crypto'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { MAGIC_LINK_DURATION } from '@/lib/customer-session'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 })
+  if (rateLimitResponse) return rateLimitResponse
+
+  const { orgId: orgParam } = await params
 
 
   try {
   try {
-    const body = await request.json();
-    const email = (body.email as string)?.trim().toLowerCase();
+    const body = await request.json()
+    const email = (body.email as string)?.trim().toLowerCase()
 
 
     if (!email) {
     if (!email) {
-      return NextResponse.json(
-        { error: "Email is required" },
-        { status: 400 },
-      );
+      return NextResponse.json({ error: 'Email is required' }, { status: 400 })
     }
     }
 
 
     // Resolve slug or id to real org
     // Resolve slug or id to real org
-    const org = await resolvePortalOrg(orgParam);
+    const org = await resolvePortalOrg(orgParam)
 
 
     if (!org) {
     if (!org) {
       // Don't leak org existence - return generic success
       // Don't leak org existence - return generic success
-      return NextResponse.json({ success: true });
+      return NextResponse.json({ success: true })
     }
     }
 
 
-    const orgId = org.id;
+    const orgId = org.id
 
 
     // Check portal is enabled
     // Check portal is enabled
     const portalSetting = await db.appSetting.findUnique({
     const portalSetting = await db.appSetting.findUnique({
@@ -45,10 +48,10 @@ export async function POST(
           key: SETTING_KEYS.PORTAL_ENABLED,
           key: SETTING_KEYS.PORTAL_ENABLED,
         },
         },
       },
       },
-    });
+    })
 
 
-    if (portalSetting?.value !== "true") {
-      return NextResponse.json({ success: true });
+    if (portalSetting?.value !== 'true') {
+      return NextResponse.json({ success: true })
     }
     }
 
 
     // Look up customer
     // Look up customer
@@ -58,15 +61,15 @@ export async function POST(
         organizationId: orgId,
         organizationId: orgId,
       },
       },
       select: { id: true, name: true },
       select: { id: true, name: true },
-    });
+    })
 
 
     if (!customer) {
     if (!customer) {
       // Don't leak customer existence
       // Don't leak customer existence
-      return NextResponse.json({ success: true });
+      return NextResponse.json({ success: true })
     }
     }
 
 
     // Create magic link
     // Create magic link
-    const token = randomBytes(32).toString("hex");
+    const token = randomBytes(32).toString('hex')
     await db.customerMagicLink.create({
     await db.customerMagicLink.create({
       data: {
       data: {
         token,
         token,
@@ -74,40 +77,38 @@ export async function POST(
         organizationId: orgId,
         organizationId: orgId,
         expiresAt: new Date(Date.now() + MAGIC_LINK_DURATION),
         expiresAt: new Date(Date.now() + MAGIC_LINK_DURATION),
       },
       },
-    });
+    })
 
 
     // Send email - use the URL param (slug) so the verify link matches the user's URL
     // Send email - use the URL param (slug) so the verify link matches the user's URL
     const appUrl =
     const appUrl =
       process.env.NEXT_PUBLIC_APP_URL ||
       process.env.NEXT_PUBLIC_APP_URL ||
-      (process.env.VERCEL_URL
-        ? `https://${process.env.VERCEL_URL}`
-        : "http://localhost:3000");
+      (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+
+    const magicLinkUrl = `${appUrl}/portal/${orgParam}/auth/verify?token=${token}`
+    const fromAddress = await getOrgFromAddress(orgId)
 
 
-    const magicLinkUrl = `${appUrl}/portal/${orgParam}/auth/verify?token=${token}`;
-    const fromAddress = await getOrgFromAddress(orgId);
+    // Plain, left-aligned email. We include the magic link both as a
+    // clickable anchor and as a raw URL on its own line so customers can
+    // copy/paste it if the auto-link does not work in their mail client.
+    const escapedName = escapeHtml(customer.name)
+    const escapedOrg = escapeHtml(org.name)
+    const escapedUrl = escapeHtml(magicLinkUrl)
 
 
     await sendOrgMail(orgId, {
     await sendOrgMail(orgId, {
       from: fromAddress,
       from: fromAddress,
       to: email,
       to: email,
       subject: `Sign in to ${org.name} portal`,
       subject: `Sign in to ${org.name} portal`,
-      html: `
-        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
-          <h2>Sign in to ${org.name}</h2>
-          <p>Hi ${customer.name},</p>
-          <p>Click the button below to sign in to your customer portal:</p>
-          <p style="text-align: center; margin: 32px 0;">
-            <a href="${magicLinkUrl}" style="background-color: #0f172a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; display: inline-block;">
-              Sign In
-            </a>
-          </p>
-          <p style="color: #64748b; font-size: 14px;">This link expires in 15 minutes. If you didn't request this, you can ignore this email.</p>
-        </div>
-      `,
-    });
-
-    return NextResponse.json({ success: true });
+      html: `<div style="font-family: -apple-system, Segoe UI, Helvetica, Arial, sans-serif; font-size: 15px; color: #0f172a; line-height: 1.55;">
+<p>Hi ${escapedName},</p>
+<p>Use the link below to sign in to the ${escapedOrg} customer portal:</p>
+<p><a href="${escapedUrl}">${escapedUrl}</a></p>
+<p>This link expires in 15 minutes. If you didn't request this, you can ignore this email.</p>
+</div>`,
+    })
+
+    return NextResponse.json({ success: true })
   } catch (error) {
   } catch (error) {
-    console.error("[portal-auth-request]", error);
-    return NextResponse.json({ success: true });
+    console.error('[portal-auth-request]', error)
+    return NextResponse.json({ success: true })
   }
   }
 }
 }

+ 109 - 0
src/app/api/public/portal/[orgId]/auth/sms-request/route.ts

@@ -0,0 +1,109 @@
+import { NextResponse } from 'next/server'
+import { randomInt } from 'crypto'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { getOrgSmsProvider, sendOrgSms } from '@/lib/sms'
+import { getPhoneLookupVariants, normalizePortalPhone } from '@/lib/portal-phone'
+
+export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 })
+  if (rateLimitResponse) return rateLimitResponse
+
+  const { orgId: orgParam } = await params
+
+  try {
+    const body = await request.json()
+    const phone = (body.phone as string)?.trim()
+
+    if (!phone) {
+      return NextResponse.json({ error: 'Phone is required' }, { status: 400 })
+    }
+
+    // Resolve slug or id to real org
+    const org = await resolvePortalOrg(orgParam)
+
+    if (!org) {
+      // Don't leak org existence - return generic success
+      return NextResponse.json({ success: true })
+    }
+
+    const orgId = org.id
+
+    // Fetch the relevant org settings in one query.
+    const settings = await db.appSetting.findMany({
+      where: {
+        organizationId: orgId,
+        key: {
+          in: [SETTING_KEYS.PORTAL_ENABLED, SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE],
+        },
+      },
+      select: { key: true, value: true },
+    })
+    const settingMap = new Map(settings.map((s) => [s.key, s.value]))
+
+    if (settingMap.get(SETTING_KEYS.PORTAL_ENABLED) !== 'true') {
+      return NextResponse.json({ success: true })
+    }
+
+    // Check SMS is configured for this org - don't leak that it isn't
+    const smsProvider = await getOrgSmsProvider(orgId)
+    if (!smsProvider) {
+      return NextResponse.json({ success: true })
+    }
+
+    // Normalize the phone using the workshop's default country code as a
+    // fallback. Customers don't always type the country code, and existing
+    // customer records may not have it stored either.
+    const defaultCountryCode = settingMap.get(SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE) ?? null
+    const e164 = normalizePortalPhone(phone, defaultCountryCode)
+    if (!e164) {
+      return NextResponse.json({ success: true })
+    }
+
+    // Look up customer trying multiple variants of the phone — covers
+    // legacy records stored without a country code or with a trunk prefix.
+    const phoneVariants = getPhoneLookupVariants(e164, defaultCountryCode)
+    const customer = await db.customer.findFirst({
+      where: {
+        organizationId: orgId,
+        phone: { in: phoneVariants },
+      },
+      select: { id: true, name: true },
+    })
+
+    if (!customer) {
+      // Don't leak customer existence
+      return NextResponse.json({ success: true })
+    }
+
+    // Generate a 6-digit code
+    const code = String(randomInt(0, 1_000_000)).padStart(6, '0')
+
+    await db.customerSmsCode.create({
+      data: {
+        code,
+        phone: e164,
+        organizationId: orgId,
+        expiresAt: new Date(Date.now() + 15 * 60 * 1000),
+      },
+    })
+
+    // Send the SMS. If it fails, log but still return generic success so we
+    // don't leak provider errors to the caller.
+    try {
+      await sendOrgSms(orgId, {
+        to: e164,
+        body: `${code} is your sign-in code for ${org.name}. Expires in 15 minutes.`,
+      })
+    } catch (smsError) {
+      console.error('[portal-auth-sms-request] sms send failed', smsError)
+    }
+
+    return NextResponse.json({ success: true })
+  } catch (error) {
+    console.error('[portal-auth-sms-request]', error)
+    return NextResponse.json({ success: true })
+  }
+}

+ 126 - 0
src/app/api/public/portal/[orgId]/auth/sms-verify/route.ts

@@ -0,0 +1,126 @@
+import { NextResponse } from 'next/server'
+import { randomBytes } from 'crypto'
+import { cookies } from 'next/headers'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { CUSTOMER_SESSION_COOKIE, CUSTOMER_SESSION_DURATION } from '@/lib/customer-session'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { getPhoneLookupVariants, normalizePortalPhone } from '@/lib/portal-phone'
+
+export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 })
+  if (rateLimitResponse) return rateLimitResponse
+
+  const { orgId: orgParam } = await params
+
+  try {
+    const body = await request.json()
+    const phone = typeof body.phone === 'string' ? body.phone.trim() : ''
+    const code = typeof body.code === 'string' ? body.code.trim() : ''
+
+    if (!phone || !code || code.length !== 6) {
+      return NextResponse.json({ success: false, error: 'Invalid request' }, { status: 400 })
+    }
+
+    // Resolve slug or id to real org
+    const org = await resolvePortalOrg(orgParam)
+
+    if (!org) {
+      return NextResponse.json(
+        { success: false, error: 'Invalid or expired code' },
+        { status: 400 }
+      )
+    }
+
+    const orgId = org.id
+
+    // Normalize the phone with the workshop's default country code so the
+    // lookup matches what sms-request stored (always E.164).
+    const countrySetting = await db.appSetting.findUnique({
+      where: {
+        organizationId_key: {
+          organizationId: orgId,
+          key: SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE,
+        },
+      },
+      select: { value: true },
+    })
+    const defaultCountryCode = countrySetting?.value ?? null
+    const e164 = normalizePortalPhone(phone, defaultCountryCode)
+    if (!e164) {
+      return NextResponse.json(
+        { success: false, error: 'Invalid or expired code' },
+        { status: 400 }
+      )
+    }
+
+    const codeRow = await db.customerSmsCode.findFirst({
+      where: {
+        phone: e164,
+        code,
+        organizationId: orgId,
+        usedAt: null,
+        expiresAt: { gt: new Date() },
+      },
+      orderBy: { createdAt: 'desc' },
+    })
+
+    if (!codeRow) {
+      return NextResponse.json(
+        { success: false, error: 'Invalid or expired code' },
+        { status: 400 }
+      )
+    }
+
+    // Mark the code as used
+    await db.customerSmsCode.update({
+      where: { id: codeRow.id },
+      data: { usedAt: new Date() },
+    })
+
+    // Find the customer — try the same variants as sms-request so legacy
+    // records without country codes still resolve.
+    const phoneVariants = getPhoneLookupVariants(e164, defaultCountryCode)
+    const customer = await db.customer.findFirst({
+      where: {
+        organizationId: orgId,
+        phone: { in: phoneVariants },
+      },
+      select: { id: true },
+    })
+
+    if (!customer) {
+      return NextResponse.json(
+        { success: false, error: 'Invalid or expired code' },
+        { status: 400 }
+      )
+    }
+
+    // Create session
+    const sessionToken = randomBytes(32).toString('hex')
+    const expires = new Date(Date.now() + CUSTOMER_SESSION_DURATION)
+    await db.customerSession.create({
+      data: {
+        token: sessionToken,
+        customerId: customer.id,
+        organizationId: orgId,
+        expiresAt: expires,
+      },
+    })
+
+    const cookieStore = await cookies()
+    cookieStore.set(CUSTOMER_SESSION_COOKIE, sessionToken, {
+      httpOnly: true,
+      secure: process.env.NODE_ENV === 'production',
+      sameSite: 'lax',
+      path: '/portal',
+      maxAge: CUSTOMER_SESSION_DURATION / 1000,
+    })
+
+    return NextResponse.json({ success: true })
+  } catch (error) {
+    console.error('[portal-auth-sms-verify]', error)
+    return NextResponse.json({ success: false, error: 'Invalid or expired code' }, { status: 400 })
+  }
+}

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

@@ -1,261 +1,61 @@
-import { NextResponse } from "next/server";
-import { renderToBuffer } from "@react-pdf/renderer";
-import "@/features/vehicles/Components/invoice-pdf/fonts";
-import { headers } from "next/headers";
-import { db } from "@/lib/db";
-import { InvoicePDF } from "@/features/vehicles/Components/InvoicePDF";
-import React from "react";
-import { readFile } from "fs/promises";
-import { resolveUploadPath } from "@/lib/resolve-upload-path";
-import { getFeatures } from "@/lib/features";
-import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
-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";
+import { NextResponse } from 'next/server'
+import { headers } from 'next/headers'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { buildInvoicePdfBuffer } from '@/features/invoices/Pdf/buildInvoicePdfBuffer'
 
 
 export async function GET(
 export async function GET(
-  _request: Request,
+  request: Request,
   { params }: { params: Promise<{ orgId: string; token: string }> }
   { params }: { params: Promise<{ orgId: string; token: string }> }
 ) {
 ) {
-  const limited = rateLimit(_request, { limit: 20, windowMs: 60_000 });
-  if (limited) return limited;
+  const limited = rateLimit(request, { limit: 20, windowMs: 60_000 })
+  if (limited) return limited
 
 
   try {
   try {
-    const { orgId: orgParam, token } = await params;
+    const { orgId: orgParam, token } = await params
 
 
     // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
     // 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 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;
-    } catch {
-      pdfMessages = (await import(`../../../../../../../../../messages/en/pdf.json`)).default;
-    }
-    const labels = {
-      ...pdfMessages.invoice,
-      ...pdfMessages.common,
-    };
+    const resolvedOrg = await resolvePortalOrg(orgParam)
+    const orgId = resolvedOrg?.id ?? orgParam
 
 
+    // Look up the record by public share token and verify it belongs to the
+    // org in the URL. The token lookup must stay in the route because the
+    // shared helper takes a service-record id.
     const record = await db.serviceRecord.findUnique({
     const record = await db.serviceRecord.findUnique({
       where: { publicToken: token },
       where: { publicToken: token },
-      include: {
-        partItems: true,
-        laborItems: true,
-        payments: { orderBy: { date: "desc" } },
-        // Linked technician (FK) is the source of truth for the tech name;
-        // the legacy `techName` string is only a fallback for old records.
-        technician: { select: { name: true } },
-        vehicle: {
-          select: {
-            make: true,
-            model: true,
-            year: true,
-            vin: true,
-            licensePlate: true,
-            mileage: true,
-            userId: true,
-            organizationId: true,
-            customer: {
-              select: {
-                name: true,
-                email: true,
-                phone: true,
-                address: true,
-                company: true,
-                taxId: true,
-              },
-            },
-          },
-        },
+      select: {
+        id: true,
+        vehicle: { select: { organizationId: true } },
       },
       },
-    });
+    })
 
 
     if (!record || record.vehicle.organizationId !== orgId) {
     if (!record || record.vehicle.organizationId !== orgId) {
-      return NextResponse.json({ error: "Not found" }, { status: 404 });
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
     }
     }
 
 
-    // Fetch findings for this service record (open ones to show on invoice)
-    const findings = await db.vehicleFinding.findMany({
-      where: { serviceRecordId: record.id, status: { not: "resolved" } },
-      select: { description: true, severity: true, notes: true },
-      orderBy: { createdAt: "desc" },
-    });
-
-    // Fetch custom field values for this service record
-    const customFieldValues = await db.customFieldValue.findMany({
-      where: { entityId: record.id, entityType: "service_record" },
-      include: { field: { select: { label: true, fieldType: true, isActive: true, sortOrder: true } } },
-      orderBy: { field: { sortOrder: "asc" } },
-    });
-
-    const customFields = customFieldValues
-      .filter(v => v.field.isActive && v.value)
-      .map(v => ({ fieldId: v.fieldId, label: v.field.label, value: v.value, fieldType: v.field.fieldType }));
-
-    const [settings, org] = await Promise.all([
-      db.appSetting.findMany({
-        where: { organizationId: record.vehicle.organizationId },
-      }),
-      record.vehicle.organizationId
-        ? db.organization.findUnique({
-            where: { id: record.vehicle.organizationId },
-            select: { name: true, portalSlug: true },
-          })
-        : null,
-    ]);
-
-    const settingsMap: Record<string, string> = {};
-    for (const s of settings) settingsMap[s.key] = s.value;
+    const headerStore = await headers()
+    const result = await buildInvoicePdfBuffer(record.id, headerStore.get('accept-language'))
 
 
-    // Override labels for marine service type
-    const serviceType = settingsMap["workshop.serviceType"] || "automotive";
-    if (serviceType === "marine") {
-      if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine;
-      if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine;
-      if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine;
-      if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine;
-      // Override unit labels for engine hours
-      labels.km = "hrs";
-      labels.mi = "hrs";
+    if (!result) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
     }
     }
 
 
-    // Custom tax label override (e.g. "VAT", "MVA", "GST", "MwSt.")
-    const customTaxLabel = settingsMap["workshop.taxLabel"]?.trim();
-    if (customTaxLabel) {
-      labels.tax = `${customTaxLabel} ({rate}%)`;
-    }
-
-    // Load logo
-    let logoDataUri: string | undefined;
-    const logoPath = settingsMap["workshop.logo"];
-    if (logoPath) {
-      try {
-        const fullPath = resolveUploadPath(logoPath);
-        const logoBuffer = await readFile(fullPath);
-        const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
-        const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml" };
-        const mime = mimeMap[ext] || "image/png";
-        logoDataUri = `data:${mime};base64,${logoBuffer.toString("base64")}`;
-      } catch {
-        // Skip
+    return new NextResponse(
+      result.buffer.buffer.slice(
+        result.buffer.byteOffset,
+        result.buffer.byteOffset + result.buffer.byteLength
+      ) as ArrayBuffer,
+      {
+        headers: {
+          'Content-Type': 'application/pdf',
+          'Content-Disposition': `attachment; filename="${result.filename}"`,
+          'X-Robots-Tag': 'noindex, nofollow',
+        },
       }
       }
-    }
-
-    const invoiceSettings = {
-      bankAccount: settingsMap["invoice.bankAccount"] || "",
-      orgNumber: settingsMap["invoice.orgNumber"] || "",
-      paymentTerms: settingsMap["invoice.paymentTerms"] || "",
-      footerNote: settingsMap["invoice.footerNote"] || "",
-      showBankAccount: settingsMap["invoice.showBankAccount"] === "true",
-      showOrgNumber: settingsMap["invoice.showOrgNumber"] === "true",
-      dueDays: Number(settingsMap["invoice.dueDays"]) || 0,
-      currencyCode: settingsMap["workshop.currencyCode"] || "USD",
-      unitSystem: settingsMap["workshop.unitSystem"] || "imperial",
-      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
-      timezone: settingsMap["workshop.timezone"] || undefined,
-    };
-
-    const pdfDateFormat = settingsMap["workshop.dateFormat"] || undefined;
-    const pdfTimezone = settingsMap["workshop.timezone"] || undefined;
-
-    const paidFromPayments = record.payments.reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
-    const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost;
-    const totalPaidForPdf = record.manuallyPaid ? effectiveTotal : paidFromPayments;
-
-    const paymentSummary = record.payments.length > 0 || record.manuallyPaid
-      ? {
-          totalPaid: totalPaidForPdf,
-          payments: record.payments.map((p: { amount: number; date: Date; method: string }) => ({
-            amount: p.amount,
-            date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
-            method: p.method,
-          })),
-        }
-      : undefined;
-
-    // Fetch layout config
-    const layoutConfigSetting = await db.appSetting.findUnique({
-      where: { organizationId_key: { organizationId: record.vehicle.organizationId, key: "invoice.layoutConfig" } },
-    });
-    const layoutConfig = mergeWithDefaults(
-      layoutConfigSetting?.value ? JSON.parse(layoutConfigSetting.value) : {}
-    );
-
-    const template = {
-      primaryColor: settingsMap["invoice.primaryColor"] || "#d97706",
-      fontFamily: settingsMap["invoice.fontFamily"] || "Helvetica",
-      showLogo: settingsMap["invoice.showLogo"] !== "false",
-      showCompanyName: settingsMap["invoice.showCompanyName"] !== "false",
-      headerStyle: settingsMap["invoice.headerStyle"] || "standard",
-      logoSize: Number(settingsMap["invoice.logoSize"]) || 100,
-      layoutConfig,
-    };
-
-    // Check if Torqvoice branding should be shown
-    const features = await getFeatures(record.vehicle.organizationId);
-    let torqvoiceLogoDataUri: string | undefined;
-    if (!features.brandingRemoved) {
-      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
-    }
-
-    const appUrl = process.env.NEXT_PUBLIC_APP_URL
-      || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
-    const portalSlug = org?.portalSlug;
-    const portalEnabled = settingsMap["portal.enabled"] === "true";
-    const portalUrl = portalEnabled
-      ? `${appUrl}/portal/${portalSlug || orgId}`
-      : undefined;
-
-    // Generate Telegram QR if the telegram_qr section is visible in layout
-    let telegramQrDataUri: string | undefined;
-    const telegramBotUsername = settingsMap["telegram.botUsername"];
-    const telegramQrVisible = layoutConfig.sections.some(
-      (s: { id: string; visible: boolean }) => s.id === "telegram_qr" && s.visible
-    );
-    if (telegramBotUsername && telegramQrVisible) {
-      const { generateQrDataUri } = await import("@/lib/qr");
-      telegramQrDataUri = await generateQrDataUri(`https://t.me/${telegramBotUsername}`, 200);
-    }
-
-    const element = React.createElement(InvoicePDF, {
-      data: { ...record, customFields, findings },
-      workshop: {
-        name: org?.name || "",
-        address: settingsMap["workshop.address"] || "",
-        phone: settingsMap["workshop.phone"] || "",
-        email: settingsMap["workshop.email"] || "",
-      },
-      invoiceSettings,
-      paymentSummary,
-      logoDataUri,
-      template,
-      torqvoiceLogoDataUri,
-      portalUrl,
-      telegramQrDataUri,
-      telegramLabel: labels?.telegramConnect || "Chat with us on Telegram",
-      labels,
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    }) as any;
-    const buffer = await renderToBuffer(element);
-
-    const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
-
-    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
-      headers: {
-        "Content-Type": "application/pdf",
-        "Content-Disposition": `attachment; filename="${invoiceNum}.pdf"`,
-        "X-Robots-Tag": "noindex, nofollow",
-      },
-    });
+    )
   } catch (error) {
   } catch (error) {
-    console.error("[Public PDF] Error:", error);
-    return NextResponse.json({ error: "Failed to generate PDF" }, { status: 500 });
+    console.error('[Public PDF] Error:', error)
+    return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
   }
   }
 }
 }

+ 1 - 1
src/app/layout.tsx

@@ -91,7 +91,7 @@ export default async function RootLayout({
         <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
         <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
         <script
         <script
           dangerouslySetInnerHTML={{
           dangerouslySetInnerHTML={{
-            __html: `(function(){try{if(location.pathname.indexOf('/share/')===0||location.pathname.indexOf('/share/')>0){document.documentElement.classList.add('light');return}var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){}})()`,
+            __html: `(function(){try{var p=location.pathname;if(p.indexOf('/share/')===0||p.indexOf('/portal')===0){document.documentElement.classList.add('light');return}var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){}})()`,
           }}
           }}
         />
         />
       </head>
       </head>

+ 285 - 0
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -0,0 +1,285 @@
+/**
+ * Shared invoice PDF builder.
+ *
+ * This helper renders an invoice PDF for a given service record id. It is used
+ * by both the public share-token PDF route and the customer-portal session PDF
+ * route. The caller is responsible for verifying that the requester is allowed
+ * to access the record — this helper trusts the caller and only validates that
+ * the record exists.
+ *
+ * The file is intentionally long because building the PDF requires:
+ *   - loading the service record with all related items
+ *   - loading findings and custom fields
+ *   - loading workshop settings, organization info and the layout config
+ *   - resolving the customer locale and PDF translations
+ *   - reading the workshop logo from disk and converting it to a data URI
+ *   - optionally generating a Telegram QR code
+ *   - resolving Torqvoice branding visibility
+ *   - constructing the InvoicePDF React element and rendering it to a buffer
+ *
+ * Splitting these into separate modules would just spread the same logic
+ * across multiple files without making it easier to read, so they live here
+ * with a couple of small internal helpers.
+ */
+
+import { renderToBuffer } from '@react-pdf/renderer'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import React from 'react'
+import { readFile } from 'fs/promises'
+import { db } from '@/lib/db'
+import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { getFeatures } from '@/lib/features'
+import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
+import { formatDateForPdf } from '@/lib/format'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+
+type PdfMessages = Record<string, Record<string, string>>
+
+async function loadPdfMessages(locale: string): Promise<PdfMessages> {
+  try {
+    return (await import(`../../../../messages/${locale}/pdf.json`)).default
+  } catch {
+    return (await import(`../../../../messages/en/pdf.json`)).default
+  }
+}
+
+async function loadLogoDataUri(logoPath: string | undefined): Promise<string | undefined> {
+  if (!logoPath) return undefined
+  try {
+    const fullPath = resolveUploadPath(logoPath)
+    const logoBuffer = await readFile(fullPath)
+    const ext = logoPath.split('.').pop()?.toLowerCase() || 'png'
+    const mimeMap: Record<string, string> = {
+      png: 'image/png',
+      jpg: 'image/jpeg',
+      jpeg: 'image/jpeg',
+      webp: 'image/webp',
+      svg: 'image/svg+xml',
+    }
+    const mime = mimeMap[ext] || 'image/png'
+    return `data:${mime};base64,${logoBuffer.toString('base64')}`
+  } catch {
+    return undefined
+  }
+}
+
+export async function buildInvoicePdfBuffer(
+  serviceRecordId: string,
+  acceptLanguageHeader: string | null
+): Promise<{ buffer: Uint8Array; filename: string } | null> {
+  const record = await db.serviceRecord.findUnique({
+    where: { id: serviceRecordId },
+    include: {
+      partItems: true,
+      laborItems: true,
+      payments: { orderBy: { date: 'desc' } },
+      // Linked technician (FK) is the source of truth for the tech name;
+      // the legacy `techName` string is only a fallback for old records.
+      technician: { select: { name: true } },
+      vehicle: {
+        select: {
+          make: true,
+          model: true,
+          year: true,
+          vin: true,
+          licensePlate: true,
+          mileage: true,
+          userId: true,
+          organizationId: true,
+          customer: {
+            select: {
+              name: true,
+              email: true,
+              phone: true,
+              address: true,
+              company: true,
+              taxId: true,
+            },
+          },
+        },
+      },
+    },
+  })
+
+  if (!record) return null
+
+  const orgId = record.vehicle.organizationId
+  if (!orgId) return null
+
+  // Resolve locale + load PDF translations
+  const locale = await resolveCustomerLocale(orgId, acceptLanguageHeader)
+  const pdfMessages = await loadPdfMessages(locale)
+  const labels = {
+    ...pdfMessages.invoice,
+    ...pdfMessages.common,
+  }
+
+  // Fetch findings for this service record (open ones to show on invoice)
+  const findings = await db.vehicleFinding.findMany({
+    where: { serviceRecordId: record.id, status: { not: 'resolved' } },
+    select: { description: true, severity: true, notes: true },
+    orderBy: { createdAt: 'desc' },
+  })
+
+  // Fetch custom field values for this service record
+  const customFieldValues = await db.customFieldValue.findMany({
+    where: { entityId: record.id, entityType: 'service_record' },
+    include: {
+      field: { select: { label: true, fieldType: true, isActive: true, sortOrder: true } },
+    },
+    orderBy: { field: { sortOrder: 'asc' } },
+  })
+
+  const customFields = customFieldValues
+    .filter((v) => v.field.isActive && v.value)
+    .map((v) => ({
+      fieldId: v.fieldId,
+      label: v.field.label,
+      value: v.value,
+      fieldType: v.field.fieldType,
+    }))
+
+  const [settings, org] = await Promise.all([
+    db.appSetting.findMany({
+      where: { organizationId: orgId },
+    }),
+    orgId
+      ? db.organization.findUnique({
+          where: { id: orgId },
+          select: { name: true, portalSlug: true },
+        })
+      : null,
+  ])
+
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
+
+  // Override labels for marine service type
+  const serviceType = settingsMap['workshop.serviceType'] || 'automotive'
+  if (serviceType === 'marine') {
+    if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine
+    if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine
+    if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine
+    if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine
+    // Override unit labels for engine hours
+    labels.km = 'hrs'
+    labels.mi = 'hrs'
+  }
+
+  // Custom tax label override (e.g. "VAT", "MVA", "GST", "MwSt.")
+  const customTaxLabel = settingsMap['workshop.taxLabel']?.trim()
+  if (customTaxLabel) {
+    labels.tax = `${customTaxLabel} ({rate}%)`
+  }
+
+  const logoDataUri = await loadLogoDataUri(settingsMap['workshop.logo'])
+
+  const invoiceSettings = {
+    bankAccount: settingsMap['invoice.bankAccount'] || '',
+    orgNumber: settingsMap['invoice.orgNumber'] || '',
+    paymentTerms: settingsMap['invoice.paymentTerms'] || '',
+    footerNote: settingsMap['invoice.footerNote'] || '',
+    showBankAccount: settingsMap['invoice.showBankAccount'] === 'true',
+    showOrgNumber: settingsMap['invoice.showOrgNumber'] === 'true',
+    dueDays: Number(settingsMap['invoice.dueDays']) || 0,
+    currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
+    unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
+    dateFormat: settingsMap['workshop.dateFormat'] || undefined,
+    timezone: settingsMap['workshop.timezone'] || undefined,
+  }
+
+  const pdfDateFormat = settingsMap['workshop.dateFormat'] || undefined
+  const pdfTimezone = settingsMap['workshop.timezone'] || undefined
+
+  const paidFromPayments = record.payments.reduce(
+    (sum: number, p: { amount: number }) => sum + p.amount,
+    0
+  )
+  const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
+  const totalPaidForPdf = record.manuallyPaid ? effectiveTotal : paidFromPayments
+
+  const paymentSummary =
+    record.payments.length > 0 || record.manuallyPaid
+      ? {
+          totalPaid: totalPaidForPdf,
+          payments: record.payments.map((p: { amount: number; date: Date; method: string }) => ({
+            amount: p.amount,
+            date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
+            method: p.method,
+          })),
+        }
+      : undefined
+
+  // Fetch layout config
+  const layoutConfigSetting = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId: orgId, key: 'invoice.layoutConfig' } },
+  })
+  const layoutConfig = mergeWithDefaults(
+    layoutConfigSetting?.value ? JSON.parse(layoutConfigSetting.value) : {}
+  )
+
+  const template = {
+    primaryColor: settingsMap['invoice.primaryColor'] || '#d97706',
+    fontFamily: settingsMap['invoice.fontFamily'] || 'Helvetica',
+    showLogo: settingsMap['invoice.showLogo'] !== 'false',
+    showCompanyName: settingsMap['invoice.showCompanyName'] !== 'false',
+    headerStyle: settingsMap['invoice.headerStyle'] || 'standard',
+    logoSize: Number(settingsMap['invoice.logoSize']) || 100,
+    layoutConfig,
+  }
+
+  // Check if Torqvoice branding should be shown
+  const features = await getFeatures(orgId)
+  let torqvoiceLogoDataUri: string | undefined
+  if (!features.brandingRemoved) {
+    torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
+  }
+
+  const appUrl =
+    process.env.NEXT_PUBLIC_APP_URL ||
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const portalSlug = org?.portalSlug
+  const portalEnabled = settingsMap['portal.enabled'] === 'true'
+  const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
+
+  // Generate Telegram QR if the telegram_qr section is visible in layout
+  let telegramQrDataUri: string | undefined
+  const telegramBotUsername = settingsMap['telegram.botUsername']
+  const telegramQrVisible = layoutConfig.sections.some(
+    (s: { id: string; visible: boolean }) => s.id === 'telegram_qr' && s.visible
+  )
+  if (telegramBotUsername && telegramQrVisible) {
+    const { generateQrDataUri } = await import('@/lib/qr')
+    telegramQrDataUri = await generateQrDataUri(`https://t.me/${telegramBotUsername}`, 200)
+  }
+
+  const element = React.createElement(InvoicePDF, {
+    data: { ...record, customFields, findings },
+    workshop: {
+      name: org?.name || '',
+      address: settingsMap['workshop.address'] || '',
+      phone: settingsMap['workshop.phone'] || '',
+      email: settingsMap['workshop.email'] || '',
+    },
+    invoiceSettings,
+    paymentSummary,
+    logoDataUri,
+    template,
+    torqvoiceLogoDataUri,
+    portalUrl,
+    telegramQrDataUri,
+    telegramLabel: labels?.telegramConnect || 'Chat with us on Telegram',
+    labels,
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  }) as any
+  const buffer = await renderToBuffer(element)
+
+  const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
+
+  return {
+    buffer,
+    filename: `${invoiceNum}.pdf`,
+  }
+}

+ 144 - 110
src/features/portal/Actions/portalActions.ts

@@ -1,35 +1,34 @@
-"use server";
+'use server'
 
 
-import { db } from "@/lib/db";
-import { getCustomerSession, type CustomerSessionData } from "@/lib/customer-session";
-import { notify } from "@/lib/notify";
-import { withAuth, type ActionResult } from "@/lib/with-auth";
-import { revalidatePath } from "next/cache";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { db } from '@/lib/db'
+import { getCustomerSession, type CustomerSessionData } from '@/lib/customer-session'
+import { notify } from '@/lib/notify'
+import { withAuth, type ActionResult } from '@/lib/with-auth'
+import { revalidatePath } from 'next/cache'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 
 
 type PortalResult<T = unknown> = {
 type PortalResult<T = unknown> = {
-  success: boolean;
-  data?: T;
-  error?: string;
-};
+  success: boolean
+  data?: T
+  error?: string
+}
 
 
 async function withPortalAuth<T>(
 async function withPortalAuth<T>(
-  action: (session: CustomerSessionData) => Promise<T>,
+  action: (session: CustomerSessionData) => Promise<T>
 ): Promise<PortalResult<T>> {
 ): Promise<PortalResult<T>> {
   try {
   try {
-    const session = await getCustomerSession();
+    const session = await getCustomerSession()
 
 
     if (!session) {
     if (!session) {
-      return { success: false, error: "Not authenticated" };
+      return { success: false, error: 'Not authenticated' }
     }
     }
 
 
-    const data = await action(session);
-    return { success: true, data };
+    const data = await action(session)
+    return { success: true, data }
   } catch (error) {
   } catch (error) {
-    const message =
-      error instanceof Error ? error.message : "An unexpected error occurred";
-    console.error("[portalAction]", message);
-    return { success: false, error: message };
+    const message = error instanceof Error ? error.message : 'An unexpected error occurred'
+    console.error('[portalAction]', message)
+    return { success: false, error: message }
   }
   }
 }
 }
 
 
@@ -37,7 +36,7 @@ async function withPortalAuth<T>(
 
 
 export async function getPortalDashboard() {
 export async function getPortalDashboard() {
   return withPortalAuth(async ({ customerId, organizationId }) => {
   return withPortalAuth(async ({ customerId, organizationId }) => {
-    const [customer, vehicles, recentInvoices, recentQuotes, pendingRequests] =
+    const [customer, vehicles, recentInvoices, activeJobs, recentQuotes, pendingRequests] =
       await Promise.all([
       await Promise.all([
         db.customer.findUnique({
         db.customer.findUnique({
           where: { id: customerId },
           where: { id: customerId },
@@ -47,9 +46,12 @@ export async function getPortalDashboard() {
           where: { customerId, organizationId },
           where: { customerId, organizationId },
           select: { id: true },
           select: { id: true },
         }),
         }),
+        // Recent INVOICES: only completed records — anything before that
+        // is still a work-order in progress and not really an invoice yet.
         db.serviceRecord.findMany({
         db.serviceRecord.findMany({
           where: {
           where: {
             vehicle: { customerId, organizationId },
             vehicle: { customerId, organizationId },
+            status: 'completed',
           },
           },
           select: {
           select: {
             id: true,
             id: true,
@@ -62,7 +64,24 @@ export async function getPortalDashboard() {
             publicToken: true,
             publicToken: true,
             vehicle: { select: { make: true, model: true } },
             vehicle: { select: { make: true, model: true } },
           },
           },
-          orderBy: [{ startDateTime: { sort: "desc", nulls: "last" } }, { serviceDate: "desc" }],
+          orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
+          take: 5,
+        }),
+        // Active jobs: work currently being done on the customer's vehicles.
+        db.serviceRecord.findMany({
+          where: {
+            vehicle: { customerId, organizationId },
+            status: { in: ['pending', 'in-progress', 'waiting-parts'] },
+          },
+          select: {
+            id: true,
+            title: true,
+            status: true,
+            startDateTime: true,
+            serviceDate: true,
+            vehicle: { select: { make: true, model: true } },
+          },
+          orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
           take: 5,
           take: 5,
         }),
         }),
         db.quote.findMany({
         db.quote.findMany({
@@ -77,11 +96,11 @@ export async function getPortalDashboard() {
             publicToken: true,
             publicToken: true,
             vehicle: { select: { make: true, model: true } },
             vehicle: { select: { make: true, model: true } },
           },
           },
-          orderBy: { createdAt: "desc" },
+          orderBy: { createdAt: 'desc' },
           take: 5,
           take: 5,
         }),
         }),
         db.serviceRequest.findMany({
         db.serviceRequest.findMany({
-          where: { customerId, organizationId, status: "pending" },
+          where: { customerId, organizationId, status: 'pending' },
           select: {
           select: {
             id: true,
             id: true,
             description: true,
             description: true,
@@ -90,29 +109,30 @@ export async function getPortalDashboard() {
             createdAt: true,
             createdAt: true,
             vehicle: { select: { make: true, model: true } },
             vehicle: { select: { make: true, model: true } },
           },
           },
-          orderBy: { createdAt: "desc" },
+          orderBy: { createdAt: 'desc' },
         }),
         }),
-      ]);
+      ])
 
 
-    // Count open invoices (not fully paid)
-    const allInvoices = await db.serviceRecord.findMany({
-      where: { vehicle: { customerId, organizationId } },
+    // Open invoices: completed records that the customer still owes money on.
+    // Pre-completion records are not counted — they're not yet invoices.
+    const completedInvoices = await db.serviceRecord.findMany({
+      where: { vehicle: { customerId, organizationId }, status: 'completed' },
       select: {
       select: {
         totalAmount: true,
         totalAmount: true,
         manuallyPaid: true,
         manuallyPaid: true,
         payments: { select: { amount: true } },
         payments: { select: { amount: true } },
       },
       },
-    });
+    })
 
 
-    const openInvoiceCount = allInvoices.filter((inv) => {
-      if (inv.manuallyPaid) return false;
-      const paid = inv.payments.reduce((sum, p) => sum + p.amount, 0);
-      return paid < inv.totalAmount;
-    }).length;
+    const openInvoiceCount = completedInvoices.filter((inv) => {
+      if (inv.manuallyPaid) return false
+      const paid = inv.payments.reduce((sum, p) => sum + p.amount, 0)
+      return paid < inv.totalAmount
+    }).length
 
 
     const pendingQuoteCount = await db.quote.count({
     const pendingQuoteCount = await db.quote.count({
-      where: { customerId, organizationId, status: "sent" },
-    });
+      where: { customerId, organizationId, status: 'sent' },
+    })
 
 
     return {
     return {
       customer,
       customer,
@@ -120,10 +140,11 @@ export async function getPortalDashboard() {
       openInvoiceCount,
       openInvoiceCount,
       pendingQuoteCount,
       pendingQuoteCount,
       recentInvoices,
       recentInvoices,
+      activeJobs,
       recentQuotes,
       recentQuotes,
       pendingRequests,
       pendingRequests,
-    };
-  });
+    }
+  })
 }
 }
 
 
 // ─── Vehicles ─────────────────────────────────────────────────────────────
 // ─── Vehicles ─────────────────────────────────────────────────────────────
@@ -147,10 +168,10 @@ export async function getPortalVehicles() {
           },
           },
         },
         },
       },
       },
-      orderBy: { createdAt: "desc" },
-    });
-    return vehicles;
-  });
+      orderBy: { createdAt: 'desc' },
+    })
+    return vehicles
+  })
 }
 }
 
 
 export async function getPortalVehicleDetail(vehicleId: string) {
 export async function getPortalVehicleDetail(vehicleId: string) {
@@ -180,7 +201,7 @@ export async function getPortalVehicleDetail(vehicleId: string) {
             startDateTime: true,
             startDateTime: true,
             publicToken: true,
             publicToken: true,
           },
           },
-          orderBy: [{ startDateTime: { sort: "desc", nulls: "last" } }, { serviceDate: "desc" }],
+          orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
         },
         },
         inspections: {
         inspections: {
           select: {
           select: {
@@ -192,26 +213,31 @@ export async function getPortalVehicleDetail(vehicleId: string) {
             template: { select: { name: true } },
             template: { select: { name: true } },
             items: { select: { condition: true } },
             items: { select: { condition: true } },
           },
           },
-          orderBy: { createdAt: "desc" },
+          orderBy: { createdAt: 'desc' },
         },
         },
       },
       },
-    });
+    })
 
 
     if (!vehicle) {
     if (!vehicle) {
-      throw new Error("Vehicle not found");
+      throw new Error('Vehicle not found')
     }
     }
 
 
-    return vehicle;
-  });
+    return vehicle
+  })
 }
 }
 
 
 // ─── Invoices ─────────────────────────────────────────────────────────────
 // ─── Invoices ─────────────────────────────────────────────────────────────
 
 
 export async function getPortalInvoices() {
 export async function getPortalInvoices() {
   return withPortalAuth(async ({ customerId, organizationId }) => {
   return withPortalAuth(async ({ customerId, organizationId }) => {
+    // Only completed records are surfaced as "invoices" to customers.
+    // In-progress / pending / waiting-parts records are work orders, not
+    // invoices, and showing them here causes confusion (prices may still
+    // change, customer can't act on them).
     const invoices = await db.serviceRecord.findMany({
     const invoices = await db.serviceRecord.findMany({
       where: {
       where: {
         vehicle: { customerId, organizationId },
         vehicle: { customerId, organizationId },
+        status: 'completed',
       },
       },
       select: {
       select: {
         id: true,
         id: true,
@@ -226,10 +252,10 @@ export async function getPortalInvoices() {
         vehicle: { select: { make: true, model: true } },
         vehicle: { select: { make: true, model: true } },
         payments: { select: { amount: true } },
         payments: { select: { amount: true } },
       },
       },
-      orderBy: [{ startDateTime: { sort: "desc", nulls: "last" } }, { serviceDate: "desc" }],
-    });
-    return invoices;
-  });
+      orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
+    })
+    return invoices
+  })
 }
 }
 
 
 // ─── Quotes ───────────────────────────────────────────────────────────────
 // ─── Quotes ───────────────────────────────────────────────────────────────
@@ -248,10 +274,10 @@ export async function getPortalQuotes() {
         publicToken: true,
         publicToken: true,
         vehicle: { select: { make: true, model: true } },
         vehicle: { select: { make: true, model: true } },
       },
       },
-      orderBy: { createdAt: "desc" },
-    });
-    return quotes;
-  });
+      orderBy: { createdAt: 'desc' },
+    })
+    return quotes
+  })
 }
 }
 
 
 // ─── Inspections ──────────────────────────────────────────────────────────
 // ─── Inspections ──────────────────────────────────────────────────────────
@@ -272,63 +298,61 @@ export async function getPortalInspections() {
         template: { select: { name: true } },
         template: { select: { name: true } },
         items: { select: { condition: true } },
         items: { select: { condition: true } },
       },
       },
-      orderBy: { createdAt: "desc" },
-    });
-    return inspections;
-  });
+      orderBy: { createdAt: 'desc' },
+    })
+    return inspections
+  })
 }
 }
 
 
 // ─── Service Requests ─────────────────────────────────────────────────────
 // ─── Service Requests ─────────────────────────────────────────────────────
 
 
 export async function createServiceRequest(input: {
 export async function createServiceRequest(input: {
-  vehicleId: string;
-  description: string;
-  preferredDate?: string;
+  vehicleId: string
+  description: string
+  preferredDate?: string
 }) {
 }) {
   return withPortalAuth(async ({ customerId, organizationId }) => {
   return withPortalAuth(async ({ customerId, organizationId }) => {
     // Validate vehicle belongs to customer
     // Validate vehicle belongs to customer
     const vehicle = await db.vehicle.findFirst({
     const vehicle = await db.vehicle.findFirst({
       where: { id: input.vehicleId, customerId, organizationId },
       where: { id: input.vehicleId, customerId, organizationId },
       select: { id: true, make: true, model: true },
       select: { id: true, make: true, model: true },
-    });
+    })
 
 
     if (!vehicle) {
     if (!vehicle) {
-      throw new Error("Vehicle not found");
+      throw new Error('Vehicle not found')
     }
     }
 
 
     const customer = await db.customer.findUnique({
     const customer = await db.customer.findUnique({
       where: { id: customerId },
       where: { id: customerId },
       select: { name: true },
       select: { name: true },
-    });
+    })
 
 
     const serviceRequest = await db.serviceRequest.create({
     const serviceRequest = await db.serviceRequest.create({
       data: {
       data: {
         description: input.description,
         description: input.description,
-        preferredDate: input.preferredDate
-          ? new Date(input.preferredDate)
-          : null,
+        preferredDate: input.preferredDate ? new Date(input.preferredDate) : null,
         customerId,
         customerId,
         vehicleId: input.vehicleId,
         vehicleId: input.vehicleId,
         organizationId,
         organizationId,
       },
       },
-    });
+    })
 
 
     // Notify staff
     // Notify staff
-    const vehicleName = `${vehicle.make} ${vehicle.model}`;
-    const customerName = customer?.name ?? "A customer";
+    const vehicleName = `${vehicle.make} ${vehicle.model}`
+    const customerName = customer?.name ?? 'A customer'
 
 
     await notify({
     await notify({
       organizationId,
       organizationId,
-      type: "service_request",
-      title: "New Service Request",
+      type: 'service_request',
+      title: 'New Service Request',
       message: `${customerName} requested service for ${vehicleName}: ${input.description.slice(0, 100)}`,
       message: `${customerName} requested service for ${vehicleName}: ${input.description.slice(0, 100)}`,
-      entityType: "service_request",
+      entityType: 'service_request',
       entityId: serviceRequest.id,
       entityId: serviceRequest.id,
       entityUrl: `/customers/${customerId}?tab=requests`,
       entityUrl: `/customers/${customerId}?tab=requests`,
-    });
+    })
 
 
-    return serviceRequest;
-  });
+    return serviceRequest
+  })
 }
 }
 
 
 export async function getPortalServiceRequests() {
 export async function getPortalServiceRequests() {
@@ -344,72 +368,82 @@ export async function getPortalServiceRequests() {
         createdAt: true,
         createdAt: true,
         vehicle: { select: { make: true, model: true } },
         vehicle: { select: { make: true, model: true } },
       },
       },
-      orderBy: { createdAt: "desc" },
-    });
-    return requests;
-  });
+      orderBy: { createdAt: 'desc' },
+    })
+    return requests
+  })
 }
 }
 
 
 // ─── Admin: Portal Slug ──────────────────────────────────────────────────
 // ─── Admin: Portal Slug ──────────────────────────────────────────────────
 
 
-const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{1,46}[a-z0-9]$/;
-const RESERVED_SLUGS = ["auth", "api", "admin", "login", "verify", "dashboard", "settings", "portal"];
-
-export async function updatePortalSlug(
-  slug: string | null,
-): Promise<ActionResult> {
+const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{1,46}[a-z0-9]$/
+const RESERVED_SLUGS = [
+  'auth',
+  'api',
+  'admin',
+  'login',
+  'verify',
+  'dashboard',
+  'settings',
+  'portal',
+]
+
+export async function updatePortalSlug(slug: string | null): Promise<ActionResult> {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       // Allow clearing the slug
       // Allow clearing the slug
-      if (!slug || slug.trim() === "") {
+      if (!slug || slug.trim() === '') {
         await db.organization.update({
         await db.organization.update({
           where: { id: organizationId },
           where: { id: organizationId },
           data: { portalSlug: null },
           data: { portalSlug: null },
-        });
-        revalidatePath("/settings/customer-portal");
-        return { slug: null };
+        })
+        revalidatePath('/settings/customer-portal')
+        return { slug: null }
       }
       }
 
 
-      const normalized = slug.trim().toLowerCase();
+      const normalized = slug.trim().toLowerCase()
 
 
       if (!SLUG_REGEX.test(normalized)) {
       if (!SLUG_REGEX.test(normalized)) {
         throw new Error(
         throw new Error(
-          "Slug must be 3–48 characters, lowercase alphanumeric, hyphens, or underscores.",
-        );
+          'Slug must be 3–48 characters, lowercase alphanumeric, hyphens, or underscores.'
+        )
       }
       }
 
 
       if (RESERVED_SLUGS.includes(normalized)) {
       if (RESERVED_SLUGS.includes(normalized)) {
-        throw new Error("This slug is reserved. Please choose another.");
+        throw new Error('This slug is reserved. Please choose another.')
       }
       }
 
 
       // Check uniqueness
       // Check uniqueness
       const existing = await db.organization.findUnique({
       const existing = await db.organization.findUnique({
         where: { portalSlug: normalized },
         where: { portalSlug: normalized },
         select: { id: true },
         select: { id: true },
-      });
+      })
 
 
       if (existing && existing.id !== organizationId) {
       if (existing && existing.id !== organizationId) {
-        throw new Error("This slug is already taken. Please choose another.");
+        throw new Error('This slug is already taken. Please choose another.')
       }
       }
 
 
       await db.organization.update({
       await db.organization.update({
         where: { id: organizationId },
         where: { id: organizationId },
         data: { portalSlug: normalized },
         data: { portalSlug: normalized },
-      });
+      })
 
 
-      revalidatePath("/settings/customer-portal");
-      return { slug: normalized || null };
+      revalidatePath('/settings/customer-portal')
+      return { slug: normalized || null }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
       ],
       ],
-      audit: ({ result }) => result ? ({
-        action: "settings.updatePortalSlug",
-        entity: "Organization",
-        message: result.slug ? `Set portal slug to "${result.slug}"` : "Cleared portal slug",
-        metadata: { slug: result.slug },
-      }) : null,
-    },
-  );
+      audit: ({ result }) =>
+        result
+          ? {
+              action: 'settings.updatePortalSlug',
+              entity: 'Organization',
+              message: result.slug ? `Set portal slug to "${result.slug}"` : 'Cleared portal slug',
+              metadata: { slug: result.slug },
+            }
+          : null,
+    }
+  )
 }
 }

+ 351 - 113
src/features/portal/Components/CustomerPortalSettings.tsx

@@ -1,93 +1,195 @@
-"use client";
-
-import { useState, useTransition } from "react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { Switch } from "@/components/ui/switch";
-import { Input } from "@/components/ui/input";
+'use client'
+
+import { useRef, useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
 import {
 import {
   Card,
   Card,
   CardContent,
   CardContent,
   CardDescription,
   CardDescription,
+  CardFooter,
   CardHeader,
   CardHeader,
   CardTitle,
   CardTitle,
-} from "@/components/ui/card";
-import { Copy, Check, Globe, Loader2 } from "lucide-react";
+} from '@/components/ui/card'
+import { Copy, Check, Globe, Loader2, Image as ImageIcon, Trash2, Upload } from 'lucide-react'
+import { ReadOnlyBanner, ReadOnlyWrapper } from '@/app/(authenticated)/settings/read-only-guard'
+import { setSetting, setSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { updatePortalSlug } from '@/features/portal/Actions/portalActions'
 import {
 import {
-  ReadOnlyBanner,
-  ReadOnlyWrapper,
-} from "@/app/(authenticated)/settings/read-only-guard";
-import { setSetting } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { updatePortalSlug } from "@/features/portal/Actions/portalActions";
+  PORTAL_BACKGROUND_TEMPLATES,
+  type PortalBackgroundType,
+} from '@/features/portal/portal-backgrounds'
+import { cn } from '@/lib/utils'
 
 
 export function CustomerPortalSettings({
 export function CustomerPortalSettings({
   enabled: initialEnabled,
   enabled: initialEnabled,
   orgId,
   orgId,
   portalSlug: initialSlug,
   portalSlug: initialSlug,
   appUrl,
   appUrl,
+  description: initialDescription,
+  hours: initialHours,
+  backgroundType: initialBackgroundType,
+  backgroundTemplate: initialBackgroundTemplate,
+  backgroundImage: initialBackgroundImage,
 }: {
 }: {
-  enabled: boolean;
-  orgId: string;
-  portalSlug: string | null;
-  appUrl: string;
+  enabled: boolean
+  orgId: string
+  portalSlug: string | null
+  appUrl: string
+  description: string
+  hours: string
+  backgroundType: PortalBackgroundType
+  backgroundTemplate: string
+  backgroundImage: string
 }) {
 }) {
-  const router = useRouter();
-  const t = useTranslations('settings');
-  const [isPending, startTransition] = useTransition();
-  const [enabled, setEnabled] = useState(initialEnabled);
-  const [copied, setCopied] = useState(false);
-  const [slug, setSlug] = useState(initialSlug ?? "");
-  const [slugSaving, setSlugSaving] = useState(false);
+  const router = useRouter()
+  const t = useTranslations('settings')
+  const [isPending, startTransition] = useTransition()
+  const [enabled, setEnabled] = useState(initialEnabled)
+  const [copied, setCopied] = useState(false)
+  const [slug, setSlug] = useState(initialSlug ?? '')
+  const [description, setDescription] = useState(initialDescription)
+  const [hours, setHours] = useState(initialHours)
+  const [saving, setSaving] = useState(false)
+  const [backgroundType, setBackgroundType] = useState<PortalBackgroundType>(initialBackgroundType)
+  const [backgroundTemplate, setBackgroundTemplate] = useState(
+    initialBackgroundTemplate || PORTAL_BACKGROUND_TEMPLATES[0].id
+  )
+  const [backgroundImage, setBackgroundImage] = useState(initialBackgroundImage)
+  const [bgUploading, setBgUploading] = useState(false)
+  const bgFileInputRef = useRef<HTMLInputElement>(null)
 
 
-  const portalParam = slug || orgId;
-  const portalUrl = `${appUrl}/portal/${portalParam}`;
+  const portalParam = slug || orgId
+  const portalUrl = `${appUrl}/portal/${portalParam}`
 
 
   const handleToggle = (checked: boolean) => {
   const handleToggle = (checked: boolean) => {
-    setEnabled(checked);
+    setEnabled(checked)
     startTransition(async () => {
     startTransition(async () => {
-      const result = await setSetting(
-        SETTING_KEYS.PORTAL_ENABLED,
-        checked ? "true" : "false",
-      );
+      const result = await setSetting(SETTING_KEYS.PORTAL_ENABLED, checked ? 'true' : 'false')
       if (result.success) {
       if (result.success) {
-        toast.success(
-          checked ? t('portal.enabled') : t('portal.disabled'),
-        );
-        router.refresh();
+        toast.success(checked ? t('portal.enabled') : t('portal.disabled'))
+        router.refresh()
       } else {
       } else {
-        setEnabled(!checked);
-        toast.error(result.error ?? t('portal.failedUpdate'));
+        setEnabled(!checked)
+        toast.error(result.error ?? t('portal.failedUpdate'))
       }
       }
-    });
-  };
+    })
+  }
 
 
   const handleCopy = async () => {
   const handleCopy = async () => {
-    await navigator.clipboard.writeText(portalUrl);
-    setCopied(true);
-    toast.success(t('portal.urlCopied'));
-    setTimeout(() => setCopied(false), 2000);
-  };
-
-  const handleSlugSave = async () => {
-    setSlugSaving(true);
+    await navigator.clipboard.writeText(portalUrl)
+    setCopied(true)
+    toast.success(t('portal.urlCopied'))
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  const slugDirty = slug !== (initialSlug ?? '')
+  const contentDirty = description !== initialDescription || hours !== initialHours
+  const dirty = slugDirty || contentDirty
+
+  const handleSave = async () => {
+    setSaving(true)
     try {
     try {
-      const result = await updatePortalSlug(slug || null);
-      if (result.success) {
-        toast.success(slug ? t('portal.slugSaved') : t('portal.slugRemoved'));
-        router.refresh();
-      } else {
-        toast.error(result.error ?? t('portal.failedUpdateSlug'));
+      // Save slug first because it affects the portal URL and may fail
+      // independently (uniqueness, length validation).
+      if (slugDirty) {
+        const result = await updatePortalSlug(slug || null)
+        if (!result.success) {
+          toast.error(result.error ?? t('portal.failedUpdateSlug'))
+          return
+        }
+      }
+      if (contentDirty) {
+        const result = await setSettings({
+          [SETTING_KEYS.PORTAL_DESCRIPTION]: description,
+          [SETTING_KEYS.PORTAL_HOURS]: hours,
+        })
+        if (!result.success) {
+          toast.error(result.error ?? t('portal.failedUpdate'))
+          return
+        }
       }
       }
+      toast.success(t('portal.contentSaved'))
+      router.refresh()
     } finally {
     } finally {
-      setSlugSaving(false);
+      setSaving(false)
     }
     }
-  };
+  }
 
 
-  const slugDirty = slug !== (initialSlug ?? "");
+  const persistBackground = async (next: {
+    type?: PortalBackgroundType
+    template?: string
+    image?: string
+  }) => {
+    const type = next.type ?? backgroundType
+    const template = next.template ?? backgroundTemplate
+    const image = next.image ?? backgroundImage
+    const result = await setSettings({
+      [SETTING_KEYS.PORTAL_BACKGROUND_TYPE]: type,
+      [SETTING_KEYS.PORTAL_BACKGROUND_TEMPLATE]: template,
+      [SETTING_KEYS.PORTAL_BACKGROUND_IMAGE]: image,
+    })
+    if (result.success) {
+      router.refresh()
+    } else {
+      toast.error(result.error ?? t('portal.failedUpdate'))
+    }
+    return result.success
+  }
+
+  const handleBackgroundTypeChange = async (value: PortalBackgroundType) => {
+    setBackgroundType(value)
+    await persistBackground({ type: value })
+  }
+
+  const handleTemplateChoose = async (id: string) => {
+    setBackgroundTemplate(id)
+    // Picking a template implies switching to template mode.
+    if (backgroundType !== 'template') setBackgroundType('template')
+    await persistBackground({ type: 'template', template: id })
+  }
+
+  const handleBackgroundUpload = async (file: File) => {
+    setBgUploading(true)
+    const toastId = toast.loading(t('portal.background.uploading'))
+    try {
+      const formData = new FormData()
+      formData.append('file', file)
+      const res = await fetch('/api/protected/upload/portal-background', {
+        method: 'POST',
+        body: formData,
+      })
+      if (!res.ok) {
+        const err = await res.json().catch(() => ({}))
+        toast.error(err.error || t('portal.background.uploadFailed'), { id: toastId })
+        return
+      }
+      const data = (await res.json()) as { url: string }
+      setBackgroundImage(data.url)
+      // Switching to image mode automatically.
+      setBackgroundType('image')
+      await persistBackground({ type: 'image', image: data.url })
+      toast.success(t('portal.background.uploaded'), { id: toastId })
+    } catch {
+      toast.error(t('portal.background.uploadFailed'), { id: toastId })
+    } finally {
+      setBgUploading(false)
+    }
+  }
+
+  const handleBackgroundRemove = async () => {
+    setBackgroundImage('')
+    // Fall back to template mode when an image is removed.
+    setBackgroundType('template')
+    await persistBackground({ type: 'template', image: '' })
+  }
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
@@ -99,17 +201,13 @@ export function CustomerPortalSettings({
               <Globe className="h-5 w-5" />
               <Globe className="h-5 w-5" />
               {t('portal.title')}
               {t('portal.title')}
             </CardTitle>
             </CardTitle>
-            <CardDescription>
-              {t('portal.description')}
-            </CardDescription>
+            <CardDescription>{t('portal.description')}</CardDescription>
           </CardHeader>
           </CardHeader>
           <CardContent className="space-y-6">
           <CardContent className="space-y-6">
             <div className="flex items-center justify-between">
             <div className="flex items-center justify-between">
               <div className="space-y-0.5">
               <div className="space-y-0.5">
                 <Label htmlFor="portal-enabled">{t('portal.enablePortal')}</Label>
                 <Label htmlFor="portal-enabled">{t('portal.enablePortal')}</Label>
-                <p className="text-xs text-muted-foreground">
-                  {t('portal.enablePortalHint')}
-                </p>
+                <p className="text-xs text-muted-foreground">{t('portal.enablePortalHint')}</p>
               </div>
               </div>
               <Switch
               <Switch
                 id="portal-enabled"
                 id="portal-enabled"
@@ -123,64 +221,204 @@ export function CustomerPortalSettings({
               <>
               <>
                 <div className="space-y-2">
                 <div className="space-y-2">
                   <Label htmlFor="portal-slug">{t('portal.customSlug')}</Label>
                   <Label htmlFor="portal-slug">{t('portal.customSlug')}</Label>
-                  <div className="flex gap-2">
-                    <Input
-                      id="portal-slug"
-                      value={slug}
-                      onChange={(e) =>
-                        setSlug(e.target.value.toLowerCase().replace(/\s/g, ""))
-                      }
-                      placeholder="e.g. my_shop"
-                      className="font-mono text-sm"
-                    />
-                    <Button
-                      type="button"
-                      onClick={handleSlugSave}
-                      disabled={slugSaving || !slugDirty}
-                      size="sm"
-                    >
-                      {slugSaving ? (
-                        <Loader2 className="h-4 w-4 animate-spin" />
-                      ) : (
-                        t('portal.save')
-                      )}
-                    </Button>
-                  </div>
-                  <p className="text-xs text-muted-foreground">
-                    {t('portal.slugHint')}
-                  </p>
+                  <Input
+                    id="portal-slug"
+                    value={slug}
+                    onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/\s/g, ''))}
+                    placeholder="e.g. my_shop"
+                    className="font-mono text-sm"
+                  />
+                  <p className="text-xs text-muted-foreground">{t('portal.slugHint')}</p>
+                </div>
+
+                <div className="space-y-2">
+                  <Label htmlFor="portal-description">{t('portal.descriptionLabel')}</Label>
+                  <Textarea
+                    id="portal-description"
+                    value={description}
+                    onChange={(e) => setDescription(e.target.value)}
+                    placeholder={t('portal.descriptionPlaceholder')}
+                    rows={4}
+                  />
+                  <p className="text-xs text-muted-foreground">{t('portal.descriptionHint')}</p>
+                </div>
+
+                <div className="space-y-2">
+                  <Label htmlFor="portal-hours">{t('portal.hoursLabel')}</Label>
+                  <Textarea
+                    id="portal-hours"
+                    value={hours}
+                    onChange={(e) => setHours(e.target.value)}
+                    placeholder={t('portal.hoursPlaceholder')}
+                    rows={4}
+                  />
+                  <p className="text-xs text-muted-foreground">{t('portal.hoursHint')}</p>
                 </div>
                 </div>
 
 
                 <div className="space-y-2">
                 <div className="space-y-2">
                   <Label>{t('portal.portalUrl')}</Label>
                   <Label>{t('portal.portalUrl')}</Label>
                   <div className="flex gap-2">
                   <div className="flex gap-2">
-                    <Input
-                      value={portalUrl}
-                      readOnly
-                      className="font-mono text-sm"
-                    />
-                    <Button
-                      type="button"
-                      variant="outline"
-                      size="icon"
-                      onClick={handleCopy}
-                    >
-                      {copied ? (
-                        <Check className="h-4 w-4" />
-                      ) : (
-                        <Copy className="h-4 w-4" />
-                      )}
+                    <Input value={portalUrl} readOnly className="font-mono text-sm" />
+                    <Button type="button" variant="outline" size="icon" onClick={handleCopy}>
+                      {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
                     </Button>
                     </Button>
                   </div>
                   </div>
-                  <p className="text-xs text-muted-foreground">
-                    {t('portal.shareHint')}
-                  </p>
+                  <p className="text-xs text-muted-foreground">{t('portal.shareHint')}</p>
                 </div>
                 </div>
               </>
               </>
             )}
             )}
           </CardContent>
           </CardContent>
+          {enabled && (
+            <CardFooter className="justify-end border-t pt-6">
+              <Button type="button" onClick={handleSave} disabled={saving || !dirty}>
+                {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
+                {t('portal.save')}
+              </Button>
+            </CardFooter>
+          )}
         </Card>
         </Card>
+
+        {enabled && (
+          <Card>
+            <CardHeader>
+              <CardTitle className="flex items-center gap-2">
+                <ImageIcon className="h-5 w-5" />
+                {t('portal.background.title')}
+              </CardTitle>
+              <CardDescription>{t('portal.background.description')}</CardDescription>
+            </CardHeader>
+            <CardContent className="space-y-6">
+              {/* Type selector */}
+              <div className="grid grid-cols-3 gap-2">
+                {(
+                  [
+                    { id: 'none', label: t('portal.background.typeNone') },
+                    { id: 'template', label: t('portal.background.typeTemplate') },
+                    { id: 'image', label: t('portal.background.typeImage') },
+                  ] as { id: PortalBackgroundType; label: string }[]
+                ).map((opt) => (
+                  <button
+                    key={opt.id}
+                    type="button"
+                    onClick={() => handleBackgroundTypeChange(opt.id)}
+                    className={cn(
+                      'rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
+                      backgroundType === opt.id
+                        ? 'border-primary bg-primary/10 text-primary'
+                        : 'border-input bg-background hover:bg-muted'
+                    )}
+                  >
+                    {opt.label}
+                  </button>
+                ))}
+              </div>
+
+              {/* Template grid */}
+              {backgroundType === 'template' && (
+                <div className="space-y-2">
+                  <Label>{t('portal.background.chooseTemplate')}</Label>
+                  <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
+                    {PORTAL_BACKGROUND_TEMPLATES.map((tpl) => {
+                      const selected = backgroundTemplate === tpl.id
+                      return (
+                        <button
+                          key={tpl.id}
+                          type="button"
+                          onClick={() => handleTemplateChoose(tpl.id)}
+                          className={cn(
+                            'group relative h-24 overflow-hidden rounded-lg border-2 transition-all',
+                            selected
+                              ? 'border-primary ring-2 ring-primary/30'
+                              : 'border-input hover:border-primary/50'
+                          )}
+                          aria-pressed={selected}
+                        >
+                          <div className={cn('absolute inset-0', tpl.className)} />
+                          <span className="absolute inset-x-0 bottom-0 bg-background/80 px-2 py-1 text-xs font-medium backdrop-blur-sm">
+                            {t(`portal.background.templateNames.${tpl.labelKey}`)}
+                          </span>
+                          {selected && (
+                            <span className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
+                              <Check className="h-3 w-3" />
+                            </span>
+                          )}
+                        </button>
+                      )
+                    })}
+                  </div>
+                </div>
+              )}
+
+              {/* Image upload */}
+              {backgroundType === 'image' && (
+                <div className="space-y-3">
+                  <Label>{t('portal.background.uploadLabel')}</Label>
+                  <div className="flex items-center gap-4">
+                    <div className="relative h-24 w-40 shrink-0 overflow-hidden rounded-lg border bg-muted">
+                      {backgroundImage ? (
+                        // eslint-disable-next-line @next/next/no-img-element
+                        <img
+                          src={`/api/public/portal-bg/${portalParam}?v=${encodeURIComponent(backgroundImage)}`}
+                          alt=""
+                          className="h-full w-full object-cover"
+                        />
+                      ) : (
+                        <div className="flex h-full w-full items-center justify-center">
+                          <ImageIcon className="h-6 w-6 text-muted-foreground/50" />
+                        </div>
+                      )}
+                    </div>
+                    <div className="flex flex-wrap items-center gap-2">
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => bgFileInputRef.current?.click()}
+                        disabled={bgUploading}
+                      >
+                        {bgUploading ? (
+                          <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+                        ) : (
+                          <Upload className="mr-1.5 h-3.5 w-3.5" />
+                        )}
+                        {backgroundImage
+                          ? t('portal.background.replace')
+                          : t('portal.background.upload')}
+                      </Button>
+                      {backgroundImage && (
+                        <Button
+                          type="button"
+                          variant="ghost"
+                          size="sm"
+                          className="text-destructive hover:text-destructive"
+                          onClick={handleBackgroundRemove}
+                        >
+                          <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+                          {t('portal.background.remove')}
+                        </Button>
+                      )}
+                    </div>
+                    <input
+                      ref={bgFileInputRef}
+                      type="file"
+                      accept=".jpg,.jpeg,.png,.webp"
+                      className="hidden"
+                      onChange={(e) => {
+                        const file = e.target.files?.[0]
+                        if (file) handleBackgroundUpload(file)
+                        e.target.value = ''
+                      }}
+                    />
+                  </div>
+                  <p className="text-xs text-muted-foreground">
+                    {t('portal.background.uploadHint')}
+                  </p>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        )}
       </ReadOnlyWrapper>
       </ReadOnlyWrapper>
     </div>
     </div>
-  );
+  )
 }
 }

+ 101 - 0
src/features/portal/Components/PortalDirectory.tsx

@@ -0,0 +1,101 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Card, CardContent } from '@/components/ui/card'
+import { ArrowRight, Search } from 'lucide-react'
+
+export type PortalDirectoryEntry = {
+  slug: string
+  name: string
+  logo: string | null
+  address: string | null
+}
+
+export function PortalDirectory({ workshops }: { workshops: PortalDirectoryEntry[] }) {
+  const t = useTranslations('portal.directory')
+  const [query, setQuery] = useState('')
+
+  const filtered = useMemo(() => {
+    const q = query.trim().toLowerCase()
+    if (!q) return workshops
+    return workshops.filter(
+      (w) => w.name.toLowerCase().includes(q) || (w.address?.toLowerCase().includes(q) ?? false)
+    )
+  }, [workshops, query])
+
+  return (
+    <div className="min-h-svh bg-muted/30">
+      <div className="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
+        <header className="mb-8 text-center">
+          <h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{t('title')}</h1>
+          <p className="mt-3 text-base text-muted-foreground sm:text-lg">{t('description')}</p>
+        </header>
+
+        {workshops.length > 0 && (
+          <div className="mx-auto mb-8 max-w-md">
+            <div className="relative">
+              <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+              <Input
+                type="search"
+                value={query}
+                onChange={(e) => setQuery(e.target.value)}
+                placeholder={t('searchPlaceholder')}
+                className="pl-9"
+                aria-label={t('searchPlaceholder')}
+              />
+            </div>
+          </div>
+        )}
+
+        {workshops.length === 0 ? (
+          <p className="mt-12 text-center text-muted-foreground">{t('noWorkshops')}</p>
+        ) : filtered.length === 0 ? (
+          <p className="mt-12 text-center text-muted-foreground">{t('noResults')}</p>
+        ) : (
+          <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
+            {filtered.map((workshop) => (
+              <li key={workshop.slug}>
+                <Link
+                  href={`/portal/${workshop.slug}/auth/login`}
+                  className="group block focus:outline-none"
+                >
+                  <Card className="h-full transition-all group-hover:border-primary/40 group-hover:shadow-md group-focus-visible:ring-2 group-focus-visible:ring-ring">
+                    <CardContent className="flex items-center gap-4">
+                      {workshop.logo ? (
+                        // eslint-disable-next-line @next/next/no-img-element
+                        <img
+                          src={workshop.logo}
+                          alt=""
+                          className="h-14 w-14 shrink-0 rounded-lg object-contain"
+                        />
+                      ) : (
+                        <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xl font-bold text-primary">
+                          {workshop.name.charAt(0).toUpperCase()}
+                        </div>
+                      )}
+                      <div className="min-w-0 flex-1">
+                        <h2 className="truncate text-base font-semibold">{workshop.name}</h2>
+                        {workshop.address && (
+                          <p className="mt-0.5 truncate text-sm text-muted-foreground">
+                            {workshop.address}
+                          </p>
+                        )}
+                        <span className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary">
+                          {t('openPortal')}
+                          <ArrowRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5" />
+                        </span>
+                      </div>
+                    </CardContent>
+                  </Card>
+                </Link>
+              </li>
+            ))}
+          </ul>
+        )}
+      </div>
+    </div>
+  )
+}

+ 173 - 40
src/features/portal/Components/PortalHeader.tsx

@@ -1,9 +1,30 @@
-"use client";
+'use client'
 
 
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Button } from "@/components/ui/button";
-import { LogOut } from "lucide-react";
+import { useState } from 'react'
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet'
+import {
+  Car,
+  ClipboardCheck,
+  FileQuestion,
+  FileText,
+  LayoutDashboard,
+  LogOut,
+  Menu,
+  Wrench,
+} from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+const NAV_ITEMS = [
+  { key: 'dashboard' as const, href: 'dashboard', icon: LayoutDashboard },
+  { key: 'vehicles' as const, href: 'vehicles', icon: Car },
+  { key: 'invoices' as const, href: 'invoices', icon: FileText },
+  { key: 'quotes' as const, href: 'quotes', icon: FileQuestion },
+  { key: 'inspections' as const, href: 'inspections', icon: ClipboardCheck },
+]
 
 
 export function PortalHeader({
 export function PortalHeader({
   orgId,
   orgId,
@@ -11,48 +32,160 @@ export function PortalHeader({
   orgLogo,
   orgLogo,
   customerName,
   customerName,
 }: {
 }: {
-  orgId: string;
-  orgName: string;
-  orgLogo?: string | null;
-  customerName: string;
+  orgId: string
+  orgName: string
+  orgLogo?: string | null
+  customerName: string
 }) {
 }) {
-  const t = useTranslations("portal.header");
-  const router = useRouter();
+  const t = useTranslations('portal')
+  const router = useRouter()
+  const pathname = usePathname()
+  const [drawerOpen, setDrawerOpen] = useState(false)
 
 
   const handleLogout = async () => {
   const handleLogout = async () => {
-    await fetch(`/api/public/portal/${orgId}/auth/logout`, {
-      method: "POST",
-    });
-    router.push(`/portal/${orgId}/auth/login`);
-    router.refresh();
-  };
+    await fetch(`/api/public/portal/${orgId}/auth/logout`, { method: 'POST' })
+    router.push(`/portal/${orgId}/auth/login`)
+    router.refresh()
+  }
+
+  const isActive = (slug: string) => {
+    const href = `/portal/${orgId}/${slug}`
+    return pathname === href || pathname.startsWith(href + '/')
+  }
 
 
   return (
   return (
-    <header className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-4">
-      <div className="flex items-center gap-3">
-        {orgLogo ? (
-          <img
-            src={orgLogo}
-            alt={orgName}
-            className="h-8 w-8 rounded object-contain"
-          />
-        ) : (
-          <div className="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-sm font-bold text-primary">
-            {orgName.charAt(0)}
+    <header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
+      <div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 sm:px-6">
+        {/* Brand */}
+        <Link href={`/portal/${orgId}/dashboard`} className="flex shrink-0 items-center gap-2.5">
+          {orgLogo ? (
+            // eslint-disable-next-line @next/next/no-img-element
+            <img src={orgLogo} alt={orgName} className="h-8 w-8 rounded object-contain" />
+          ) : (
+            <div className="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-sm font-bold text-primary">
+              {orgName.charAt(0)}
+            </div>
+          )}
+          <span className="hidden text-sm font-semibold sm:block">{orgName}</span>
+        </Link>
+
+        {/* Desktop nav */}
+        <nav className="ml-2 hidden items-center gap-1 lg:flex">
+          {NAV_ITEMS.map((item) => (
+            <Link
+              key={item.href}
+              href={`/portal/${orgId}/${item.href}`}
+              className={cn(
+                'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
+                isActive(item.href)
+                  ? 'bg-muted text-foreground'
+                  : 'text-muted-foreground hover:bg-muted hover:text-foreground'
+              )}
+            >
+              {t(`nav.${item.key}`)}
+            </Link>
+          ))}
+        </nav>
+
+        <div className="ml-auto flex items-center gap-2">
+          {/* Primary CTA — visible on every screen */}
+          <Button asChild size="sm" className="h-9">
+            <Link href={`/portal/${orgId}/request-service`}>
+              <Wrench className="mr-1.5 h-4 w-4" />
+              <span className="hidden sm:inline">{t('nav.requestService')}</span>
+              <span className="sm:hidden">{t('header.askShort')}</span>
+            </Link>
+          </Button>
+
+          {/* Customer name + sign out (desktop) */}
+          <div className="hidden items-center gap-2 lg:flex">
+            <span className="max-w-[140px] truncate text-sm text-muted-foreground">
+              {customerName}
+            </span>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={handleLogout}
+              aria-label={t('header.signOut')}
+            >
+              <LogOut className="h-4 w-4" />
+            </Button>
           </div>
           </div>
-        )}
-        <span className="text-sm font-semibold">{orgName}</span>
-      </div>
 
 
-      <div className="flex items-center gap-3">
-        <span className="hidden text-sm text-muted-foreground sm:block">
-          {customerName}
-        </span>
-        <Button variant="ghost" size="sm" onClick={handleLogout}>
-          <LogOut className="mr-2 h-4 w-4" />
-          {t("signOut")}
-        </Button>
+          {/* Mobile menu */}
+          <Sheet open={drawerOpen} onOpenChange={setDrawerOpen}>
+            <SheetTrigger asChild>
+              <Button variant="ghost" size="icon" className="lg:hidden" aria-label="Menu">
+                <Menu className="h-5 w-5" />
+              </Button>
+            </SheetTrigger>
+            <SheetContent side="right" className="w-72 p-0">
+              <SheetTitle className="sr-only">{orgName}</SheetTitle>
+              <div className="flex h-full flex-col">
+                <div className="flex items-center gap-2.5 border-b px-5 py-4">
+                  {orgLogo ? (
+                    // eslint-disable-next-line @next/next/no-img-element
+                    <img src={orgLogo} alt={orgName} className="h-8 w-8 rounded object-contain" />
+                  ) : (
+                    <div className="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-sm font-bold text-primary">
+                      {orgName.charAt(0)}
+                    </div>
+                  )}
+                  <div className="min-w-0">
+                    <p className="truncate text-sm font-semibold">{orgName}</p>
+                    <p className="truncate text-xs text-muted-foreground">{customerName}</p>
+                  </div>
+                </div>
+                <nav className="flex flex-1 flex-col gap-1 p-3">
+                  {NAV_ITEMS.map((item) => (
+                    <Link
+                      key={item.href}
+                      href={`/portal/${orgId}/${item.href}`}
+                      onClick={() => setDrawerOpen(false)}
+                      className={cn(
+                        'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors',
+                        isActive(item.href)
+                          ? 'bg-primary/10 font-medium text-primary'
+                          : 'text-muted-foreground hover:bg-muted hover:text-foreground'
+                      )}
+                    >
+                      <item.icon className="h-4 w-4" />
+                      {t(`nav.${item.key}`)}
+                    </Link>
+                  ))}
+                  <Link
+                    href={`/portal/${orgId}/request-service`}
+                    onClick={() => setDrawerOpen(false)}
+                    className={cn(
+                      'mt-1 flex items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors',
+                      isActive('request-service')
+                        ? 'bg-primary/10 font-medium text-primary'
+                        : 'text-muted-foreground hover:bg-muted hover:text-foreground'
+                    )}
+                  >
+                    <Wrench className="h-4 w-4" />
+                    {t('nav.requestService')}
+                  </Link>
+                </nav>
+                <div className="border-t p-3">
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="w-full justify-start"
+                    onClick={() => {
+                      setDrawerOpen(false)
+                      handleLogout()
+                    }}
+                  >
+                    <LogOut className="mr-2 h-4 w-4" />
+                    {t('header.signOut')}
+                  </Button>
+                </div>
+              </div>
+            </SheetContent>
+          </Sheet>
+        </div>
       </div>
       </div>
     </header>
     </header>
-  );
+  )
 }
 }

+ 192 - 0
src/features/portal/Components/PortalLanding.tsx

@@ -0,0 +1,192 @@
+import { getTranslations } from 'next-intl/server'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Clock, ExternalLink, Mail, MapPin, Phone } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import {
+  type PortalBackgroundType,
+  resolvePortalBackgroundTemplate,
+} from '@/features/portal/portal-backgrounds'
+import { PortalLoginForm } from './PortalLoginForm'
+
+export type PortalLandingProps = {
+  orgId: string
+  orgName: string
+  orgLogo: string | null
+  description: string | null
+  hours: string | null
+  address: string | null
+  phone: string | null
+  email: string | null
+  authError?: string
+  backgroundType: PortalBackgroundType
+  backgroundTemplateId: string | null
+  backgroundImageUrl: string | null
+  smsEnabled: boolean
+  defaultCountryCode: string | null
+}
+
+export async function PortalLanding({
+  orgId,
+  orgName,
+  orgLogo,
+  description,
+  hours,
+  address,
+  phone,
+  email,
+  authError,
+  backgroundType,
+  backgroundTemplateId,
+  backgroundImageUrl,
+  smsEnabled,
+  defaultCountryCode,
+}: PortalLandingProps) {
+  const t = await getTranslations('portal.landing')
+
+  const hasContact = Boolean(address || phone || email || hours)
+
+  // Resolve which background to render. Image > template > none > default.
+  const useImage = backgroundType === 'image' && Boolean(backgroundImageUrl)
+  const useTemplate = backgroundType === 'template'
+  const useNone = backgroundType === 'none'
+  const templateClass = useTemplate
+    ? resolvePortalBackgroundTemplate(backgroundTemplateId).className
+    : ''
+
+  return (
+    <div
+      className={cn(
+        'relative min-h-svh',
+        useNone ? '' : useImage ? 'bg-muted/30' : templateClass || 'bg-muted/30'
+      )}
+    >
+      {useImage && backgroundImageUrl && (
+        <>
+          {/* eslint-disable-next-line @next/next/no-img-element */}
+          <img
+            src={backgroundImageUrl}
+            alt=""
+            aria-hidden="true"
+            className="pointer-events-none absolute inset-0 h-full w-full object-cover"
+          />
+          {/* Soft overlay so cards remain readable on busy photos. */}
+          <div
+            aria-hidden="true"
+            className="pointer-events-none absolute inset-0 bg-white/70 backdrop-blur-[2px]"
+          />
+        </>
+      )}
+      <div className="relative mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
+        {/* Hero */}
+        <header className="mb-10 flex flex-col items-center text-center sm:mb-14">
+          {orgLogo ? (
+            // eslint-disable-next-line @next/next/no-img-element
+            <img
+              src={orgLogo}
+              alt={orgName}
+              className="mb-5 h-20 w-20 rounded-xl object-contain sm:h-24 sm:w-24"
+            />
+          ) : (
+            <div className="mb-5 flex h-20 w-20 items-center justify-center rounded-xl bg-primary/10 text-3xl font-bold text-primary sm:h-24 sm:w-24">
+              {orgName.charAt(0).toUpperCase()}
+            </div>
+          )}
+          <h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{orgName}</h1>
+          {description && (
+            <p className="mt-4 max-w-2xl whitespace-pre-line text-base text-muted-foreground sm:text-lg">
+              {description}
+            </p>
+          )}
+        </header>
+
+        {/* Two-column: contact info | login */}
+        <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
+          <div className="lg:col-span-3">
+            {hasContact ? (
+              <Card className="h-full">
+                <CardHeader>
+                  <CardTitle>{t('contactTitle')}</CardTitle>
+                </CardHeader>
+                <CardContent className="space-y-5">
+                  {address && (
+                    <ContactRow icon={<MapPin className="h-5 w-5" />} label={t('address')}>
+                      <p className="whitespace-pre-line">{address}</p>
+                      <a
+                        href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address)}`}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
+                      >
+                        {t('directions')}
+                        <ExternalLink className="h-3 w-3" />
+                      </a>
+                    </ContactRow>
+                  )}
+                  {phone && (
+                    <ContactRow icon={<Phone className="h-5 w-5" />} label={t('phone')}>
+                      <a href={`tel:${phone}`} className="hover:underline">
+                        {phone}
+                      </a>
+                    </ContactRow>
+                  )}
+                  {email && (
+                    <ContactRow icon={<Mail className="h-5 w-5" />} label={t('email')}>
+                      <a href={`mailto:${email}`} className="break-all hover:underline">
+                        {email}
+                      </a>
+                    </ContactRow>
+                  )}
+                  {hours && (
+                    <ContactRow icon={<Clock className="h-5 w-5" />} label={t('hours')}>
+                      <p className="whitespace-pre-line">{hours}</p>
+                    </ContactRow>
+                  )}
+                </CardContent>
+              </Card>
+            ) : (
+              <Card className="h-full">
+                <CardHeader>
+                  <CardTitle>{t('aboutTitle')}</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <p className="text-sm text-muted-foreground">{t('noContactInfo')}</p>
+                </CardContent>
+              </Card>
+            )}
+          </div>
+
+          <div className="lg:col-span-2">
+            <PortalLoginForm
+              orgId={orgId}
+              error={authError}
+              smsEnabled={smsEnabled}
+              defaultCountryCode={defaultCountryCode}
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function ContactRow({
+  icon,
+  label,
+  children,
+}: {
+  icon: React.ReactNode
+  label: string
+  children: React.ReactNode
+}) {
+  return (
+    <div className="flex items-start gap-3">
+      <span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
+        {icon}
+      </span>
+      <div className="min-w-0 flex-1">
+        <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</p>
+        <div className="mt-0.5 text-sm text-foreground">{children}</div>
+      </div>
+    </div>
+  )
+}

+ 264 - 105
src/features/portal/Components/PortalLoginForm.tsx

@@ -1,119 +1,278 @@
-"use client";
+'use client'
 
 
-import { useState, useTransition } from "react";
-import { useTranslations } from "next-intl";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
-  Card,
-  CardContent,
-  CardDescription,
-  CardHeader,
-  CardTitle,
-} from "@/components/ui/card";
-import { Loader2, Mail } from "lucide-react";
+import { useState, useTransition } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Loader2, Mail } from 'lucide-react'
+
+type PortalLoginFormProps = {
+  orgId: string
+  error?: string
+  smsEnabled: boolean
+  defaultCountryCode: string | null
+}
 
 
 export function PortalLoginForm({
 export function PortalLoginForm({
   orgId,
   orgId,
-  orgName,
-  orgLogo,
   error,
   error,
-}: {
-  orgId: string;
-  orgName: string;
-  orgLogo?: string | null;
-  error?: string;
-}) {
-  const t = useTranslations("portal.login");
-  const [email, setEmail] = useState("");
-  const [sent, setSent] = useState(false);
-  const [isPending, startTransition] = useTransition();
+  smsEnabled,
+  defaultCountryCode,
+}: PortalLoginFormProps) {
+  const t = useTranslations('portal.login')
+
+  return (
+    <Card className="w-full">
+      <CardHeader>
+        <CardTitle>{t('signInTitle')}</CardTitle>
+        <CardDescription>{t('signInSubtitle')}</CardDescription>
+      </CardHeader>
+      <CardContent>
+        {error && (
+          <div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-center text-sm text-destructive">
+            {error}
+          </div>
+        )}
+        {smsEnabled ? (
+          <Tabs defaultValue="email" className="w-full">
+            <TabsList className="grid w-full grid-cols-2">
+              <TabsTrigger value="email">{t('tabEmail')}</TabsTrigger>
+              <TabsTrigger value="sms">{t('tabSms')}</TabsTrigger>
+            </TabsList>
+            <TabsContent value="email" className="mt-4">
+              <EmailLoginForm orgId={orgId} />
+            </TabsContent>
+            <TabsContent value="sms" className="mt-4">
+              <SmsLoginForm orgId={orgId} defaultCountryCode={defaultCountryCode} />
+            </TabsContent>
+          </Tabs>
+        ) : (
+          <EmailLoginForm orgId={orgId} />
+        )}
+      </CardContent>
+    </Card>
+  )
+}
+
+function EmailLoginForm({ orgId }: { orgId: string }) {
+  const t = useTranslations('portal.login')
+  const [email, setEmail] = useState('')
+  const [sent, setSent] = useState(false)
+  const [isPending, startTransition] = useTransition()
 
 
   const handleSubmit = (e: React.FormEvent) => {
   const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
+    e.preventDefault()
     startTransition(async () => {
     startTransition(async () => {
       await fetch(`/api/public/portal/${orgId}/auth/request`, {
       await fetch(`/api/public/portal/${orgId}/auth/request`, {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ email }),
         body: JSON.stringify({ email }),
-      });
-      setSent(true);
-    });
-  };
+      })
+      setSent(true)
+    })
+  }
+
+  if (sent) {
+    return (
+      <div className="text-center">
+        <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10">
+          <Mail className="h-6 w-6 text-green-600" />
+        </div>
+        <h3 className="text-lg font-semibold">{t('checkEmail')}</h3>
+        <p className="mt-2 text-sm text-muted-foreground">{t('checkEmailDescription')}</p>
+        <Button
+          variant="ghost"
+          className="mt-4"
+          onClick={() => {
+            setSent(false)
+            setEmail('')
+          }}
+        >
+          {t('tryDifferentEmail')}
+        </Button>
+      </div>
+    )
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="space-y-4">
+      <div className="space-y-2">
+        <Label htmlFor="email">{t('emailLabel')}</Label>
+        <Input
+          id="email"
+          type="email"
+          placeholder={t('emailPlaceholder')}
+          required
+          value={email}
+          onChange={(e) => setEmail(e.target.value)}
+        />
+      </div>
+      <Button type="submit" className="w-full" disabled={isPending}>
+        {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+        {t('sendLink')}
+      </Button>
+    </form>
+  )
+}
+
+function SmsLoginForm({
+  orgId,
+  defaultCountryCode,
+}: {
+  orgId: string
+  defaultCountryCode: string | null
+}) {
+  const t = useTranslations('portal.login')
+  const router = useRouter()
+  const [step, setStep] = useState<'phone' | 'code'>('phone')
+  // When a country code is set, `phone` holds only the local digits.
+  // When not set, `phone` holds the full E.164 number the user types.
+  const [phone, setPhone] = useState('')
+  const [code, setCode] = useState('')
+  const [verifyError, setVerifyError] = useState<string | null>(null)
+  const [isRequestPending, startRequestTransition] = useTransition()
+  const [isVerifyPending, startVerifyTransition] = useTransition()
+
+  // The full phone we send to the server. The server normalizes again as a
+  // safety net, but pre-joining here makes the UI explicit about what's
+  // being sent.
+  const fullPhone = defaultCountryCode ? `${defaultCountryCode}${phone}` : phone
+
+  const handleRequest = (e: React.FormEvent) => {
+    e.preventDefault()
+    setVerifyError(null)
+    startRequestTransition(async () => {
+      try {
+        await fetch(`/api/public/portal/${orgId}/auth/sms-request`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ phone: fullPhone }),
+        })
+      } catch {
+        // Ignore network errors and still advance to step 2; we don't want to
+        // reveal whether the phone matched an account.
+      }
+      setStep('code')
+    })
+  }
+
+  const handleVerify = (e: React.FormEvent) => {
+    e.preventDefault()
+    setVerifyError(null)
+    startVerifyTransition(async () => {
+      try {
+        const res = await fetch(`/api/public/portal/${orgId}/auth/sms-verify`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ phone: fullPhone, code }),
+        })
+        const data = (await res.json().catch(() => ({}))) as {
+          success?: boolean
+          error?: string
+        }
+        if (data.success) {
+          router.push(`/portal/${orgId}/dashboard`)
+          router.refresh()
+          return
+        }
+        setVerifyError(data.error || t('invalidCode'))
+      } catch {
+        setVerifyError(t('invalidCode'))
+      }
+    })
+  }
+
+  if (step === 'code') {
+    return (
+      <form onSubmit={handleVerify} className="space-y-4">
+        <div className="text-center">
+          <h3 className="text-lg font-semibold">{t('codeSentTitle')}</h3>
+          <p className="mt-2 text-sm text-muted-foreground">
+            {t('codeSentDescription', { phone: fullPhone })}
+          </p>
+        </div>
+        <div className="space-y-2">
+          <Label htmlFor="sms-code">{t('codeLabel')}</Label>
+          <Input
+            id="sms-code"
+            type="text"
+            inputMode="numeric"
+            autoComplete="one-time-code"
+            pattern="[0-9]{6}"
+            maxLength={6}
+            placeholder={t('codePlaceholder')}
+            required
+            value={code}
+            onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
+          />
+        </div>
+        {verifyError && (
+          <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-center text-sm text-destructive">
+            {verifyError}
+          </div>
+        )}
+        <Button type="submit" className="w-full" disabled={isVerifyPending}>
+          {isVerifyPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+          {t('verifyCode')}
+        </Button>
+        <Button
+          type="button"
+          variant="ghost"
+          className="w-full"
+          onClick={() => {
+            setStep('phone')
+            setCode('')
+            setVerifyError(null)
+          }}
+        >
+          {t('useDifferentPhone')}
+        </Button>
+      </form>
+    )
+  }
 
 
   return (
   return (
-    <div className="flex min-h-svh items-center justify-center bg-muted/30 p-4">
-      <Card className="w-full max-w-md">
-        <CardHeader className="text-center">
-          <div className="mb-4 flex justify-center">
-            {orgLogo ? (
-              <img
-                src={orgLogo}
-                alt={orgName}
-                className="h-12 w-12 rounded object-contain"
-              />
-            ) : (
-              <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-lg font-bold text-primary">
-                {orgName.charAt(0)}
-              </div>
-            )}
+    <form onSubmit={handleRequest} className="space-y-4">
+      <div className="space-y-2">
+        <Label htmlFor="sms-phone">{t('phoneLabel')}</Label>
+        {defaultCountryCode ? (
+          <div className="flex items-center rounded-md border border-input bg-background focus-within:ring-1 focus-within:ring-ring">
+            <span className="select-none border-r border-input px-3 py-2 text-sm text-muted-foreground">
+              {defaultCountryCode}
+            </span>
+            <input
+              id="sms-phone"
+              type="tel"
+              inputMode="tel"
+              autoComplete="tel-national"
+              placeholder={t('phoneLocalPlaceholder')}
+              required
+              value={phone}
+              onChange={(e) => setPhone(e.target.value.replace(/[^\d]/g, ''))}
+              className="w-full bg-transparent px-3 py-2 text-sm outline-none"
+            />
           </div>
           </div>
-          <CardTitle>{orgName}</CardTitle>
-          <CardDescription>
-            {t("signInTitle")}
-          </CardDescription>
-        </CardHeader>
-        <CardContent>
-          {error && (
-            <div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-center text-sm text-destructive">
-              {error}
-            </div>
-          )}
-          {sent ? (
-            <div className="text-center">
-              <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10">
-                <Mail className="h-6 w-6 text-green-600" />
-              </div>
-              <h3 className="text-lg font-semibold">{t("checkEmail")}</h3>
-              <p className="mt-2 text-sm text-muted-foreground">
-                {t("checkEmailDescription")}
-              </p>
-              <Button
-                variant="ghost"
-                className="mt-4"
-                onClick={() => {
-                  setSent(false);
-                  setEmail("");
-                }}
-              >
-                {t("tryDifferentEmail")}
-              </Button>
-            </div>
-          ) : (
-            <form onSubmit={handleSubmit} className="space-y-4">
-              <div className="space-y-2">
-                <Label htmlFor="email">{t("emailLabel")}</Label>
-                <Input
-                  id="email"
-                  type="email"
-                  placeholder={t("emailPlaceholder")}
-                  required
-                  value={email}
-                  onChange={(e) => setEmail(e.target.value)}
-                  autoFocus
-                />
-              </div>
-              <Button type="submit" className="w-full" disabled={isPending}>
-                {isPending && (
-                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                )}
-                {t("sendLink")}
-              </Button>
-            </form>
-          )}
-        </CardContent>
-      </Card>
-    </div>
-  );
+        ) : (
+          <Input
+            id="sms-phone"
+            type="tel"
+            inputMode="tel"
+            autoComplete="tel"
+            placeholder={t('phonePlaceholder')}
+            required
+            value={phone}
+            onChange={(e) => setPhone(e.target.value)}
+          />
+        )}
+      </div>
+      <Button type="submit" className="w-full" disabled={isRequestPending}>
+        {isRequestPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+        {t('sendCode')}
+      </Button>
+    </form>
+  )
 }
 }

+ 0 - 76
src/features/portal/Components/PortalNav.tsx

@@ -1,76 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { cn } from "@/lib/utils";
-import {
-  Car,
-  FileText,
-  LayoutDashboard,
-  ClipboardCheck,
-  FileQuestion,
-  Wrench,
-} from "lucide-react";
-
-const navItems = [
-  {
-    key: "dashboard" as const,
-    href: "dashboard",
-    icon: LayoutDashboard,
-  },
-  {
-    key: "vehicles" as const,
-    href: "vehicles",
-    icon: Car,
-  },
-  {
-    key: "invoices" as const,
-    href: "invoices",
-    icon: FileText,
-  },
-  {
-    key: "quotes" as const,
-    href: "quotes",
-    icon: FileQuestion,
-  },
-  {
-    key: "inspections" as const,
-    href: "inspections",
-    icon: ClipboardCheck,
-  },
-  {
-    key: "requestService" as const,
-    href: "request-service",
-    icon: Wrench,
-  },
-];
-
-export function PortalNav({ orgId }: { orgId: string }) {
-  const t = useTranslations("portal.nav");
-  const pathname = usePathname();
-
-  return (
-    <nav className="flex flex-col gap-1">
-      {navItems.map((item) => {
-        const href = `/portal/${orgId}/${item.href}`;
-        const isActive = pathname === href || pathname.startsWith(href + "/");
-        return (
-          <Link
-            key={item.href}
-            href={href}
-            className={cn(
-              "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
-              isActive
-                ? "bg-primary/10 font-medium text-primary"
-                : "text-muted-foreground hover:bg-muted hover:text-foreground",
-            )}
-          >
-            <item.icon className="h-4 w-4 shrink-0" />
-            <span className="truncate">{t(item.key)}</span>
-          </Link>
-        );
-      })}
-    </nav>
-  );
-}

+ 26 - 36
src/features/portal/Components/PortalShell.tsx

@@ -1,38 +1,35 @@
-import { redirect } from "next/navigation";
-import { db } from "@/lib/db";
-import { getCustomerSession } from "@/lib/customer-session";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-import { getTranslations } from "next-intl/server";
-import { PortalHeader } from "./PortalHeader";
-import { PortalNav } from "./PortalNav";
+import { redirect } from 'next/navigation'
+import { db } from '@/lib/db'
+import { getCustomerSession } from '@/lib/customer-session'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { getTranslations } from 'next-intl/server'
+import { PortalHeader } from './PortalHeader'
 
 
 export async function PortalShell({
 export async function PortalShell({
   orgId: orgParam,
   orgId: orgParam,
   children,
   children,
 }: {
 }: {
-  orgId: string;
-  children: React.ReactNode;
+  orgId: string
+  children: React.ReactNode
 }) {
 }) {
-  const t = await getTranslations('portal.shell');
+  const t = await getTranslations('portal.shell')
 
 
   // Resolve slug or id to real org
   // Resolve slug or id to real org
-  const org = await resolvePortalOrg(orgParam);
+  const org = await resolvePortalOrg(orgParam)
 
 
   if (!org) {
   if (!org) {
     return (
     return (
       <div className="flex min-h-svh items-center justify-center">
       <div className="flex min-h-svh items-center justify-center">
         <div className="text-center">
         <div className="text-center">
           <h1 className="text-2xl font-bold">{t('notFound')}</h1>
           <h1 className="text-2xl font-bold">{t('notFound')}</h1>
-          <p className="mt-2 text-muted-foreground">
-            {t('portalNotExist')}
-          </p>
+          <p className="mt-2 text-muted-foreground">{t('portalNotExist')}</p>
         </div>
         </div>
       </div>
       </div>
-    );
+    )
   }
   }
 
 
-  const orgId = org.id;
+  const orgId = org.id
 
 
   // Check portal is enabled
   // Check portal is enabled
   const portalSetting = await db.appSetting.findUnique({
   const portalSetting = await db.appSetting.findUnique({
@@ -42,36 +39,34 @@ export async function PortalShell({
         key: SETTING_KEYS.PORTAL_ENABLED,
         key: SETTING_KEYS.PORTAL_ENABLED,
       },
       },
     },
     },
-  });
+  })
 
 
-  if (portalSetting?.value !== "true") {
+  if (portalSetting?.value !== 'true') {
     return (
     return (
       <div className="flex min-h-svh items-center justify-center">
       <div className="flex min-h-svh items-center justify-center">
         <div className="text-center">
         <div className="text-center">
           <h1 className="text-2xl font-bold">{t('portalNotAvailable')}</h1>
           <h1 className="text-2xl font-bold">{t('portalNotAvailable')}</h1>
-          <p className="mt-2 text-muted-foreground">
-            {t('portalNotEnabled')}
-          </p>
+          <p className="mt-2 text-muted-foreground">{t('portalNotEnabled')}</p>
         </div>
         </div>
       </div>
       </div>
-    );
+    )
   }
   }
 
 
   // Check customer session
   // Check customer session
-  const session = await getCustomerSession();
+  const session = await getCustomerSession()
 
 
   if (!session || session.organizationId !== orgId) {
   if (!session || session.organizationId !== orgId) {
-    redirect(`/portal/${orgParam}/auth/login`);
+    redirect(`/portal/${orgParam}/auth/login`)
   }
   }
 
 
   // Get customer info
   // Get customer info
   const customer = await db.customer.findUnique({
   const customer = await db.customer.findUnique({
     where: { id: session.customerId },
     where: { id: session.customerId },
     select: { name: true },
     select: { name: true },
-  });
+  })
 
 
   if (!customer) {
   if (!customer) {
-    redirect(`/portal/${orgParam}/auth/login`);
+    redirect(`/portal/${orgParam}/auth/login`)
   }
   }
 
 
   // Get org branding
   // Get org branding
@@ -82,22 +77,17 @@ export async function PortalShell({
         key: SETTING_KEYS.COMPANY_LOGO,
         key: SETTING_KEYS.COMPANY_LOGO,
       },
       },
     },
     },
-  });
+  })
 
 
   return (
   return (
-    <div className="flex h-svh flex-col">
+    <div className="min-h-svh bg-muted/20">
       <PortalHeader
       <PortalHeader
         orgId={orgParam}
         orgId={orgParam}
         orgName={org.name}
         orgName={org.name}
         orgLogo={logoSetting?.value ? `/api/public/logo/${orgParam}` : undefined}
         orgLogo={logoSetting?.value ? `/api/public/logo/${orgParam}` : undefined}
         customerName={customer.name}
         customerName={customer.name}
       />
       />
-      <div className="flex min-h-0 flex-1">
-        <aside className="hidden w-56 shrink-0 overflow-y-auto border-r p-4 md:block">
-          <PortalNav orgId={orgParam} />
-        </aside>
-        <main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
-      </div>
+      <main className="mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-8">{children}</main>
     </div>
     </div>
-  );
+  )
 }
 }

+ 62 - 0
src/features/portal/portal-backgrounds.ts

@@ -0,0 +1,62 @@
+/**
+ * Built-in background templates for the customer portal landing page.
+ * Each template is a Tailwind class string that paints a full-bleed background.
+ * Kept as plain CSS gradients so we don't need to ship asset files.
+ */
+export type PortalBackgroundTemplate = {
+  id: string
+  /** Translation key under `settings.portal.background.templateNames` */
+  labelKey: string
+  /** Tailwind class applied to the outer landing wrapper. */
+  className: string
+}
+
+export const PORTAL_BACKGROUND_TEMPLATES: PortalBackgroundTemplate[] = [
+  {
+    id: 'default',
+    labelKey: 'default',
+    className: 'bg-muted/30',
+  },
+  {
+    id: 'sunrise',
+    labelKey: 'sunrise',
+    className: 'bg-gradient-to-br from-orange-100 via-amber-50 to-rose-100',
+  },
+  {
+    id: 'ocean',
+    labelKey: 'ocean',
+    className: 'bg-gradient-to-br from-sky-100 via-blue-50 to-cyan-100',
+  },
+  {
+    id: 'forest',
+    labelKey: 'forest',
+    className: 'bg-gradient-to-br from-emerald-100 via-green-50 to-teal-100',
+  },
+  {
+    id: 'slate',
+    labelKey: 'slate',
+    className: 'bg-gradient-to-br from-slate-100 via-slate-50 to-zinc-100',
+  },
+  {
+    id: 'sand',
+    labelKey: 'sand',
+    className: 'bg-gradient-to-br from-amber-50 via-stone-100 to-yellow-50',
+  },
+]
+
+export const DEFAULT_PORTAL_BACKGROUND_TEMPLATE = PORTAL_BACKGROUND_TEMPLATES[0]
+
+export type PortalBackgroundType = 'none' | 'template' | 'image'
+
+export function isValidPortalBackgroundType(
+  value: string | null | undefined
+): value is PortalBackgroundType {
+  return value === 'none' || value === 'template' || value === 'image'
+}
+
+export function resolvePortalBackgroundTemplate(
+  id: string | null | undefined
+): PortalBackgroundTemplate {
+  if (!id) return DEFAULT_PORTAL_BACKGROUND_TEMPLATE
+  return PORTAL_BACKGROUND_TEMPLATES.find((t) => t.id === id) ?? DEFAULT_PORTAL_BACKGROUND_TEMPLATE
+}

+ 105 - 99
src/features/settings/Schema/settingsSchema.ts

@@ -1,110 +1,116 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 
 export const SETTING_KEYS = {
 export const SETTING_KEYS = {
-  COMPANY_LOGO: "workshop.logo",
-  WORKSHOP_ADDRESS: "workshop.address",
-  WORKSHOP_PHONE: "workshop.phone",
-  WORKSHOP_EMAIL: "workshop.email",
-  DEFAULT_TAX_RATE: "workshop.defaultTaxRate",
-  TAX_ENABLED: "workshop.taxEnabled",
-  TAX_INCLUSIVE: "workshop.taxInclusive",
-  TAX_LABEL: "workshop.taxLabel",
-  INVOICE_PREFIX: "workshop.invoicePrefix",
-  INVOICE_START_NUMBER: "workshop.invoiceStartNumber",
-  CURRENCY_SYMBOL: "workshop.currencySymbol",
-  CURRENCY_CODE: "workshop.currencyCode",
-  INVOICE_BANK_ACCOUNT: "invoice.bankAccount",
-  INVOICE_ORG_NUMBER: "invoice.orgNumber",
-  INVOICE_PAYMENT_TERMS: "invoice.paymentTerms",
-  INVOICE_FOOTER_NOTE: "invoice.footerNote",
-  INVOICE_SHOW_BANK_ACCOUNT: "invoice.showBankAccount",
-  INVOICE_SHOW_ORG_NUMBER: "invoice.showOrgNumber",
-  INVOICE_DUE_DAYS: "invoice.dueDays",
-  UNIT_SYSTEM: "workshop.unitSystem",
-  DEFAULT_TECHNICIAN: "workshop.defaultTechnician",
-  DEFAULT_TECHNICIAN_ID: "workshop.defaultTechnicianId",
-  DEFAULT_LABOR_RATE: "workshop.defaultLaborRate",
-  QUOTE_PREFIX: "workshop.quotePrefix",
-  QUOTE_VALID_DAYS: "workshop.quoteValidDays",
-  EMAIL_FROM_NAME: "email.fromName",
-  EMAIL_ENABLED: "email.enabled",
-  INVOICE_TEMPLATE: "invoice.template",
-  INVOICE_PRIMARY_COLOR: "invoice.primaryColor",
-  INVOICE_FONT_FAMILY: "invoice.fontFamily",
-  INVOICE_SHOW_LOGO: "invoice.showLogo",
-  INVOICE_SHOW_COMPANY_NAME: "invoice.showCompanyName",
-  INVOICE_HEADER_STYLE: "invoice.headerStyle",
-  INVOICE_LOGO_SIZE: "invoice.logoSize",
-  QUOTE_LOGO_SIZE: "quote.logoSize",
-  PAYMENT_PROVIDERS_ENABLED: "payment.providersEnabled",
-  PAYMENT_STRIPE_SECRET_KEY: "payment.stripe.secretKey",
-  PAYMENT_STRIPE_PUBLISHABLE_KEY: "payment.stripe.publishableKey",
-  PAYMENT_STRIPE_WEBHOOK_SECRET: "payment.stripe.webhookSecret",
-  PAYMENT_VIPPS_CLIENT_ID: "payment.vipps.clientId",
-  PAYMENT_VIPPS_CLIENT_SECRET: "payment.vipps.clientSecret",
-  PAYMENT_VIPPS_SUBSCRIPTION_KEY: "payment.vipps.subscriptionKey",
-  PAYMENT_VIPPS_MSN: "payment.vipps.merchantSerialNumber",
-  PAYMENT_VIPPS_USE_TEST: "payment.vipps.useTestMode",
-  PAYMENT_PAYPAL_CLIENT_ID: "payment.paypal.clientId",
-  PAYMENT_PAYPAL_CLIENT_SECRET: "payment.paypal.clientSecret",
-  PAYMENT_PAYPAL_USE_SANDBOX: "payment.paypal.useSandbox",
-  PAYMENT_TERMS_OF_SALE: "payment.termsOfSale",
-  PAYMENT_TERMS_OF_SALE_URL: "payment.termsOfSaleUrl",
-  LICENSE_KEY: "license.key",
-  LICENSE_VALID: "license.valid",
-  LICENSE_CHECKED_AT: "license.checkedAt",
-  LICENSE_PLAN: "license.plan",
-  LICENSE_EXPIRES_AT: "license.expiresAt",
-  DATE_FORMAT: "workshop.dateFormat",
-  TIME_FORMAT: "workshop.timeFormat",
-  TIMEZONE: "workshop.timezone",
-  QUOTE_PRIMARY_COLOR: "quote.primaryColor",
-  QUOTE_FONT_FAMILY: "quote.fontFamily",
-  QUOTE_HEADER_STYLE: "quote.headerStyle",
-  PREDICTED_MAINTENANCE_ENABLED: "maintenance.enabled",
-  MAINTENANCE_SERVICE_INTERVAL: "maintenance.serviceInterval",
-  MAINTENANCE_APPROACHING_THRESHOLD: "maintenance.approachingThreshold",
-  INVENTORY_MARKUP_MULTIPLIER: "inventory.markupMultiplier",
-  SMS_TEMPLATE_INVOICE_READY: "sms.template.invoiceReady",
-  SMS_TEMPLATE_QUOTE_READY: "sms.template.quoteReady",
-  SMS_TEMPLATE_INSPECTION_READY: "sms.template.inspectionReady",
-  SMS_TEMPLATE_STATUS_IN_PROGRESS: "sms.template.statusInProgress",
-  SMS_TEMPLATE_STATUS_WAITING_PARTS: "sms.template.statusWaitingParts",
-  SMS_TEMPLATE_STATUS_READY: "sms.template.statusReady",
-  SMS_TEMPLATE_STATUS_COMPLETED: "sms.template.statusCompleted",
-  SMS_TEMPLATE_PAYMENT_RECEIVED: "sms.template.paymentReceived",
+  COMPANY_LOGO: 'workshop.logo',
+  WORKSHOP_ADDRESS: 'workshop.address',
+  WORKSHOP_PHONE: 'workshop.phone',
+  WORKSHOP_EMAIL: 'workshop.email',
+  DEFAULT_TAX_RATE: 'workshop.defaultTaxRate',
+  TAX_ENABLED: 'workshop.taxEnabled',
+  TAX_INCLUSIVE: 'workshop.taxInclusive',
+  TAX_LABEL: 'workshop.taxLabel',
+  INVOICE_PREFIX: 'workshop.invoicePrefix',
+  INVOICE_START_NUMBER: 'workshop.invoiceStartNumber',
+  CURRENCY_SYMBOL: 'workshop.currencySymbol',
+  CURRENCY_CODE: 'workshop.currencyCode',
+  INVOICE_BANK_ACCOUNT: 'invoice.bankAccount',
+  INVOICE_ORG_NUMBER: 'invoice.orgNumber',
+  INVOICE_PAYMENT_TERMS: 'invoice.paymentTerms',
+  INVOICE_FOOTER_NOTE: 'invoice.footerNote',
+  INVOICE_SHOW_BANK_ACCOUNT: 'invoice.showBankAccount',
+  INVOICE_SHOW_ORG_NUMBER: 'invoice.showOrgNumber',
+  INVOICE_DUE_DAYS: 'invoice.dueDays',
+  UNIT_SYSTEM: 'workshop.unitSystem',
+  DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
+  DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
+  DEFAULT_LABOR_RATE: 'workshop.defaultLaborRate',
+  QUOTE_PREFIX: 'workshop.quotePrefix',
+  QUOTE_VALID_DAYS: 'workshop.quoteValidDays',
+  EMAIL_FROM_NAME: 'email.fromName',
+  EMAIL_ENABLED: 'email.enabled',
+  INVOICE_TEMPLATE: 'invoice.template',
+  INVOICE_PRIMARY_COLOR: 'invoice.primaryColor',
+  INVOICE_FONT_FAMILY: 'invoice.fontFamily',
+  INVOICE_SHOW_LOGO: 'invoice.showLogo',
+  INVOICE_SHOW_COMPANY_NAME: 'invoice.showCompanyName',
+  INVOICE_HEADER_STYLE: 'invoice.headerStyle',
+  INVOICE_LOGO_SIZE: 'invoice.logoSize',
+  QUOTE_LOGO_SIZE: 'quote.logoSize',
+  PAYMENT_PROVIDERS_ENABLED: 'payment.providersEnabled',
+  PAYMENT_STRIPE_SECRET_KEY: 'payment.stripe.secretKey',
+  PAYMENT_STRIPE_PUBLISHABLE_KEY: 'payment.stripe.publishableKey',
+  PAYMENT_STRIPE_WEBHOOK_SECRET: 'payment.stripe.webhookSecret',
+  PAYMENT_VIPPS_CLIENT_ID: 'payment.vipps.clientId',
+  PAYMENT_VIPPS_CLIENT_SECRET: 'payment.vipps.clientSecret',
+  PAYMENT_VIPPS_SUBSCRIPTION_KEY: 'payment.vipps.subscriptionKey',
+  PAYMENT_VIPPS_MSN: 'payment.vipps.merchantSerialNumber',
+  PAYMENT_VIPPS_USE_TEST: 'payment.vipps.useTestMode',
+  PAYMENT_PAYPAL_CLIENT_ID: 'payment.paypal.clientId',
+  PAYMENT_PAYPAL_CLIENT_SECRET: 'payment.paypal.clientSecret',
+  PAYMENT_PAYPAL_USE_SANDBOX: 'payment.paypal.useSandbox',
+  PAYMENT_TERMS_OF_SALE: 'payment.termsOfSale',
+  PAYMENT_TERMS_OF_SALE_URL: 'payment.termsOfSaleUrl',
+  LICENSE_KEY: 'license.key',
+  LICENSE_VALID: 'license.valid',
+  LICENSE_CHECKED_AT: 'license.checkedAt',
+  LICENSE_PLAN: 'license.plan',
+  LICENSE_EXPIRES_AT: 'license.expiresAt',
+  DATE_FORMAT: 'workshop.dateFormat',
+  TIME_FORMAT: 'workshop.timeFormat',
+  TIMEZONE: 'workshop.timezone',
+  QUOTE_PRIMARY_COLOR: 'quote.primaryColor',
+  QUOTE_FONT_FAMILY: 'quote.fontFamily',
+  QUOTE_HEADER_STYLE: 'quote.headerStyle',
+  PREDICTED_MAINTENANCE_ENABLED: 'maintenance.enabled',
+  MAINTENANCE_SERVICE_INTERVAL: 'maintenance.serviceInterval',
+  MAINTENANCE_APPROACHING_THRESHOLD: 'maintenance.approachingThreshold',
+  INVENTORY_MARKUP_MULTIPLIER: 'inventory.markupMultiplier',
+  SMS_TEMPLATE_INVOICE_READY: 'sms.template.invoiceReady',
+  SMS_TEMPLATE_QUOTE_READY: 'sms.template.quoteReady',
+  SMS_TEMPLATE_INSPECTION_READY: 'sms.template.inspectionReady',
+  SMS_TEMPLATE_STATUS_IN_PROGRESS: 'sms.template.statusInProgress',
+  SMS_TEMPLATE_STATUS_WAITING_PARTS: 'sms.template.statusWaitingParts',
+  SMS_TEMPLATE_STATUS_READY: 'sms.template.statusReady',
+  SMS_TEMPLATE_STATUS_COMPLETED: 'sms.template.statusCompleted',
+  SMS_TEMPLATE_PAYMENT_RECEIVED: 'sms.template.paymentReceived',
   // Telegram templates
   // Telegram templates
-  TELEGRAM_TEMPLATE_INVOICE_READY: "telegram.template.invoiceReady",
-  TELEGRAM_TEMPLATE_QUOTE_READY: "telegram.template.quoteReady",
-  TELEGRAM_TEMPLATE_STATUS_IN_PROGRESS: "telegram.template.statusInProgress",
-  TELEGRAM_TEMPLATE_STATUS_COMPLETED: "telegram.template.statusCompleted",
-  TELEGRAM_TEMPLATE_PAYMENT_RECEIVED: "telegram.template.paymentReceived",
-  TELEGRAM_SHOW_QR_ON_INVOICE: "telegram.showQrOnInvoice",
-  PORTAL_ENABLED: "portal.enabled",
-  WORKBOARD_WEEK_START_DAY: "workboard.weekStartDay",
-  WORKBOARD_WORK_DAY_START: "workboard.workDayStart",
-  WORKBOARD_WORK_DAY_END: "workboard.workDayEnd",
-  INVOICE_LAYOUT_CONFIG: "invoice.layoutConfig",
-  QUOTE_LAYOUT_CONFIG: "quote.layoutConfig",
-  AI_PROVIDER: "ai.provider",
-  AI_API_KEY: "ai.apiKey",
-  AI_MODEL: "ai.model",
-  AI_ENABLED: "ai.enabled",
-  SERVICE_TYPE: "workshop.serviceType",
-  WORKSHOP_LOCALE: "workshop.locale",
-  FORCE_CUSTOMER_LOCALE: "workshop.forceCustomerLocale",
-} as const;
+  TELEGRAM_TEMPLATE_INVOICE_READY: 'telegram.template.invoiceReady',
+  TELEGRAM_TEMPLATE_QUOTE_READY: 'telegram.template.quoteReady',
+  TELEGRAM_TEMPLATE_STATUS_IN_PROGRESS: 'telegram.template.statusInProgress',
+  TELEGRAM_TEMPLATE_STATUS_COMPLETED: 'telegram.template.statusCompleted',
+  TELEGRAM_TEMPLATE_PAYMENT_RECEIVED: 'telegram.template.paymentReceived',
+  TELEGRAM_SHOW_QR_ON_INVOICE: 'telegram.showQrOnInvoice',
+  PORTAL_ENABLED: 'portal.enabled',
+  PORTAL_DESCRIPTION: 'portal.description',
+  PORTAL_HOURS: 'portal.hours',
+  PORTAL_BACKGROUND_TYPE: 'portal.background.type',
+  PORTAL_BACKGROUND_TEMPLATE: 'portal.background.template',
+  PORTAL_BACKGROUND_IMAGE: 'portal.background.image',
+  WORKBOARD_WEEK_START_DAY: 'workboard.weekStartDay',
+  WORKBOARD_WORK_DAY_START: 'workboard.workDayStart',
+  WORKBOARD_WORK_DAY_END: 'workboard.workDayEnd',
+  INVOICE_LAYOUT_CONFIG: 'invoice.layoutConfig',
+  QUOTE_LAYOUT_CONFIG: 'quote.layoutConfig',
+  AI_PROVIDER: 'ai.provider',
+  AI_API_KEY: 'ai.apiKey',
+  AI_MODEL: 'ai.model',
+  AI_ENABLED: 'ai.enabled',
+  SERVICE_TYPE: 'workshop.serviceType',
+  WORKSHOP_LOCALE: 'workshop.locale',
+  WORKSHOP_DEFAULT_COUNTRY_CODE: 'workshop.defaultCountryCode',
+  FORCE_CUSTOMER_LOCALE: 'workshop.forceCustomerLocale',
+} as const
 
 
-export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
+export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]
 
 
 export const workshopSettingsSchema = z.object({
 export const workshopSettingsSchema = z.object({
   [SETTING_KEYS.WORKSHOP_ADDRESS]: z.string().optional(),
   [SETTING_KEYS.WORKSHOP_ADDRESS]: z.string().optional(),
   [SETTING_KEYS.WORKSHOP_PHONE]: z.string().optional(),
   [SETTING_KEYS.WORKSHOP_PHONE]: z.string().optional(),
-  [SETTING_KEYS.WORKSHOP_EMAIL]: z.string().email("Invalid email").optional().or(z.literal("")),
+  [SETTING_KEYS.WORKSHOP_EMAIL]: z.string().email('Invalid email').optional().or(z.literal('')),
   [SETTING_KEYS.DEFAULT_TAX_RATE]: z.string().optional(),
   [SETTING_KEYS.DEFAULT_TAX_RATE]: z.string().optional(),
   [SETTING_KEYS.INVOICE_PREFIX]: z.string().optional(),
   [SETTING_KEYS.INVOICE_PREFIX]: z.string().optional(),
   [SETTING_KEYS.CURRENCY_SYMBOL]: z.string().optional(),
   [SETTING_KEYS.CURRENCY_SYMBOL]: z.string().optional(),
-});
+})
 
 
 export const invoiceSettingsSchema = z.object({
 export const invoiceSettingsSchema = z.object({
   [SETTING_KEYS.INVOICE_BANK_ACCOUNT]: z.string().optional(),
   [SETTING_KEYS.INVOICE_BANK_ACCOUNT]: z.string().optional(),
@@ -114,7 +120,7 @@ export const invoiceSettingsSchema = z.object({
   [SETTING_KEYS.INVOICE_SHOW_BANK_ACCOUNT]: z.string().optional(),
   [SETTING_KEYS.INVOICE_SHOW_BANK_ACCOUNT]: z.string().optional(),
   [SETTING_KEYS.INVOICE_SHOW_ORG_NUMBER]: z.string().optional(),
   [SETTING_KEYS.INVOICE_SHOW_ORG_NUMBER]: z.string().optional(),
   [SETTING_KEYS.INVOICE_DUE_DAYS]: z.string().optional(),
   [SETTING_KEYS.INVOICE_DUE_DAYS]: z.string().optional(),
-});
+})
 
 
-export type WorkshopSettings = z.infer<typeof workshopSettingsSchema>;
-export type InvoiceSettings = z.infer<typeof invoiceSettingsSchema>;
+export type WorkshopSettings = z.infer<typeof workshopSettingsSchema>
+export type InvoiceSettings = z.infer<typeof invoiceSettingsSchema>

+ 94 - 81
src/features/sms/Actions/smsActions.ts

@@ -1,74 +1,91 @@
-"use server";
+'use server'
 
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { sendOrgSms, getOrgSmsPhoneNumber } from "@/lib/sms";
-import { requireFeature } from "@/lib/features";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { SMS_TEMPLATE_DEFAULTS } from "@/lib/sms-templates";
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { sendOrgSms, getOrgSmsPhoneNumber, normalizeOrgPhone } from '@/lib/sms'
+import { requireFeature } from '@/lib/features'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
 
 
 export async function sendSmsToCustomer(input: {
 export async function sendSmsToCustomer(input: {
-  customerId: string;
-  body: string;
-  relatedEntityType?: string;
-  relatedEntityId?: string;
+  customerId: string
+  body: string
+  relatedEntityType?: string
+  relatedEntityId?: string
 }) {
 }) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      await requireFeature(organizationId, "sms");
+      await requireFeature(organizationId, 'sms')
 
 
       const customer = await db.customer.findFirst({
       const customer = await db.customer.findFirst({
         where: { id: input.customerId, organizationId },
         where: { id: input.customerId, organizationId },
         select: { id: true, phone: true, name: true },
         select: { id: true, phone: true, name: true },
-      });
+      })
 
 
-      if (!customer) throw new Error("Customer not found");
-      if (!customer.phone) throw new Error("Customer has no phone number");
+      if (!customer) throw new Error('Customer not found')
+      if (!customer.phone) throw new Error('Customer has no phone number')
 
 
-      const fromNumber = await getOrgSmsPhoneNumber(organizationId);
-      if (!fromNumber) throw new Error("SMS phone number is not configured");
+      const fromNumber = await getOrgSmsPhoneNumber(organizationId)
+      if (!fromNumber) throw new Error('SMS phone number is not configured')
+
+      // Normalize the destination phone using the workshop's default
+      // country code so the audit-log row records the same E.164 number
+      // the provider will receive. sendOrgSms re-normalizes internally as
+      // defense in depth, so this stays correct even if either side
+      // changes later.
+      const normalizedTo = await normalizeOrgPhone(organizationId, customer.phone)
+      if (!normalizedTo) {
+        throw new Error(
+          'Customer phone is not in a format we can dial. ' +
+            "Set a default country code in Settings → Localization, or update the customer's phone to E.164 format."
+        )
+      }
 
 
       // Create the message record first
       // Create the message record first
       const message = await db.smsMessage.create({
       const message = await db.smsMessage.create({
         data: {
         data: {
-          direction: "outbound",
+          direction: 'outbound',
           fromNumber,
           fromNumber,
-          toNumber: customer.phone,
+          toNumber: normalizedTo,
           body: input.body,
           body: input.body,
-          status: "queued",
+          status: 'queued',
           organizationId,
           organizationId,
           customerId: customer.id,
           customerId: customer.id,
           relatedEntityType: input.relatedEntityType,
           relatedEntityType: input.relatedEntityType,
           relatedEntityId: input.relatedEntityId,
           relatedEntityId: input.relatedEntityId,
         },
         },
-      });
+      })
 
 
       try {
       try {
         const result = await sendOrgSms(organizationId, {
         const result = await sendOrgSms(organizationId, {
-          to: customer.phone,
+          to: normalizedTo,
           body: input.body,
           body: input.body,
-        });
+        })
 
 
         await db.smsMessage.update({
         await db.smsMessage.update({
           where: { id: message.id },
           where: { id: message.id },
           data: {
           data: {
-            status: "sent",
+            status: 'sent',
             providerMsgId: result.providerMsgId,
             providerMsgId: result.providerMsgId,
           },
           },
-        });
+        })
 
 
-        return { id: message.id, status: "sent" as const, customerPhone: customer.phone, customerName: customer.name };
+        return {
+          id: message.id,
+          status: 'sent' as const,
+          customerPhone: result.to,
+          customerName: customer.name,
+        }
       } catch (error) {
       } catch (error) {
-        const errorMessage =
-          error instanceof Error ? error.message : "Unknown error";
+        const errorMessage = error instanceof Error ? error.message : 'Unknown error'
 
 
         await db.smsMessage.update({
         await db.smsMessage.update({
           where: { id: message.id },
           where: { id: message.id },
-          data: { status: "failed", errorMessage },
-        });
+          data: { status: 'failed', errorMessage },
+        })
 
 
-        throw new Error(`Failed to send SMS: ${errorMessage}`);
+        throw new Error(`Failed to send SMS: ${errorMessage}`)
       }
       }
     },
     },
     {
     {
@@ -76,21 +93,17 @@ export async function sendSmsToCustomer(input: {
         { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
       ],
       ],
       audit: ({ result }) => ({
       audit: ({ result }) => ({
-        action: "sms.send",
-        entity: "SmsMessage",
+        action: 'sms.send',
+        entity: 'SmsMessage',
         entityId: result.id,
         entityId: result.id,
         message: `Sent SMS to ${result.customerName} (${result.customerPhone})`,
         message: `Sent SMS to ${result.customerName} (${result.customerPhone})`,
         metadata: { messageId: result.id },
         metadata: { messageId: result.id },
       }),
       }),
-    },
-  );
+    }
+  )
 }
 }
 
 
-export async function getConversation(
-  customerId: string,
-  cursor?: string,
-  limit: number = 50,
-) {
+export async function getConversation(customerId: string, cursor?: string, limit: number = 50) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const messages = await db.smsMessage.findMany({
       const messages = await db.smsMessage.findMany({
@@ -98,7 +111,7 @@ export async function getConversation(
           organizationId,
           organizationId,
           customerId,
           customerId,
         },
         },
-        orderBy: { createdAt: "desc" },
+        orderBy: { createdAt: 'desc' },
         take: limit + 1,
         take: limit + 1,
         ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
         ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
         select: {
         select: {
@@ -110,22 +123,22 @@ export async function getConversation(
           fromNumber: true,
           fromNumber: true,
           toNumber: true,
           toNumber: true,
         },
         },
-      });
+      })
 
 
-      const hasMore = messages.length > limit;
-      const items = hasMore ? messages.slice(0, limit) : messages;
+      const hasMore = messages.length > limit
+      const items = hasMore ? messages.slice(0, limit) : messages
 
 
       return {
       return {
         messages: items.reverse(),
         messages: items.reverse(),
         nextCursor: hasMore ? items[items.length - 1]?.id : null,
         nextCursor: hasMore ? items[items.length - 1]?.id : null,
-      };
+      }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
         { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function deleteSmsMessage(messageId: string) {
 export async function deleteSmsMessage(messageId: string) {
@@ -133,18 +146,18 @@ export async function deleteSmsMessage(messageId: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const message = await db.smsMessage.findFirst({
       const message = await db.smsMessage.findFirst({
         where: { id: messageId, organizationId },
         where: { id: messageId, organizationId },
-      });
-      if (!message) throw new Error("Message not found");
+      })
+      if (!message) throw new Error('Message not found')
 
 
-      await db.smsMessage.delete({ where: { id: messageId } });
-      return { deleted: true };
+      await db.smsMessage.delete({ where: { id: messageId } })
+      return { deleted: true }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
         { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function deleteConversation(customerId: string) {
 export async function deleteConversation(customerId: string) {
@@ -153,21 +166,21 @@ export async function deleteConversation(customerId: string) {
       const customer = await db.customer.findFirst({
       const customer = await db.customer.findFirst({
         where: { id: customerId, organizationId },
         where: { id: customerId, organizationId },
         select: { id: true },
         select: { id: true },
-      });
-      if (!customer) throw new Error("Customer not found");
+      })
+      if (!customer) throw new Error('Customer not found')
 
 
       const { count } = await db.smsMessage.deleteMany({
       const { count } = await db.smsMessage.deleteMany({
         where: { organizationId, customerId },
         where: { organizationId, customerId },
-      });
+      })
 
 
-      return { deleted: count };
+      return { deleted: count }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
         { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function getSmsTemplates() {
 export async function getSmsTemplates() {
@@ -182,7 +195,7 @@ export async function getSmsTemplates() {
         SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
         SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
         SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
         SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
         SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
         SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
-      ];
+      ]
       const [settings, org, user] = await Promise.all([
       const [settings, org, user] = await Promise.all([
         db.appSetting.findMany({
         db.appSetting.findMany({
           where: { organizationId, key: { in: smsKeys } },
           where: { organizationId, key: { in: smsKeys } },
@@ -195,25 +208,25 @@ export async function getSmsTemplates() {
           where: { id: userId },
           where: { id: userId },
           select: { name: true },
           select: { name: true },
         }),
         }),
-      ]);
+      ])
 
 
-      const templates: Record<string, string> = {};
+      const templates: Record<string, string> = {}
       for (const key of smsKeys) {
       for (const key of smsKeys) {
-        const found = settings.find((s) => s.key === key);
-        templates[key] = found?.value || SMS_TEMPLATE_DEFAULTS[key] || "";
+        const found = settings.find((s) => s.key === key)
+        templates[key] = found?.value || SMS_TEMPLATE_DEFAULTS[key] || ''
       }
       }
       return {
       return {
         templates,
         templates,
-        companyName: org?.name || "",
-        currentUser: user?.name || "",
-      };
+        companyName: org?.name || '',
+        currentUser: user?.name || '',
+      }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
         { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function getRecentSmsThreads(offset: number = 0, limit: number = 50) {
 export async function getRecentSmsThreads(offset: number = 0, limit: number = 50) {
@@ -230,7 +243,7 @@ export async function getRecentSmsThreads(offset: number = 0, limit: number = 50
           name: true,
           name: true,
           phone: true,
           phone: true,
           smsMessages: {
           smsMessages: {
-            orderBy: { createdAt: "desc" },
+            orderBy: { createdAt: 'desc' },
             take: 1,
             take: 1,
             select: {
             select: {
               id: true,
               id: true,
@@ -242,15 +255,15 @@ export async function getRecentSmsThreads(offset: number = 0, limit: number = 50
           },
           },
         },
         },
         orderBy: {
         orderBy: {
-          smsMessages: { _count: "desc" },
+          smsMessages: { _count: 'desc' },
         },
         },
         skip: offset,
         skip: offset,
         take: limit + 1,
         take: limit + 1,
-      });
+      })
 
 
-      const filtered = customers.filter((c) => c.smsMessages.length > 0);
-      const hasMore = filtered.length > limit;
-      const items = hasMore ? filtered.slice(0, limit) : filtered;
+      const filtered = customers.filter((c) => c.smsMessages.length > 0)
+      const hasMore = filtered.length > limit
+      const items = hasMore ? filtered.slice(0, limit) : filtered
 
 
       const threads = items
       const threads = items
         .map((c) => ({
         .map((c) => ({
@@ -262,15 +275,15 @@ export async function getRecentSmsThreads(offset: number = 0, limit: number = 50
         .sort(
         .sort(
           (a, b) =>
           (a, b) =>
             new Date(b.lastMessage.createdAt).getTime() -
             new Date(b.lastMessage.createdAt).getTime() -
-            new Date(a.lastMessage.createdAt).getTime(),
-        );
+            new Date(a.lastMessage.createdAt).getTime()
+        )
 
 
-      return { threads, hasMore };
+      return { threads, hasMore }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
         { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }

+ 31 - 33
src/features/sms/Actions/smsSettingsActions.ts

@@ -1,31 +1,29 @@
-"use server";
+'use server'
 
 
-import { randomBytes } from "crypto";
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { revalidatePath } from "next/cache";
-import { ALL_ORG_SMS_KEYS, ORG_SMS_KEYS } from "../Schema/smsSettingsSchema";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { sendOrgSms } from "@/lib/sms";
+import { randomBytes } from 'crypto'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { revalidatePath } from 'next/cache'
+import { ALL_ORG_SMS_KEYS, ORG_SMS_KEYS } from '../Schema/smsSettingsSchema'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { sendOrgSms } from '@/lib/sms'
 
 
 export async function getSmsSettings() {
 export async function getSmsSettings() {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const settings = await db.appSetting.findMany({
       const settings = await db.appSetting.findMany({
         where: { organizationId, key: { in: ALL_ORG_SMS_KEYS } },
         where: { organizationId, key: { in: ALL_ORG_SMS_KEYS } },
-      });
-      const map: Record<string, string> = {};
+      })
+      const map: Record<string, string> = {}
       for (const s of settings) {
       for (const s of settings) {
-        map[s.key] = s.value;
+        map[s.key] = s.value
       }
       }
-      return map;
+      return map
     },
     },
     {
     {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
-      ],
-    },
-  );
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
 }
 }
 
 
 export async function setSmsSettings(entries: Record<string, string>) {
 export async function setSmsSettings(entries: Record<string, string>) {
@@ -39,10 +37,10 @@ export async function setSmsSettings(entries: Record<string, string>) {
             key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
             key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
           },
           },
         },
         },
-      });
+      })
 
 
       if (!existing?.value) {
       if (!existing?.value) {
-        entries[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] = randomBytes(24).toString("hex");
+        entries[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] = randomBytes(24).toString('hex')
       }
       }
 
 
       await db.$transaction(
       await db.$transaction(
@@ -51,11 +49,11 @@ export async function setSmsSettings(entries: Record<string, string>) {
             where: { organizationId_key: { organizationId, key } },
             where: { organizationId_key: { organizationId, key } },
             update: { value },
             update: { value },
             create: { userId, organizationId, key, value },
             create: { userId, organizationId, key, value },
-          }),
-        ),
-      );
-      revalidatePath("/settings/sms");
-      return true;
+          })
+        )
+      )
+      revalidatePath('/settings/sms')
+      return true
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -64,23 +62,23 @@ export async function setSmsSettings(entries: Record<string, string>) {
           subject: PermissionSubject.SETTINGS,
           subject: PermissionSubject.SETTINGS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function testSmsSend(testPhone: string) {
 export async function testSmsSend(testPhone: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       if (!testPhone?.trim()) {
       if (!testPhone?.trim()) {
-        throw new Error("Please enter a phone number to send the test SMS to");
+        throw new Error('Please enter a phone number to send the test SMS to')
       }
       }
 
 
-      await sendOrgSms(organizationId, {
+      const result = await sendOrgSms(organizationId, {
         to: testPhone.trim(),
         to: testPhone.trim(),
-        body: "SMS test from Torqvoice — your SMS provider is configured correctly.",
-      });
+        body: 'SMS test from Torqvoice — your SMS provider is configured correctly.',
+      })
 
 
-      return { sentTo: testPhone.trim() };
+      return { sentTo: result.to }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -89,6 +87,6 @@ export async function testSmsSend(testPhone: string) {
           subject: PermissionSubject.SETTINGS,
           subject: PermissionSubject.SETTINGS,
         },
         },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }

+ 113 - 0
src/lib/portal-phone.ts

@@ -0,0 +1,113 @@
+/**
+ * Phone normalization for the customer portal SMS sign-in flow.
+ *
+ * The portal accepts phone numbers from non-technical customers who don't
+ * always include a country code, and existing customer records in the
+ * database may also be stored without one. We normalize on read instead of
+ * forcing data migration: given a workshop's default country code, we
+ * derive an E.164 form for sending the SMS, and a small set of variants
+ * to use when looking up the matching `Customer` row.
+ *
+ * No external library — pure string manipulation.
+ */
+
+/** Strip whitespace, dashes, parentheses, dots from a phone string. */
+function stripPunctuation(input: string): string {
+  return input.replace(/[\s\-().]/g, '')
+}
+
+/**
+ * Normalize a workshop default country code to the canonical "+XX" form.
+ * Accepts any of these reasonable inputs:
+ *
+ *   "+47"   → "+47"
+ *   "47"    → "+47"
+ *   "0047"  → "+47"   (00 is the international dialing prefix, same as +)
+ *   " +47 " → "+47"
+ *   "+1"    → "+1"
+ *   "+371"  → "+371"  (Latvia, 3-digit code)
+ *
+ * Returns null when the input is empty, all-junk, the wrong shape, or
+ * outside the 1–3 digit range that real ITU E.164 country codes use.
+ */
+export function normalizeCountryCode(input: string | null | undefined): string | null {
+  if (!input) return null
+  const stripped = stripPunctuation(input)
+  if (!stripped) return null
+
+  // 00 international prefix is equivalent to +
+  let withPlus: string
+  if (stripped.startsWith('+')) {
+    withPlus = stripped
+  } else if (stripped.startsWith('00')) {
+    withPlus = `+${stripped.slice(2)}`
+  } else {
+    withPlus = `+${stripped}`
+  }
+
+  // Real country codes are 1–3 digits and never start with 0.
+  if (!/^\+[1-9]\d{0,2}$/.test(withPlus)) return null
+  return withPlus
+}
+
+/**
+ * Normalize a phone string into an E.164 number using the workshop's
+ * default country code as a fallback when the input has no `+` prefix.
+ *
+ * Returns null when the result still wouldn't be valid E.164.
+ *
+ *   normalizePortalPhone('+47 123 45 678', '+47') → '+4712345678'
+ *   normalizePortalPhone('012 34 56 78',   '+47') → '+4712345678'
+ *   normalizePortalPhone('12345678',       '+47') → '+4712345678'
+ *   normalizePortalPhone('12345678',       null)  → null
+ */
+export function normalizePortalPhone(
+  input: string,
+  defaultCountryCode: string | null
+): string | null {
+  const stripped = stripPunctuation(input)
+  if (!stripped) return null
+
+  let e164: string
+  if (stripped.startsWith('+')) {
+    e164 = stripped
+  } else {
+    const cc = normalizeCountryCode(defaultCountryCode)
+    if (!cc) return null
+    // Strip a single leading 0 (NO/DE/FR/etc. trunk prefix) before
+    // appending the country code.
+    const local = stripped.replace(/^0+/, '')
+    if (!local) return null
+    e164 = `${cc}${local}`
+  }
+
+  if (!/^\+[1-9]\d{6,14}$/.test(e164)) return null
+  return e164
+}
+
+/**
+ * Build the array of strings to try matching against `customer.phone`.
+ *
+ * Customer phone numbers in the database may have been entered before
+ * E.164 normalization existed, so they could be in any of these forms:
+ *   - "+4712345678" (E.164)
+ *   - "12345678"    (local digits, no trunk prefix)
+ *   - "012345678"   (local digits, with trunk prefix)
+ * We return all plausible variants given a normalized E.164 input plus
+ * the workshop's default country code.
+ */
+export function getPhoneLookupVariants(e164: string, defaultCountryCode: string | null): string[] {
+  const variants = new Set<string>()
+  variants.add(e164)
+
+  const cc = normalizeCountryCode(defaultCountryCode)
+  if (cc && e164.startsWith(cc)) {
+    const local = e164.slice(cc.length)
+    if (local) {
+      variants.add(local)
+      variants.add(`0${local}`)
+    }
+  }
+
+  return Array.from(variants)
+}

+ 130 - 98
src/lib/sms.ts

@@ -1,28 +1,25 @@
-import { db } from "./db";
-import { ORG_SMS_KEYS } from "@/features/sms/Schema/smsSettingsSchema";
+import { db } from './db'
+import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { normalizePortalPhone } from './portal-phone'
 
 
-export type SmsProvider = "twilio" | "vonage" | "telnyx";
+export type SmsProvider = 'twilio' | 'vonage' | 'telnyx'
 
 
 export interface SendSmsOptions {
 export interface SendSmsOptions {
-  to: string;
-  body: string;
+  to: string
+  body: string
 }
 }
 
 
-type SettingsMap = Map<string, string>;
+type SettingsMap = Map<string, string>
 
 
-async function getOrgSettings(
-  organizationId: string,
-  keys: string[],
-): Promise<SettingsMap> {
+async function getOrgSettings(organizationId: string, keys: string[]): Promise<SettingsMap> {
   const rows = await db.appSetting.findMany({
   const rows = await db.appSetting.findMany({
     where: { organizationId, key: { in: keys } },
     where: { organizationId, key: { in: keys } },
-  });
-  return new Map(rows.map((r) => [r.key, r.value]));
+  })
+  return new Map(rows.map((r) => [r.key, r.value]))
 }
 }
 
 
-export async function getOrgSmsProvider(
-  organizationId: string,
-): Promise<SmsProvider | null> {
+export async function getOrgSmsProvider(organizationId: string): Promise<SmsProvider | null> {
   const setting = await db.appSetting.findUnique({
   const setting = await db.appSetting.findUnique({
     where: {
     where: {
       organizationId_key: {
       organizationId_key: {
@@ -30,17 +27,15 @@ export async function getOrgSmsProvider(
         key: ORG_SMS_KEYS.SMS_PROVIDER,
         key: ORG_SMS_KEYS.SMS_PROVIDER,
       },
       },
     },
     },
-  });
-  const value = setting?.value;
-  if (value === "twilio" || value === "vonage" || value === "telnyx") {
-    return value;
+  })
+  const value = setting?.value
+  if (value === 'twilio' || value === 'vonage' || value === 'telnyx') {
+    return value
   }
   }
-  return null;
+  return null
 }
 }
 
 
-export async function getOrgSmsPhoneNumber(
-  organizationId: string,
-): Promise<string | null> {
+export async function getOrgSmsPhoneNumber(organizationId: string): Promise<string | null> {
   const setting = await db.appSetting.findUnique({
   const setting = await db.appSetting.findUnique({
     where: {
     where: {
       organizationId_key: {
       organizationId_key: {
@@ -48,80 +43,80 @@ export async function getOrgSmsPhoneNumber(
         key: ORG_SMS_KEYS.SMS_PHONE_NUMBER,
         key: ORG_SMS_KEYS.SMS_PHONE_NUMBER,
       },
       },
     },
     },
-  });
-  return setting?.value || null;
+  })
+  return setting?.value || null
 }
 }
 
 
 // ─── Twilio ──────────────────────────────────────────────────────────────────
 // ─── Twilio ──────────────────────────────────────────────────────────────────
 
 
 interface TwilioResult {
 interface TwilioResult {
-  sid: string;
-  status: string;
+  sid: string
+  status: string
 }
 }
 
 
 async function sendViaTwilio(
 async function sendViaTwilio(
   settings: SettingsMap,
   settings: SettingsMap,
   from: string,
   from: string,
   to: string,
   to: string,
-  body: string,
+  body: string
 ): Promise<TwilioResult> {
 ): Promise<TwilioResult> {
-  const accountSid = settings.get(ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID);
-  const authToken = settings.get(ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN);
+  const accountSid = settings.get(ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID)
+  const authToken = settings.get(ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN)
 
 
   if (!accountSid || !authToken) {
   if (!accountSid || !authToken) {
-    throw new Error("Twilio is not configured. Add your Account SID and Auth Token.");
+    throw new Error('Twilio is not configured. Add your Account SID and Auth Token.')
   }
   }
 
 
-  const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
-  const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
+  const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
+  const credentials = Buffer.from(`${accountSid}:${authToken}`).toString('base64')
 
 
-  const params = new URLSearchParams({ From: from, To: to, Body: body });
+  const params = new URLSearchParams({ From: from, To: to, Body: body })
 
 
   const res = await fetch(url, {
   const res = await fetch(url, {
-    method: "POST",
+    method: 'POST',
     headers: {
     headers: {
       Authorization: `Basic ${credentials}`,
       Authorization: `Basic ${credentials}`,
-      "Content-Type": "application/x-www-form-urlencoded",
+      'Content-Type': 'application/x-www-form-urlencoded',
     },
     },
     body: params.toString(),
     body: params.toString(),
-  });
+  })
 
 
   if (!res.ok) {
   if (!res.ok) {
-    const err = await res.json().catch(() => ({}));
-    throw new Error(`Twilio error: ${(err as { message?: string }).message || res.statusText}`);
+    const err = await res.json().catch(() => ({}))
+    throw new Error(`Twilio error: ${(err as { message?: string }).message || res.statusText}`)
   }
   }
 
 
-  const data = (await res.json()) as TwilioResult;
-  return { sid: data.sid, status: data.status };
+  const data = (await res.json()) as TwilioResult
+  return { sid: data.sid, status: data.status }
 }
 }
 
 
 // ─── Vonage ──────────────────────────────────────────────────────────────────
 // ─── Vonage ──────────────────────────────────────────────────────────────────
 
 
 interface VonageResult {
 interface VonageResult {
-  messageId: string;
-  status: string;
+  messageId: string
+  status: string
 }
 }
 
 
 async function sendViaVonage(
 async function sendViaVonage(
   settings: SettingsMap,
   settings: SettingsMap,
   from: string,
   from: string,
   to: string,
   to: string,
-  body: string,
+  body: string
 ): Promise<VonageResult> {
 ): Promise<VonageResult> {
-  const apiKey = settings.get(ORG_SMS_KEYS.SMS_VONAGE_API_KEY);
-  const apiSecret = settings.get(ORG_SMS_KEYS.SMS_VONAGE_API_SECRET);
+  const apiKey = settings.get(ORG_SMS_KEYS.SMS_VONAGE_API_KEY)
+  const apiSecret = settings.get(ORG_SMS_KEYS.SMS_VONAGE_API_SECRET)
 
 
   if (!apiKey || !apiSecret) {
   if (!apiKey || !apiSecret) {
-    throw new Error("Vonage is not configured. Add your API Key and Secret.");
+    throw new Error('Vonage is not configured. Add your API Key and Secret.')
   }
   }
 
 
   // Vonage REST API expects digits only (no '+' prefix)
   // Vonage REST API expects digits only (no '+' prefix)
-  const vonageTo = to.replace(/^\+/, "");
-  const vonageFrom = from.replace(/^\+/, "");
+  const vonageTo = to.replace(/^\+/, '')
+  const vonageFrom = from.replace(/^\+/, '')
 
 
-  const res = await fetch("https://rest.nexmo.com/sms/json", {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
+  const res = await fetch('https://rest.nexmo.com/sms/json', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
     body: JSON.stringify({
       api_key: apiKey,
       api_key: apiKey,
       api_secret: apiSecret,
       api_secret: apiSecret,
@@ -129,110 +124,147 @@ async function sendViaVonage(
       to: vonageTo,
       to: vonageTo,
       text: body,
       text: body,
     }),
     }),
-  });
+  })
 
 
   if (!res.ok) {
   if (!res.ok) {
-    throw new Error(`Vonage error: ${res.statusText}`);
+    throw new Error(`Vonage error: ${res.statusText}`)
   }
   }
 
 
   const data = (await res.json()) as {
   const data = (await res.json()) as {
-    messages: { "message-id": string; status: string; "error-text"?: string }[];
-  };
-  const msg = data.messages?.[0];
+    messages: { 'message-id': string; status: string; 'error-text'?: string }[]
+  }
+  const msg = data.messages?.[0]
 
 
-  if (msg?.status !== "0") {
-    throw new Error(`Vonage error: ${msg?.["error-text"] || "Unknown error"}`);
+  if (msg?.status !== '0') {
+    throw new Error(`Vonage error: ${msg?.['error-text'] || 'Unknown error'}`)
   }
   }
 
 
-  return { messageId: msg["message-id"], status: msg.status };
+  return { messageId: msg['message-id'], status: msg.status }
 }
 }
 
 
 // ─── Telnyx ──────────────────────────────────────────────────────────────────
 // ─── Telnyx ──────────────────────────────────────────────────────────────────
 
 
 interface TelnyxResult {
 interface TelnyxResult {
-  id: string;
+  id: string
 }
 }
 
 
 async function sendViaTelnyx(
 async function sendViaTelnyx(
   settings: SettingsMap,
   settings: SettingsMap,
   from: string,
   from: string,
   to: string,
   to: string,
-  body: string,
+  body: string
 ): Promise<TelnyxResult> {
 ): Promise<TelnyxResult> {
-  const apiKey = settings.get(ORG_SMS_KEYS.SMS_TELNYX_API_KEY);
+  const apiKey = settings.get(ORG_SMS_KEYS.SMS_TELNYX_API_KEY)
 
 
   if (!apiKey) {
   if (!apiKey) {
-    throw new Error("Telnyx is not configured. Add your API Key.");
+    throw new Error('Telnyx is not configured. Add your API Key.')
   }
   }
 
 
-  const res = await fetch("https://api.telnyx.com/v2/messages", {
-    method: "POST",
+  const res = await fetch('https://api.telnyx.com/v2/messages', {
+    method: 'POST',
     headers: {
     headers: {
       Authorization: `Bearer ${apiKey}`,
       Authorization: `Bearer ${apiKey}`,
-      "Content-Type": "application/json",
+      'Content-Type': 'application/json',
     },
     },
     body: JSON.stringify({ from, to, text: body }),
     body: JSON.stringify({ from, to, text: body }),
-  });
+  })
 
 
   if (!res.ok) {
   if (!res.ok) {
-    const err = await res.json().catch(() => ({}));
-    const errors = (err as { errors?: { detail?: string }[] }).errors;
-    throw new Error(`Telnyx error: ${errors?.[0]?.detail || res.statusText}`);
+    const err = await res.json().catch(() => ({}))
+    const errors = (err as { errors?: { detail?: string }[] }).errors
+    throw new Error(`Telnyx error: ${errors?.[0]?.detail || res.statusText}`)
   }
   }
 
 
-  const data = (await res.json()) as { data: { id: string } };
-  return { id: data.data.id };
+  const data = (await res.json()) as { data: { id: string } }
+  return { id: data.data.id }
 }
 }
 
 
 // ─── Validation ─────────────────────────────────────────────────────────────
 // ─── Validation ─────────────────────────────────────────────────────────────
 
 
-const E164_REGEX = /^\+[1-9]\d{6,14}$/;
+const E164_REGEX = /^\+[1-9]\d{6,14}$/
 
 
 export function isValidE164(phone: string): boolean {
 export function isValidE164(phone: string): boolean {
-  return E164_REGEX.test(phone);
+  return E164_REGEX.test(phone)
+}
+
+// ─── Phone normalization ────────────────────────────────────────────────────
+
+/**
+ * Resolve a customer phone (which may be local digits, may have a trunk
+ * prefix, may use 00 instead of +) into E.164 using the workshop's
+ * configured default country code as a fallback. Returns null when the
+ * input cannot be turned into a valid E.164 number.
+ *
+ * Use this when the caller needs the canonical number BEFORE calling
+ * sendOrgSms — for example to record what was actually sent in an audit
+ * log. Callers that don't care can rely on sendOrgSms doing the same
+ * normalization internally (idempotent).
+ */
+export async function normalizeOrgPhone(
+  organizationId: string,
+  phone: string
+): Promise<string | null> {
+  const setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key: SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE,
+      },
+    },
+    select: { value: true },
+  })
+  return normalizePortalPhone(phone, setting?.value ?? null)
 }
 }
 
 
 // ─── Main dispatch ───────────────────────────────────────────────────────────
 // ─── Main dispatch ───────────────────────────────────────────────────────────
 
 
 export interface SendSmsResult {
 export interface SendSmsResult {
-  providerMsgId: string;
+  providerMsgId: string
+  /** The normalized E.164 number the provider was actually called with. */
+  to: string
 }
 }
 
 
 export async function sendOrgSms(
 export async function sendOrgSms(
   organizationId: string,
   organizationId: string,
-  options: SendSmsOptions,
+  options: SendSmsOptions
 ): Promise<SendSmsResult> {
 ): Promise<SendSmsResult> {
-  if (!isValidE164(options.to)) {
-    throw new Error(
-      "Invalid phone number format. Must be E.164 format (e.g. +15551234567).",
-    );
-  }
-
-  const provider = await getOrgSmsProvider(organizationId);
+  const provider = await getOrgSmsProvider(organizationId)
   if (!provider) {
   if (!provider) {
-    throw new Error("SMS is not configured. Set up an SMS provider in Settings.");
+    throw new Error('SMS is not configured. Set up an SMS provider in Settings.')
   }
   }
 
 
-  const from = await getOrgSmsPhoneNumber(organizationId);
+  const from = await getOrgSmsPhoneNumber(organizationId)
   if (!from) {
   if (!from) {
-    throw new Error("SMS phone number is not configured.");
+    throw new Error('SMS phone number is not configured.')
   }
   }
 
 
-  const allKeys = Object.values(ORG_SMS_KEYS);
-  const settings = await getOrgSettings(organizationId, allKeys);
+  // Load the SMS provider settings + the workshop's default country code
+  // in a single query so we can normalize the destination phone before
+  // any provider sees it. Twilio/Vonage/Telnyx all require strict E.164.
+  const allKeys = [...Object.values(ORG_SMS_KEYS), SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE]
+  const settings = await getOrgSettings(organizationId, allKeys)
+
+  const defaultCountryCode = settings.get(SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE) ?? null
+  const normalizedTo = normalizePortalPhone(options.to, defaultCountryCode)
+  if (!normalizedTo) {
+    throw new Error(
+      'Invalid phone number format. Must be E.164 format (e.g. +15551234567), ' +
+        'or set a default country code in Settings → Localization.'
+    )
+  }
 
 
   switch (provider) {
   switch (provider) {
-    case "twilio": {
-      const result = await sendViaTwilio(settings, from, options.to, options.body);
-      return { providerMsgId: result.sid };
+    case 'twilio': {
+      const result = await sendViaTwilio(settings, from, normalizedTo, options.body)
+      return { providerMsgId: result.sid, to: normalizedTo }
     }
     }
-    case "vonage": {
-      const result = await sendViaVonage(settings, from, options.to, options.body);
-      return { providerMsgId: result.messageId };
+    case 'vonage': {
+      const result = await sendViaVonage(settings, from, normalizedTo, options.body)
+      return { providerMsgId: result.messageId, to: normalizedTo }
     }
     }
-    case "telnyx": {
-      const result = await sendViaTelnyx(settings, from, options.to, options.body);
-      return { providerMsgId: result.id };
+    case 'telnyx': {
+      const result = await sendViaTelnyx(settings, from, normalizedTo, options.body)
+      return { providerMsgId: result.id, to: normalizedTo }
     }
     }
   }
   }
 }
 }