فهرست منبع

Guided workshop setup and locale-correct documents (#473)

* Guided workshop setup and locale-correct documents

* onboarding workflow

* Check every setup step before creating the workshop

Ready can be reached from the step bar after a step was emptied again, so Create now returns to the first incomplete step. The preview uses the marine words for a marine workshop, and printed quantities keep their stored decimals.

* Send a Norwegian header in the Norwegian-computer setup test
Bernt Christian Egeland 4 روز پیش
والد
کامیت
08b85ecef3
88فایلهای تغییر یافته به همراه6802 افزوده شده و 249 حذف شده
  1. 138 0
      e2e/specs/cloud/workshop-setup.spec.ts
  2. 8 3
      e2e/support/cloud.ts
  3. 163 0
      messages/de/onboarding.json
  4. 5 1
      messages/de/pdf.json
  5. 4 1
      messages/de/settings.json
  6. 163 0
      messages/en/onboarding.json
  7. 5 1
      messages/en/pdf.json
  8. 6 3
      messages/en/settings.json
  9. 163 0
      messages/es/onboarding.json
  10. 5 1
      messages/es/pdf.json
  11. 6 3
      messages/es/settings.json
  12. 163 0
      messages/fr/onboarding.json
  13. 5 1
      messages/fr/pdf.json
  14. 8 5
      messages/fr/settings.json
  15. 163 0
      messages/it/onboarding.json
  16. 5 1
      messages/it/pdf.json
  17. 8 5
      messages/it/settings.json
  18. 163 0
      messages/lt/onboarding.json
  19. 5 1
      messages/lt/pdf.json
  20. 6 3
      messages/lt/settings.json
  21. 163 0
      messages/nb/onboarding.json
  22. 5 1
      messages/nb/pdf.json
  23. 7 4
      messages/nb/settings.json
  24. 163 0
      messages/nl/onboarding.json
  25. 5 1
      messages/nl/pdf.json
  26. 6 3
      messages/nl/settings.json
  27. 163 0
      messages/pl/onboarding.json
  28. 5 1
      messages/pl/pdf.json
  29. 7 4
      messages/pl/settings.json
  30. 163 0
      messages/pt-BR/onboarding.json
  31. 5 1
      messages/pt-BR/pdf.json
  32. 6 3
      messages/pt-BR/settings.json
  33. 163 0
      messages/pt-PT/onboarding.json
  34. 5 1
      messages/pt-PT/pdf.json
  35. 6 3
      messages/pt-PT/settings.json
  36. 163 0
      messages/ru/onboarding.json
  37. 5 1
      messages/ru/pdf.json
  38. 6 3
      messages/ru/settings.json
  39. 163 0
      messages/tr/onboarding.json
  40. 5 1
      messages/tr/pdf.json
  41. 6 3
      messages/tr/settings.json
  42. 450 0
      src/__tests__/features/onboarding/setup.test.ts
  43. 216 0
      src/__tests__/lib/country-defaults.test.ts
  44. 121 0
      src/__tests__/lib/document-numbers.test.ts
  45. 3 1
      src/app/(authenticated)/inventory/[id]/page.tsx
  46. 3 1
      src/app/(authenticated)/inventory/page.tsx
  47. 36 38
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  48. 6 4
      src/app/(public)/onboarding/page.tsx
  49. 1 0
      src/app/(public)/share/inspection/[orgId]/[token]/page.tsx
  50. 1 0
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  51. 1 0
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  52. 1 0
      src/features/inspections/Pdf/buildCertificatePdfBuffer.ts
  53. 6 2
      src/features/inspections/Pdf/buildCertificatePrint.ts
  54. 16 1
      src/features/inventory/Lib/units.ts
  55. 15 9
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  56. 15 6
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  57. 16 8
      src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts
  58. 4 1
      src/features/invoice-designer/Pdf/warrantyPrint.ts
  59. 1 0
      src/features/invoices/Pdf/buildInvoicePdfBuffer.ts
  60. 1 0
      src/features/invoices/Pdf/buildWorkOrderPdfBuffer.ts
  61. 44 15
      src/features/onboarding/Actions/createOnboardingOrg.ts
  62. 0 102
      src/features/onboarding/Components/OnboardingForm.tsx
  63. 28 0
      src/features/onboarding/Components/setup/LaterInSettings.tsx
  64. 123 0
      src/features/onboarding/Components/setup/SearchPicker.tsx
  65. 190 0
      src/features/onboarding/Components/setup/SetupPreview.tsx
  66. 79 0
      src/features/onboarding/Components/setup/SetupStepper.tsx
  67. 528 0
      src/features/onboarding/Components/setup/SetupWizard.tsx
  68. 148 0
      src/features/onboarding/Components/setup/StepReady.tsx
  69. 395 0
      src/features/onboarding/Components/setup/StepRegion.tsx
  70. 190 0
      src/features/onboarding/Components/setup/StepTax.tsx
  71. 247 0
      src/features/onboarding/Components/setup/StepWorkshop.tsx
  72. 25 0
      src/features/onboarding/Components/setup/useScreenLanguage.ts
  73. 17 0
      src/features/onboarding/Lib/onboardingTranslator.ts
  74. 186 0
      src/features/onboarding/Lib/previewInvoice.ts
  75. 221 0
      src/features/onboarding/Lib/setupDraft.ts
  76. 35 0
      src/features/onboarding/Lib/setupSettings.ts
  77. 75 0
      src/features/onboarding/Lib/starterDesign.ts
  78. 33 1
      src/features/onboarding/Schema/onboardingSchema.ts
  79. 1 0
      src/features/portal/Actions/portalActions.ts
  80. 4 0
      src/features/quotes/Components/QuotePDF.tsx
  81. 1 0
      src/features/quotes/Pdf/buildQuotePdfBuffer.ts
  82. 5 1
      src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx
  83. 1 0
      src/features/settings/Lib/memberReadableSettings.ts
  84. 7 0
      src/features/settings/Schema/settingsSchema.ts
  85. 4 0
      src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx
  86. 27 0
      src/lib/analytics.ts
  87. 801 0
      src/lib/country-defaults.ts
  88. 62 0
      src/lib/document-numbers.ts

+ 138 - 0
e2e/specs/cloud/workshop-setup.spec.ts

@@ -0,0 +1,138 @@
+import { expect, test } from '@playwright/test'
+import { signUpWithPassword } from '../../support/cloud'
+import { foreignServiceRecordId, organizationIdFor, workshopSetting } from '../../support/db'
+import { fillSettled, settle } from '../../support/hydration'
+import { pdfContent } from '../../support/pdf'
+
+/**
+ * The guided setup of a new workshop, through the case that asked for it: an
+ * owner in France whose browser speaks English. The language customers get
+ * used to follow the owner's screens, so their invoices came out in English.
+ * Here the country comes from the clock, French is suggested for the
+ * documents, the owner's screens stay English, and the first invoice they
+ * download is French.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+// Signed out: these specs sign up as strangers do.
+test.use({
+  storageState: { cookies: [], origins: [] },
+  timezoneId: 'Europe/Paris',
+  locale: 'en-US',
+})
+
+const stamp = Date.now()
+const EMAIL = `e2e-setup-${stamp}@example.com`
+const WORKSHOP = `Garage Martin ${stamp}`
+
+test('a workshop in France gets French documents from an English browser', async ({ page }) => {
+  await signUpWithPassword(page, { name: 'E2E Setup', email: EMAIL, password: `E2e-pass-${stamp}` })
+  await settle(page)
+
+  // Step 1: the country comes from the clock, and with it French for the
+  // customers, opened under the owner's English by itself.
+  await expect(page.getByRole('heading', { name: 'Set up your workshop' })).toBeVisible()
+  await fillSettled(page.locator('#workshopName'), WORKSHOP)
+  await expect(page.locator('#country')).toContainText('France')
+  await expect(page.locator('#language')).toContainText('English')
+  await expect(page.locator('#docLanguage')).toContainText('Français')
+  await page.getByRole('button', { name: 'Continue' }).click()
+
+  // Step 2: the region.
+  await expect(page.getByRole('heading', { name: 'Language and region' })).toBeVisible()
+  await expect(page.getByText(/Euro \(EUR\)/)).toBeVisible()
+  await page.getByRole('button', { name: 'Continue' }).click()
+
+  // Step 3: a rate the owner types, under the name used in France.
+  await expect(page.getByRole('heading', { name: 'Taxes on your invoices' })).toBeVisible()
+  await expect(page.locator('#taxName')).toHaveValue('TVA')
+  await expect(page.getByRole('button', { name: 'Continue' })).toBeDisabled()
+  await fillSettled(page.locator('#taxRate'), '20')
+  await page.getByRole('button', { name: 'Continue' }).click()
+
+  // Step 4: the last look, then the workshop.
+  await expect(page.getByRole('heading', { name: `Ready to open ${WORKSHOP}` })).toBeVisible()
+  await expect(page.getByText('Your screens: English')).toBeVisible()
+  await page.getByRole('button', { name: 'Create workshop' }).click()
+  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 60_000 })
+
+  const organizationId = await organizationIdFor(EMAIL)
+  expect(await workshopSetting(organizationId, 'workshop.locale')).toBe('fr')
+  expect(await workshopSetting(organizationId, 'workshop.currencyCode')).toBe('EUR')
+  expect(await workshopSetting(organizationId, 'workshop.taxLabel')).toBe('TVA')
+  expect(await workshopSetting(organizationId, 'workshop.defaultTaxRate')).toBe('20')
+
+  // The owner's screens are English, and the invoice they download is French.
+  const jobId = await foreignServiceRecordId(organizationId)
+  const response = await page.request.get(`/api/protected/services/${jobId}/pdf`)
+  expect(response.ok()).toBe(true)
+  const pdf = await pdfContent(await response.body())
+  expect(pdf.flat).toContain('FACTURE')
+  // The design prints the box headings in capitals: "FACTURER À".
+  expect(pdf.flat.toLowerCase()).toContain('facturer à')
+  expect(pdf.flat.toLowerCase()).not.toContain('bill to')
+})
+
+/**
+ * Someone on a Norwegian computer whose workshop is in the United Kingdom.
+ * Picking the country turns the page and the documents English, keeping what
+ * was typed; picking a language afterwards is theirs, and the country no
+ * longer moves it.
+ */
+test.describe('the language follows the country until the owner picks one', () => {
+  // The config pins every request to English; a Norwegian browser sends its own header.
+  test.use({
+    timezoneId: 'Europe/Oslo',
+    locale: 'nb-NO',
+    extraHTTPHeaders: { 'accept-language': 'nb-NO,nb;q=0.9' },
+  })
+
+  const email = `e2e-setup-uk-${stamp}@example.com`
+  const workshop = `Riverside Motors ${stamp}`
+
+  test('a British workshop set up from a Norwegian computer', async ({ page }) => {
+    await signUpWithPassword(page, { name: 'E2E UK', email, password: `E2e-pass-${stamp}` })
+    await settle(page)
+    await expect(page.locator('html')).toHaveAttribute('lang', 'nb')
+    await fillSettled(page.locator('#workshopName'), workshop)
+
+    // The country: "UK" finds it, and the page turns English with the name kept.
+    await page.locator('#country').click()
+    await page.keyboard.type('UK')
+    await page.keyboard.press('Enter')
+    await expect(page.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 })
+    await expect(page.getByRole('heading', { name: 'Set up your workshop' })).toBeVisible()
+    await expect(page.locator('#workshopName')).toHaveValue(workshop)
+    await expect(page.locator('#language')).toContainText('English')
+
+    // The owner picks Norwegian: their screens and their customers get it.
+    await page.locator('#language').click()
+    await page.keyboard.type('Norsk')
+    await page.keyboard.press('Enter')
+    await expect(page.locator('html')).toHaveAttribute('lang', 'nb', { timeout: 15_000 })
+    await expect(page.locator('#workshopName')).toHaveValue(workshop)
+    await expect(page.locator('#country')).toContainText('Storbritannia')
+
+    // A picked language stays when the country changes.
+    await page.locator('#country').click()
+    await page.keyboard.type('Irland')
+    await page.keyboard.press('Enter')
+    await expect(page.locator('#country')).toContainText('Irland')
+    await settle(page)
+    await expect(page.locator('html')).toHaveAttribute('lang', 'nb')
+
+    // One language for everything: no customer language of its own.
+    await expect(page.locator('#docLanguage')).toHaveCount(0)
+    await page.locator('form button[type="submit"]').click()
+    await page.locator('form button[type="submit"]').click()
+    await fillSettled(page.locator('#taxRate'), '23')
+    await page.locator('form button[type="submit"]').click()
+    await page.locator('form button[type="submit"]').click()
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 60_000 })
+
+    const organizationId = await organizationIdFor(email)
+    expect(await workshopSetting(organizationId, 'workshop.locale')).toBe('nb')
+    expect(await workshopSetting(organizationId, 'workshop.currencyCode')).toBe('EUR')
+    expect(await workshopSetting(organizationId, 'workshop.defaultTaxRate')).toBe('23')
+  })
+})

+ 8 - 3
e2e/support/cloud.ts

@@ -1,5 +1,5 @@
 import { type BrowserContext, expect, type Page } from '@playwright/test'
 import { type BrowserContext, expect, type Page } from '@playwright/test'
-import { settle } from './hydration'
+import { fillSettled, settle } from './hydration'
 
 
 /**
 /**
  * Driving the app in cloud mode, as a stranger arrives at it.
  * Driving the app in cloud mode, as a stranger arrives at it.
@@ -102,14 +102,19 @@ export async function signInWithPassword(
   await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
   await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
 }
 }
 
 
-/** Names the workshop and finishes onboarding, with or without the sample data. */
+/**
+ * Names the workshop and finishes onboarding on the suggested settings, with
+ * or without the sample data: the guided setup's "skip to Ready" path.
+ */
 export async function completeOnboarding(
 export async function completeOnboarding(
   page: Page,
   page: Page,
   workshopName: string,
   workshopName: string,
   { sampleData }: { sampleData: boolean }
   { sampleData }: { sampleData: boolean }
 ): Promise<void> {
 ): Promise<void> {
   await settle(page)
   await settle(page)
-  await page.locator('#workshopName').fill(workshopName)
+  await fillSettled(page.locator('#workshopName'), workshopName)
+  await page.getByRole('button', { name: 'Use the suggestions and skip to Ready' }).click()
+  await expect(page.getByRole('button', { name: 'Create workshop' })).toBeVisible()
   const sample = page.locator('#loadSampleData')
   const sample = page.locator('#loadSampleData')
   const checked =
   const checked =
     (await sample.getAttribute('aria-checked')) === 'true' ||
     (await sample.getAttribute('aria-checked')) === 'true' ||

+ 163 - 0
messages/de/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Diese Installation hat bereits ihre Werkstatt",
     "title": "Diese Installation hat bereits ihre Werkstatt",
     "description": "Torqvoice betreibt hier eine Werkstatt, und sie ist bereits eingerichtet. Um darin zu arbeiten, bitten Sie den Inhaber, {email} unter Einstellungen, dann Team einzuladen.",
     "description": "Torqvoice betreibt hier eine Werkstatt, und sie ist bereits eingerichtet. Um darin zu arbeiten, bitten Sie den Inhaber, {email} unter Einstellungen, dann Team einzuladen.",
     "signOut": "Abmelden"
     "signOut": "Abmelden"
+  },
+  "setup": {
+    "stepsLabel": "Einrichtungsschritte",
+    "steps": {
+      "workshop": "Werkstatt",
+      "region": "Sprache und Region",
+      "tax": "Steuern",
+      "ready": "Fertig"
+    },
+    "back": "Zurück",
+    "continue": "Weiter",
+    "workshop": {
+      "title": "Richten Sie Ihre Werkstatt ein",
+      "subtitle": "Vier kurze Schritte, damit Ihre erste Rechnung stimmt. Alles lässt sich später in den Einstellungen ändern.",
+      "language": "Sprache",
+      "languageBoth": "Ihre Bildschirme und die Rechnungen, Angebote und E-Mails an Ihre Kunden verwenden sie.",
+      "languageFromCountry": "Vorschlag für {country}. Ihre Bildschirme und die Rechnungen, Angebote und E-Mails an Ihre Kunden verwenden sie.",
+      "languageMissing": "Torqvoice gibt es noch nicht auf {language}, daher wird für {country} Englisch vorgeschlagen. Wählen Sie eine andere Sprache, wenn Sie möchten.",
+      "languageScreensOnly": "Wird auf Ihren Bildschirmen verwendet.",
+      "customerLanguageDifferent": "Für Kunden eine andere Sprache verwenden",
+      "customerLanguageSame": "Wie meine ({language})",
+      "customerLanguageFromCountry": "Vorschlag für {country}.",
+      "name": "Name der Werkstatt",
+      "namePlaceholder": "Meine Autowerkstatt",
+      "kindLegend": "Woran arbeiten Sie hauptsächlich?",
+      "kinds": {
+        "cars": {
+          "title": "Autos und Transporter",
+          "hint": "Hubraum in Litern"
+        },
+        "motorcycles": {
+          "title": "Motorräder",
+          "hint": "Hubraum in cc"
+        },
+        "marine": {
+          "title": "Maritim",
+          "hint": "Boote, Rumpfnummern und Motorstunden"
+        }
+      },
+      "country": "Wo ist die Werkstatt?",
+      "countryPlaceholder": "Land wählen",
+      "countrySearch": "Länder suchen",
+      "noCountry": "Kein Land gefunden",
+      "countryDetected": "Aus Ihrer Zeitzone ermittelt, {zone}. Der nächste Schritt schlägt vor, was in {country} üblich ist, und Sie bestätigen jeden Punkt.",
+      "countryChosen": "Der nächste Schritt schlägt vor, was in {country} üblich ist, und Sie bestätigen jeden Punkt.",
+      "countryMissing": "Wählen Sie das Land, dann schlägt der nächste Schritt Sprache, Währung und Datumsformat vor.",
+      "skip": "Vorschläge übernehmen und direkt zu Fertig",
+      "nameMissing": "Geben Sie der Werkstatt einen Namen mit mindestens 2 Zeichen."
+    },
+    "region": {
+      "title": "Sprache und Region",
+      "subtitle": "Vorschläge für {country}. Prüfen Sie jeden Punkt: Er bestimmt, was Ihre Kunden lesen.",
+      "subtitleNoCountry": "Prüfen Sie jeden Punkt: Er bestimmt, was Ihre Kunden lesen.",
+      "docLanguage": "Die Sprache, die Ihre Kunden erhalten",
+      "docLanguageHint": "Rechnungen, Angebote, Arbeitsaufträge und E-Mails werden in dieser Sprache geschrieben, egal wer in der Werkstatt sie druckt oder sendet.",
+      "languageSearch": "Sprachen suchen",
+      "noLanguage": "Keine Sprache gefunden",
+      "defaults": "Regionale Standards",
+      "change": "Ändern",
+      "done": "Fertig",
+      "currency": "Währung",
+      "currencySearch": "Währungen suchen",
+      "noCurrency": "Keine Währung gefunden",
+      "dates": "Datum und Uhrzeit",
+      "dateFormat": "Datumsformat",
+      "clock": "Uhr",
+      "clock12": "12 Stunden",
+      "clock24": "24 Stunden",
+      "timeZone": "Zeitzone",
+      "zoneSearch": "Zeitzonen suchen",
+      "noZone": "Keine Zeitzone gefunden",
+      "units": "Einheiten",
+      "distance": "Strecke",
+      "distanceKm": "Kilometer",
+      "distanceMiles": "Meilen",
+      "measure": "Flüssigkeiten und Teile",
+      "measureMetric": "Liter und Kilogramm",
+      "measureImperial": "Quarts, Gallonen und Pfund",
+      "engine": "Hubraum",
+      "engineLitres": "Liter",
+      "engineCc": "Kubikzentimeter",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, Hubraum in {engine}",
+      "engineLitresShort": "Litern",
+      "engineCcShort": "cc",
+      "phone": "Telefonnummern",
+      "phoneValue": "{code}, wenn keine angegeben ist",
+      "phoneNone": "Keine Vorwahl ergänzt",
+      "phonePrefix": "Ländervorwahl",
+      "phoneHint": "Wird Kundennummern ohne Ländervorwahl vorangestellt, zum Beispiel +49.",
+      "phoneInvalid": "Verwenden Sie + und Ziffern, zum Beispiel +49.",
+      "later": "Sie können das später unter <b>{localization}</b> ändern. Einheiten und Motorgröße finden Sie unter <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Steuern auf Ihren Rechnungen",
+      "subtitle": "Wie Steuern auf dem, was Sie senden, berechnet und benannt werden.",
+      "charges": "Berechnen Sie Steuern auf Ihre Arbeit?",
+      "yes": "Ja",
+      "no": "Nein",
+      "rate": "Regelsatz",
+      "rateHint": "Von Ihnen eingegeben. Torqvoice rät nie einen Steuersatz; prüfen Sie ihn mit Ihrer Steuerberatung.",
+      "rateInvalid": "Geben Sie einen Satz zwischen 0 und 100 ein.",
+      "name": "Bezeichnung auf Rechnungen",
+      "namePlaceholder": "Steuer",
+      "nameHint": "Wird gedruckt als {example}.",
+      "nameSuggested": "Vorschlag für {country}. Wird gedruckt als {example}.",
+      "pricesLegend": "Die Preise, die Sie eingeben, sind",
+      "before": "Ohne Steuer",
+      "beforeExample": "Sie geben {price} ein. Der Kunde zahlt {price} + {tax} {label} = {total}.",
+      "including": "Inklusive Steuer",
+      "includingExample": "Sie geben {total} ein. Der Kunde zahlt {total}, davon sind {tax} {label}.",
+      "noTax": "Es wird keine Steuerzeile gedruckt, und die Summe ist das, was Sie eingeben.",
+      "skippedHint": "Vorerst übersprungen, daher wird keine Steuer berechnet. Wählen Sie Ja, um sie hier einzurichten.",
+      "later": "Sie können das später unter <b>{tax}</b> ändern. Dort richten Sie auch zwei Steuern auf einer Rechnung ein, wie GST und QST, oder Steuer nur auf Teile.",
+      "skip": "Vorerst überspringen",
+      "rateMissing": "Geben Sie Ihren Steuersatz ein, oder wählen Sie Nein, wenn Sie keine Steuer berechnen."
+    },
+    "ready": {
+      "title": "Bereit, {name} zu eröffnen",
+      "subtitle": "Ein letzter Blick. Alles hier lässt sich später in den Einstellungen ändern.",
+      "rowWorkshop": "Werkstatt",
+      "rowCustomers": "Kunden erhalten",
+      "rowScreens": "Ihre Bildschirme: {language}",
+      "rowMoney": "Geld und Datum",
+      "rowUnits": "Einheiten",
+      "rowTax": "Steuer",
+      "kinds": {
+        "cars": "Autos und Transporter",
+        "motorcycles": "Motorräder",
+        "marine": "maritim"
+      },
+      "taxBefore": "{label} {rate} %, Preise ohne Steuer eingegeben",
+      "taxIncluding": "{label} {rate} %, Preise inklusive Steuer eingegeben",
+      "taxOff": "Keine Steuer",
+      "taxSkipped": "Noch nicht eingerichtet",
+      "edit": "Bearbeiten",
+      "address": "Adresse auf Ihren Rechnungen",
+      "optional": "(optional)",
+      "addressPlaceholder": "Straße, Postleitzahl und Ort",
+      "create": "Werkstatt anlegen"
+    },
+    "preview": {
+      "label": "Rechnungsvorschau",
+      "first": "Ihre erste Rechnung, so wie ein Kunde sie erhält",
+      "live": "Ändert sich mit Ihrer Auswahl",
+      "loading": "Vorschau wird gezeichnet",
+      "enlarge": "In voller Größe anzeigen"
+    },
+    "sample": {
+      "jobTitle": "Ölwechsel und Inspektion",
+      "serviceType": "Wartung",
+      "customerName": "Lena Hoffmann",
+      "customerAddress": "Bahnhofstraße 12, 80335 München",
+      "laborLine": "Ölwechsel und Inspektion",
+      "partLine": "Ölfilter",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Motoröl 5W-30",
+      "part3": "Luftfilter",
+      "part4": "Scheibenwischerblätter",
+      "labor2": "Luftfilter wechseln",
+      "labor3": "Wischerblätter wechseln",
+      "labor4": "Bremsenprüfung"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/de/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Bereich",
     "conditionMapArea": "Bereich",
     "conditionMapKind": "Art",
     "conditionMapKind": "Art",
     "conditionMapSeverity": "Schwere",
     "conditionMapSeverity": "Schwere",
-    "conditionMapNote": "Notiz"
+    "conditionMapNote": "Notiz",
+    "serviceTypeMaintenance": "Wartung",
+    "serviceTypeRepair": "Reparatur",
+    "serviceTypeUpgrade": "Verbesserung",
+    "serviceTypeInspection": "Überprüfung"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ARBEITSAUFTRAG",
     "title": "ARBEITSAUFTRAG",

+ 4 - 1
messages/de/settings.json

@@ -1189,7 +1189,7 @@
     "typeKeyEnabled": "HSN/TSN an Fahrzeugen erfassen",
     "typeKeyEnabled": "HSN/TSN an Fahrzeugen erfassen",
     "typeKeyHint": "Ergänzt Fahrzeuge um Hersteller- und Typschlüsselnummer aus der Zulassungsbescheinigung Teil I (Felder 2.1 und 2.2). Sie erscheint am Fahrzeug, am Arbeitsauftrag und auf allen gedruckten Dokumenten, und Fahrzeuge lassen sich über die Suche danach finden. Beim Ausschalten wird sie wieder ausgeblendet, eingegebene Werte bleiben erhalten.",
     "typeKeyHint": "Ergänzt Fahrzeuge um Hersteller- und Typschlüsselnummer aus der Zulassungsbescheinigung Teil I (Felder 2.1 und 2.2). Sie erscheint am Fahrzeug, am Arbeitsauftrag und auf allen gedruckten Dokumenten, und Fahrzeuge lassen sich über die Suche danach finden. Beim Ausschalten wird sie wieder ausgeblendet, eingegebene Werte bleiben erhalten.",
     "unitsTitle": "Einheiten",
     "unitsTitle": "Einheiten",
-    "unitsDescription": "Wählen Sie zwischen metrisch (km, Liter) und imperial (Meilen, Gallonen).",
+    "unitsDescription": "Wie Strecken, Flüssigkeiten und Teile in dieser Werkstatt angegeben werden.",
     "unitSystem": "Einheitensystem",
     "unitSystem": "Einheitensystem",
     "metric": "Metrisch (km, Liter)",
     "metric": "Metrisch (km, Liter)",
     "imperial": "Imperial (Meilen, Gallonen)",
     "imperial": "Imperial (Meilen, Gallonen)",
@@ -1199,6 +1199,9 @@
     "liters": "Liter (L)",
     "liters": "Liter (L)",
     "miles": "Meilen (mi)",
     "miles": "Meilen (mi)",
     "gallons": "Gallonen (gal)",
     "gallons": "Gallonen (gal)",
+    "measureSystem": "Flüssigkeiten und Teile",
+    "measureMetric": "Liter und Kilogramm",
+    "measureImperial": "Quarts, Gallonen und Pfund",
     "engineSizeUnit": "Hubraum",
     "engineSizeUnit": "Hubraum",
     "engineSizeLitres": "Liter (1,6 L)",
     "engineSizeLitres": "Liter (1,6 L)",
     "engineSizeCc": "Kubikzentimeter (1598 cc)",
     "engineSizeCc": "Kubikzentimeter (1598 cc)",

+ 163 - 0
messages/en/onboarding.json

@@ -90,5 +90,168 @@
     "title": "This installation already has its workshop",
     "title": "This installation already has its workshop",
     "description": "Torqvoice here runs one workshop, and it has been set up. To work in it, ask its owner to invite {email} from Settings, then Team.",
     "description": "Torqvoice here runs one workshop, and it has been set up. To work in it, ask its owner to invite {email} from Settings, then Team.",
     "signOut": "Sign out"
     "signOut": "Sign out"
+  },
+  "setup": {
+    "stepsLabel": "Setup steps",
+    "steps": {
+      "workshop": "Workshop",
+      "region": "Language and region",
+      "tax": "Taxes",
+      "ready": "Ready"
+    },
+    "back": "Back",
+    "continue": "Continue",
+    "workshop": {
+      "title": "Set up your workshop",
+      "subtitle": "Four short steps, so your first invoice comes out right. You can change all of it later in Settings.",
+      "language": "Language",
+      "languageBoth": "Your screens and your customers' invoices, quotes and emails use it.",
+      "languageFromCountry": "Suggested for {country}. Your screens and your customers' invoices, quotes and emails use it.",
+      "languageMissing": "Torqvoice is not available in {language} yet, so English is suggested for {country}. Pick another language if you prefer.",
+      "languageScreensOnly": "Used on your screens.",
+      "customerLanguageDifferent": "Use a different language for customers",
+      "customerLanguageSame": "Same as mine ({language})",
+      "customerLanguageFromCountry": "Suggested for {country}.",
+      "name": "Workshop name",
+      "namePlaceholder": "My Auto Workshop",
+      "kindLegend": "What do you mostly work on?",
+      "kinds": {
+        "cars": {
+          "title": "Cars and vans",
+          "hint": "Engine size in litres"
+        },
+        "motorcycles": {
+          "title": "Motorcycles",
+          "hint": "Engine size in cc"
+        },
+        "marine": {
+          "title": "Marine",
+          "hint": "Vessels, hull numbers and engine hours"
+        }
+      },
+      "country": "Where is the workshop?",
+      "countryPlaceholder": "Choose a country",
+      "countrySearch": "Search countries",
+      "noCountry": "No country found",
+      "countryDetected": "Found from your time zone, {zone}. The next step suggests what is usual in {country}, and you confirm each one.",
+      "countryChosen": "The next step suggests what is usual in {country}, and you confirm each one.",
+      "countryMissing": "Choose the country, and the next step suggests its language, currency and date format.",
+      "skip": "Use the suggestions and skip to Ready",
+      "nameMissing": "Give the workshop a name of at least 2 characters."
+    },
+    "region": {
+      "title": "Language and region",
+      "subtitle": "Suggested for {country}. Check each one: they decide what your customers read.",
+      "subtitleNoCountry": "Check each one: they decide what your customers read.",
+      "docLanguage": "The language your customers get",
+      "docLanguageHint": "Invoices, quotes, work orders and emails are written in it, whoever in the workshop prints or sends them.",
+      "languageSearch": "Search languages",
+      "noLanguage": "No language found",
+      "defaults": "Regional defaults",
+      "change": "Change",
+      "done": "Done",
+      "currency": "Currency",
+      "currencySearch": "Search currencies",
+      "noCurrency": "No currency found",
+      "dates": "Dates and time",
+      "dateFormat": "Date format",
+      "clock": "Clock",
+      "clock12": "12-hour",
+      "clock24": "24-hour",
+      "timeZone": "Time zone",
+      "zoneSearch": "Search time zones",
+      "noZone": "No time zone found",
+      "units": "Units",
+      "distance": "Distance",
+      "distanceKm": "Kilometres",
+      "distanceMiles": "Miles",
+      "measure": "Fluids and parts",
+      "measureMetric": "Litres and kilograms",
+      "measureImperial": "Quarts, gallons and pounds",
+      "engine": "Engine size",
+      "engineLitres": "Litres",
+      "engineCc": "Cubic centimetres",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, engine size in {engine}",
+      "engineLitresShort": "litres",
+      "engineCcShort": "cc",
+      "phone": "Phone numbers",
+      "phoneValue": "{code} when none is typed",
+      "phoneNone": "No prefix added",
+      "phonePrefix": "Country code",
+      "phoneHint": "Added to customer numbers typed without a country code, like +44.",
+      "phoneInvalid": "Use + and digits, like +44.",
+      "later": "You can change these later in <b>{localization}</b>. Units and engine size are in <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Taxes on your invoices",
+      "subtitle": "How tax is added and named on what you send.",
+      "charges": "Do you charge tax on your work?",
+      "yes": "Yes",
+      "no": "No",
+      "rate": "Standard rate",
+      "rateHint": "Typed by you. Torqvoice never guesses a tax rate; check it with your accountant.",
+      "rateInvalid": "Enter a rate between 0 and 100.",
+      "name": "Name on invoices",
+      "namePlaceholder": "Tax",
+      "nameHint": "Prints as {example}.",
+      "nameSuggested": "Suggested for {country}. Prints as {example}.",
+      "pricesLegend": "The prices you type are",
+      "before": "Before tax",
+      "beforeExample": "You type {price}. The customer pays {price} + {tax} {label} = {total}.",
+      "including": "Including tax",
+      "includingExample": "You type {total}. The customer pays {total}, of which {tax} is {label}.",
+      "noTax": "No tax line is printed, and the total is what you typed.",
+      "skippedHint": "Skipped for now, so no tax is added. Choose Yes to set it up here.",
+      "later": "You can change this later in <b>{tax}</b>. That is also where you set up two taxes on one invoice, like GST and QST, or tax on parts only.",
+      "skip": "Skip for now",
+      "rateMissing": "Type your tax rate, or choose No if you do not charge tax."
+    },
+    "ready": {
+      "title": "Ready to open {name}",
+      "subtitle": "One last look. Everything below can be changed later in Settings.",
+      "rowWorkshop": "Workshop",
+      "rowCustomers": "Customers get",
+      "rowScreens": "Your screens: {language}",
+      "rowMoney": "Money and dates",
+      "rowUnits": "Units",
+      "rowTax": "Tax",
+      "kinds": {
+        "cars": "cars and vans",
+        "motorcycles": "motorcycles",
+        "marine": "marine"
+      },
+      "taxBefore": "{label} {rate}%, prices typed before tax",
+      "taxIncluding": "{label} {rate}%, prices typed with tax included",
+      "taxOff": "No tax",
+      "taxSkipped": "Not set up yet",
+      "edit": "Edit",
+      "address": "Address on your invoices",
+      "optional": "(optional)",
+      "addressPlaceholder": "Street, postcode and town",
+      "create": "Create workshop"
+    },
+    "preview": {
+      "label": "Invoice preview",
+      "first": "Your first invoice, as a customer gets it",
+      "live": "Changes as you choose",
+      "loading": "Drawing the preview",
+      "enlarge": "Show at full size"
+    },
+    "sample": {
+      "jobTitle": "Oil change and inspection",
+      "serviceType": "Service",
+      "customerName": "Sam Taylor",
+      "customerAddress": "12 Station Road, Riverside",
+      "laborLine": "Oil change and inspection",
+      "partLine": "Oil filter",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Engine oil 5W-30",
+      "part3": "Air filter",
+      "part4": "Wiper blades",
+      "labor2": "Air filter replacement",
+      "labor3": "Wiper blade replacement",
+      "labor4": "Brake check"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/en/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Area",
     "conditionMapArea": "Area",
     "conditionMapKind": "Type",
     "conditionMapKind": "Type",
     "conditionMapSeverity": "Severity",
     "conditionMapSeverity": "Severity",
-    "conditionMapNote": "Note"
+    "conditionMapNote": "Note",
+    "serviceTypeMaintenance": "Maintenance",
+    "serviceTypeRepair": "Repair",
+    "serviceTypeUpgrade": "Upgrade",
+    "serviceTypeInspection": "Inspection"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "WORK ORDER",
     "title": "WORK ORDER",

+ 6 - 3
messages/en/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Record HSN/TSN on vehicles",
     "typeKeyEnabled": "Record HSN/TSN on vehicles",
     "typeKeyHint": "Adds the manufacturer and type key from the German registration certificate (fields 2.1 and 2.2) to vehicles. It shows on the vehicle, the work order and every printed document, and vehicles can be found by it in search. Turning it off hides it again and keeps what was entered.",
     "typeKeyHint": "Adds the manufacturer and type key from the German registration certificate (fields 2.1 and 2.2) to vehicles. It shows on the vehicle, the work order and every printed document, and vehicles can be found by it in search. Turning it off hides it again and keeps what was entered.",
     "unitsTitle": "Units",
     "unitsTitle": "Units",
-    "unitsDescription": "Choose between metric (km, liters) and imperial (miles, gallons) units.",
+    "unitsDescription": "How distances, fluids and parts are written in this workshop.",
     "unitSystem": "Unit System",
     "unitSystem": "Unit System",
     "metric": "Metric (km, liters)",
     "metric": "Metric (km, liters)",
     "imperial": "Imperial (miles, gallons)",
     "imperial": "Imperial (miles, gallons)",
     "distanceLabel": "Distance",
     "distanceLabel": "Distance",
     "volumeLabel": "Volume",
     "volumeLabel": "Volume",
-    "kilometers": "kilometers (km)",
+    "kilometers": "Kilometres (km)",
     "liters": "liters (L)",
     "liters": "liters (L)",
-    "miles": "miles (mi)",
+    "miles": "Miles (mi)",
     "gallons": "gallons (gal)",
     "gallons": "gallons (gal)",
+    "measureSystem": "Fluids and parts",
+    "measureMetric": "Litres and kilograms",
+    "measureImperial": "Quarts, gallons and pounds",
     "engineSizeUnit": "Engine size",
     "engineSizeUnit": "Engine size",
     "engineSizeLitres": "Litres (1.6 L)",
     "engineSizeLitres": "Litres (1.6 L)",
     "engineSizeCc": "Cubic centimetres (1598 cc)",
     "engineSizeCc": "Cubic centimetres (1598 cc)",

+ 163 - 0
messages/es/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Esta instalación ya tiene su taller",
     "title": "Esta instalación ya tiene su taller",
     "description": "Torqvoice aquí gestiona un taller, y ya está configurado. Para trabajar en él, pide a su propietario que invite a {email} desde Configuración, luego Equipo.",
     "description": "Torqvoice aquí gestiona un taller, y ya está configurado. Para trabajar en él, pide a su propietario que invite a {email} desde Configuración, luego Equipo.",
     "signOut": "Cerrar sesión"
     "signOut": "Cerrar sesión"
+  },
+  "setup": {
+    "stepsLabel": "Pasos de configuración",
+    "steps": {
+      "workshop": "Taller",
+      "region": "Idioma y región",
+      "tax": "Impuestos",
+      "ready": "Listo"
+    },
+    "back": "Atrás",
+    "continue": "Continuar",
+    "workshop": {
+      "title": "Configura tu taller",
+      "subtitle": "Cuatro pasos cortos, para que tu primera factura salga bien. Podrás cambiarlo todo más tarde en Configuración.",
+      "language": "Idioma",
+      "languageBoth": "Tus pantallas y las facturas, presupuestos y correos de tus clientes lo usan.",
+      "languageFromCountry": "Sugerido para {country}. Tus pantallas y las facturas, presupuestos y correos de tus clientes lo usan.",
+      "languageMissing": "Torqvoice todavía no está disponible en {language}, así que se sugiere el inglés para {country}. Elige otro idioma si lo prefieres.",
+      "languageScreensOnly": "Se usa en tus pantallas.",
+      "customerLanguageDifferent": "Usar otro idioma para los clientes",
+      "customerLanguageSame": "Igual que el mío ({language})",
+      "customerLanguageFromCountry": "Sugerido para {country}.",
+      "name": "Nombre del taller",
+      "namePlaceholder": "Mi taller",
+      "kindLegend": "¿En qué trabajas sobre todo?",
+      "kinds": {
+        "cars": {
+          "title": "Coches y furgonetas",
+          "hint": "Cilindrada en litros"
+        },
+        "motorcycles": {
+          "title": "Motos",
+          "hint": "Cilindrada en cc"
+        },
+        "marine": {
+          "title": "Náutico",
+          "hint": "Embarcaciones, números de casco y horas de motor"
+        }
+      },
+      "country": "¿Dónde está el taller?",
+      "countryPlaceholder": "Elige un país",
+      "countrySearch": "Buscar países",
+      "noCountry": "No se encontró el país",
+      "countryDetected": "Detectado por tu zona horaria, {zone}. El siguiente paso sugiere lo habitual en {country}, y tú confirmas cada opción.",
+      "countryChosen": "El siguiente paso sugiere lo habitual en {country}, y tú confirmas cada opción.",
+      "countryMissing": "Elige el país y el siguiente paso sugerirá su idioma, moneda y formato de fecha.",
+      "skip": "Usar las sugerencias e ir a Listo",
+      "nameMissing": "Pon al taller un nombre de al menos 2 caracteres."
+    },
+    "region": {
+      "title": "Idioma y región",
+      "subtitle": "Sugerido para {country}. Revisa cada opción: deciden lo que leen tus clientes.",
+      "subtitleNoCountry": "Revisa cada opción: deciden lo que leen tus clientes.",
+      "docLanguage": "El idioma que reciben tus clientes",
+      "docLanguageHint": "Las facturas, presupuestos, órdenes de trabajo y correos se escriben en este idioma, sea quien sea del taller quien los imprima o envíe.",
+      "languageSearch": "Buscar idiomas",
+      "noLanguage": "No se encontró el idioma",
+      "defaults": "Ajustes regionales",
+      "change": "Cambiar",
+      "done": "Listo",
+      "currency": "Moneda",
+      "currencySearch": "Buscar monedas",
+      "noCurrency": "No se encontró la moneda",
+      "dates": "Fechas y hora",
+      "dateFormat": "Formato de fecha",
+      "clock": "Reloj",
+      "clock12": "12 horas",
+      "clock24": "24 horas",
+      "timeZone": "Zona horaria",
+      "zoneSearch": "Buscar zonas horarias",
+      "noZone": "No se encontró la zona horaria",
+      "units": "Unidades",
+      "distance": "Distancia",
+      "distanceKm": "Kilómetros",
+      "distanceMiles": "Millas",
+      "measure": "Líquidos y piezas",
+      "measureMetric": "Litros y kilogramos",
+      "measureImperial": "Cuartos, galones y libras",
+      "engine": "Cilindrada",
+      "engineLitres": "Litros",
+      "engineCc": "Centímetros cúbicos",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, cilindrada en {engine}",
+      "engineLitresShort": "litros",
+      "engineCcShort": "cc",
+      "phone": "Números de teléfono",
+      "phoneValue": "{code} cuando no se escribe ninguno",
+      "phoneNone": "No se añade prefijo",
+      "phonePrefix": "Prefijo del país",
+      "phoneHint": "Se añade a los números de clientes escritos sin prefijo, como +34.",
+      "phoneInvalid": "Usa + y dígitos, como +34.",
+      "later": "Puedes cambiarlos más adelante en <b>{localization}</b>. Las unidades y la cilindrada están en <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Impuestos en tus facturas",
+      "subtitle": "Cómo se añade y se nombra el impuesto en lo que envías.",
+      "charges": "¿Cobras impuestos por tu trabajo?",
+      "yes": "Sí",
+      "no": "No",
+      "rate": "Tipo general",
+      "rateHint": "Lo escribes tú. Torqvoice nunca adivina un tipo impositivo; compruébalo con tu asesor.",
+      "rateInvalid": "Escribe un tipo entre 0 y 100.",
+      "name": "Nombre en las facturas",
+      "namePlaceholder": "Impuesto",
+      "nameHint": "Se imprime como {example}.",
+      "nameSuggested": "Sugerido para {country}. Se imprime como {example}.",
+      "pricesLegend": "Los precios que escribes son",
+      "before": "Sin impuestos",
+      "beforeExample": "Escribes {price}. El cliente paga {price} + {tax} de {label} = {total}.",
+      "including": "Con impuestos",
+      "includingExample": "Escribes {total}. El cliente paga {total}, de los que {tax} son {label}.",
+      "noTax": "No se imprime ninguna línea de impuesto y el total es lo que escribiste.",
+      "skippedHint": "Omitido por ahora, así que no se añade ningún impuesto. Elige Sí para configurarlo aquí.",
+      "later": "Puedes cambiarlo más adelante en <b>{tax}</b>. Ahí también configuras dos impuestos en una factura, como GST y QST, o impuesto solo en las piezas.",
+      "skip": "Omitir por ahora",
+      "rateMissing": "Escribe tu tipo de impuesto, o elige No si no cobras impuestos."
+    },
+    "ready": {
+      "title": "Listo para abrir {name}",
+      "subtitle": "Un último vistazo. Todo lo de abajo se puede cambiar más tarde en Configuración.",
+      "rowWorkshop": "Taller",
+      "rowCustomers": "Los clientes reciben",
+      "rowScreens": "Tus pantallas: {language}",
+      "rowMoney": "Dinero y fechas",
+      "rowUnits": "Unidades",
+      "rowTax": "Impuesto",
+      "kinds": {
+        "cars": "coches y furgonetas",
+        "motorcycles": "motos",
+        "marine": "náutico"
+      },
+      "taxBefore": "{label} {rate} %, precios escritos sin impuestos",
+      "taxIncluding": "{label} {rate} %, precios escritos con impuestos",
+      "taxOff": "Sin impuestos",
+      "taxSkipped": "Aún no configurado",
+      "edit": "Editar",
+      "address": "Dirección en tus facturas",
+      "optional": "(opcional)",
+      "addressPlaceholder": "Calle, código postal y ciudad",
+      "create": "Crear taller"
+    },
+    "preview": {
+      "label": "Vista previa de la factura",
+      "first": "Tu primera factura, tal como la recibe un cliente",
+      "live": "Cambia según lo que elijas",
+      "loading": "Dibujando la vista previa",
+      "enlarge": "Ver a tamaño completo"
+    },
+    "sample": {
+      "jobTitle": "Cambio de aceite y revisión",
+      "serviceType": "Mantenimiento",
+      "customerName": "Lucía Martín",
+      "customerAddress": "Calle Mayor 12, 28013 Madrid",
+      "laborLine": "Cambio de aceite y revisión",
+      "partLine": "Filtro de aceite",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Aceite de motor 5W-30",
+      "part3": "Filtro de aire",
+      "part4": "Escobillas limpiaparabrisas",
+      "labor2": "Cambio del filtro de aire",
+      "labor3": "Cambio de escobillas",
+      "labor4": "Revisión de frenos"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/es/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Zona",
     "conditionMapArea": "Zona",
     "conditionMapKind": "Tipo",
     "conditionMapKind": "Tipo",
     "conditionMapSeverity": "Gravedad",
     "conditionMapSeverity": "Gravedad",
-    "conditionMapNote": "Nota"
+    "conditionMapNote": "Nota",
+    "serviceTypeMaintenance": "Mantenimiento",
+    "serviceTypeRepair": "Reparación",
+    "serviceTypeUpgrade": "Mejora",
+    "serviceTypeInspection": "Inspección"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ORDEN DE TRABAJO",
     "title": "ORDEN DE TRABAJO",

+ 6 - 3
messages/es/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Registrar HSN/TSN en los vehículos",
     "typeKeyEnabled": "Registrar HSN/TSN en los vehículos",
     "typeKeyHint": "Añade a los vehículos la clave del fabricante y del tipo del permiso de circulación alemán (campos 2.1 y 2.2). Aparece en el vehículo, en la orden de trabajo y en todos los documentos impresos, y los vehículos se pueden encontrar por ella en la búsqueda. Al desactivarla se oculta de nuevo y se conserva lo introducido.",
     "typeKeyHint": "Añade a los vehículos la clave del fabricante y del tipo del permiso de circulación alemán (campos 2.1 y 2.2). Aparece en el vehículo, en la orden de trabajo y en todos los documentos impresos, y los vehículos se pueden encontrar por ella en la búsqueda. Al desactivarla se oculta de nuevo y se conserva lo introducido.",
     "unitsTitle": "Unidades",
     "unitsTitle": "Unidades",
-    "unitsDescription": "Elija entre metrico (km, litros) e imperial (millas, galones).",
+    "unitsDescription": "Cómo se escriben las distancias, los líquidos y las piezas en este taller.",
     "unitSystem": "Sistema de unidades",
     "unitSystem": "Sistema de unidades",
     "metric": "Metrico (km, litros)",
     "metric": "Metrico (km, litros)",
     "imperial": "Imperial (millas, galones)",
     "imperial": "Imperial (millas, galones)",
     "distanceLabel": "Distancia",
     "distanceLabel": "Distancia",
     "volumeLabel": "Volumen",
     "volumeLabel": "Volumen",
-    "kilometers": "kilometros (km)",
+    "kilometers": "Kilómetros (km)",
     "liters": "litros (L)",
     "liters": "litros (L)",
-    "miles": "millas (mi)",
+    "miles": "Millas (mi)",
     "gallons": "galones (gal)",
     "gallons": "galones (gal)",
+    "measureSystem": "Líquidos y piezas",
+    "measureMetric": "Litros y kilogramos",
+    "measureImperial": "Cuartos, galones y libras",
     "engineSizeUnit": "Cilindrada",
     "engineSizeUnit": "Cilindrada",
     "engineSizeLitres": "Litros (1,6 L)",
     "engineSizeLitres": "Litros (1,6 L)",
     "engineSizeCc": "Centímetros cúbicos (1598 cc)",
     "engineSizeCc": "Centímetros cúbicos (1598 cc)",

+ 163 - 0
messages/fr/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Cette installation a déjà son atelier",
     "title": "Cette installation a déjà son atelier",
     "description": "Torqvoice gère ici un seul atelier, et il est déjà configuré. Pour y travailler, demandez à son propriétaire d'inviter {email} depuis Paramètres, puis Équipe.",
     "description": "Torqvoice gère ici un seul atelier, et il est déjà configuré. Pour y travailler, demandez à son propriétaire d'inviter {email} depuis Paramètres, puis Équipe.",
     "signOut": "Se déconnecter"
     "signOut": "Se déconnecter"
+  },
+  "setup": {
+    "stepsLabel": "Étapes de configuration",
+    "steps": {
+      "workshop": "Atelier",
+      "region": "Langue et région",
+      "tax": "Taxes",
+      "ready": "Prêt"
+    },
+    "back": "Retour",
+    "continue": "Continuer",
+    "workshop": {
+      "title": "Configurez votre atelier",
+      "subtitle": "Quatre courtes étapes, pour que votre première facture soit juste. Vous pourrez tout modifier plus tard dans les Paramètres.",
+      "language": "Langue",
+      "languageBoth": "Vos écrans et les factures, devis et e-mails de vos clients l'utilisent.",
+      "languageFromCountry": "Suggérée pour {country}. Vos écrans et les factures, devis et e-mails de vos clients l'utilisent.",
+      "languageMissing": "Torqvoice n'est pas encore disponible en {language}, l'anglais est donc suggéré pour {country}. Choisissez une autre langue si vous préférez.",
+      "languageScreensOnly": "Utilisée sur vos écrans.",
+      "customerLanguageDifferent": "Utiliser une autre langue pour les clients",
+      "customerLanguageSame": "Comme la mienne ({language})",
+      "customerLanguageFromCountry": "Suggérée pour {country}.",
+      "name": "Nom de l'atelier",
+      "namePlaceholder": "Mon garage",
+      "kindLegend": "Sur quoi travaillez-vous surtout ?",
+      "kinds": {
+        "cars": {
+          "title": "Voitures et utilitaires",
+          "hint": "Cylindrée en litres"
+        },
+        "motorcycles": {
+          "title": "Motos",
+          "hint": "Cylindrée en cc"
+        },
+        "marine": {
+          "title": "Marine",
+          "hint": "Navires, numéros de coque et heures moteur"
+        }
+      },
+      "country": "Où se trouve l'atelier ?",
+      "countryPlaceholder": "Choisir un pays",
+      "countrySearch": "Rechercher un pays",
+      "noCountry": "Aucun pays trouvé",
+      "countryDetected": "Trouvé grâce à votre fuseau horaire, {zone}. L'étape suivante propose ce qui est courant en {country}, et vous confirmez chaque choix.",
+      "countryChosen": "L'étape suivante propose ce qui est courant en {country}, et vous confirmez chaque choix.",
+      "countryMissing": "Choisissez le pays, et l'étape suivante propose sa langue, sa devise et son format de date.",
+      "skip": "Utiliser les suggestions et passer à Prêt",
+      "nameMissing": "Donnez à l'atelier un nom d'au moins 2 caractères."
+    },
+    "region": {
+      "title": "Langue et région",
+      "subtitle": "Suggéré pour {country}. Vérifiez chaque choix : ils décident de ce que lisent vos clients.",
+      "subtitleNoCountry": "Vérifiez chaque choix : ils décident de ce que lisent vos clients.",
+      "docLanguage": "La langue que reçoivent vos clients",
+      "docLanguageHint": "Les factures, devis, ordres de travail et e-mails sont rédigés dans cette langue, quelle que soit la personne de l'atelier qui les imprime ou les envoie.",
+      "languageSearch": "Rechercher une langue",
+      "noLanguage": "Aucune langue trouvée",
+      "defaults": "Réglages régionaux",
+      "change": "Modifier",
+      "done": "Terminé",
+      "currency": "Devise",
+      "currencySearch": "Rechercher une devise",
+      "noCurrency": "Aucune devise trouvée",
+      "dates": "Dates et heure",
+      "dateFormat": "Format de date",
+      "clock": "Horloge",
+      "clock12": "12 heures",
+      "clock24": "24 heures",
+      "timeZone": "Fuseau horaire",
+      "zoneSearch": "Rechercher un fuseau horaire",
+      "noZone": "Aucun fuseau horaire trouvé",
+      "units": "Unités",
+      "distance": "Distance",
+      "distanceKm": "Kilomètres",
+      "distanceMiles": "Miles",
+      "measure": "Liquides et pièces",
+      "measureMetric": "Litres et kilogrammes",
+      "measureImperial": "Quarts, gallons et livres",
+      "engine": "Cylindrée",
+      "engineLitres": "Litres",
+      "engineCc": "Centimètres cubes",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, cylindrée en {engine}",
+      "engineLitresShort": "litres",
+      "engineCcShort": "cc",
+      "phone": "Numéros de téléphone",
+      "phoneValue": "{code} si aucun n'est saisi",
+      "phoneNone": "Aucun indicatif ajouté",
+      "phonePrefix": "Indicatif pays",
+      "phoneHint": "Ajouté aux numéros des clients saisis sans indicatif pays, comme +33.",
+      "phoneInvalid": "Utilisez + et des chiffres, comme +33.",
+      "later": "Vous pourrez les modifier plus tard dans <b>{localization}</b>. Les unités et la cylindrée se trouvent dans <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Taxes sur vos factures",
+      "subtitle": "Comment la taxe est ajoutée et nommée sur ce que vous envoyez.",
+      "charges": "Facturez-vous une taxe sur votre travail ?",
+      "yes": "Oui",
+      "no": "Non",
+      "rate": "Taux normal",
+      "rateHint": "Saisi par vous. Torqvoice ne devine jamais un taux de taxe ; vérifiez-le avec votre comptable.",
+      "rateInvalid": "Saisissez un taux entre 0 et 100.",
+      "name": "Nom sur les factures",
+      "namePlaceholder": "Taxe",
+      "nameHint": "S'imprime ainsi : {example}.",
+      "nameSuggested": "Suggéré pour {country}. S'imprime ainsi : {example}.",
+      "pricesLegend": "Les prix que vous saisissez sont",
+      "before": "Hors taxe",
+      "beforeExample": "Vous saisissez {price}. Le client paie {price} + {tax} de {label} = {total}.",
+      "including": "Taxe comprise",
+      "includingExample": "Vous saisissez {total}. Le client paie {total}, dont {tax} de {label}.",
+      "noTax": "Aucune ligne de taxe n'est imprimée, et le total est ce que vous avez saisi.",
+      "skippedHint": "Passé pour l'instant : aucune taxe n'est ajoutée. Choisissez Oui pour la configurer ici.",
+      "later": "Vous pourrez le modifier plus tard dans <b>{tax}</b>. C'est aussi là que vous configurez deux taxes sur une facture, comme la TPS et la TVQ, ou une taxe sur les pièces seulement.",
+      "skip": "Passer pour l'instant",
+      "rateMissing": "Saisissez votre taux de taxe, ou choisissez Non si vous ne facturez pas de taxe."
+    },
+    "ready": {
+      "title": "Prêt à ouvrir {name}",
+      "subtitle": "Un dernier coup d'œil. Tout ce qui suit peut être modifié plus tard dans les Paramètres.",
+      "rowWorkshop": "Atelier",
+      "rowCustomers": "Les clients reçoivent",
+      "rowScreens": "Vos écrans : {language}",
+      "rowMoney": "Argent et dates",
+      "rowUnits": "Unités",
+      "rowTax": "Taxe",
+      "kinds": {
+        "cars": "voitures et utilitaires",
+        "motorcycles": "motos",
+        "marine": "marine"
+      },
+      "taxBefore": "{label} {rate} %, prix saisis hors taxe",
+      "taxIncluding": "{label} {rate} %, prix saisis taxe comprise",
+      "taxOff": "Pas de taxe",
+      "taxSkipped": "Pas encore configuré",
+      "edit": "Modifier",
+      "address": "Adresse sur vos factures",
+      "optional": "(facultatif)",
+      "addressPlaceholder": "Rue, code postal et ville",
+      "create": "Créer l'atelier"
+    },
+    "preview": {
+      "label": "Aperçu de la facture",
+      "first": "Votre première facture, telle qu'un client la reçoit",
+      "live": "Change selon vos choix",
+      "loading": "Préparation de l'aperçu",
+      "enlarge": "Afficher en taille réelle"
+    },
+    "sample": {
+      "jobTitle": "Vidange et contrôle",
+      "serviceType": "Entretien",
+      "customerName": "Camille Dubois",
+      "customerAddress": "12 rue des Lilas, 69003 Lyon",
+      "laborLine": "Vidange et contrôle",
+      "partLine": "Filtre à huile",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Huile moteur 5W-30",
+      "part3": "Filtre à air",
+      "part4": "Balais d'essuie-glace",
+      "labor2": "Remplacement du filtre à air",
+      "labor3": "Remplacement des balais d'essuie-glace",
+      "labor4": "Contrôle des freins"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/fr/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Zone",
     "conditionMapArea": "Zone",
     "conditionMapKind": "Type",
     "conditionMapKind": "Type",
     "conditionMapSeverity": "Gravité",
     "conditionMapSeverity": "Gravité",
-    "conditionMapNote": "Note"
+    "conditionMapNote": "Note",
+    "serviceTypeMaintenance": "Maintenance",
+    "serviceTypeRepair": "Réparation",
+    "serviceTypeUpgrade": "Mise à Niveau",
+    "serviceTypeInspection": "Inspection"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ORDRE DE TRAVAIL",
     "title": "ORDRE DE TRAVAIL",

+ 8 - 5
messages/fr/settings.json

@@ -1188,17 +1188,20 @@
     "typeKeyTitle": "Code type allemand (HSN/TSN)",
     "typeKeyTitle": "Code type allemand (HSN/TSN)",
     "typeKeyEnabled": "Enregistrer le HSN/TSN sur les véhicules",
     "typeKeyEnabled": "Enregistrer le HSN/TSN sur les véhicules",
     "typeKeyHint": "Ajoute aux véhicules le code constructeur et le code type du certificat d'immatriculation allemand (champs 2.1 et 2.2). Il s'affiche sur le véhicule, l'ordre de réparation et tous les documents imprimés, et la recherche retrouve les véhicules par ce code. Une fois désactivé, il est de nouveau masqué et les valeurs saisies sont conservées.",
     "typeKeyHint": "Ajoute aux véhicules le code constructeur et le code type du certificat d'immatriculation allemand (champs 2.1 et 2.2). Il s'affiche sur le véhicule, l'ordre de réparation et tous les documents imprimés, et la recherche retrouve les véhicules par ce code. Une fois désactivé, il est de nouveau masqué et les valeurs saisies sont conservées.",
-    "unitsTitle": "Unites",
-    "unitsDescription": "Choisissez entre metrique (km, litres) et imperial (miles, gallons).",
-    "unitSystem": "Système d'unites",
+    "unitsTitle": "Unités",
+    "unitsDescription": "Comment les distances, les liquides et les pièces sont écrits dans cet atelier.",
+    "unitSystem": "Système d'unités",
     "metric": "Metrique (km, litres)",
     "metric": "Metrique (km, litres)",
     "imperial": "Impérial (miles, gallons)",
     "imperial": "Impérial (miles, gallons)",
     "distanceLabel": "Distance",
     "distanceLabel": "Distance",
     "volumeLabel": "Volume",
     "volumeLabel": "Volume",
-    "kilometers": "kilometres (km)",
+    "kilometers": "Kilomètres (km)",
     "liters": "litres (L)",
     "liters": "litres (L)",
-    "miles": "miles (mi)",
+    "miles": "Miles (mi)",
     "gallons": "gallons (gal)",
     "gallons": "gallons (gal)",
+    "measureSystem": "Liquides et pièces",
+    "measureMetric": "Litres et kilogrammes",
+    "measureImperial": "Quarts, gallons et livres",
     "engineSizeUnit": "Cylindrée",
     "engineSizeUnit": "Cylindrée",
     "engineSizeLitres": "Litres (1,6 L)",
     "engineSizeLitres": "Litres (1,6 L)",
     "engineSizeCc": "Centimètres cubes (1598 cc)",
     "engineSizeCc": "Centimètres cubes (1598 cc)",

+ 163 - 0
messages/it/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Questa installazione ha già la sua officina",
     "title": "Questa installazione ha già la sua officina",
     "description": "Torqvoice qui gestisce un'unica officina, già configurata. Per lavorarci, chiedi al proprietario di invitare {email} da Impostazioni, poi Team.",
     "description": "Torqvoice qui gestisce un'unica officina, già configurata. Per lavorarci, chiedi al proprietario di invitare {email} da Impostazioni, poi Team.",
     "signOut": "Esci"
     "signOut": "Esci"
+  },
+  "setup": {
+    "stepsLabel": "Passaggi di configurazione",
+    "steps": {
+      "workshop": "Officina",
+      "region": "Lingua e regione",
+      "tax": "Imposte",
+      "ready": "Pronto"
+    },
+    "back": "Indietro",
+    "continue": "Continua",
+    "workshop": {
+      "title": "Configura la tua officina",
+      "subtitle": "Quattro brevi passaggi, perché la tua prima fattura sia giusta. Potrai cambiare tutto più tardi nelle Impostazioni.",
+      "language": "Lingua",
+      "languageBoth": "Le tue schermate e le fatture, i preventivi e le email ai tuoi clienti la usano.",
+      "languageFromCountry": "Suggerita per {country}. Le tue schermate e le fatture, i preventivi e le email ai tuoi clienti la usano.",
+      "languageMissing": "Torqvoice non è ancora disponibile in {language}, quindi per {country} è suggerito l'inglese. Scegli un'altra lingua se preferisci.",
+      "languageScreensOnly": "Usata sulle tue schermate.",
+      "customerLanguageDifferent": "Usa un'altra lingua per i clienti",
+      "customerLanguageSame": "Uguale alla mia ({language})",
+      "customerLanguageFromCountry": "Suggerita per {country}.",
+      "name": "Nome dell'officina",
+      "namePlaceholder": "La mia officina",
+      "kindLegend": "Su cosa lavori soprattutto?",
+      "kinds": {
+        "cars": {
+          "title": "Auto e furgoni",
+          "hint": "Cilindrata in litri"
+        },
+        "motorcycles": {
+          "title": "Moto",
+          "hint": "Cilindrata in cc"
+        },
+        "marine": {
+          "title": "Nautico",
+          "hint": "Imbarcazioni, numeri di scafo e ore motore"
+        }
+      },
+      "country": "Dove si trova l'officina?",
+      "countryPlaceholder": "Scegli un paese",
+      "countrySearch": "Cerca paesi",
+      "noCountry": "Nessun paese trovato",
+      "countryDetected": "Trovato dal tuo fuso orario, {zone}. Il passaggio successivo propone ciò che è consueto in {country}, e tu confermi ogni scelta.",
+      "countryChosen": "Il passaggio successivo propone ciò che è consueto in {country}, e tu confermi ogni scelta.",
+      "countryMissing": "Scegli il paese e il passaggio successivo ne proporrà lingua, valuta e formato della data.",
+      "skip": "Usa i suggerimenti e vai a Pronto",
+      "nameMissing": "Dai all'officina un nome di almeno 2 caratteri."
+    },
+    "region": {
+      "title": "Lingua e regione",
+      "subtitle": "Suggerito per {country}. Controlla ogni scelta: decidono cosa leggono i tuoi clienti.",
+      "subtitleNoCountry": "Controlla ogni scelta: decidono cosa leggono i tuoi clienti.",
+      "docLanguage": "La lingua che ricevono i tuoi clienti",
+      "docLanguageHint": "Fatture, preventivi, ordini di lavoro ed email sono scritti in questa lingua, chiunque in officina li stampi o li invii.",
+      "languageSearch": "Cerca lingue",
+      "noLanguage": "Nessuna lingua trovata",
+      "defaults": "Impostazioni regionali",
+      "change": "Cambia",
+      "done": "Fatto",
+      "currency": "Valuta",
+      "currencySearch": "Cerca valute",
+      "noCurrency": "Nessuna valuta trovata",
+      "dates": "Date e ora",
+      "dateFormat": "Formato data",
+      "clock": "Orologio",
+      "clock12": "12 ore",
+      "clock24": "24 ore",
+      "timeZone": "Fuso orario",
+      "zoneSearch": "Cerca fusi orari",
+      "noZone": "Nessun fuso orario trovato",
+      "units": "Unità",
+      "distance": "Distanza",
+      "distanceKm": "Chilometri",
+      "distanceMiles": "Miglia",
+      "measure": "Fluidi e ricambi",
+      "measureMetric": "Litri e chilogrammi",
+      "measureImperial": "Quarti, galloni e libbre",
+      "engine": "Cilindrata",
+      "engineLitres": "Litri",
+      "engineCc": "Centimetri cubi",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, cilindrata in {engine}",
+      "engineLitresShort": "litri",
+      "engineCcShort": "cc",
+      "phone": "Numeri di telefono",
+      "phoneValue": "{code} se non ne viene scritto uno",
+      "phoneNone": "Nessun prefisso aggiunto",
+      "phonePrefix": "Prefisso internazionale",
+      "phoneHint": "Aggiunto ai numeri dei clienti scritti senza prefisso, come +39.",
+      "phoneInvalid": "Usa + e cifre, come +39.",
+      "later": "Puoi modificarli in seguito in <b>{localization}</b>. Unità e cilindrata si trovano in <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Imposte sulle tue fatture",
+      "subtitle": "Come l'imposta viene aggiunta e chiamata su ciò che invii.",
+      "charges": "Applichi imposte sul tuo lavoro?",
+      "yes": "Sì",
+      "no": "No",
+      "rate": "Aliquota ordinaria",
+      "rateHint": "Scritta da te. Torqvoice non indovina mai un'aliquota; verificala con il tuo commercialista.",
+      "rateInvalid": "Inserisci un'aliquota tra 0 e 100.",
+      "name": "Nome sulle fatture",
+      "namePlaceholder": "Imposta",
+      "nameHint": "Viene stampata come {example}.",
+      "nameSuggested": "Suggerito per {country}. Viene stampata come {example}.",
+      "pricesLegend": "I prezzi che scrivi sono",
+      "before": "Senza imposte",
+      "beforeExample": "Scrivi {price}. Il cliente paga {price} + {tax} di {label} = {total}.",
+      "including": "Imposte incluse",
+      "includingExample": "Scrivi {total}. Il cliente paga {total}, di cui {tax} di {label}.",
+      "noTax": "Non viene stampata alcuna riga d'imposta e il totale è ciò che hai scritto.",
+      "skippedHint": "Saltato per ora, quindi non viene aggiunta alcuna imposta. Scegli Sì per configurarla qui.",
+      "later": "Puoi modificarlo in seguito in <b>{tax}</b>. Lì imposti anche due imposte su una fattura, come GST e QST, o l'imposta solo sui ricambi.",
+      "skip": "Salta per ora",
+      "rateMissing": "Inserisci la tua aliquota, oppure scegli No se non applichi imposte."
+    },
+    "ready": {
+      "title": "Pronto ad aprire {name}",
+      "subtitle": "Un ultimo sguardo. Tutto ciò che segue si può cambiare più tardi nelle Impostazioni.",
+      "rowWorkshop": "Officina",
+      "rowCustomers": "I clienti ricevono",
+      "rowScreens": "Le tue schermate: {language}",
+      "rowMoney": "Denaro e date",
+      "rowUnits": "Unità",
+      "rowTax": "Imposta",
+      "kinds": {
+        "cars": "auto e furgoni",
+        "motorcycles": "moto",
+        "marine": "nautico"
+      },
+      "taxBefore": "{label} {rate}%, prezzi scritti senza imposte",
+      "taxIncluding": "{label} {rate}%, prezzi scritti con imposte incluse",
+      "taxOff": "Nessuna imposta",
+      "taxSkipped": "Non ancora configurato",
+      "edit": "Modifica",
+      "address": "Indirizzo sulle tue fatture",
+      "optional": "(facoltativo)",
+      "addressPlaceholder": "Via, CAP e città",
+      "create": "Crea officina"
+    },
+    "preview": {
+      "label": "Anteprima della fattura",
+      "first": "La tua prima fattura, come la riceve un cliente",
+      "live": "Cambia con le tue scelte",
+      "loading": "Preparazione dell'anteprima",
+      "enlarge": "Mostra a grandezza piena"
+    },
+    "sample": {
+      "jobTitle": "Cambio olio e controllo",
+      "serviceType": "Manutenzione",
+      "customerName": "Giulia Romano",
+      "customerAddress": "Via Roma 12, 20121 Milano",
+      "laborLine": "Cambio olio e controllo",
+      "partLine": "Filtro dell'olio",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Olio motore 5W-30",
+      "part3": "Filtro aria",
+      "part4": "Spazzole tergicristallo",
+      "labor2": "Sostituzione filtro aria",
+      "labor3": "Sostituzione spazzole tergicristallo",
+      "labor4": "Controllo freni"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/it/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Zona",
     "conditionMapArea": "Zona",
     "conditionMapKind": "Tipo",
     "conditionMapKind": "Tipo",
     "conditionMapSeverity": "Gravità",
     "conditionMapSeverity": "Gravità",
-    "conditionMapNote": "Nota"
+    "conditionMapNote": "Nota",
+    "serviceTypeMaintenance": "Manutenzione",
+    "serviceTypeRepair": "Riparazione",
+    "serviceTypeUpgrade": "Aggiornamento",
+    "serviceTypeInspection": "Ispezione"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ORDINE DI LAVORO",
     "title": "ORDINE DI LAVORO",

+ 8 - 5
messages/it/settings.json

@@ -1188,17 +1188,20 @@
     "typeKeyTitle": "Codice tipo tedesco (HSN/TSN)",
     "typeKeyTitle": "Codice tipo tedesco (HSN/TSN)",
     "typeKeyEnabled": "Registra HSN/TSN sui veicoli",
     "typeKeyEnabled": "Registra HSN/TSN sui veicoli",
     "typeKeyHint": "Aggiunge ai veicoli il codice costruttore e il codice tipo del libretto di circolazione tedesco (campi 2.1 e 2.2). Compare sul veicolo, sull'ordine di lavoro e su tutti i documenti stampati, e la ricerca trova i veicoli in base a esso. Disattivandolo viene di nuovo nascosto e i valori inseriti restano salvati.",
     "typeKeyHint": "Aggiunge ai veicoli il codice costruttore e il codice tipo del libretto di circolazione tedesco (campi 2.1 e 2.2). Compare sul veicolo, sull'ordine di lavoro e su tutti i documenti stampati, e la ricerca trova i veicoli in base a esso. Disattivandolo viene di nuovo nascosto e i valori inseriti restano salvati.",
-    "unitsTitle": "Unita",
-    "unitsDescription": "Scegli tra sistema metrico (km, litri) e imperiale (miglia, galloni).",
-    "unitSystem": "Sistema di unita",
+    "unitsTitle": "Unità",
+    "unitsDescription": "Come sono scritte distanze, fluidi e ricambi in questa officina.",
+    "unitSystem": "Sistema di unità",
     "metric": "Metrico (km, litri)",
     "metric": "Metrico (km, litri)",
     "imperial": "Imperiale (miglia, galloni)",
     "imperial": "Imperiale (miglia, galloni)",
     "distanceLabel": "Distanza",
     "distanceLabel": "Distanza",
     "volumeLabel": "Volume",
     "volumeLabel": "Volume",
-    "kilometers": "chilometri (km)",
+    "kilometers": "Chilometri (km)",
     "liters": "litri (L)",
     "liters": "litri (L)",
-    "miles": "miglia (mi)",
+    "miles": "Miglia (mi)",
     "gallons": "galloni (gal)",
     "gallons": "galloni (gal)",
+    "measureSystem": "Fluidi e ricambi",
+    "measureMetric": "Litri e chilogrammi",
+    "measureImperial": "Quarti, galloni e libbre",
     "engineSizeUnit": "Cilindrata",
     "engineSizeUnit": "Cilindrata",
     "engineSizeLitres": "Litri (1,6 L)",
     "engineSizeLitres": "Litri (1,6 L)",
     "engineSizeCc": "Centimetri cubi (1598 cc)",
     "engineSizeCc": "Centimetri cubi (1598 cc)",

+ 163 - 0
messages/lt/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Ši instaliacija jau turi savo dirbtuvę",
     "title": "Ši instaliacija jau turi savo dirbtuvę",
     "description": "Torqvoice čia aptarnauja vieną dirbtuvę, ir ji jau sukurta. Norėdami joje dirbti, paprašykite savininko pakviesti {email} per Nustatymai, tada Komanda.",
     "description": "Torqvoice čia aptarnauja vieną dirbtuvę, ir ji jau sukurta. Norėdami joje dirbti, paprašykite savininko pakviesti {email} per Nustatymai, tada Komanda.",
     "signOut": "Atsijungti"
     "signOut": "Atsijungti"
+  },
+  "setup": {
+    "stepsLabel": "Sąrankos žingsniai",
+    "steps": {
+      "workshop": "Dirbtuvės",
+      "region": "Kalba ir regionas",
+      "tax": "Mokesčiai",
+      "ready": "Paruošta"
+    },
+    "back": "Atgal",
+    "continue": "Tęsti",
+    "workshop": {
+      "title": "Susikurkite savo dirbtuves",
+      "subtitle": "Keturi trumpi žingsniai, kad pirmoji sąskaita būtų teisinga. Viską galėsite pakeisti vėliau nustatymuose.",
+      "language": "Kalba",
+      "languageBoth": "Ja naudojami jūsų ekranai ir klientams siunčiamos sąskaitos, pasiūlymai bei el. laiškai.",
+      "languageFromCountry": "Pasiūlyta šaliai {country}. Ja naudojami jūsų ekranai ir klientams siunčiamos sąskaitos, pasiūlymai bei el. laiškai.",
+      "languageMissing": "Torqvoice dar nėra {language} kalba, todėl šaliai {country} siūloma anglų kalba. Jei norite, pasirinkite kitą kalbą.",
+      "languageScreensOnly": "Naudojama jūsų ekranuose.",
+      "customerLanguageDifferent": "Klientams naudoti kitą kalbą",
+      "customerLanguageSame": "Tokia pati kaip mano ({language})",
+      "customerLanguageFromCountry": "Pasiūlyta šaliai {country}.",
+      "name": "Dirbtuvių pavadinimas",
+      "namePlaceholder": "Mano dirbtuvės",
+      "kindLegend": "Su kuo daugiausia dirbate?",
+      "kinds": {
+        "cars": {
+          "title": "Automobiliai ir furgonai",
+          "hint": "Variklio tūris litrais"
+        },
+        "motorcycles": {
+          "title": "Motociklai",
+          "hint": "Variklio tūris cc"
+        },
+        "marine": {
+          "title": "Jūrinės",
+          "hint": "Laivai, korpuso numeriai ir variklio valandos"
+        }
+      },
+      "country": "Kur yra dirbtuvės?",
+      "countryPlaceholder": "Pasirinkite šalį",
+      "countrySearch": "Ieškoti šalių",
+      "noCountry": "Šalis nerasta",
+      "countryDetected": "Nustatyta pagal jūsų laiko juostą, {zone}. Kitame žingsnyje bus pasiūlyta, kas įprasta šalyje {country}, o jūs patvirtinsite kiekvieną pasirinkimą.",
+      "countryChosen": "Kitame žingsnyje bus pasiūlyta, kas įprasta šalyje {country}, o jūs patvirtinsite kiekvieną pasirinkimą.",
+      "countryMissing": "Pasirinkite šalį, ir kitame žingsnyje bus pasiūlyta jos kalba, valiuta ir datos formatas.",
+      "skip": "Naudoti pasiūlymus ir pereiti prie Paruošta",
+      "nameMissing": "Įveskite dirbtuvių pavadinimą, bent 2 simbolių."
+    },
+    "region": {
+      "title": "Kalba ir regionas",
+      "subtitle": "Pasiūlyta šaliai {country}. Patikrinkite kiekvieną: jie lemia, ką skaito jūsų klientai.",
+      "subtitleNoCountry": "Patikrinkite kiekvieną: jie lemia, ką skaito jūsų klientai.",
+      "docLanguage": "Kalba, kuria klientai gauna dokumentus",
+      "docLanguageHint": "Sąskaitos, pasiūlymai, darbo užsakymai ir el. laiškai rašomi šia kalba, nesvarbu, kas dirbtuvėse juos spausdina ar siunčia.",
+      "languageSearch": "Ieškoti kalbų",
+      "noLanguage": "Kalba nerasta",
+      "defaults": "Regioniniai numatytieji",
+      "change": "Keisti",
+      "done": "Atlikta",
+      "currency": "Valiuta",
+      "currencySearch": "Ieškoti valiutų",
+      "noCurrency": "Valiuta nerasta",
+      "dates": "Datos ir laikas",
+      "dateFormat": "Datos formatas",
+      "clock": "Laikrodis",
+      "clock12": "12 valandų",
+      "clock24": "24 valandų",
+      "timeZone": "Laiko juosta",
+      "zoneSearch": "Ieškoti laiko juostų",
+      "noZone": "Laiko juosta nerasta",
+      "units": "Vienetai",
+      "distance": "Atstumas",
+      "distanceKm": "Kilometrai",
+      "distanceMiles": "Mylios",
+      "measure": "Skysčiai ir dalys",
+      "measureMetric": "Litrai ir kilogramai",
+      "measureImperial": "Kvortos, galonai ir svarai",
+      "engine": "Variklio tūris",
+      "engineLitres": "Litrai",
+      "engineCc": "Kubiniai centimetrai",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, variklio tūris {engine}",
+      "engineLitresShort": "litrais",
+      "engineCcShort": "cc",
+      "phone": "Telefono numeriai",
+      "phoneValue": "{code}, kai neįvestas joks",
+      "phoneNone": "Kodas nepridedamas",
+      "phonePrefix": "Šalies kodas",
+      "phoneHint": "Pridedamas prie klientų numerių, įvestų be šalies kodo, pvz. +370.",
+      "phoneInvalid": "Naudokite + ir skaitmenis, pvz. +370.",
+      "later": "Vėliau juos galite pakeisti skiltyje <b>{localization}</b>. Vienetai ir variklio tūris yra skiltyje <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Mokesčiai jūsų sąskaitose",
+      "subtitle": "Kaip mokestis pridedamas ir vadinamas tame, ką siunčiate.",
+      "charges": "Ar už savo darbą taikote mokestį?",
+      "yes": "Taip",
+      "no": "Ne",
+      "rate": "Standartinis tarifas",
+      "rateHint": "Įvedate jūs. Torqvoice niekada nespėja mokesčio tarifo; pasitikrinkite su savo buhalteriu.",
+      "rateInvalid": "Įveskite tarifą nuo 0 iki 100.",
+      "name": "Pavadinimas sąskaitose",
+      "namePlaceholder": "Mokestis",
+      "nameHint": "Spausdinama kaip {example}.",
+      "nameSuggested": "Pasiūlyta šaliai {country}. Spausdinama kaip {example}.",
+      "pricesLegend": "Jūsų įvedamos kainos yra",
+      "before": "Be mokesčio",
+      "beforeExample": "Įvedate {price}. Klientas moka {price} + {tax} {label} = {total}.",
+      "including": "Su mokesčiu",
+      "includingExample": "Įvedate {total}. Klientas moka {total}, iš kurių {tax} yra {label}.",
+      "noTax": "Mokesčio eilutė nespausdinama, o suma yra tai, ką įvedėte.",
+      "skippedHint": "Kol kas praleista, todėl mokestis nepridedamas. Pasirinkite Taip, kad nustatytumėte čia.",
+      "later": "Vėliau tai galite pakeisti skiltyje <b>{tax}</b>. Ten taip pat nustatysite du mokesčius vienoje sąskaitoje, kaip GST ir QST, arba mokestį tik dalims.",
+      "skip": "Kol kas praleisti",
+      "rateMissing": "Įveskite mokesčio tarifą arba pasirinkite Ne, jei mokesčio netaikote."
+    },
+    "ready": {
+      "title": "Pasiruošta atidaryti {name}",
+      "subtitle": "Paskutinis žvilgsnis. Viską, kas žemiau, galėsite pakeisti vėliau nustatymuose.",
+      "rowWorkshop": "Dirbtuvės",
+      "rowCustomers": "Klientai gauna",
+      "rowScreens": "Jūsų ekranai: {language}",
+      "rowMoney": "Pinigai ir datos",
+      "rowUnits": "Vienetai",
+      "rowTax": "Mokestis",
+      "kinds": {
+        "cars": "automobiliai ir furgonai",
+        "motorcycles": "motociklai",
+        "marine": "jūrinės"
+      },
+      "taxBefore": "{label} {rate} %, kainos įvedamos be mokesčio",
+      "taxIncluding": "{label} {rate} %, kainos įvedamos su mokesčiu",
+      "taxOff": "Be mokesčio",
+      "taxSkipped": "Dar nenustatyta",
+      "edit": "Redaguoti",
+      "address": "Adresas jūsų sąskaitose",
+      "optional": "(neprivaloma)",
+      "addressPlaceholder": "Gatvė, pašto kodas ir miestas",
+      "create": "Sukurti dirbtuves"
+    },
+    "preview": {
+      "label": "Sąskaitos peržiūra",
+      "first": "Jūsų pirmoji sąskaita, kokią gauna klientas",
+      "live": "Keičiasi pagal jūsų pasirinkimus",
+      "loading": "Piešiama peržiūra",
+      "enlarge": "Rodyti visu dydžiu"
+    },
+    "sample": {
+      "jobTitle": "Alyvos keitimas ir patikra",
+      "serviceType": "Techninė priežiūra",
+      "customerName": "Rūta Kazlauskienė",
+      "customerAddress": "Stoties g. 12, 01100 Vilnius",
+      "laborLine": "Alyvos keitimas ir patikra",
+      "partLine": "Alyvos filtras",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Variklio alyva 5W-30",
+      "part3": "Oro filtras",
+      "part4": "Valytuvų šluotelės",
+      "labor2": "Oro filtro keitimas",
+      "labor3": "Valytuvų šluotelių keitimas",
+      "labor4": "Stabdžių patikra"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/lt/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Vieta",
     "conditionMapArea": "Vieta",
     "conditionMapKind": "Tipas",
     "conditionMapKind": "Tipas",
     "conditionMapSeverity": "Sunkumas",
     "conditionMapSeverity": "Sunkumas",
-    "conditionMapNote": "Pastaba"
+    "conditionMapNote": "Pastaba",
+    "serviceTypeMaintenance": "Priežiūra",
+    "serviceTypeRepair": "Remontas",
+    "serviceTypeUpgrade": "Pagerinimas",
+    "serviceTypeInspection": "Apžiūra"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "DARBO UŽSAKYMAS",
     "title": "DARBO UŽSAKYMAS",

+ 6 - 3
messages/lt/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Registruoti HSN/TSN transporto priemonėms",
     "typeKeyEnabled": "Registruoti HSN/TSN transporto priemonėms",
     "typeKeyHint": "Prideda transporto priemonėms gamintojo ir tipo kodą iš Vokietijos registracijos liudijimo (2.1 ir 2.2 laukeliai). Jis rodomas prie transporto priemonės, darbo užsakyme ir visuose spausdinamuose dokumentuose, o paieška pagal jį randa transporto priemones. Išjungus jis vėl paslepiamas, o įvestos reikšmės išsaugomos.",
     "typeKeyHint": "Prideda transporto priemonėms gamintojo ir tipo kodą iš Vokietijos registracijos liudijimo (2.1 ir 2.2 laukeliai). Jis rodomas prie transporto priemonės, darbo užsakyme ir visuose spausdinamuose dokumentuose, o paieška pagal jį randa transporto priemones. Išjungus jis vėl paslepiamas, o įvestos reikšmės išsaugomos.",
     "unitsTitle": "Vienetai",
     "unitsTitle": "Vienetai",
-    "unitsDescription": "Pasirinkite tarp metrinės (km, litrai) ir imperinės (mylios, galonai) sistemos.",
+    "unitsDescription": "Kaip šiose dirbtuvėse rašomi atstumai, skysčiai ir dalys.",
     "unitSystem": "Vienetų sistema",
     "unitSystem": "Vienetų sistema",
     "metric": "Metrinė (km, litrai)",
     "metric": "Metrinė (km, litrai)",
     "imperial": "Imperinė (mylios, galonai)",
     "imperial": "Imperinė (mylios, galonai)",
     "distanceLabel": "Atstumas",
     "distanceLabel": "Atstumas",
     "volumeLabel": "Tūris",
     "volumeLabel": "Tūris",
-    "kilometers": "kilometrai (km)",
+    "kilometers": "Kilometrai (km)",
     "liters": "litrai (l)",
     "liters": "litrai (l)",
-    "miles": "mylios (mi)",
+    "miles": "Mylios (mi)",
     "gallons": "galonai (gal)",
     "gallons": "galonai (gal)",
+    "measureSystem": "Skysčiai ir dalys",
+    "measureMetric": "Litrai ir kilogramai",
+    "measureImperial": "Kvortos, galonai ir svarai",
     "engineSizeUnit": "Variklio tūris",
     "engineSizeUnit": "Variklio tūris",
     "engineSizeLitres": "Litrai (1,6 L)",
     "engineSizeLitres": "Litrai (1,6 L)",
     "engineSizeCc": "Kubiniai centimetrai (1598 cc)",
     "engineSizeCc": "Kubiniai centimetrai (1598 cc)",

+ 163 - 0
messages/nb/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Denne installasjonen har allerede verkstedet sitt",
     "title": "Denne installasjonen har allerede verkstedet sitt",
     "description": "Torqvoice kjører ett verksted her, og det er satt opp. For å jobbe i det, be eieren invitere {email} fra Innstillinger, deretter Team.",
     "description": "Torqvoice kjører ett verksted her, og det er satt opp. For å jobbe i det, be eieren invitere {email} fra Innstillinger, deretter Team.",
     "signOut": "Logg ut"
     "signOut": "Logg ut"
+  },
+  "setup": {
+    "stepsLabel": "Oppsettsteg",
+    "steps": {
+      "workshop": "Verksted",
+      "region": "Språk og region",
+      "tax": "Avgifter",
+      "ready": "Klar"
+    },
+    "back": "Tilbake",
+    "continue": "Fortsett",
+    "workshop": {
+      "title": "Sett opp verkstedet ditt",
+      "subtitle": "Fire korte steg, så den første fakturaen blir riktig. Du kan endre alt senere i Innstillinger.",
+      "language": "Språk",
+      "languageBoth": "Skjermene dine og fakturaer, tilbud og e-post til kundene dine bruker det.",
+      "languageFromCountry": "Foreslått for {country}. Skjermene dine og fakturaer, tilbud og e-post til kundene dine bruker det.",
+      "languageMissing": "Torqvoice finnes ikke på {language} ennå, så engelsk er foreslått for {country}. Velg et annet språk om du vil.",
+      "languageScreensOnly": "Brukes på skjermene dine.",
+      "customerLanguageDifferent": "Bruk et annet språk for kundene",
+      "customerLanguageSame": "Samme som mitt ({language})",
+      "customerLanguageFromCountry": "Foreslått for {country}.",
+      "name": "Navn på verkstedet",
+      "namePlaceholder": "Mitt bilverksted",
+      "kindLegend": "Hva jobber du mest med?",
+      "kinds": {
+        "cars": {
+          "title": "Biler og varebiler",
+          "hint": "Motorvolum i liter"
+        },
+        "motorcycles": {
+          "title": "Motorsykler",
+          "hint": "Motorvolum i cc"
+        },
+        "marine": {
+          "title": "Marine",
+          "hint": "Fartøy, skrognummer og motortimer"
+        }
+      },
+      "country": "Hvor ligger verkstedet?",
+      "countryPlaceholder": "Velg et land",
+      "countrySearch": "Søk etter land",
+      "noCountry": "Fant ikke landet",
+      "countryDetected": "Funnet fra tidssonen din, {zone}. Neste steg foreslår det som er vanlig i {country}, og du bekrefter hvert valg.",
+      "countryChosen": "Neste steg foreslår det som er vanlig i {country}, og du bekrefter hvert valg.",
+      "countryMissing": "Velg landet, så foreslår neste steg språk, valuta og datoformat.",
+      "skip": "Bruk forslagene og hopp til Klar",
+      "nameMissing": "Gi verkstedet et navn på minst 2 tegn."
+    },
+    "region": {
+      "title": "Språk og region",
+      "subtitle": "Foreslått for {country}. Sjekk hvert valg: de bestemmer hva kundene dine leser.",
+      "subtitleNoCountry": "Sjekk hvert valg: de bestemmer hva kundene dine leser.",
+      "docLanguage": "Språket kundene dine får",
+      "docLanguageHint": "Fakturaer, tilbud, arbeidsordrer og e-post skrives på dette språket, uansett hvem i verkstedet som skriver ut eller sender dem.",
+      "languageSearch": "Søk etter språk",
+      "noLanguage": "Fant ikke språket",
+      "defaults": "Regionale standarder",
+      "change": "Endre",
+      "done": "Ferdig",
+      "currency": "Valuta",
+      "currencySearch": "Søk etter valuta",
+      "noCurrency": "Fant ikke valutaen",
+      "dates": "Datoer og klokkeslett",
+      "dateFormat": "Datoformat",
+      "clock": "Klokke",
+      "clock12": "12 timer",
+      "clock24": "24 timer",
+      "timeZone": "Tidssone",
+      "zoneSearch": "Søk etter tidssone",
+      "noZone": "Fant ikke tidssonen",
+      "units": "Enheter",
+      "distance": "Avstand",
+      "distanceKm": "Kilometer",
+      "distanceMiles": "Miles",
+      "measure": "Væsker og deler",
+      "measureMetric": "Liter og kilo",
+      "measureImperial": "Quarts, gallons og pund",
+      "engine": "Motorvolum",
+      "engineLitres": "Liter",
+      "engineCc": "Kubikkcentimeter",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, motorvolum i {engine}",
+      "engineLitresShort": "liter",
+      "engineCcShort": "cc",
+      "phone": "Telefonnumre",
+      "phoneValue": "{code} når ingen er skrevet",
+      "phoneNone": "Ingen landkode legges til",
+      "phonePrefix": "Landkode",
+      "phoneHint": "Legges til kundenumre skrevet uten landkode, som +47.",
+      "phoneInvalid": "Bruk + og sifre, som +47.",
+      "later": "Du kan endre dette senere i <b>{localization}</b>. Enheter og motorstørrelse finner du i <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Avgifter på fakturaene dine",
+      "subtitle": "Hvordan avgift legges til og navngis på det du sender.",
+      "charges": "Tar du avgift på arbeidet ditt?",
+      "yes": "Ja",
+      "no": "Nei",
+      "rate": "Standardsats",
+      "rateHint": "Skrevet inn av deg. Torqvoice gjetter aldri en avgiftssats; sjekk den med regnskapsføreren din.",
+      "rateInvalid": "Skriv inn en sats mellom 0 og 100.",
+      "name": "Navn på fakturaer",
+      "namePlaceholder": "Avgift",
+      "nameHint": "Skrives ut som {example}.",
+      "nameSuggested": "Foreslått for {country}. Skrives ut som {example}.",
+      "pricesLegend": "Prisene du skriver inn er",
+      "before": "Uten avgift",
+      "beforeExample": "Du skriver {price}. Kunden betaler {price} + {tax} {label} = {total}.",
+      "including": "Med avgift",
+      "includingExample": "Du skriver {total}. Kunden betaler {total}, hvorav {tax} er {label}.",
+      "noTax": "Ingen avgiftslinje skrives ut, og totalen er det du skrev inn.",
+      "skippedHint": "Hoppet over for nå, så ingen avgift legges til. Velg Ja for å sette den opp her.",
+      "later": "Du kan endre dette senere i <b>{tax}</b>. Der setter du også opp to avgifter på én faktura, som GST og QST, eller avgift bare på deler.",
+      "skip": "Hopp over for nå",
+      "rateMissing": "Skriv inn avgiftssatsen, eller velg Nei hvis du ikke tar avgift."
+    },
+    "ready": {
+      "title": "Klar til å åpne {name}",
+      "subtitle": "En siste titt. Alt nedenfor kan endres senere i Innstillinger.",
+      "rowWorkshop": "Verksted",
+      "rowCustomers": "Kundene får",
+      "rowScreens": "Dine skjermer: {language}",
+      "rowMoney": "Penger og datoer",
+      "rowUnits": "Enheter",
+      "rowTax": "Avgift",
+      "kinds": {
+        "cars": "biler og varebiler",
+        "motorcycles": "motorsykler",
+        "marine": "marine"
+      },
+      "taxBefore": "{label} {rate} %, priser skrevet uten avgift",
+      "taxIncluding": "{label} {rate} %, priser skrevet med avgift",
+      "taxOff": "Ingen avgift",
+      "taxSkipped": "Ikke satt opp ennå",
+      "edit": "Endre",
+      "address": "Adresse på fakturaene dine",
+      "optional": "(valgfritt)",
+      "addressPlaceholder": "Gate, postnummer og sted",
+      "create": "Opprett verksted"
+    },
+    "preview": {
+      "label": "Forhåndsvisning av faktura",
+      "first": "Den første fakturaen din, slik en kunde får den",
+      "live": "Endres etter hva du velger",
+      "loading": "Tegner forhåndsvisningen",
+      "enlarge": "Vis i full størrelse"
+    },
+    "sample": {
+      "jobTitle": "Oljeskift og kontroll",
+      "serviceType": "Service",
+      "customerName": "Ingrid Hansen",
+      "customerAddress": "Storgata 12, 0155 Oslo",
+      "laborLine": "Oljeskift og kontroll",
+      "partLine": "Oljefilter",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Motorolje 5W-30",
+      "part3": "Luftfilter",
+      "part4": "Vindusviskerblader",
+      "labor2": "Bytte av luftfilter",
+      "labor3": "Bytte av viskerblader",
+      "labor4": "Bremsekontroll"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/nb/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Område",
     "conditionMapArea": "Område",
     "conditionMapKind": "Type",
     "conditionMapKind": "Type",
     "conditionMapSeverity": "Alvorlighet",
     "conditionMapSeverity": "Alvorlighet",
-    "conditionMapNote": "Notat"
+    "conditionMapNote": "Notat",
+    "serviceTypeMaintenance": "Vedlikehold",
+    "serviceTypeRepair": "Reparasjon",
+    "serviceTypeUpgrade": "Oppgradering",
+    "serviceTypeInspection": "Inspeksjon"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ARBEIDSORDRE",
     "title": "ARBEIDSORDRE",

+ 7 - 4
messages/nb/settings.json

@@ -200,7 +200,7 @@
     "serviceTypeDescription": "Velg typen tjeneste verkstedet ditt tilbyr. Dette endrer feltetiketter i hele applikasjonen.",
     "serviceTypeDescription": "Velg typen tjeneste verkstedet ditt tilbyr. Dette endrer feltetiketter i hele applikasjonen.",
     "automotive": "Bil",
     "automotive": "Bil",
     "automotiveDescription": "Biler, lastebiler og andre kjøretøy",
     "automotiveDescription": "Biler, lastebiler og andre kjøretøy",
-    "marineService": "Båt",
+    "marineService": "Marine",
     "marineServiceDescription": "Båter, yachter og marine fartøy"
     "marineServiceDescription": "Båter, yachter og marine fartøy"
   },
   },
   "account": {
   "account": {
@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Registrer HSN/TSN på kjøretøy",
     "typeKeyEnabled": "Registrer HSN/TSN på kjøretøy",
     "typeKeyHint": "Legger til produsent- og typenøkkelen fra det tyske vognkortet (felt 2.1 og 2.2) på kjøretøy. Den vises på kjøretøyet, arbeidsordren og alle utskrevne dokumenter, og kjøretøy kan finnes med den i søket. Slås den av, skjules den igjen, og det som er lagt inn beholdes.",
     "typeKeyHint": "Legger til produsent- og typenøkkelen fra det tyske vognkortet (felt 2.1 og 2.2) på kjøretøy. Den vises på kjøretøyet, arbeidsordren og alle utskrevne dokumenter, og kjøretøy kan finnes med den i søket. Slås den av, skjules den igjen, og det som er lagt inn beholdes.",
     "unitsTitle": "Enheter",
     "unitsTitle": "Enheter",
-    "unitsDescription": "Velg mellom metrisk (km, liter) og imperial (miles, gallons).",
+    "unitsDescription": "Hvordan avstander, væsker og deler skrives i dette verkstedet.",
     "unitSystem": "Enhetssystem",
     "unitSystem": "Enhetssystem",
     "metric": "Metrisk (km, liter)",
     "metric": "Metrisk (km, liter)",
     "imperial": "Imperisk (miles, gallon)",
     "imperial": "Imperisk (miles, gallon)",
     "distanceLabel": "Avstand",
     "distanceLabel": "Avstand",
     "volumeLabel": "Volum",
     "volumeLabel": "Volum",
-    "kilometers": "kilometer (km)",
+    "kilometers": "Kilometer (km)",
     "liters": "liter (L)",
     "liters": "liter (L)",
-    "miles": "miles (mi)",
+    "miles": "Miles (mi)",
     "gallons": "gallon (gal)",
     "gallons": "gallon (gal)",
+    "measureSystem": "Væsker og deler",
+    "measureMetric": "Liter og kilo",
+    "measureImperial": "Quarts, gallons og pund",
     "engineSizeUnit": "Motorvolum",
     "engineSizeUnit": "Motorvolum",
     "engineSizeLitres": "Liter (1,6 L)",
     "engineSizeLitres": "Liter (1,6 L)",
     "engineSizeCc": "Kubikkcentimeter (1598 cc)",
     "engineSizeCc": "Kubikkcentimeter (1598 cc)",

+ 163 - 0
messages/nl/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Deze installatie heeft haar werkplaats al",
     "title": "Deze installatie heeft haar werkplaats al",
     "description": "Torqvoice draait hier één werkplaats en die is al ingericht. Om erin te werken vraag je de eigenaar om {email} uit te nodigen via Instellingen, dan Team.",
     "description": "Torqvoice draait hier één werkplaats en die is al ingericht. Om erin te werken vraag je de eigenaar om {email} uit te nodigen via Instellingen, dan Team.",
     "signOut": "Uitloggen"
     "signOut": "Uitloggen"
+  },
+  "setup": {
+    "stepsLabel": "Installatiestappen",
+    "steps": {
+      "workshop": "Werkplaats",
+      "region": "Taal en regio",
+      "tax": "Belastingen",
+      "ready": "Klaar"
+    },
+    "back": "Terug",
+    "continue": "Doorgaan",
+    "workshop": {
+      "title": "Richt uw werkplaats in",
+      "subtitle": "Vier korte stappen, zodat uw eerste factuur klopt. U kunt alles later wijzigen in Instellingen.",
+      "language": "Taal",
+      "languageBoth": "Uw schermen en de facturen, offertes en e-mails aan uw klanten gebruiken deze taal.",
+      "languageFromCountry": "Voorgesteld voor {country}. Uw schermen en de facturen, offertes en e-mails aan uw klanten gebruiken deze taal.",
+      "languageMissing": "Torqvoice is nog niet beschikbaar in het {language}, dus voor {country} wordt Engels voorgesteld. Kies een andere taal als u dat wilt.",
+      "languageScreensOnly": "Gebruikt op uw schermen.",
+      "customerLanguageDifferent": "Een andere taal voor klanten gebruiken",
+      "customerLanguageSame": "Zelfde als de mijne ({language})",
+      "customerLanguageFromCountry": "Voorgesteld voor {country}.",
+      "name": "Naam van de werkplaats",
+      "namePlaceholder": "Mijn garage",
+      "kindLegend": "Waar werkt u vooral aan?",
+      "kinds": {
+        "cars": {
+          "title": "Auto's en bestelwagens",
+          "hint": "Cilinderinhoud in liters"
+        },
+        "motorcycles": {
+          "title": "Motoren",
+          "hint": "Cilinderinhoud in cc"
+        },
+        "marine": {
+          "title": "Maritiem",
+          "hint": "Vaartuigen, rompnummers en motoruren"
+        }
+      },
+      "country": "Waar is de werkplaats?",
+      "countryPlaceholder": "Kies een land",
+      "countrySearch": "Landen zoeken",
+      "noCountry": "Geen land gevonden",
+      "countryDetected": "Gevonden via uw tijdzone, {zone}. De volgende stap stelt voor wat in {country} gebruikelijk is, en u bevestigt elke keuze.",
+      "countryChosen": "De volgende stap stelt voor wat in {country} gebruikelijk is, en u bevestigt elke keuze.",
+      "countryMissing": "Kies het land, dan stelt de volgende stap de taal, valuta en datumnotatie voor.",
+      "skip": "Suggesties gebruiken en naar Klaar gaan",
+      "nameMissing": "Geef de werkplaats een naam van minstens 2 tekens."
+    },
+    "region": {
+      "title": "Taal en regio",
+      "subtitle": "Voorgesteld voor {country}. Controleer elke keuze: ze bepalen wat uw klanten lezen.",
+      "subtitleNoCountry": "Controleer elke keuze: ze bepalen wat uw klanten lezen.",
+      "docLanguage": "De taal die uw klanten krijgen",
+      "docLanguageHint": "Facturen, offertes, werkorders en e-mails worden in deze taal geschreven, wie ze in de werkplaats ook afdrukt of verstuurt.",
+      "languageSearch": "Talen zoeken",
+      "noLanguage": "Geen taal gevonden",
+      "defaults": "Regionale standaarden",
+      "change": "Wijzigen",
+      "done": "Klaar",
+      "currency": "Valuta",
+      "currencySearch": "Valuta zoeken",
+      "noCurrency": "Geen valuta gevonden",
+      "dates": "Datums en tijd",
+      "dateFormat": "Datumnotatie",
+      "clock": "Klok",
+      "clock12": "12 uur",
+      "clock24": "24 uur",
+      "timeZone": "Tijdzone",
+      "zoneSearch": "Tijdzones zoeken",
+      "noZone": "Geen tijdzone gevonden",
+      "units": "Eenheden",
+      "distance": "Afstand",
+      "distanceKm": "Kilometers",
+      "distanceMiles": "Mijlen",
+      "measure": "Vloeistoffen en onderdelen",
+      "measureMetric": "Liters en kilogrammen",
+      "measureImperial": "Quarts, gallons en ponden",
+      "engine": "Cilinderinhoud",
+      "engineLitres": "Liters",
+      "engineCc": "Kubieke centimeters",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, cilinderinhoud in {engine}",
+      "engineLitresShort": "liters",
+      "engineCcShort": "cc",
+      "phone": "Telefoonnummers",
+      "phoneValue": "{code} als er geen is getypt",
+      "phoneNone": "Geen landcode toegevoegd",
+      "phonePrefix": "Landcode",
+      "phoneHint": "Toegevoegd aan klantnummers die zonder landcode zijn getypt, zoals +31.",
+      "phoneInvalid": "Gebruik + en cijfers, zoals +31.",
+      "later": "Je kunt dit later wijzigen in <b>{localization}</b>. Eenheden en motorinhoud staan in <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Belastingen op uw facturen",
+      "subtitle": "Hoe belasting wordt toegevoegd en genoemd op wat u verstuurt.",
+      "charges": "Rekent u belasting over uw werk?",
+      "yes": "Ja",
+      "no": "Nee",
+      "rate": "Standaardtarief",
+      "rateHint": "Door u getypt. Torqvoice raadt nooit een belastingtarief; controleer het met uw boekhouder.",
+      "rateInvalid": "Vul een tarief tussen 0 en 100 in.",
+      "name": "Naam op facturen",
+      "namePlaceholder": "Belasting",
+      "nameHint": "Wordt afgedrukt als {example}.",
+      "nameSuggested": "Voorgesteld voor {country}. Wordt afgedrukt als {example}.",
+      "pricesLegend": "De prijzen die u typt zijn",
+      "before": "Exclusief belasting",
+      "beforeExample": "U typt {price}. De klant betaalt {price} + {tax} {label} = {total}.",
+      "including": "Inclusief belasting",
+      "includingExample": "U typt {total}. De klant betaalt {total}, waarvan {tax} {label} is.",
+      "noTax": "Er wordt geen belastingregel afgedrukt en het totaal is wat u typte.",
+      "skippedHint": "Voorlopig overgeslagen, dus er wordt geen belasting toegevoegd. Kies Ja om het hier in te stellen.",
+      "later": "Je kunt dit later wijzigen in <b>{tax}</b>. Daar stel je ook twee belastingen op één factuur in, zoals GST en QST, of belasting alleen op onderdelen.",
+      "skip": "Nu overslaan",
+      "rateMissing": "Vul uw belastingtarief in, of kies Nee als u geen belasting rekent."
+    },
+    "ready": {
+      "title": "Klaar om {name} te openen",
+      "subtitle": "Nog één blik. Alles hieronder kan later in Instellingen worden gewijzigd.",
+      "rowWorkshop": "Werkplaats",
+      "rowCustomers": "Klanten krijgen",
+      "rowScreens": "Uw schermen: {language}",
+      "rowMoney": "Geld en datums",
+      "rowUnits": "Eenheden",
+      "rowTax": "Belasting",
+      "kinds": {
+        "cars": "auto's en bestelwagens",
+        "motorcycles": "motoren",
+        "marine": "maritiem"
+      },
+      "taxBefore": "{label} {rate}%, prijzen exclusief belasting getypt",
+      "taxIncluding": "{label} {rate}%, prijzen inclusief belasting getypt",
+      "taxOff": "Geen belasting",
+      "taxSkipped": "Nog niet ingesteld",
+      "edit": "Wijzigen",
+      "address": "Adres op uw facturen",
+      "optional": "(optioneel)",
+      "addressPlaceholder": "Straat, postcode en plaats",
+      "create": "Werkplaats aanmaken"
+    },
+    "preview": {
+      "label": "Factuurvoorbeeld",
+      "first": "Uw eerste factuur, zoals een klant die krijgt",
+      "live": "Verandert met uw keuzes",
+      "loading": "Voorbeeld wordt getekend",
+      "enlarge": "Op ware grootte tonen"
+    },
+    "sample": {
+      "jobTitle": "Olie verversen en controle",
+      "serviceType": "Onderhoud",
+      "customerName": "Sanne de Vries",
+      "customerAddress": "Stationsstraat 12, 3511 Utrecht",
+      "laborLine": "Olie verversen en controle",
+      "partLine": "Oliefilter",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Motorolie 5W-30",
+      "part3": "Luchtfilter",
+      "part4": "Ruitenwisserbladen",
+      "labor2": "Luchtfilter vervangen",
+      "labor3": "Ruitenwisserbladen vervangen",
+      "labor4": "Remmencontrole"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/nl/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Plaats",
     "conditionMapArea": "Plaats",
     "conditionMapKind": "Type",
     "conditionMapKind": "Type",
     "conditionMapSeverity": "Ernst",
     "conditionMapSeverity": "Ernst",
-    "conditionMapNote": "Notitie"
+    "conditionMapNote": "Notitie",
+    "serviceTypeMaintenance": "Onderhoud",
+    "serviceTypeRepair": "Reparatie",
+    "serviceTypeUpgrade": "Upgrade",
+    "serviceTypeInspection": "Inspectie"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "WERKORDER",
     "title": "WERKORDER",

+ 6 - 3
messages/nl/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "HSN/TSN bij voertuigen vastleggen",
     "typeKeyEnabled": "HSN/TSN bij voertuigen vastleggen",
     "typeKeyHint": "Voegt de fabrikant- en typecode van het Duitse kentekenbewijs (velden 2.1 en 2.2) toe aan voertuigen. De code staat bij het voertuig, op de werkorder en op alle afgedrukte documenten, en voertuigen zijn erop te vinden via zoeken. Uitschakelen verbergt de code weer en bewaart wat is ingevoerd.",
     "typeKeyHint": "Voegt de fabrikant- en typecode van het Duitse kentekenbewijs (velden 2.1 en 2.2) toe aan voertuigen. De code staat bij het voertuig, op de werkorder en op alle afgedrukte documenten, en voertuigen zijn erop te vinden via zoeken. Uitschakelen verbergt de code weer en bewaart wat is ingevoerd.",
     "unitsTitle": "Eenheden",
     "unitsTitle": "Eenheden",
-    "unitsDescription": "Kies tussen metrisch (km, liters) en imperiaal (mijlen, gallons).",
+    "unitsDescription": "Hoe afstanden, vloeistoffen en onderdelen in deze werkplaats worden geschreven.",
     "unitSystem": "Eenhedensysteem",
     "unitSystem": "Eenhedensysteem",
     "metric": "Metrisch (km, liters)",
     "metric": "Metrisch (km, liters)",
     "imperial": "Imperiaal (mijlen, gallons)",
     "imperial": "Imperiaal (mijlen, gallons)",
     "distanceLabel": "Afstand",
     "distanceLabel": "Afstand",
     "volumeLabel": "Volume",
     "volumeLabel": "Volume",
-    "kilometers": "kilometers (km)",
+    "kilometers": "Kilometer (km)",
     "liters": "liters (L)",
     "liters": "liters (L)",
-    "miles": "mijlen (mi)",
+    "miles": "Mijl (mi)",
     "gallons": "gallons (gal)",
     "gallons": "gallons (gal)",
+    "measureSystem": "Vloeistoffen en onderdelen",
+    "measureMetric": "Liter en kilogram",
+    "measureImperial": "Quarts, gallons en ponden",
     "engineSizeUnit": "Cilinderinhoud",
     "engineSizeUnit": "Cilinderinhoud",
     "engineSizeLitres": "Liter (1,6 L)",
     "engineSizeLitres": "Liter (1,6 L)",
     "engineSizeCc": "Kubieke centimeter (1598 cc)",
     "engineSizeCc": "Kubieke centimeter (1598 cc)",

+ 163 - 0
messages/pl/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Ta instalacja ma już swój warsztat",
     "title": "Ta instalacja ma już swój warsztat",
     "description": "Torqvoice prowadzi tu jeden warsztat i został on już skonfigurowany. Aby w nim pracować, poproś właściciela o zaproszenie {email} w Ustawieniach, potem Zespół.",
     "description": "Torqvoice prowadzi tu jeden warsztat i został on już skonfigurowany. Aby w nim pracować, poproś właściciela o zaproszenie {email} w Ustawieniach, potem Zespół.",
     "signOut": "Wyloguj się"
     "signOut": "Wyloguj się"
+  },
+  "setup": {
+    "stepsLabel": "Kroki konfiguracji",
+    "steps": {
+      "workshop": "Warsztat",
+      "region": "Język i region",
+      "tax": "Podatki",
+      "ready": "Gotowe"
+    },
+    "back": "Wstecz",
+    "continue": "Dalej",
+    "workshop": {
+      "title": "Skonfiguruj swój warsztat",
+      "subtitle": "Cztery krótkie kroki, aby Twoja pierwsza faktura była poprawna. Wszystko zmienisz później w Ustawieniach.",
+      "language": "Język",
+      "languageBoth": "Używają go Twoje ekrany oraz faktury, oferty i e-maile do Twoich klientów.",
+      "languageFromCountry": "Propozycja dla kraju {country}. Używają go Twoje ekrany oraz faktury, oferty i e-maile do Twoich klientów.",
+      "languageMissing": "Torqvoice nie jest jeszcze dostępny w tym języku ({language}), więc dla kraju {country} proponujemy angielski. Wybierz inny język, jeśli wolisz.",
+      "languageScreensOnly": "Używany na Twoich ekranach.",
+      "customerLanguageDifferent": "Użyj innego języka dla klientów",
+      "customerLanguageSame": "Taki sam jak mój ({language})",
+      "customerLanguageFromCountry": "Propozycja dla kraju {country}.",
+      "name": "Nazwa warsztatu",
+      "namePlaceholder": "Mój warsztat",
+      "kindLegend": "Nad czym głównie pracujesz?",
+      "kinds": {
+        "cars": {
+          "title": "Samochody i dostawczaki",
+          "hint": "Pojemność silnika w litrach"
+        },
+        "motorcycles": {
+          "title": "Motocykle",
+          "hint": "Pojemność silnika w cc"
+        },
+        "marine": {
+          "title": "Branża morska",
+          "hint": "Jednostki pływające, numery kadłubów i motogodziny"
+        }
+      },
+      "country": "Gdzie jest warsztat?",
+      "countryPlaceholder": "Wybierz kraj",
+      "countrySearch": "Szukaj krajów",
+      "noCountry": "Nie znaleziono kraju",
+      "countryDetected": "Ustalone na podstawie strefy czasowej, {zone}. Następny krok podpowie, co jest typowe w kraju {country}, a Ty potwierdzisz każdy wybór.",
+      "countryChosen": "Następny krok podpowie, co jest typowe w kraju {country}, a Ty potwierdzisz każdy wybór.",
+      "countryMissing": "Wybierz kraj, a następny krok podpowie jego język, walutę i format daty.",
+      "skip": "Użyj podpowiedzi i przejdź do Gotowe",
+      "nameMissing": "Nadaj warsztatowi nazwę o długości co najmniej 2 znaków."
+    },
+    "region": {
+      "title": "Język i region",
+      "subtitle": "Propozycje dla kraju {country}. Sprawdź każdą: decydują o tym, co czytają Twoi klienci.",
+      "subtitleNoCountry": "Sprawdź każdą pozycję: decydują o tym, co czytają Twoi klienci.",
+      "docLanguage": "Język, w którym klienci otrzymują dokumenty",
+      "docLanguageHint": "Faktury, oferty, zlecenia i e-maile są pisane w tym języku, niezależnie od tego, kto w warsztacie je drukuje lub wysyła.",
+      "languageSearch": "Szukaj języków",
+      "noLanguage": "Nie znaleziono języka",
+      "defaults": "Ustawienia regionalne",
+      "change": "Zmień",
+      "done": "Gotowe",
+      "currency": "Waluta",
+      "currencySearch": "Szukaj walut",
+      "noCurrency": "Nie znaleziono waluty",
+      "dates": "Daty i godzina",
+      "dateFormat": "Format daty",
+      "clock": "Zegar",
+      "clock12": "12-godzinny",
+      "clock24": "24-godzinny",
+      "timeZone": "Strefa czasowa",
+      "zoneSearch": "Szukaj stref czasowych",
+      "noZone": "Nie znaleziono strefy czasowej",
+      "units": "Jednostki",
+      "distance": "Odległość",
+      "distanceKm": "Kilometry",
+      "distanceMiles": "Mile",
+      "measure": "Płyny i części",
+      "measureMetric": "Litry i kilogramy",
+      "measureImperial": "Kwarty, galony i funty",
+      "engine": "Pojemność silnika",
+      "engineLitres": "Litry",
+      "engineCc": "Centymetry sześcienne",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, pojemność silnika w {engine}",
+      "engineLitresShort": "litrach",
+      "engineCcShort": "cc",
+      "phone": "Numery telefonów",
+      "phoneValue": "{code}, gdy nie wpisano żadnego",
+      "phoneNone": "Bez dodawania kierunkowego",
+      "phonePrefix": "Kierunkowy kraju",
+      "phoneHint": "Dodawany do numerów klientów wpisanych bez kierunkowego, np. +48.",
+      "phoneInvalid": "Użyj + i cyfr, np. +48.",
+      "later": "Możesz je później zmienić w <b>{localization}</b>. Jednostki i pojemność silnika znajdziesz w <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Podatki na Twoich fakturach",
+      "subtitle": "Jak podatek jest doliczany i nazywany na tym, co wysyłasz.",
+      "charges": "Czy naliczasz podatek od swojej pracy?",
+      "yes": "Tak",
+      "no": "Nie",
+      "rate": "Stawka podstawowa",
+      "rateHint": "Wpisujesz ją sam. Torqvoice nigdy nie zgaduje stawki podatku; sprawdź ją z księgowym.",
+      "rateInvalid": "Wpisz stawkę od 0 do 100.",
+      "name": "Nazwa na fakturach",
+      "namePlaceholder": "Podatek",
+      "nameHint": "Drukowane jako {example}.",
+      "nameSuggested": "Propozycja dla kraju {country}. Drukowane jako {example}.",
+      "pricesLegend": "Ceny, które wpisujesz, są",
+      "before": "Bez podatku",
+      "beforeExample": "Wpisujesz {price}. Klient płaci {price} + {tax} {label} = {total}.",
+      "including": "Z podatkiem",
+      "includingExample": "Wpisujesz {total}. Klient płaci {total}, z czego {tax} to {label}.",
+      "noTax": "Nie jest drukowana żadna linia podatku, a suma jest tym, co wpisałeś.",
+      "skippedHint": "Na razie pominięto, więc podatek nie jest doliczany. Wybierz Tak, aby go ustawić tutaj.",
+      "later": "Możesz to później zmienić w <b>{tax}</b>. Tam też ustawisz dwa podatki na jednej fakturze, jak GST i QST, albo podatek tylko od części.",
+      "skip": "Pomiń na razie",
+      "rateMissing": "Wpisz stawkę podatku albo wybierz Nie, jeśli nie naliczasz podatku."
+    },
+    "ready": {
+      "title": "Gotowe, by otworzyć {name}",
+      "subtitle": "Ostatnie spojrzenie. Wszystko poniżej zmienisz później w Ustawieniach.",
+      "rowWorkshop": "Warsztat",
+      "rowCustomers": "Klienci otrzymują",
+      "rowScreens": "Twoje ekrany: {language}",
+      "rowMoney": "Pieniądze i daty",
+      "rowUnits": "Jednostki",
+      "rowTax": "Podatek",
+      "kinds": {
+        "cars": "samochody i dostawczaki",
+        "motorcycles": "motocykle",
+        "marine": "branża morska"
+      },
+      "taxBefore": "{label} {rate}%, ceny wpisywane bez podatku",
+      "taxIncluding": "{label} {rate}%, ceny wpisywane z podatkiem",
+      "taxOff": "Bez podatku",
+      "taxSkipped": "Jeszcze nie skonfigurowano",
+      "edit": "Edytuj",
+      "address": "Adres na Twoich fakturach",
+      "optional": "(opcjonalnie)",
+      "addressPlaceholder": "Ulica, kod pocztowy i miejscowość",
+      "create": "Utwórz warsztat"
+    },
+    "preview": {
+      "label": "Podgląd faktury",
+      "first": "Twoja pierwsza faktura, tak jak otrzyma ją klient",
+      "live": "Zmienia się wraz z Twoimi wyborami",
+      "loading": "Rysowanie podglądu",
+      "enlarge": "Pokaż w pełnym rozmiarze"
+    },
+    "sample": {
+      "jobTitle": "Wymiana oleju i przegląd",
+      "serviceType": "Serwis",
+      "customerName": "Anna Nowak",
+      "customerAddress": "ul. Dworcowa 12, 00-001 Warszawa",
+      "laborLine": "Wymiana oleju i przegląd",
+      "partLine": "Filtr oleju",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Olej silnikowy 5W-30",
+      "part3": "Filtr powietrza",
+      "part4": "Pióra wycieraczek",
+      "labor2": "Wymiana filtra powietrza",
+      "labor3": "Wymiana piór wycieraczek",
+      "labor4": "Kontrola hamulców"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/pl/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Miejsce",
     "conditionMapArea": "Miejsce",
     "conditionMapKind": "Typ",
     "conditionMapKind": "Typ",
     "conditionMapSeverity": "Stopień",
     "conditionMapSeverity": "Stopień",
-    "conditionMapNote": "Notatka"
+    "conditionMapNote": "Notatka",
+    "serviceTypeMaintenance": "Konserwacja",
+    "serviceTypeRepair": "Naprawa",
+    "serviceTypeUpgrade": "Modernizacja",
+    "serviceTypeInspection": "Inspekcja"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ZLECENIE",
     "title": "ZLECENIE",

+ 7 - 4
messages/pl/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Zapisuj HSN/TSN przy pojazdach",
     "typeKeyEnabled": "Zapisuj HSN/TSN przy pojazdach",
     "typeKeyHint": "Dodaje do pojazdów kod producenta i kod typu z niemieckiego dowodu rejestracyjnego (pola 2.1 i 2.2). Jest widoczny przy pojeździe, na zleceniu i na wszystkich drukowanych dokumentach, a wyszukiwarka znajduje po nim pojazdy. Po wyłączeniu jest ponownie ukryty, a wprowadzone wartości zostają zachowane.",
     "typeKeyHint": "Dodaje do pojazdów kod producenta i kod typu z niemieckiego dowodu rejestracyjnego (pola 2.1 i 2.2). Jest widoczny przy pojeździe, na zleceniu i na wszystkich drukowanych dokumentach, a wyszukiwarka znajduje po nim pojazdy. Po wyłączeniu jest ponownie ukryty, a wprowadzone wartości zostają zachowane.",
     "unitsTitle": "Jednostki",
     "unitsTitle": "Jednostki",
-    "unitsDescription": "Wybierz między metrycznym (km, litry) a imperialnym (mile, galony).",
+    "unitsDescription": "Jak w tym warsztacie zapisuje się odległości, płyny i części.",
     "unitSystem": "System jednostek",
     "unitSystem": "System jednostek",
     "metric": "Metryczny (km, litry)",
     "metric": "Metryczny (km, litry)",
     "imperial": "Imperialny (mile, galony)",
     "imperial": "Imperialny (mile, galony)",
-    "distanceLabel": "Odleglosc",
+    "distanceLabel": "Odległość",
     "volumeLabel": "Objetosc",
     "volumeLabel": "Objetosc",
-    "kilometers": "kilometry (km)",
+    "kilometers": "Kilometry (km)",
     "liters": "litry (L)",
     "liters": "litry (L)",
-    "miles": "mile (mi)",
+    "miles": "Mile (mi)",
     "gallons": "galony (gal)",
     "gallons": "galony (gal)",
+    "measureSystem": "Płyny i części",
+    "measureMetric": "Litry i kilogramy",
+    "measureImperial": "Kwarty, galony i funty",
     "engineSizeUnit": "Pojemność silnika",
     "engineSizeUnit": "Pojemność silnika",
     "engineSizeLitres": "Litry (1,6 L)",
     "engineSizeLitres": "Litry (1,6 L)",
     "engineSizeCc": "Centymetry sześcienne (1598 cc)",
     "engineSizeCc": "Centymetry sześcienne (1598 cc)",

+ 163 - 0
messages/pt-BR/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Esta instalação já tem sua oficina",
     "title": "Esta instalação já tem sua oficina",
     "description": "O Torqvoice aqui roda uma oficina, e ela já foi configurada. Para trabalhar nela, peça ao proprietário que convide {email} em Configurações, depois Equipe.",
     "description": "O Torqvoice aqui roda uma oficina, e ela já foi configurada. Para trabalhar nela, peça ao proprietário que convide {email} em Configurações, depois Equipe.",
     "signOut": "Sair"
     "signOut": "Sair"
+  },
+  "setup": {
+    "stepsLabel": "Etapas de configuração",
+    "steps": {
+      "workshop": "Oficina",
+      "region": "Idioma e região",
+      "tax": "Impostos",
+      "ready": "Pronto"
+    },
+    "back": "Voltar",
+    "continue": "Continuar",
+    "workshop": {
+      "title": "Configure sua oficina",
+      "subtitle": "Quatro etapas curtas, para que sua primeira fatura saia certa. Você pode mudar tudo depois em Configurações.",
+      "language": "Idioma",
+      "languageBoth": "Suas telas e as faturas, orçamentos e e-mails dos seus clientes usam esse idioma.",
+      "languageFromCountry": "Sugerido para {country}. Suas telas e as faturas, orçamentos e e-mails dos seus clientes usam esse idioma.",
+      "languageMissing": "O Torqvoice ainda não está disponível em {language}, então o inglês é sugerido para {country}. Escolha outro idioma se preferir.",
+      "languageScreensOnly": "Usado nas suas telas.",
+      "customerLanguageDifferent": "Usar outro idioma para os clientes",
+      "customerLanguageSame": "Igual ao meu ({language})",
+      "customerLanguageFromCountry": "Sugerido para {country}.",
+      "name": "Nome da oficina",
+      "namePlaceholder": "Minha oficina",
+      "kindLegend": "No que você trabalha mais?",
+      "kinds": {
+        "cars": {
+          "title": "Carros e utilitários",
+          "hint": "Cilindrada em litros"
+        },
+        "motorcycles": {
+          "title": "Motos",
+          "hint": "Cilindrada em cc"
+        },
+        "marine": {
+          "title": "Náutico",
+          "hint": "Embarcações, números de casco e horas de motor"
+        }
+      },
+      "country": "Onde fica a oficina?",
+      "countryPlaceholder": "Escolha um país",
+      "countrySearch": "Pesquisar países",
+      "noCountry": "Nenhum país encontrado",
+      "countryDetected": "Encontrado pelo seu fuso horário, {zone}. A próxima etapa sugere o que é comum em {country}, e você confirma cada escolha.",
+      "countryChosen": "A próxima etapa sugere o que é comum em {country}, e você confirma cada escolha.",
+      "countryMissing": "Escolha o país e a próxima etapa sugere o idioma, a moeda e o formato de data.",
+      "skip": "Usar as sugestões e ir para Pronto",
+      "nameMissing": "Dê à oficina um nome com pelo menos 2 caracteres."
+    },
+    "region": {
+      "title": "Idioma e região",
+      "subtitle": "Sugerido para {country}. Confira cada escolha: elas decidem o que seus clientes leem.",
+      "subtitleNoCountry": "Confira cada escolha: elas decidem o que seus clientes leem.",
+      "docLanguage": "O idioma que seus clientes recebem",
+      "docLanguageHint": "Faturas, orçamentos, ordens de serviço e e-mails são escritos neste idioma, seja quem for da oficina que os imprima ou envie.",
+      "languageSearch": "Pesquisar idiomas",
+      "noLanguage": "Nenhum idioma encontrado",
+      "defaults": "Padrões regionais",
+      "change": "Alterar",
+      "done": "Concluído",
+      "currency": "Moeda",
+      "currencySearch": "Pesquisar moedas",
+      "noCurrency": "Nenhuma moeda encontrada",
+      "dates": "Datas e hora",
+      "dateFormat": "Formato de data",
+      "clock": "Relógio",
+      "clock12": "12 horas",
+      "clock24": "24 horas",
+      "timeZone": "Fuso horário",
+      "zoneSearch": "Pesquisar fusos horários",
+      "noZone": "Nenhum fuso horário encontrado",
+      "units": "Unidades",
+      "distance": "Distância",
+      "distanceKm": "Quilômetros",
+      "distanceMiles": "Milhas",
+      "measure": "Fluidos e peças",
+      "measureMetric": "Litros e quilos",
+      "measureImperial": "Quartos, galões e libras",
+      "engine": "Cilindrada",
+      "engineLitres": "Litros",
+      "engineCc": "Centímetros cúbicos",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, cilindrada em {engine}",
+      "engineLitresShort": "litros",
+      "engineCcShort": "cc",
+      "phone": "Números de telefone",
+      "phoneValue": "{code} quando nenhum é digitado",
+      "phoneNone": "Nenhum código adicionado",
+      "phonePrefix": "Código do país",
+      "phoneHint": "Adicionado aos números de clientes digitados sem código do país, como +55.",
+      "phoneInvalid": "Use + e dígitos, como +55.",
+      "later": "Você pode alterar isso depois em <b>{localization}</b>. Unidades e cilindrada ficam em <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Impostos nas suas faturas",
+      "subtitle": "Como o imposto é somado e nomeado no que você envia.",
+      "charges": "Você cobra imposto sobre o seu trabalho?",
+      "yes": "Sim",
+      "no": "Não",
+      "rate": "Alíquota padrão",
+      "rateHint": "Digitada por você. O Torqvoice nunca adivinha uma alíquota; confira com o seu contador.",
+      "rateInvalid": "Digite uma alíquota entre 0 e 100.",
+      "name": "Nome nas faturas",
+      "namePlaceholder": "Imposto",
+      "nameHint": "Sai impresso como {example}.",
+      "nameSuggested": "Sugerido para {country}. Sai impresso como {example}.",
+      "pricesLegend": "Os preços que você digita são",
+      "before": "Sem imposto",
+      "beforeExample": "Você digita {price}. O cliente paga {price} + {tax} de {label} = {total}.",
+      "including": "Com imposto",
+      "includingExample": "Você digita {total}. O cliente paga {total}, dos quais {tax} são {label}.",
+      "noTax": "Nenhuma linha de imposto é impressa, e o total é o que você digitou.",
+      "skippedHint": "Pulado por enquanto, então nenhum imposto é adicionado. Escolha Sim para configurá-lo aqui.",
+      "later": "Você pode alterar isso depois em <b>{tax}</b>. É lá também que você configura dois impostos em uma fatura, como GST e QST, ou imposto só nas peças.",
+      "skip": "Pular por enquanto",
+      "rateMissing": "Digite sua alíquota, ou escolha Não se você não cobra imposto."
+    },
+    "ready": {
+      "title": "Pronto para abrir {name}",
+      "subtitle": "Uma última olhada. Tudo abaixo pode ser mudado depois em Configurações.",
+      "rowWorkshop": "Oficina",
+      "rowCustomers": "Os clientes recebem",
+      "rowScreens": "Suas telas: {language}",
+      "rowMoney": "Dinheiro e datas",
+      "rowUnits": "Unidades",
+      "rowTax": "Imposto",
+      "kinds": {
+        "cars": "carros e utilitários",
+        "motorcycles": "motos",
+        "marine": "náutico"
+      },
+      "taxBefore": "{label} {rate}%, preços digitados sem imposto",
+      "taxIncluding": "{label} {rate}%, preços digitados com imposto",
+      "taxOff": "Sem imposto",
+      "taxSkipped": "Ainda não configurado",
+      "edit": "Editar",
+      "address": "Endereço nas suas faturas",
+      "optional": "(opcional)",
+      "addressPlaceholder": "Rua, CEP e cidade",
+      "create": "Criar oficina"
+    },
+    "preview": {
+      "label": "Prévia da fatura",
+      "first": "Sua primeira fatura, como o cliente a recebe",
+      "live": "Muda conforme suas escolhas",
+      "loading": "Desenhando a prévia",
+      "enlarge": "Ver em tamanho real"
+    },
+    "sample": {
+      "jobTitle": "Troca de óleo e revisão",
+      "serviceType": "Manutenção",
+      "customerName": "Ana Souza",
+      "customerAddress": "Rua das Flores, 12, São Paulo",
+      "laborLine": "Troca de óleo e revisão",
+      "partLine": "Filtro de óleo",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Óleo do motor 5W-30",
+      "part3": "Filtro de ar",
+      "part4": "Palhetas do limpador",
+      "labor2": "Troca do filtro de ar",
+      "labor3": "Troca das palhetas",
+      "labor4": "Verificação dos freios"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/pt-BR/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Local",
     "conditionMapArea": "Local",
     "conditionMapKind": "Tipo",
     "conditionMapKind": "Tipo",
     "conditionMapSeverity": "Gravidade",
     "conditionMapSeverity": "Gravidade",
-    "conditionMapNote": "Nota"
+    "conditionMapNote": "Nota",
+    "serviceTypeMaintenance": "Manutenção",
+    "serviceTypeRepair": "Reparo",
+    "serviceTypeUpgrade": "Atualização",
+    "serviceTypeInspection": "Inspeção"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ORDEM DE SERVIÇO",
     "title": "ORDEM DE SERVIÇO",

+ 6 - 3
messages/pt-BR/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Registrar HSN/TSN nos veículos",
     "typeKeyEnabled": "Registrar HSN/TSN nos veículos",
     "typeKeyHint": "Adiciona aos veículos o código do fabricante e do tipo do documento de registro alemão (campos 2.1 e 2.2). Ele aparece no veículo, na ordem de serviço e em todos os documentos impressos, e a busca encontra veículos por ele. Ao desativar, ele é ocultado de novo e o que foi digitado é mantido.",
     "typeKeyHint": "Adiciona aos veículos o código do fabricante e do tipo do documento de registro alemão (campos 2.1 e 2.2). Ele aparece no veículo, na ordem de serviço e em todos os documentos impressos, e a busca encontra veículos por ele. Ao desativar, ele é ocultado de novo e o que foi digitado é mantido.",
     "unitsTitle": "Unidades",
     "unitsTitle": "Unidades",
-    "unitsDescription": "Escolha entre métrico (km, litros) e imperial (milhas, galões).",
+    "unitsDescription": "Como distâncias, fluidos e peças são escritos nesta oficina.",
     "unitSystem": "Sistema de unidades",
     "unitSystem": "Sistema de unidades",
     "metric": "Métrico (km, litros)",
     "metric": "Métrico (km, litros)",
     "imperial": "Imperial (milhas, galões)",
     "imperial": "Imperial (milhas, galões)",
     "distanceLabel": "Distância",
     "distanceLabel": "Distância",
     "volumeLabel": "Volume",
     "volumeLabel": "Volume",
-    "kilometers": "quilômetros (km)",
+    "kilometers": "Quilômetros (km)",
     "liters": "litros (L)",
     "liters": "litros (L)",
-    "miles": "milhas (mi)",
+    "miles": "Milhas (mi)",
     "gallons": "galões (gal)",
     "gallons": "galões (gal)",
+    "measureSystem": "Fluidos e peças",
+    "measureMetric": "Litros e quilos",
+    "measureImperial": "Quartos, galões e libras",
     "engineSizeUnit": "Cilindrada",
     "engineSizeUnit": "Cilindrada",
     "engineSizeLitres": "Litros (1,6 L)",
     "engineSizeLitres": "Litros (1,6 L)",
     "engineSizeCc": "Centímetros cúbicos (1598 cc)",
     "engineSizeCc": "Centímetros cúbicos (1598 cc)",

+ 163 - 0
messages/pt-PT/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Esta instalação já tem a sua oficina",
     "title": "Esta instalação já tem a sua oficina",
     "description": "Esta instalação do Torqvoice gere uma única oficina, que já foi configurada. Para trabalhar nela, peça ao proprietário que convide {email} em Definições, Equipa.",
     "description": "Esta instalação do Torqvoice gere uma única oficina, que já foi configurada. Para trabalhar nela, peça ao proprietário que convide {email} em Definições, Equipa.",
     "signOut": "Terminar sessão"
     "signOut": "Terminar sessão"
+  },
+  "setup": {
+    "stepsLabel": "Passos de configuração",
+    "steps": {
+      "workshop": "Oficina",
+      "region": "Idioma e região",
+      "tax": "Impostos",
+      "ready": "Pronto"
+    },
+    "back": "Voltar",
+    "continue": "Continuar",
+    "workshop": {
+      "title": "Configure a sua oficina",
+      "subtitle": "Quatro passos curtos, para que a sua primeira fatura saia certa. Pode alterar tudo mais tarde nas Definições.",
+      "language": "Idioma",
+      "languageBoth": "Os seus ecrãs e as faturas, orçamentos e e-mails dos seus clientes usam este idioma.",
+      "languageFromCountry": "Sugerido para {country}. Os seus ecrãs e as faturas, orçamentos e e-mails dos seus clientes usam este idioma.",
+      "languageMissing": "O Torqvoice ainda não está disponível em {language}, por isso o inglês é sugerido para {country}. Escolha outro idioma se preferir.",
+      "languageScreensOnly": "Usado nos seus ecrãs.",
+      "customerLanguageDifferent": "Usar outro idioma para os clientes",
+      "customerLanguageSame": "Igual ao meu ({language})",
+      "customerLanguageFromCountry": "Sugerido para {country}.",
+      "name": "Nome da oficina",
+      "namePlaceholder": "A minha oficina",
+      "kindLegend": "Em que trabalha sobretudo?",
+      "kinds": {
+        "cars": {
+          "title": "Automóveis e comerciais",
+          "hint": "Cilindrada em litros"
+        },
+        "motorcycles": {
+          "title": "Motociclos",
+          "hint": "Cilindrada em cc"
+        },
+        "marine": {
+          "title": "Náutica",
+          "hint": "Embarcações, números de casco e horas de motor"
+        }
+      },
+      "country": "Onde fica a oficina?",
+      "countryPlaceholder": "Escolha um país",
+      "countrySearch": "Pesquisar países",
+      "noCountry": "Nenhum país encontrado",
+      "countryDetected": "Encontrado pelo seu fuso horário, {zone}. O passo seguinte sugere o que é habitual em {country}, e confirma cada escolha.",
+      "countryChosen": "O passo seguinte sugere o que é habitual em {country}, e confirma cada escolha.",
+      "countryMissing": "Escolha o país e o passo seguinte sugere o idioma, a moeda e o formato de data.",
+      "skip": "Usar as sugestões e ir para Pronto",
+      "nameMissing": "Dê à oficina um nome com pelo menos 2 caracteres."
+    },
+    "region": {
+      "title": "Idioma e região",
+      "subtitle": "Sugerido para {country}. Verifique cada escolha: decidem o que os seus clientes leem.",
+      "subtitleNoCountry": "Verifique cada escolha: decidem o que os seus clientes leem.",
+      "docLanguage": "O idioma que os seus clientes recebem",
+      "docLanguageHint": "Faturas, orçamentos, ordens de trabalho e e-mails são escritos neste idioma, seja quem for na oficina que os imprima ou envie.",
+      "languageSearch": "Pesquisar idiomas",
+      "noLanguage": "Nenhum idioma encontrado",
+      "defaults": "Predefinições regionais",
+      "change": "Alterar",
+      "done": "Concluído",
+      "currency": "Moeda",
+      "currencySearch": "Pesquisar moedas",
+      "noCurrency": "Nenhuma moeda encontrada",
+      "dates": "Datas e hora",
+      "dateFormat": "Formato de data",
+      "clock": "Relógio",
+      "clock12": "12 horas",
+      "clock24": "24 horas",
+      "timeZone": "Fuso horário",
+      "zoneSearch": "Pesquisar fusos horários",
+      "noZone": "Nenhum fuso horário encontrado",
+      "units": "Unidades",
+      "distance": "Distância",
+      "distanceKm": "Quilómetros",
+      "distanceMiles": "Milhas",
+      "measure": "Fluidos e peças",
+      "measureMetric": "Litros e quilogramas",
+      "measureImperial": "Quartos, galões e libras",
+      "engine": "Cilindrada",
+      "engineLitres": "Litros",
+      "engineCc": "Centímetros cúbicos",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, cilindrada em {engine}",
+      "engineLitresShort": "litros",
+      "engineCcShort": "cc",
+      "phone": "Números de telefone",
+      "phoneValue": "{code} quando nenhum é escrito",
+      "phoneNone": "Nenhum indicativo adicionado",
+      "phonePrefix": "Indicativo do país",
+      "phoneHint": "Adicionado aos números de clientes escritos sem indicativo, como +351.",
+      "phoneInvalid": "Use + e algarismos, como +351.",
+      "later": "Pode alterar isto mais tarde em <b>{localization}</b>. As unidades e a cilindrada estão em <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Impostos nas suas faturas",
+      "subtitle": "Como o imposto é acrescentado e designado no que envia.",
+      "charges": "Cobra imposto sobre o seu trabalho?",
+      "yes": "Sim",
+      "no": "Não",
+      "rate": "Taxa normal",
+      "rateHint": "Escrita por si. O Torqvoice nunca adivinha uma taxa de imposto; confirme-a com o seu contabilista.",
+      "rateInvalid": "Escreva uma taxa entre 0 e 100.",
+      "name": "Nome nas faturas",
+      "namePlaceholder": "Imposto",
+      "nameHint": "É impresso como {example}.",
+      "nameSuggested": "Sugerido para {country}. É impresso como {example}.",
+      "pricesLegend": "Os preços que escreve são",
+      "before": "Sem imposto",
+      "beforeExample": "Escreve {price}. O cliente paga {price} + {tax} de {label} = {total}.",
+      "including": "Com imposto",
+      "includingExample": "Escreve {total}. O cliente paga {total}, dos quais {tax} são {label}.",
+      "noTax": "Não é impressa nenhuma linha de imposto, e o total é o que escreveu.",
+      "skippedHint": "Saltado por agora, por isso não é acrescentado nenhum imposto. Escolha Sim para o configurar aqui.",
+      "later": "Pode alterar isto mais tarde em <b>{tax}</b>. É também aí que configura dois impostos numa fatura, como GST e QST, ou imposto só nas peças.",
+      "skip": "Saltar por agora",
+      "rateMissing": "Escreva a sua taxa de imposto, ou escolha Não se não cobra imposto."
+    },
+    "ready": {
+      "title": "Pronto para abrir {name}",
+      "subtitle": "Um último olhar. Tudo o que está abaixo pode ser alterado mais tarde nas Definições.",
+      "rowWorkshop": "Oficina",
+      "rowCustomers": "Os clientes recebem",
+      "rowScreens": "Os seus ecrãs: {language}",
+      "rowMoney": "Dinheiro e datas",
+      "rowUnits": "Unidades",
+      "rowTax": "Imposto",
+      "kinds": {
+        "cars": "automóveis e comerciais",
+        "motorcycles": "motociclos",
+        "marine": "náutica"
+      },
+      "taxBefore": "{label} {rate}%, preços escritos sem imposto",
+      "taxIncluding": "{label} {rate}%, preços escritos com imposto",
+      "taxOff": "Sem imposto",
+      "taxSkipped": "Ainda não configurado",
+      "edit": "Editar",
+      "address": "Morada nas suas faturas",
+      "optional": "(opcional)",
+      "addressPlaceholder": "Rua, código postal e localidade",
+      "create": "Criar oficina"
+    },
+    "preview": {
+      "label": "Pré-visualização da fatura",
+      "first": "A sua primeira fatura, tal como o cliente a recebe",
+      "live": "Muda com as suas escolhas",
+      "loading": "A desenhar a pré-visualização",
+      "enlarge": "Ver em tamanho real"
+    },
+    "sample": {
+      "jobTitle": "Mudança de óleo e revisão",
+      "serviceType": "Manutenção",
+      "customerName": "Inês Ferreira",
+      "customerAddress": "Rua das Flores 12, 1200-195 Lisboa",
+      "laborLine": "Mudança de óleo e revisão",
+      "partLine": "Filtro de óleo",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Óleo de motor 5W-30",
+      "part3": "Filtro de ar",
+      "part4": "Escovas limpa-vidros",
+      "labor2": "Substituição do filtro de ar",
+      "labor3": "Substituição das escovas",
+      "labor4": "Verificação dos travões"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/pt-PT/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Zona",
     "conditionMapArea": "Zona",
     "conditionMapKind": "Tipo",
     "conditionMapKind": "Tipo",
     "conditionMapSeverity": "Gravidade",
     "conditionMapSeverity": "Gravidade",
-    "conditionMapNote": "Nota"
+    "conditionMapNote": "Nota",
+    "serviceTypeMaintenance": "Manutenção",
+    "serviceTypeRepair": "Reparação",
+    "serviceTypeUpgrade": "Melhoria",
+    "serviceTypeInspection": "Inspeção"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ORDEM DE TRABALHO",
     "title": "ORDEM DE TRABALHO",

+ 6 - 3
messages/pt-PT/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Registar HSN/TSN nos veículos",
     "typeKeyEnabled": "Registar HSN/TSN nos veículos",
     "typeKeyHint": "Acrescenta aos veículos o código do fabricante e do tipo do certificado de matrícula alemão (campos 2.1 e 2.2). Aparece no veículo, na ordem de reparação e em todos os documentos impressos, e a pesquisa encontra veículos por ele. Ao desativar, volta a ficar oculto e o que foi introduzido é mantido.",
     "typeKeyHint": "Acrescenta aos veículos o código do fabricante e do tipo do certificado de matrícula alemão (campos 2.1 e 2.2). Aparece no veículo, na ordem de reparação e em todos os documentos impressos, e a pesquisa encontra veículos por ele. Ao desativar, volta a ficar oculto e o que foi introduzido é mantido.",
     "unitsTitle": "Unidades",
     "unitsTitle": "Unidades",
-    "unitsDescription": "Escolha entre unidades métricas (km, litros) e imperiais (milhas, galões).",
+    "unitsDescription": "Como as distâncias, os fluidos e as peças são escritos nesta oficina.",
     "unitSystem": "Sistema de unidades",
     "unitSystem": "Sistema de unidades",
     "metric": "Métrico (km, litros)",
     "metric": "Métrico (km, litros)",
     "imperial": "Imperial (milhas, galões)",
     "imperial": "Imperial (milhas, galões)",
     "distanceLabel": "Distância",
     "distanceLabel": "Distância",
     "volumeLabel": "Volume",
     "volumeLabel": "Volume",
-    "kilometers": "quilómetros (km)",
+    "kilometers": "Quilómetros (km)",
     "liters": "litros (L)",
     "liters": "litros (L)",
-    "miles": "milhas (mi)",
+    "miles": "Milhas (mi)",
     "gallons": "galões (gal)",
     "gallons": "galões (gal)",
+    "measureSystem": "Fluidos e peças",
+    "measureMetric": "Litros e quilogramas",
+    "measureImperial": "Quartos, galões e libras",
     "engineSizeUnit": "Cilindrada",
     "engineSizeUnit": "Cilindrada",
     "engineSizeLitres": "Litros (1,6 L)",
     "engineSizeLitres": "Litros (1,6 L)",
     "engineSizeCc": "Centímetros cúbicos (1598 cc)",
     "engineSizeCc": "Centímetros cúbicos (1598 cc)",

+ 163 - 0
messages/ru/onboarding.json

@@ -90,5 +90,168 @@
     "title": "У этой установки уже есть своя мастерская",
     "title": "У этой установки уже есть своя мастерская",
     "description": "Здесь Torqvoice обслуживает одну мастерскую, и она уже настроена. Чтобы работать в ней, попросите владельца пригласить {email} в разделе Настройки, затем Команда.",
     "description": "Здесь Torqvoice обслуживает одну мастерскую, и она уже настроена. Чтобы работать в ней, попросите владельца пригласить {email} в разделе Настройки, затем Команда.",
     "signOut": "Выйти"
     "signOut": "Выйти"
+  },
+  "setup": {
+    "stepsLabel": "Шаги настройки",
+    "steps": {
+      "workshop": "Мастерская",
+      "region": "Язык и регион",
+      "tax": "Налоги",
+      "ready": "Готово"
+    },
+    "back": "Назад",
+    "continue": "Продолжить",
+    "workshop": {
+      "title": "Настройте свою мастерскую",
+      "subtitle": "Четыре коротких шага, чтобы первый счёт был правильным. Всё это можно изменить позже в настройках.",
+      "language": "Язык",
+      "languageBoth": "Он используется на ваших экранах и в счетах, сметах и письмах вашим клиентам.",
+      "languageFromCountry": "Предложено для страны {country}. Он используется на ваших экранах и в счетах, сметах и письмах вашим клиентам.",
+      "languageMissing": "Torqvoice пока недоступен на этом языке ({language}), поэтому для страны {country} предложен английский. Выберите другой язык, если хотите.",
+      "languageScreensOnly": "Используется на ваших экранах.",
+      "customerLanguageDifferent": "Другой язык для клиентов",
+      "customerLanguageSame": "Как у меня ({language})",
+      "customerLanguageFromCountry": "Предложено для страны {country}.",
+      "name": "Название мастерской",
+      "namePlaceholder": "Моя мастерская",
+      "kindLegend": "С чем вы в основном работаете?",
+      "kinds": {
+        "cars": {
+          "title": "Легковые и фургоны",
+          "hint": "Объём двигателя в литрах"
+        },
+        "motorcycles": {
+          "title": "Мотоциклы",
+          "hint": "Объём двигателя в cc"
+        },
+        "marine": {
+          "title": "Морской",
+          "hint": "Суда, номера корпусов и моточасы"
+        }
+      },
+      "country": "Где находится мастерская?",
+      "countryPlaceholder": "Выберите страну",
+      "countrySearch": "Поиск стран",
+      "noCountry": "Страна не найдена",
+      "countryDetected": "Определено по вашему часовому поясу, {zone}. Следующий шаг предложит то, что принято в стране {country}, а вы подтвердите каждый выбор.",
+      "countryChosen": "Следующий шаг предложит то, что принято в стране {country}, а вы подтвердите каждый выбор.",
+      "countryMissing": "Выберите страну, и следующий шаг предложит её язык, валюту и формат даты.",
+      "skip": "Принять предложения и перейти к шагу «Готово»",
+      "nameMissing": "Дайте мастерской название не короче 2 символов."
+    },
+    "region": {
+      "title": "Язык и регион",
+      "subtitle": "Предложено для страны {country}. Проверьте каждый пункт: от них зависит, что читают ваши клиенты.",
+      "subtitleNoCountry": "Проверьте каждый пункт: от них зависит, что читают ваши клиенты.",
+      "docLanguage": "Язык, на котором клиенты получают документы",
+      "docLanguageHint": "Счета, сметы, заказ-наряды и письма пишутся на этом языке, кто бы в мастерской их ни печатал или отправлял.",
+      "languageSearch": "Поиск языков",
+      "noLanguage": "Язык не найден",
+      "defaults": "Региональные настройки",
+      "change": "Изменить",
+      "done": "Готово",
+      "currency": "Валюта",
+      "currencySearch": "Поиск валют",
+      "noCurrency": "Валюта не найдена",
+      "dates": "Даты и время",
+      "dateFormat": "Формат даты",
+      "clock": "Часы",
+      "clock12": "12-часовой",
+      "clock24": "24-часовой",
+      "timeZone": "Часовой пояс",
+      "zoneSearch": "Поиск часовых поясов",
+      "noZone": "Часовой пояс не найден",
+      "units": "Единицы",
+      "distance": "Расстояние",
+      "distanceKm": "Километры",
+      "distanceMiles": "Мили",
+      "measure": "Жидкости и детали",
+      "measureMetric": "Литры и килограммы",
+      "measureImperial": "Кварты, галлоны и фунты",
+      "engine": "Объём двигателя",
+      "engineLitres": "Литры",
+      "engineCc": "Кубические сантиметры",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, объём двигателя в {engine}",
+      "engineLitresShort": "литрах",
+      "engineCcShort": "cc",
+      "phone": "Телефонные номера",
+      "phoneValue": "{code}, если код не указан",
+      "phoneNone": "Код не добавляется",
+      "phonePrefix": "Код страны",
+      "phoneHint": "Добавляется к номерам клиентов, введённым без кода страны, например +7.",
+      "phoneInvalid": "Используйте + и цифры, например +7.",
+      "later": "Это можно изменить позже в разделе <b>{localization}</b>. Единицы и объём двигателя находятся в разделе <b>{workshop}</b>."
+    },
+    "tax": {
+      "title": "Налоги в ваших счетах",
+      "subtitle": "Как налог добавляется и называется в том, что вы отправляете.",
+      "charges": "Вы начисляете налог на свою работу?",
+      "yes": "Да",
+      "no": "Нет",
+      "rate": "Основная ставка",
+      "rateHint": "Вводится вами. Torqvoice никогда не угадывает ставку налога; уточните её у бухгалтера.",
+      "rateInvalid": "Введите ставку от 0 до 100.",
+      "name": "Название в счетах",
+      "namePlaceholder": "Налог",
+      "nameHint": "Печатается как {example}.",
+      "nameSuggested": "Предложено для страны {country}. Печатается как {example}.",
+      "pricesLegend": "Цены, которые вы вводите,",
+      "before": "Без налога",
+      "beforeExample": "Вы вводите {price}. Клиент платит {price} + {tax} {label} = {total}.",
+      "including": "С налогом",
+      "includingExample": "Вы вводите {total}. Клиент платит {total}, из них {tax} составляет {label}.",
+      "noTax": "Строка налога не печатается, и итог равен тому, что вы ввели.",
+      "skippedHint": "Пока пропущено, поэтому налог не добавляется. Выберите «Да», чтобы настроить его здесь.",
+      "later": "Это можно изменить позже в разделе <b>{tax}</b>. Там же настраиваются два налога в одном счёте, как GST и QST, или налог только на детали.",
+      "skip": "Пропустить пока",
+      "rateMissing": "Введите ставку налога или выберите «Нет», если вы не начисляете налог."
+    },
+    "ready": {
+      "title": "Всё готово, чтобы открыть {name}",
+      "subtitle": "Последний взгляд. Всё ниже можно изменить позже в настройках.",
+      "rowWorkshop": "Мастерская",
+      "rowCustomers": "Клиенты получают",
+      "rowScreens": "Ваши экраны: {language}",
+      "rowMoney": "Деньги и даты",
+      "rowUnits": "Единицы",
+      "rowTax": "Налог",
+      "kinds": {
+        "cars": "легковые и фургоны",
+        "motorcycles": "мотоциклы",
+        "marine": "морской"
+      },
+      "taxBefore": "{label} {rate} %, цены вводятся без налога",
+      "taxIncluding": "{label} {rate} %, цены вводятся с налогом",
+      "taxOff": "Без налога",
+      "taxSkipped": "Ещё не настроено",
+      "edit": "Изменить",
+      "address": "Адрес в ваших счетах",
+      "optional": "(необязательно)",
+      "addressPlaceholder": "Улица, индекс и город",
+      "create": "Создать мастерскую"
+    },
+    "preview": {
+      "label": "Предпросмотр счёта",
+      "first": "Ваш первый счёт таким, каким его получит клиент",
+      "live": "Меняется по мере выбора",
+      "loading": "Рисуем предпросмотр",
+      "enlarge": "Показать в полном размере"
+    },
+    "sample": {
+      "jobTitle": "Замена масла и осмотр",
+      "serviceType": "Обслуживание",
+      "customerName": "Анна Иванова",
+      "customerAddress": "ул. Вокзальная, 12, Москва",
+      "laborLine": "Замена масла и осмотр",
+      "partLine": "Масляный фильтр",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Моторное масло 5W-30",
+      "part3": "Воздушный фильтр",
+      "part4": "Щётки стеклоочистителя",
+      "labor2": "Замена воздушного фильтра",
+      "labor3": "Замена щёток стеклоочистителя",
+      "labor4": "Проверка тормозов"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/ru/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Место",
     "conditionMapArea": "Место",
     "conditionMapKind": "Тип",
     "conditionMapKind": "Тип",
     "conditionMapSeverity": "Степень",
     "conditionMapSeverity": "Степень",
-    "conditionMapNote": "Заметка"
+    "conditionMapNote": "Заметка",
+    "serviceTypeMaintenance": "Техобслуживание",
+    "serviceTypeRepair": "Ремонт",
+    "serviceTypeUpgrade": "Модернизация",
+    "serviceTypeInspection": "Осмотр"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "ЗАКАЗ-НАРЯД",
     "title": "ЗАКАЗ-НАРЯД",

+ 6 - 3
messages/ru/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Указывать HSN/TSN у автомобилей",
     "typeKeyEnabled": "Указывать HSN/TSN у автомобилей",
     "typeKeyHint": "Добавляет к автомобилям код производителя и код типа из немецкого свидетельства о регистрации (поля 2.1 и 2.2). Он отображается у автомобиля, в заказ-наряде и во всех печатных документах, а поиск находит автомобили по нему. После отключения он снова скрывается, введённые значения сохраняются.",
     "typeKeyHint": "Добавляет к автомобилям код производителя и код типа из немецкого свидетельства о регистрации (поля 2.1 и 2.2). Он отображается у автомобиля, в заказ-наряде и во всех печатных документах, а поиск находит автомобили по нему. После отключения он снова скрывается, введённые значения сохраняются.",
     "unitsTitle": "Единицы измерения",
     "unitsTitle": "Единицы измерения",
-    "unitsDescription": "Выберите между метрической (км, литры) и имперской (мили, галлоны) системой.",
+    "unitsDescription": "Как в этой мастерской записываются расстояния, жидкости и детали.",
     "unitSystem": "Система единиц",
     "unitSystem": "Система единиц",
     "metric": "Метрическая (км, литры)",
     "metric": "Метрическая (км, литры)",
     "imperial": "Имперская (мили, галлоны)",
     "imperial": "Имперская (мили, галлоны)",
     "distanceLabel": "Расстояние",
     "distanceLabel": "Расстояние",
     "volumeLabel": "Объём",
     "volumeLabel": "Объём",
-    "kilometers": "километры (км)",
+    "kilometers": "Километры (км)",
     "liters": "литры (л)",
     "liters": "литры (л)",
-    "miles": "мили (mi)",
+    "miles": "Мили (mi)",
     "gallons": "галлоны (gal)",
     "gallons": "галлоны (gal)",
+    "measureSystem": "Жидкости и детали",
+    "measureMetric": "Литры и килограммы",
+    "measureImperial": "Кварты, галлоны и фунты",
     "engineSizeUnit": "Объём двигателя",
     "engineSizeUnit": "Объём двигателя",
     "engineSizeLitres": "Литры (1,6 L)",
     "engineSizeLitres": "Литры (1,6 L)",
     "engineSizeCc": "Кубические сантиметры (1598 cc)",
     "engineSizeCc": "Кубические сантиметры (1598 cc)",

+ 163 - 0
messages/tr/onboarding.json

@@ -90,5 +90,168 @@
     "title": "Bu kurulumun atölyesi zaten var",
     "title": "Bu kurulumun atölyesi zaten var",
     "description": "Torqvoice burada tek bir atölye çalıştırıyor ve o kurulmuş durumda. İçinde çalışmak için sahibinden {email} adresini Ayarlar, ardından Ekip bölümünden davet etmesini isteyin.",
     "description": "Torqvoice burada tek bir atölye çalıştırıyor ve o kurulmuş durumda. İçinde çalışmak için sahibinden {email} adresini Ayarlar, ardından Ekip bölümünden davet etmesini isteyin.",
     "signOut": "Çıkış yap"
     "signOut": "Çıkış yap"
+  },
+  "setup": {
+    "stepsLabel": "Kurulum adımları",
+    "steps": {
+      "workshop": "Atölye",
+      "region": "Dil ve bölge",
+      "tax": "Vergiler",
+      "ready": "Hazır"
+    },
+    "back": "Geri",
+    "continue": "Devam",
+    "workshop": {
+      "title": "Atölyenizi kurun",
+      "subtitle": "İlk faturanızın doğru çıkması için dört kısa adım. Hepsini daha sonra Ayarlar'dan değiştirebilirsiniz.",
+      "language": "Dil",
+      "languageBoth": "Ekranlarınız ve müşterilerinize giden faturalar, teklifler ve e-postalar bu dili kullanır.",
+      "languageFromCountry": "{country} için önerildi. Ekranlarınız ve müşterilerinize giden faturalar, teklifler ve e-postalar bu dili kullanır.",
+      "languageMissing": "Torqvoice henüz {language} olarak sunulmuyor, bu yüzden {country} için İngilizce önerildi. İsterseniz başka bir dil seçin.",
+      "languageScreensOnly": "Ekranlarınızda kullanılır.",
+      "customerLanguageDifferent": "Müşteriler için farklı bir dil kullan",
+      "customerLanguageSame": "Benimkiyle aynı ({language})",
+      "customerLanguageFromCountry": "{country} için önerildi.",
+      "name": "Atölye adı",
+      "namePlaceholder": "Benim Atölyem",
+      "kindLegend": "En çok ne üzerinde çalışıyorsunuz?",
+      "kinds": {
+        "cars": {
+          "title": "Otomobiller ve hafif ticari araçlar",
+          "hint": "Motor hacmi litre olarak"
+        },
+        "motorcycles": {
+          "title": "Motosikletler",
+          "hint": "Motor hacmi cc olarak"
+        },
+        "marine": {
+          "title": "Denizcilik",
+          "hint": "Tekneler, gövde numaraları ve motor saatleri"
+        }
+      },
+      "country": "Atölye nerede?",
+      "countryPlaceholder": "Bir ülke seçin",
+      "countrySearch": "Ülke ara",
+      "noCountry": "Ülke bulunamadı",
+      "countryDetected": "Saat diliminizden bulundu, {zone}. Sonraki adım {country} için olağan olanı önerir ve siz her birini onaylarsınız.",
+      "countryChosen": "Sonraki adım {country} için olağan olanı önerir ve siz her birini onaylarsınız.",
+      "countryMissing": "Ülkeyi seçin, sonraki adım dilini, para birimini ve tarih biçimini önersin.",
+      "skip": "Önerileri kullan ve Hazır adımına geç",
+      "nameMissing": "Atölyeye en az 2 karakterlik bir ad verin."
+    },
+    "region": {
+      "title": "Dil ve bölge",
+      "subtitle": "{country} için önerildi. Her birini kontrol edin: müşterilerinizin ne okuyacağını bunlar belirler.",
+      "subtitleNoCountry": "Her birini kontrol edin: müşterilerinizin ne okuyacağını bunlar belirler.",
+      "docLanguage": "Müşterilerinizin aldığı dil",
+      "docLanguageHint": "Faturalar, teklifler, iş emirleri ve e-postalar, atölyede kim yazdırırsa ya da gönderirse göndersin bu dilde yazılır.",
+      "languageSearch": "Dil ara",
+      "noLanguage": "Dil bulunamadı",
+      "defaults": "Bölgesel varsayılanlar",
+      "change": "Değiştir",
+      "done": "Tamam",
+      "currency": "Para birimi",
+      "currencySearch": "Para birimi ara",
+      "noCurrency": "Para birimi bulunamadı",
+      "dates": "Tarih ve saat",
+      "dateFormat": "Tarih biçimi",
+      "clock": "Saat",
+      "clock12": "12 saat",
+      "clock24": "24 saat",
+      "timeZone": "Saat dilimi",
+      "zoneSearch": "Saat dilimi ara",
+      "noZone": "Saat dilimi bulunamadı",
+      "units": "Birimler",
+      "distance": "Mesafe",
+      "distanceKm": "Kilometre",
+      "distanceMiles": "Mil",
+      "measure": "Sıvılar ve parçalar",
+      "measureMetric": "Litre ve kilogram",
+      "measureImperial": "Quart, galon ve pound",
+      "engine": "Motor hacmi",
+      "engineLitres": "Litre",
+      "engineCc": "Santimetreküp",
+      "unitsSummary": "{distance}, {measure}",
+      "unitsSummaryEngine": "{distance}, {measure}, motor hacmi {engine} olarak",
+      "engineLitresShort": "litre",
+      "engineCcShort": "cc",
+      "phone": "Telefon numaraları",
+      "phoneValue": "Hiçbiri yazılmadığında {code}",
+      "phoneNone": "Ülke kodu eklenmez",
+      "phonePrefix": "Ülke kodu",
+      "phoneHint": "Ülke kodu olmadan yazılan müşteri numaralarına eklenir, örneğin +90.",
+      "phoneInvalid": "+ ve rakam kullanın, örneğin +90.",
+      "later": "Bunları daha sonra <b>{localization}</b> bölümünden değiştirebilirsiniz. Birimler ve motor hacmi <b>{workshop}</b> bölümündedir."
+    },
+    "tax": {
+      "title": "Faturalarınızdaki vergiler",
+      "subtitle": "Gönderdiklerinizde verginin nasıl eklendiği ve adlandırıldığı.",
+      "charges": "İşiniz için vergi alıyor musunuz?",
+      "yes": "Evet",
+      "no": "Hayır",
+      "rate": "Standart oran",
+      "rateHint": "Sizin yazdığınız. Torqvoice bir vergi oranını asla tahmin etmez; muhasebecinizle kontrol edin.",
+      "rateInvalid": "0 ile 100 arasında bir oran girin.",
+      "name": "Faturalardaki adı",
+      "namePlaceholder": "Vergi",
+      "nameHint": "{example} olarak basılır.",
+      "nameSuggested": "{country} için önerildi. {example} olarak basılır.",
+      "pricesLegend": "Yazdığınız fiyatlar",
+      "before": "Vergi hariç",
+      "beforeExample": "{price} yazarsınız. Müşteri {price} + {tax} {label} = {total} öder.",
+      "including": "Vergi dahil",
+      "includingExample": "{total} yazarsınız. Müşteri {total} öder, bunun {tax} kadarı {label}.",
+      "noTax": "Vergi satırı basılmaz ve toplam, yazdığınız tutardır.",
+      "skippedHint": "Şimdilik atlandı, bu yüzden vergi eklenmiyor. Burada ayarlamak için Evet'i seçin.",
+      "later": "Bunu daha sonra <b>{tax}</b> bölümünden değiştirebilirsiniz. Bir faturada GST ve QST gibi iki vergi ya da yalnızca parçalarda vergi de orada ayarlanır.",
+      "skip": "Şimdilik atla",
+      "rateMissing": "Vergi oranınızı yazın veya vergi almıyorsanız Hayır'ı seçin."
+    },
+    "ready": {
+      "title": "{name} açılmaya hazır",
+      "subtitle": "Son bir bakış. Aşağıdaki her şey daha sonra Ayarlar'dan değiştirilebilir.",
+      "rowWorkshop": "Atölye",
+      "rowCustomers": "Müşterilerin aldığı",
+      "rowScreens": "Ekranlarınız: {language}",
+      "rowMoney": "Para ve tarihler",
+      "rowUnits": "Birimler",
+      "rowTax": "Vergi",
+      "kinds": {
+        "cars": "otomobiller ve hafif ticari araçlar",
+        "motorcycles": "motosikletler",
+        "marine": "denizcilik"
+      },
+      "taxBefore": "{label} %{rate}, fiyatlar vergi hariç yazılır",
+      "taxIncluding": "{label} %{rate}, fiyatlar vergi dahil yazılır",
+      "taxOff": "Vergi yok",
+      "taxSkipped": "Henüz ayarlanmadı",
+      "edit": "Düzenle",
+      "address": "Faturalarınızdaki adres",
+      "optional": "(isteğe bağlı)",
+      "addressPlaceholder": "Sokak, posta kodu ve şehir",
+      "create": "Atölyeyi oluştur"
+    },
+    "preview": {
+      "label": "Fatura önizlemesi",
+      "first": "İlk faturanız, müşterinin aldığı haliyle",
+      "live": "Seçimlerinize göre değişir",
+      "loading": "Önizleme çiziliyor",
+      "enlarge": "Tam boyutta göster"
+    },
+    "sample": {
+      "jobTitle": "Yağ değişimi ve kontrol",
+      "serviceType": "Bakım",
+      "customerName": "Ayşe Yılmaz",
+      "customerAddress": "İstasyon Caddesi 12, Kadıköy, İstanbul",
+      "laborLine": "Yağ değişimi ve kontrol",
+      "partLine": "Yağ filtresi",
+      "workshopName": "Torqvoice Auto",
+      "part2": "Motor yağı 5W-30",
+      "part3": "Hava filtresi",
+      "part4": "Silecek lastikleri",
+      "labor2": "Hava filtresi değişimi",
+      "labor3": "Silecek lastiği değişimi",
+      "labor4": "Fren kontrolü"
+    }
   }
   }
 }
 }

+ 5 - 1
messages/tr/pdf.json

@@ -221,7 +221,11 @@
     "conditionMapArea": "Bölge",
     "conditionMapArea": "Bölge",
     "conditionMapKind": "Tür",
     "conditionMapKind": "Tür",
     "conditionMapSeverity": "Önem",
     "conditionMapSeverity": "Önem",
-    "conditionMapNote": "Not"
+    "conditionMapNote": "Not",
+    "serviceTypeMaintenance": "Bakım",
+    "serviceTypeRepair": "Onarım",
+    "serviceTypeUpgrade": "Yükseltme",
+    "serviceTypeInspection": "Muayene"
   },
   },
   "workOrder": {
   "workOrder": {
     "title": "İŞ EMRİ",
     "title": "İŞ EMRİ",

+ 6 - 3
messages/tr/settings.json

@@ -1189,16 +1189,19 @@
     "typeKeyEnabled": "Araçlarda HSN/TSN kaydet",
     "typeKeyEnabled": "Araçlarda HSN/TSN kaydet",
     "typeKeyHint": "Araçlara Alman ruhsatındaki üretici ve tip kodunu (2.1 ve 2.2 alanları) ekler. Araçta, iş emrinde ve tüm basılı belgelerde görünür, arama da araçları bu kodla bulur. Kapatıldığında yeniden gizlenir, girilen değerler korunur.",
     "typeKeyHint": "Araçlara Alman ruhsatındaki üretici ve tip kodunu (2.1 ve 2.2 alanları) ekler. Araçta, iş emrinde ve tüm basılı belgelerde görünür, arama da araçları bu kodla bulur. Kapatıldığında yeniden gizlenir, girilen değerler korunur.",
     "unitsTitle": "Birimler",
     "unitsTitle": "Birimler",
-    "unitsDescription": "Metrik (km, litre) ve emperyal (mil, galon) birimler arasında seçim yapın.",
+    "unitsDescription": "Bu atölyede mesafelerin, sıvıların ve parçaların nasıl yazıldığı.",
     "unitSystem": "Birim Sistemi",
     "unitSystem": "Birim Sistemi",
     "metric": "Metrik (km, litre)",
     "metric": "Metrik (km, litre)",
     "imperial": "Emperyal (mil, galon)",
     "imperial": "Emperyal (mil, galon)",
     "distanceLabel": "Mesafe",
     "distanceLabel": "Mesafe",
     "volumeLabel": "Hacim",
     "volumeLabel": "Hacim",
-    "kilometers": "kilometre (km)",
+    "kilometers": "Kilometre (km)",
     "liters": "litre (L)",
     "liters": "litre (L)",
-    "miles": "mil (mi)",
+    "miles": "Mil (mi)",
     "gallons": "galon (gal)",
     "gallons": "galon (gal)",
+    "measureSystem": "Sıvılar ve parçalar",
+    "measureMetric": "Litre ve kilogram",
+    "measureImperial": "Quart, galon ve pound",
     "engineSizeUnit": "Motor hacmi",
     "engineSizeUnit": "Motor hacmi",
     "engineSizeLitres": "Litre (1,6 L)",
     "engineSizeLitres": "Litre (1,6 L)",
     "engineSizeCc": "Santimetreküp (1598 cc)",
     "engineSizeCc": "Santimetreküp (1598 cc)",

+ 450 - 0
src/__tests__/features/onboarding/setup.test.ts

@@ -0,0 +1,450 @@
+import { describe, expect, it, vi } from 'vitest'
+// The first-run content is translated by next-intl itself, not the suite's stand-in.
+vi.unmock('next-intl')
+
+import fr from '../../../../messages/fr/onboarding.json'
+import frPdf from '../../../../messages/fr/pdf.json'
+import { invoiceLabels } from '@/features/invoice-designer/Lib/invoiceLabels'
+import { withMarineDocumentLabels } from '@/features/invoice-designer/Lib/marineLabels'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { pickerScore } from '@/features/onboarding/Components/setup/SearchPicker'
+import { countrySearchTerms } from '@/lib/country-defaults'
+import { exampleDate } from '@/features/onboarding/Components/setup/StepRegion'
+import { previewInvoiceInput } from '@/features/onboarding/Lib/previewInvoice'
+import { starterDesignSettings, starterLayout } from '@/features/onboarding/Lib/starterDesign'
+import {
+  initialDraft,
+  parseTaxRate,
+  type SetupDraft,
+  setupPayload,
+  taxStepComplete,
+  firstIncompleteStep,
+  withCountry,
+  withCustomerLanguage,
+  withKind,
+  withLanguage,
+  withSameLanguage,
+} from '@/features/onboarding/Lib/setupDraft'
+import { setupSettings } from '@/features/onboarding/Lib/setupSettings'
+import { onboardingSchema } from '@/features/onboarding/Schema/onboardingSchema'
+
+/** The French workshop from the complaint: an English browser in Lyon. */
+function frenchWorkshop(): SetupDraft {
+  return {
+    ...initialDraft({ timeZone: 'Europe/Paris', languages: ['en-US', 'en'], uiLocale: 'en' }),
+    workshopName: 'Garage Martin',
+    taxRate: '20',
+  }
+}
+
+describe('the first draft', () => {
+  it('suggests French documents for a workshop in France, whatever the browser speaks', () => {
+    const draft = frenchWorkshop()
+    expect(draft).toMatchObject({
+      country: 'FR',
+      locale: 'fr',
+      currencyCode: 'EUR',
+      dateFormat: 'dd/MM/yyyy',
+      timeFormat: '24h',
+      timezone: 'Europe/Paris',
+      distance: 'metric',
+      measure: 'metric',
+      dialCode: '+33',
+      taxLabel: 'TVA',
+    })
+  })
+
+  it('gives a British workshop miles with litres', () => {
+    const draft = initialDraft({ timeZone: 'Europe/London', languages: ['en-GB'], uiLocale: 'en' })
+    expect(draft).toMatchObject({
+      country: 'GB',
+      distance: 'imperial',
+      measure: 'metric',
+      currencyCode: 'GBP',
+    })
+  })
+
+  it('waits for a country when the browser does not say', () => {
+    const draft = initialDraft({ timeZone: 'UTC', languages: ['en'], uiLocale: 'de' })
+    expect(draft.country).toBeNull()
+    expect(draft.locale).toBe('de')
+  })
+})
+
+describe('changing the country or the kind of work', () => {
+  it('moves the regional choices and keeps the name, address and a typed tax name', () => {
+    const typed = {
+      ...frenchWorkshop(),
+      address: 'Lyon',
+      taxLabel: 'Taxe locale',
+      taxLabelEdited: true,
+    }
+    const german = withCountry(typed, 'DE', 'en-US')
+    expect(german).toMatchObject({
+      country: 'DE',
+      locale: 'de',
+      dateFormat: 'dd.MM.yyyy',
+      dialCode: '+49',
+      workshopName: 'Garage Martin',
+      address: 'Lyon',
+      taxLabel: 'Taxe locale',
+    })
+  })
+
+  it('suggests a new tax name while the owner has not typed one', () => {
+    expect(withCountry(frenchWorkshop(), 'NO', undefined).taxLabel).toBe('MVA')
+  })
+
+  it('writes engine size in cc for motorcycles and back in litres for cars', () => {
+    const bikes = withKind(frenchWorkshop(), 'motorcycles')
+    expect(bikes.engineSizeUnit).toBe('cc')
+    expect(withKind(bikes, 'cars').engineSizeUnit).toBe('litres')
+  })
+})
+
+describe('the tax step', () => {
+  it('needs a rate between 0 and 100 when the workshop charges tax', () => {
+    expect(parseTaxRate('20')).toBe(20)
+    expect(parseTaxRate('5,5')).toBe(5.5)
+    expect(parseTaxRate('')).toBeNull()
+    expect(parseTaxRate('120')).toBeNull()
+    expect(taxStepComplete({ ...frenchWorkshop(), taxRate: '' })).toBe(false)
+    expect(taxStepComplete({ ...frenchWorkshop(), taxChoice: 'no', taxRate: '' })).toBe(true)
+    expect(taxStepComplete({ ...frenchWorkshop(), taxChoice: 'skipped', taxRate: '' })).toBe(true)
+  })
+})
+
+describe('creating from Ready', () => {
+  // Ready is reachable from the step bar after an earlier step was emptied again.
+  it('finds the first step still missing something', () => {
+    expect(firstIncompleteStep(frenchWorkshop())).toBeNull()
+    expect(firstIncompleteStep({ ...frenchWorkshop(), taxRate: '' })).toBe('tax')
+    expect(firstIncompleteStep({ ...frenchWorkshop(), taxRate: '150' })).toBe('tax')
+    expect(firstIncompleteStep({ ...frenchWorkshop(), dialCode: '33' })).toBe('region')
+    expect(firstIncompleteStep({ ...frenchWorkshop(), dialCode: '' })).toBeNull()
+    expect(
+      firstIncompleteStep({ ...frenchWorkshop(), workshopName: ' G ', dialCode: 'x', taxRate: '' })
+    ).toBe('workshop')
+  })
+})
+
+describe('what gets saved', () => {
+  it('passes the onboarding schema', () => {
+    expect(onboardingSchema.safeParse(setupPayload(frenchWorkshop())).success).toBe(true)
+  })
+
+  it('writes every choice as the settings pages save it', () => {
+    const payload = onboardingSchema.parse(
+      setupPayload({ ...frenchWorkshop(), address: '8 avenue Jean Jaurès, Lyon' })
+    )
+    expect(setupSettings(payload.setup!, payload.timeZone)).toEqual({
+      'workshop.serviceType': 'automotive',
+      'workshop.locale': 'fr',
+      'workshop.forceCustomerLocale': 'false',
+      'workshop.currencyCode': 'EUR',
+      'workshop.dateFormat': 'dd/MM/yyyy',
+      'workshop.timeFormat': '24h',
+      'workshop.unitSystem': 'metric',
+      'workshop.measureSystem': 'metric',
+      'workshop.engineSizeUnit': 'litres',
+      'workshop.defaultCountryCode': '+33',
+      'workshop.taxEnabled': 'true',
+      'workshop.defaultTaxRate': '20',
+      'workshop.taxInclusive': 'false',
+      'workshop.taxLabel': 'TVA',
+      'workshop.address': '8 avenue Jean Jaurès, Lyon',
+      'workshop.timezone': 'Europe/Paris',
+    })
+  })
+
+  it('switches tax off when the step was skipped', () => {
+    const payload = onboardingSchema.parse(
+      setupPayload({ ...frenchWorkshop(), taxChoice: 'skipped' })
+    )
+    const rows = setupSettings(payload.setup!, payload.timeZone)
+    expect(rows['workshop.taxEnabled']).toBe('false')
+    expect(rows['workshop.defaultTaxRate']).toBe('0')
+    expect(rows['workshop.taxLabel']).toBeUndefined()
+  })
+
+  it('leaves engine size out for a boat workshop', () => {
+    const payload = onboardingSchema.parse(setupPayload(withKind(frenchWorkshop(), 'marine')))
+    const rows = setupSettings(payload.setup!, payload.timeZone)
+    expect(rows['workshop.serviceType']).toBe('marine')
+    expect(rows['workshop.engineSizeUnit']).toBeUndefined()
+  })
+
+  it('refuses a currency or country the app does not know', () => {
+    const payload = setupPayload(frenchWorkshop())
+    expect(
+      onboardingSchema.safeParse({ ...payload, setup: { ...payload.setup, currencyCode: 'XXX' } })
+        .success
+    ).toBe(false)
+    expect(
+      onboardingSchema.safeParse({ ...payload, setup: { ...payload.setup, country: 'ZZ' } }).success
+    ).toBe(false)
+  })
+
+  it('still accepts the old form, with no setup', () => {
+    expect(
+      onboardingSchema.safeParse({ workshopName: 'Garage', loadSampleData: false }).success
+    ).toBe(true)
+  })
+})
+
+describe('the preview', () => {
+  it('is drawn by the real invoice builder, in French, with the TVA line and euro totals', () => {
+    const draft = { ...frenchWorkshop(), address: '8 avenue Jean Jaurès, Lyon' }
+    const spec = buildInvoicePrintSpec(
+      previewInvoiceInput(
+        draft,
+        invoiceLabels(frPdf, 'invoice'),
+        fr.setup.sample,
+        new Date(Date.UTC(2026, 9, 28, 12))
+      )
+    )
+    const text = JSON.stringify(spec)
+    expect(text).toContain('Garage Martin')
+    expect(text).toContain('FACTURE')
+    expect(text).toContain('Facturer à')
+    expect(text).toContain('TVA (20%)')
+    expect(text).toContain('28/10/2026')
+    // Four parts and four labour lines, 302,45 € before tax.
+    expect(text).toContain('Huile moteur 5W-30')
+    expect(text).toContain('4,5 l')
+    expect(text).toContain('Contrôle des freins')
+    expect(text).toMatch(/302,45\s€/)
+    expect(text).toMatch(/362,94\s€/)
+    expect(text).toContain('camille.dubois@example.com')
+  })
+
+  it('measures the oil in quarts for a workshop on US measures', () => {
+    const us = withCountry(frenchWorkshop(), 'US', 'en-US')
+    const text = JSON.stringify(
+      buildInvoicePrintSpec(
+        previewInvoiceInput(us, invoiceLabels(frPdf, 'invoice'), fr.setup.sample)
+      )
+    )
+    expect(text).toContain('5 qt')
+  })
+
+  it('uses the boat yard words for a marine workshop', () => {
+    const marine = withKind(frenchWorkshop(), 'marine')
+    const labels = withMarineDocumentLabels(invoiceLabels(frPdf, 'invoice'), frPdf)
+    const text = JSON.stringify(
+      buildInvoicePrintSpec(previewInvoiceInput(marine, labels, fr.setup.sample))
+    )
+    expect(text).toContain(frPdf.invoice.vehicleMarine)
+    expect(text).toContain('Yamaha')
+    const cars = JSON.stringify(
+      buildInvoicePrintSpec(
+        previewInvoiceInput(frenchWorkshop(), invoiceLabels(frPdf, 'invoice'), fr.setup.sample)
+      )
+    )
+    expect(cars).not.toContain(frPdf.invoice.vehicleMarine)
+  })
+})
+
+describe('the first-run content', () => {
+  it('is written in the language customers get, not the screens', async () => {
+    const { onboardingTranslator } = await import('@/features/onboarding/Lib/onboardingTranslator')
+    const t = await onboardingTranslator('fr')
+    expect(t('sample.prefix')).toBe('Exemple :')
+    expect(t('sample.parts.oilFilter')).toBe('Filtre à huile')
+    expect((await onboardingTranslator('nb'))('sample.parts.oilFilter')).toBe('Oljefilter')
+  })
+})
+
+describe('the dates row', () => {
+  // 13:05 UTC on 29 October 2026, after Europe moved its clocks back.
+  const now = new Date(Date.UTC(2026, 9, 29, 13, 5))
+
+  it('shows the time now at the workshop', () => {
+    expect(exampleDate('dd.MM.yyyy', '24h', true, 'Europe/Oslo', now)).toBe('29.10.2026, 14:05')
+    expect(exampleDate('MM/dd/yyyy', '12h', true, 'America/New_York', now)).toBe(
+      '10/29/2026, 9:05 AM'
+    )
+  })
+
+  it('shows the workshop its own date where it is already tomorrow', () => {
+    expect(exampleDate('dd/MM/yyyy', '24h', true, 'Pacific/Auckland', now)).toBe(
+      '30/10/2026, 02:05'
+    )
+  })
+
+  it('writes midnight and noon on a 12-hour clock', () => {
+    expect(
+      exampleDate('dd/MM/yyyy', '12h', true, 'UTC', new Date(Date.UTC(2026, 0, 5, 0, 0)))
+    ).toBe('05/01/2026, 12:00 AM')
+    expect(
+      exampleDate('dd/MM/yyyy', '12h', true, 'UTC', new Date(Date.UTC(2026, 0, 5, 12, 0)))
+    ).toBe('05/01/2026, 12:00 PM')
+  })
+
+  it('falls back to the browser zone for a zone it does not know', () => {
+    expect(exampleDate('yyyy-MM-dd', '24h', false, 'Not/AZone', now)).toMatch(/^2026-10-(29|30)$/)
+  })
+})
+
+describe('searching the pickers', () => {
+  const uk = `Storbritannia ${countrySearchTerms('GB')} GB`
+  const ukraine = `Ukraina ${countrySearchTerms('UA')} UA`
+
+  it('puts the United Kingdom above Ukraine for "UK"', () => {
+    expect(pickerScore(uk, 'UK')).toBeGreaterThan(pickerScore(ukraine, 'UK'))
+  })
+
+  it('does not match letters scattered through a name', () => {
+    expect(pickerScore(`Usbekistan ${countrySearchTerms('UZ')} UZ`, 'UK')).toBe(0)
+  })
+
+  it('finds by the start of any word and by a phrase', () => {
+    expect(pickerScore(uk, 'engl')).toBeGreaterThan(0)
+    expect(pickerScore(uk, 'great brit')).toBeGreaterThan(0)
+    expect(pickerScore('Norske kroner (NOK) NOK', 'nok')).toBe(1)
+    expect(pickerScore('America/Los Angeles America/Los_Angeles', 'los angeles')).toBeGreaterThan(0)
+  })
+})
+
+describe('the language', () => {
+  it('starts from the browser, and a detected country suggests its own', () => {
+    expect(
+      initialDraft({ timeZone: 'UTC', languages: ['de'], uiLocale: 'de' }).languageSource
+    ).toBe('browser')
+    expect(frenchWorkshop()).toMatchObject({ locale: 'fr', languageSource: 'country' })
+  })
+
+  it('follows a country the owner picks while they have not picked a language', () => {
+    const norwegianPc = initialDraft({ timeZone: 'Europe/Oslo', languages: ['nb'], uiLocale: 'nb' })
+    expect(withCountry(norwegianPc, 'GB', 'nb')).toMatchObject({
+      locale: 'en',
+      languageSource: 'country',
+      currencyCode: 'GBP',
+    })
+  })
+
+  it('suggests English where the app does not ship the country language', () => {
+    const norwegianPc = initialDraft({ timeZone: 'Europe/Oslo', languages: ['nb'], uiLocale: 'nb' })
+    expect(withCountry(norwegianPc, 'SE', 'nb')).toMatchObject({
+      locale: 'en',
+      currencyCode: 'SEK',
+    })
+    expect(withCountry(norwegianPc, 'JP', 'nb').locale).toBe('en')
+  })
+
+  it('keeps a language the owner picked, whatever country follows', () => {
+    const picked = withLanguage(
+      initialDraft({ timeZone: 'Europe/Oslo', languages: ['nb'], uiLocale: 'nb' }),
+      'nb'
+    )
+    expect(withCountry(picked, 'ES', 'nb')).toMatchObject({
+      country: 'ES',
+      locale: 'nb',
+      languageSource: 'chosen',
+      currencyCode: 'EUR',
+    })
+  })
+
+  it('uses a picked language among those a country speaks', () => {
+    const picked = withLanguage(
+      initialDraft({ timeZone: 'Europe/Oslo', languages: ['nb'], uiLocale: 'nb' }),
+      'nl'
+    )
+    expect(withCountry(picked, 'BE', 'en-US')).toMatchObject({ locale: 'nl', taxLabel: 'BTW' })
+  })
+})
+
+describe('one language, and customers on another only when asked', () => {
+  const norwegianPc = () =>
+    initialDraft({ timeZone: 'Europe/Oslo', languages: ['nb'], uiLocale: 'nb' })
+
+  it('gives everything the owner language where the country speaks it', () => {
+    expect(norwegianPc()).toMatchObject({ locale: 'nb', customerLanguageSeparate: false })
+  })
+
+  it('opens the customer language by itself where the detected country speaks another', () => {
+    expect(frenchWorkshop()).toMatchObject({ locale: 'fr', customerLanguageSeparate: true })
+  })
+
+  it('keeps a separate customer language when the owner changes their own', () => {
+    expect(withLanguage(frenchWorkshop(), 'de')).toMatchObject({
+      locale: 'fr',
+      customerLanguageSeparate: true,
+      languageSource: 'chosen',
+    })
+  })
+
+  it('moves customers with the owner while they share it', () => {
+    expect(withLanguage(norwegianPc(), 'de').locale).toBe('de')
+  })
+
+  it('puts customers back on the owner language', () => {
+    expect(withSameLanguage(frenchWorkshop(), 'en')).toMatchObject({
+      locale: 'en',
+      customerLanguageSeparate: false,
+    })
+  })
+
+  it('keeps a picked customer language when the country changes', () => {
+    const picked = withCustomerLanguage(norwegianPc(), 'de')
+    expect(withCountry(picked, 'ES', 'nb')).toMatchObject({
+      locale: 'de',
+      customerLanguageSeparate: true,
+      currencyCode: 'EUR',
+    })
+  })
+
+  it('folds a separate language away when a country suggests one for everything', () => {
+    expect(withCountry(frenchWorkshop(), 'GB', 'en-US')).toMatchObject({
+      locale: 'en',
+      customerLanguageSeparate: false,
+    })
+  })
+})
+
+describe('the time zone', () => {
+  it('moves to the picked country unless the browser is already there', () => {
+    const norwegianPc = initialDraft({ timeZone: 'Europe/Oslo', languages: ['nb'], uiLocale: 'nb' })
+    expect(norwegianPc.timezone).toBe('Europe/Oslo')
+    expect(withCountry(norwegianPc, 'IE', 'nb').timezone).toBe('Europe/Dublin')
+    expect(withCountry(norwegianPc, 'NO', 'nb').timezone).toBe('Europe/Oslo')
+  })
+})
+
+describe('the starter template', () => {
+  it('gives a new workshop the Compact template for invoices and quotes', () => {
+    const rows = starterDesignSettings()
+    for (const prefix of ['invoice', 'quote']) {
+      expect(rows[`${prefix}.headerStyle`]).toBe('compact')
+      expect(rows[`${prefix}.activeDesign`]).toBe('preset:compact')
+      const layout = JSON.parse(rows[`${prefix}.layoutConfig`])
+      expect(layout.version).toBe(3)
+      expect(layout.document).toMatchObject({ fontSize: 9, margin: 30 })
+    }
+  })
+
+  it('prints the title, the number and the dates on the invoices of a new workshop', () => {
+    for (const prefix of ['invoice', 'quote']) {
+      const layout = JSON.parse(starterDesignSettings()[`${prefix}.layoutConfig`])
+      const title = layout.sections.find((s: { id: string }) => s.id === 'document_title')
+      expect(title?.visible).toBe(true)
+    }
+  })
+
+  it('is a layout the schema accepts, every section accounted for', () => {
+    const layout = starterLayout()
+    expect(layout.sections.find((s) => s.id === 'customer')).toMatchObject({
+      visible: true,
+      boxed: false,
+    })
+    expect(layout.sections.find((s) => s.id === 'findings')?.visible).toBe(false)
+  })
+
+  it('is what the preview draws', () => {
+    const spec = buildInvoicePrintSpec(
+      previewInvoiceInput(frenchWorkshop(), invoiceLabels(frPdf, 'invoice'), fr.setup.sample)
+    )
+    expect(spec.page.fontSize).toBe(9)
+    expect(spec.page.margin.left).toBe(30)
+  })
+})

+ 216 - 0
src/__tests__/lib/country-defaults.test.ts

@@ -0,0 +1,216 @@
+import { describe, expect, it } from 'vitest'
+import {
+  COUNTRY_CODES,
+  countryDefaults,
+  countrySearchTerms,
+  timeZoneFor,
+  dateFormatFor,
+  detectCountry,
+  timeFormatFor,
+} from '@/lib/country-defaults'
+import { currencyDefinition } from '@/lib/currencies'
+
+describe('countryDefaults', () => {
+  it('suggests what a French workshop writes', () => {
+    expect(countryDefaults('FR')).toEqual({
+      country: 'FR',
+      language: 'fr',
+      locale: 'fr',
+      currency: 'EUR',
+      dialCode: '+33',
+      distance: 'metric',
+      measure: 'metric',
+      dateFormat: 'dd/MM/yyyy',
+      timeFormat: '24h',
+      taxLabel: 'TVA',
+    })
+  })
+
+  it('gives the United Kingdom miles with litres', () => {
+    expect(countryDefaults('GB')).toMatchObject({
+      locale: 'en',
+      currency: 'GBP',
+      dialCode: '+44',
+      distance: 'imperial',
+      measure: 'metric',
+      dateFormat: 'dd/MM/yyyy',
+      taxLabel: 'VAT',
+    })
+  })
+
+  it('gives the United States miles, gallons, month-first dates and a 12-hour clock', () => {
+    expect(countryDefaults('US')).toMatchObject({
+      currency: 'USD',
+      distance: 'imperial',
+      measure: 'imperial',
+      dateFormat: 'MM/dd/yyyy',
+      timeFormat: '12h',
+    })
+  })
+
+  it('writes German and Norwegian dates with dots', () => {
+    expect(countryDefaults('DE')).toMatchObject({
+      locale: 'de',
+      dateFormat: 'dd.MM.yyyy',
+      taxLabel: 'MwSt.',
+    })
+    expect(countryDefaults('NO')).toMatchObject({
+      locale: 'nb',
+      currency: 'NOK',
+      dateFormat: 'dd.MM.yyyy',
+      taxLabel: 'MVA',
+    })
+  })
+
+  it('follows the owner where a country speaks several languages', () => {
+    expect(countryDefaults('BE', 'fr-BE')).toMatchObject({ locale: 'fr', taxLabel: 'TVA' })
+    expect(countryDefaults('BE', 'nl-BE')).toMatchObject({ locale: 'nl', taxLabel: 'BTW' })
+    expect(countryDefaults('CA', 'fr-CA')).toMatchObject({ locale: 'fr' })
+    expect(countryDefaults('CA', 'en-US')).toMatchObject({ locale: 'en' })
+  })
+
+  it('does not follow a language the country does not speak', () => {
+    expect(countryDefaults('FR', 'en-US').locale).toBe('fr')
+  })
+
+  it('keeps Portugal and Brazil apart', () => {
+    expect(countryDefaults('PT').locale).toBe('pt-PT')
+    expect(countryDefaults('BR').locale).toBe('pt-BR')
+  })
+
+  it('suggests the euro in Bulgaria, which adopted it on 1 January 2026', () => {
+    expect(countryDefaults('BG').currency).toBe('EUR')
+  })
+
+  it('leaves the language open where the app does not ship it', () => {
+    expect(countryDefaults('JP').locale).toBeUndefined()
+    expect(countryDefaults('JP').currency).toBe('JPY')
+  })
+
+  it('only suggests currencies the app offers', () => {
+    for (const cc of COUNTRY_CODES) {
+      const { currency } = countryDefaults(cc)
+      if (currency) expect(currencyDefinition(currency), cc).toBeDefined()
+    }
+  })
+})
+
+describe('date and clock from the browser locale data', () => {
+  it('reads date order and separator', () => {
+    expect(dateFormatFor('en-US')).toBe('MM/dd/yyyy')
+    expect(dateFormatFor('en-GB')).toBe('dd/MM/yyyy')
+    expect(dateFormatFor('de-DE')).toBe('dd.MM.yyyy')
+    expect(dateFormatFor('sv-SE')).toBe('yyyy-MM-dd')
+  })
+
+  it('reads the clock', () => {
+    expect(timeFormatFor('en-US')).toBe('12h')
+    expect(timeFormatFor('fr-FR')).toBe('24h')
+  })
+})
+
+describe('detectCountry', () => {
+  it('trusts the time zone over an English browser', () => {
+    expect(detectCountry('Europe/Paris', ['en-US', 'en'])).toBe('FR')
+  })
+
+  it('falls back to the browser language region', () => {
+    expect(detectCountry('UTC', ['fr-BE', 'fr'])).toBe('BE')
+    expect(detectCountry(undefined, ['nb-NO'])).toBe('NO')
+  })
+
+  it('says nothing when neither tells', () => {
+    expect(detectCountry('UTC', ['en'])).toBeNull()
+  })
+})
+
+describe('the country list', () => {
+  it('covers every inhabited country and territory Intl knows by its current code', () => {
+    const names = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' })
+    // Uninhabited, retired or not a country of its own.
+    const left = new Set([
+      'AQ',
+      'BV',
+      'GS',
+      'HM',
+      'TF',
+      'UM',
+      'IO',
+      'EH',
+      'AC',
+      'CP',
+      'CQ',
+      'DG',
+      'EA',
+      'IC',
+      'TA',
+      'XK',
+      'EU',
+      'EZ',
+      'UN',
+      'QO',
+      'ZZ',
+    ])
+    const missing: string[] = []
+    for (let a = 65; a <= 90; a++) {
+      for (let b = 65; b <= 90; b++) {
+        const code = String.fromCharCode(a, b)
+        const name = names.of(code)
+        if (!name || left.has(code) || name.startsWith('Pseudo')) continue
+        // Retired codes resolve to a current country's name under another code.
+        const current = [...COUNTRY_CODES, ...left].some(
+          (cc) => cc !== code && names.of(cc) === name
+        )
+        if (!current && !COUNTRY_CODES.includes(code)) missing.push(`${code} ${name}`)
+      }
+    }
+    expect(missing).toEqual([])
+  })
+
+  it('gives the Crown Dependencies pounds, +44 and miles', () => {
+    for (const cc of ['JE', 'GG', 'IM']) {
+      expect(countryDefaults(cc)).toMatchObject({
+        currency: 'GBP',
+        dialCode: '+44',
+        distance: 'imperial',
+        measure: 'metric',
+      })
+    }
+  })
+
+  it('finds the United Kingdom by the names people type', () => {
+    const terms = countrySearchTerms('GB')
+    for (const word of ['UK', 'England', 'Great Britain', 'United Kingdom']) {
+      expect(terms).toContain(word)
+    }
+  })
+
+  it('finds a country by its own name', () => {
+    expect(countrySearchTerms('DE')).toContain('Deutschland')
+    expect(countrySearchTerms('NO')).toContain('Norge')
+  })
+})
+
+describe('timeZoneFor', () => {
+  it("keeps the browser's zone when it is in the country", () => {
+    expect(timeZoneFor('US', 'America/Chicago')).toBe('America/Chicago')
+  })
+
+  it("gives the country's own zone when the browser is elsewhere", () => {
+    expect(timeZoneFor('IE', 'Europe/Oslo')).toBe('Europe/Dublin')
+    expect(timeZoneFor('GB', 'Europe/Oslo')).toBe('Europe/London')
+    expect(timeZoneFor('SE', 'Europe/Oslo')).toBe('Europe/Stockholm')
+  })
+
+  it('starts a country of several zones at its main one', () => {
+    expect(timeZoneFor('US', 'Europe/Oslo')).toBe('America/New_York')
+    expect(timeZoneFor('AU', 'Europe/Oslo')).toBe('Australia/Sydney')
+    expect(timeZoneFor('CA', 'Europe/Oslo')).toBe('America/Toronto')
+    expect(timeZoneFor('BR', 'Europe/Oslo')).toBe('America/Sao_Paulo')
+  })
+
+  it('has a zone for every country in the list', () => {
+    const without = COUNTRY_CODES.filter((cc) => !timeZoneFor(cc, undefined))
+    expect(without).toEqual([])
+  })
+})

+ 121 - 0
src/__tests__/lib/document-numbers.test.ts

@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest'
+import dePdf from '../../../messages/de/pdf.json'
+import enPdf from '../../../messages/en/pdf.json'
+import frPdf from '../../../messages/fr/pdf.json'
+import { invoiceLabels } from '@/features/invoice-designer/Lib/invoiceLabels'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+import { documentNumbers, serviceTypeName } from '@/lib/document-numbers'
+
+const NBSP = ' '
+
+describe('documentNumbers', () => {
+  it('writes distances grouped the way the language does', () => {
+    expect(documentNumbers('fr').distance(86200)).toBe(`86${NBSP}200`)
+    expect(documentNumbers('de').distance(86200)).toBe('86.200')
+    expect(documentNumbers('en').distance(86200)).toBe('86,200')
+    expect(documentNumbers('nb').distance(86200)).toBe(`86${NBSP}200`)
+  })
+
+  it('never prints the narrow space PDF fonts lack', () => {
+    expect(documentNumbers('fr').distance(1234567)).not.toContain(' ')
+  })
+
+  it('writes hours and quantities with the decimal mark, never grouped', () => {
+    expect(documentNumbers('fr').count(0.8)).toBe('0,8')
+    expect(documentNumbers('de').count(1.5)).toBe('1,5')
+    expect(documentNumbers('en').count(1.5)).toBe('1.5')
+    expect(documentNumbers('fr').count(1000)).toBe('1000')
+    // A stored quantity keeps its precision: the line is charged on all of it.
+    expect(documentNumbers('fr').count(0.125)).toBe('0,125')
+    expect(documentNumbers('en').count(0.004)).toBe('0.004')
+    expect(documentNumbers('en').count(0.1 + 0.2)).toBe('0.3')
+    expect(documentNumbers('fr').quantity(2.5, 'l')).toBe('2,5 l')
+    expect(documentNumbers('fr').quantity(4)).toBe('4')
+  })
+
+  it('writes exactly what it did before when no language is given', () => {
+    const legacy = documentNumbers(undefined)
+    expect(legacy.count(0.8)).toBe('0.8')
+    expect(legacy.quantity(2.5, 'l')).toBe('2.5 l')
+    expect(legacy.distance(86200)).toBe((86200).toLocaleString())
+  })
+
+  it('names the service type in the document language, or as stored', () => {
+    const fr = invoiceLabels(frPdf, 'invoice')
+    expect(serviceTypeName('repair', fr)).toBe('Réparation')
+    expect(serviceTypeName('custom', fr)).toBe('custom')
+  })
+})
+
+function job(): InvoiceData {
+  return {
+    id: 'job',
+    title: 'Service',
+    description: null,
+    type: 'repair',
+    serviceDate: new Date(Date.UTC(2026, 9, 28, 12)),
+    shopName: null,
+    techName: null,
+    mileage: 86200,
+    diagnosticNotes: null,
+    invoiceNotes: null,
+    subtotal: 136.9,
+    taxRate: 0,
+    taxAmount: 0,
+    totalAmount: 136.9,
+    cost: 136.9,
+    invoiceNumber: '1001',
+    partItems: [
+      { partNumber: null, name: 'Oil', quantity: 2.5, unit: 'l', unitPrice: 11.5, total: 28.75 },
+    ],
+    laborItems: [{ description: 'Labour', hours: 0.8, rate: 95, total: 76 }],
+    vehicle: {
+      make: 'Volkswagen',
+      model: 'Golf',
+      year: 2018,
+      vin: null,
+      licensePlate: 'AB-123-CD',
+      mileage: 86200,
+      customer: null,
+    },
+  }
+}
+
+function printed(locale: string, pdf: Record<string, Record<string, string>>): string {
+  return JSON.stringify(
+    buildInvoicePrintSpec({
+      data: job(),
+      invoiceSettings: { currencyCode: 'EUR', unitSystem: 'metric', dateFormat: 'dd/MM/yyyy' },
+      labels: invoiceLabels(pdf, 'invoice'),
+      locale,
+    })
+  )
+}
+
+describe('the printed invoice', () => {
+  it('writes French numbers and the French service type on a French invoice', () => {
+    const text = printed('fr', frPdf)
+    expect(text).toContain(`86${NBSP}200 km`)
+    expect(text).toContain('0,8')
+    expect(text).toContain('2,5 l')
+    expect(text).toContain('Réparation')
+    expect(text).not.toContain('86,200')
+    expect(text).not.toContain('repair')
+  })
+
+  it('writes German numbers on a German invoice', () => {
+    const text = printed('de', dePdf)
+    expect(text).toContain('86.200 km')
+    expect(text).toContain('0,8')
+    expect(text).toContain('Reparatur')
+  })
+
+  it('leaves an English invoice as it was', () => {
+    const text = printed('en', enPdf)
+    expect(text).toContain('86,200 km')
+    expect(text).toContain('0.8')
+    expect(text).toContain('2.5 l')
+    expect(text).toContain('Repair')
+  })
+})

+ 3 - 1
src/app/(authenticated)/inventory/[id]/page.tsx

@@ -1,3 +1,4 @@
+import { measureSystemIn } from '@/features/inventory/Lib/units'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { notFound } from 'next/navigation'
 import { notFound } from 'next/navigation'
 import {
 import {
@@ -37,6 +38,7 @@ export default async function InventoryPartDetailPage({
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
       SETTING_KEYS.UNIT_SYSTEM,
       SETTING_KEYS.UNIT_SYSTEM,
+      SETTING_KEYS.MEASURE_SYSTEM,
       SETTING_KEYS.DATE_FORMAT,
       SETTING_KEYS.DATE_FORMAT,
       SETTING_KEYS.TIME_FORMAT,
       SETTING_KEYS.TIME_FORMAT,
       SETTING_KEYS.TIMEZONE,
       SETTING_KEYS.TIMEZONE,
@@ -90,7 +92,7 @@ export default async function InventoryPartDetailPage({
         markupMultiplier={Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1}
         markupMultiplier={Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1}
         categories={categoriesResult.data ?? []}
         categories={categoriesResult.data ?? []}
         lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
         lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
-        unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
+        unitSystem={measureSystemIn(settings)}
       />
       />
     </>
     </>
   )
   )

+ 3 - 1
src/app/(authenticated)/inventory/page.tsx

@@ -1,3 +1,4 @@
+import { measureSystemIn } from '@/features/inventory/Lib/units'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import {
 import {
@@ -45,6 +46,7 @@ export default async function InventoryPage({
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
       SETTING_KEYS.INVENTORY_DEFAULT_UNIT,
       SETTING_KEYS.INVENTORY_DEFAULT_UNIT,
       SETTING_KEYS.UNIT_SYSTEM,
       SETTING_KEYS.UNIT_SYSTEM,
+      SETTING_KEYS.MEASURE_SYSTEM,
       SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
       SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
     ]),
     ]),
     hasAnyReorderPoint(),
     hasAnyReorderPoint(),
@@ -78,7 +80,7 @@ export default async function InventoryPage({
           currencyCode={currencyCode}
           currencyCode={currencyCode}
           markupMultiplier={markupMultiplier}
           markupMultiplier={markupMultiplier}
           defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}
           defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}
-          unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
+          unitSystem={measureSystemIn(settings)}
           sortBy={sort.sortBy || ''}
           sortBy={sort.sortBy || ''}
           sortOrder={sort.sortOrder}
           sortOrder={sort.sortOrder}
           lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
           lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}

+ 36 - 38
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -33,6 +33,7 @@ import {
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { type EngineSizeUnit, engineSizeUnitOf } from '@/lib/engine-size'
 import { type EngineSizeUnit, engineSizeUnitOf } from '@/lib/engine-size'
+import { measureSystemIn } from '@/features/inventory/Lib/units'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
 import {
 import {
@@ -102,6 +103,9 @@ export function WorkshopSettings({
     settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
     settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
   )
   )
   const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial')
   const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial')
+  const [measureSystem, setMeasureSystem] = useState<'metric' | 'imperial'>(
+    measureSystemIn(settings)
+  )
   const [engineSizeUnit, setEngineSizeUnit] = useState<EngineSizeUnit>(
   const [engineSizeUnit, setEngineSizeUnit] = useState<EngineSizeUnit>(
     engineSizeUnitOf(settings[SETTING_KEYS.ENGINE_SIZE_UNIT])
     engineSizeUnitOf(settings[SETTING_KEYS.ENGINE_SIZE_UNIT])
   )
   )
@@ -202,6 +206,7 @@ export function WorkshopSettings({
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
+      [SETTING_KEYS.MEASURE_SYSTEM]: measureSystem,
       [SETTING_KEYS.ENGINE_SIZE_UNIT]: engineSizeUnit,
       [SETTING_KEYS.ENGINE_SIZE_UNIT]: engineSizeUnit,
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
@@ -516,43 +521,36 @@ export function WorkshopSettings({
             </div>
             </div>
             <p className="text-sm text-muted-foreground">{t('workshop.unitsDescription')}</p>
             <p className="text-sm text-muted-foreground">{t('workshop.unitsDescription')}</p>
 
 
-            <div className="space-y-2">
-              <Label htmlFor="unitSystem">{t('workshop.unitSystem')}</Label>
-              <Select value={unitSystem} onValueChange={setUnitSystem}>
-                <SelectTrigger className="w-48">
-                  <SelectValue />
-                </SelectTrigger>
-                <SelectContent>
-                  <SelectItem value="metric">{t('workshop.metric')}</SelectItem>
-                  <SelectItem value="imperial">{t('workshop.imperial')}</SelectItem>
-                </SelectContent>
-              </Select>
-            </div>
-
-            <div className="rounded-lg border p-3 text-sm text-muted-foreground">
-              {unitSystem === 'metric' ? (
-                <div className="space-y-1">
-                  <p>
-                    {t('workshop.distanceLabel')}:{' '}
-                    <span className="font-medium text-foreground">{t('workshop.kilometers')}</span>
-                  </p>
-                  <p>
-                    {t('workshop.volumeLabel')}:{' '}
-                    <span className="font-medium text-foreground">{t('workshop.liters')}</span>
-                  </p>
-                </div>
-              ) : (
-                <div className="space-y-1">
-                  <p>
-                    {t('workshop.distanceLabel')}:{' '}
-                    <span className="font-medium text-foreground">{t('workshop.miles')}</span>
-                  </p>
-                  <p>
-                    {t('workshop.volumeLabel')}:{' '}
-                    <span className="font-medium text-foreground">{t('workshop.gallons')}</span>
-                  </p>
-                </div>
-              )}
+            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="unitSystem">{t('workshop.distanceLabel')}</Label>
+                <Select value={unitSystem} onValueChange={setUnitSystem}>
+                  <SelectTrigger id="unitSystem" className="w-full sm:w-56">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="metric">{t('workshop.kilometers')}</SelectItem>
+                    <SelectItem value="imperial">{t('workshop.miles')}</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="measureSystem">{t('workshop.measureSystem')}</Label>
+                <Select
+                  value={measureSystem}
+                  onValueChange={(value) =>
+                    setMeasureSystem(value === 'metric' ? 'metric' : 'imperial')
+                  }
+                >
+                  <SelectTrigger id="measureSystem" className="w-full sm:w-56">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="metric">{t('workshop.measureMetric')}</SelectItem>
+                    <SelectItem value="imperial">{t('workshop.measureImperial')}</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
             </div>
             </div>
 
 
             {/* A marine workshop's engine field holds a model and power, not a displacement. */}
             {/* A marine workshop's engine field holds a model and power, not a displacement. */}
@@ -584,7 +582,7 @@ export function WorkshopSettings({
                 className="w-48"
                 className="w-48"
                 value={defaultUnit}
                 value={defaultUnit}
                 onChange={setDefaultUnit}
                 onChange={setDefaultUnit}
-                unitSystem={unitSystem}
+                unitSystem={measureSystem}
               />
               />
               <p className="text-sm text-muted-foreground">{t('workshop.defaultStockUnitHint')}</p>
               <p className="text-sm text-muted-foreground">{t('workshop.defaultStockUnitHint')}</p>
             </div>
             </div>

+ 6 - 4
src/app/(public)/onboarding/page.tsx

@@ -3,7 +3,7 @@ import { redirect } from 'next/navigation'
 import { safeRedirectPath } from '@/lib/safe-redirect'
 import { safeRedirectPath } from '@/lib/safe-redirect'
 import { auth } from '@/lib/auth'
 import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
-import { OnboardingForm } from '@/features/onboarding/Components/OnboardingForm'
+import { SetupWizard } from '@/features/onboarding/Components/setup/SetupWizard'
 import { SingleWorkshopNotice } from '@/features/onboarding/Components/SingleWorkshopNotice'
 import { SingleWorkshopNotice } from '@/features/onboarding/Components/SingleWorkshopNotice'
 import { organizationAllowance } from '@/lib/features'
 import { organizationAllowance } from '@/lib/features'
 
 
@@ -36,13 +36,15 @@ export default async function OnboardingPage({
   const allowance = await organizationAllowance(session.user.id)
   const allowance = await organizationAllowance(session.user.id)
 
 
   return (
   return (
-    <div className="grid-bg flex min-h-screen items-center justify-center p-4">
-      <div className="absolute inset-0 overflow-hidden">
+    <div
+      className={`grid-bg relative flex min-h-screen justify-center px-4 py-8 sm:px-6 sm:py-10 ${allowance.allowed ? 'items-start' : 'items-center'}`}
+    >
+      <div className="pointer-events-none absolute inset-0 overflow-hidden">
         <div className="absolute -top-40 -right-40 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
         <div className="absolute -top-40 -right-40 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
         <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
         <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
       </div>
       </div>
       {allowance.allowed ? (
       {allowance.allowed ? (
-        <OnboardingForm redirectTo={redirectTo} />
+        <SetupWizard redirectTo={redirectTo} />
       ) : (
       ) : (
         <SingleWorkshopNotice email={session.user.email} />
         <SingleWorkshopNotice email={session.user.email} />
       )}
       )}

+ 1 - 0
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -192,6 +192,7 @@ export default async function PublicInspectionPage({
         : undefined,
         : undefined,
       workshop: { ...workshop, slogan: settingsMap['workshop.slogan'] || undefined },
       workshop: { ...workshop, slogan: settingsMap['workshop.slogan'] || undefined },
       labels,
       labels,
+      locale,
       logoDataUri: toPublic(documentLogoPath(settingsMap, 'certificate')) || undefined,
       logoDataUri: toPublic(documentLogoPath(settingsMap, 'certificate')) || undefined,
       signatureDataUri: await certificateSignatureDataUri(orgId, inspection),
       signatureDataUri: await certificateSignatureDataUri(orgId, inspection),
       torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),
       torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),

+ 1 - 0
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -142,6 +142,7 @@ export default async function PublicInvoicePage({
     telegramQrDataUri: telegramQr?.dataUri,
     telegramQrDataUri: telegramQr?.dataUri,
     telegramLabel: labels?.telegramConnect,
     telegramLabel: labels?.telegramConnect,
     labels,
     labels,
+    locale,
     conditionMap,
     conditionMap,
     inspectionResults: assembly.inspectionResults,
     inspectionResults: assembly.inspectionResults,
   })
   })

+ 1 - 0
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -256,6 +256,7 @@ export default async function PublicQuotePage({
     },
     },
     customFields,
     customFields,
     labels,
     labels,
+    locale,
     layoutConfig,
     layoutConfig,
     conditionMap,
     conditionMap,
     inspectionResults,
     inspectionResults,

+ 1 - 0
src/features/inspections/Pdf/buildCertificatePdfBuffer.ts

@@ -159,6 +159,7 @@ export async function buildCertificatePdfBuffer({
       slogan: settingsMap['workshop.slogan'] || undefined,
       slogan: settingsMap['workshop.slogan'] || undefined,
     },
     },
     labels,
     labels,
+    locale,
     logoDataUri,
     logoDataUri,
     signatureDataUri,
     signatureDataUri,
     torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),
     torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),

+ 6 - 2
src/features/inspections/Pdf/buildCertificatePrint.ts

@@ -1,3 +1,4 @@
+import { documentNumbers } from '@/lib/document-numbers'
 import { DEFAULT_DATE_FORMAT, formatDateForPdf } from '@/lib/format'
 import { DEFAULT_DATE_FORMAT, formatDateForPdf } from '@/lib/format'
 import {
 import {
   getDefaultLayout,
   getDefaultLayout,
@@ -96,6 +97,8 @@ export interface CertificatePrintInput {
   data: CertificatePrintData
   data: CertificatePrintData
   workshop?: { name: string; address: string; phone: string; email: string; slogan?: string }
   workshop?: { name: string; address: string; phone: string; email: string; slogan?: string }
   labels?: Record<string, string>
   labels?: Record<string, string>
+  /** The document's language, the one its labels are in; numbers are written its way. */
+  locale?: string
   logoDataUri?: string
   logoDataUri?: string
   /** The inspector's saved signature, drawn on the signature line. */
   /** The inspector's saved signature, drawn on the signature line. */
   signatureDataUri?: string
   signatureDataUri?: string
@@ -271,6 +274,7 @@ export function certificateDataFor(input: {
 export function buildCertificatePrintSpec(input: CertificatePrintInput): DocumentSpec {
 export function buildCertificatePrintSpec(input: CertificatePrintInput): DocumentSpec {
   const { data, workshop, template } = input
   const { data, workshop, template } = input
   const labels = input.labels ?? {}
   const labels = input.labels ?? {}
+  const nums = documentNumbers(input.locale)
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
   const layout = input.layoutConfig
   const layout = input.layoutConfig
     ? mergeWithDefaults({ ...input.layoutConfig, documentType: 'certificate' })
     ? mergeWithDefaults({ ...input.layoutConfig, documentType: 'certificate' })
@@ -303,7 +307,7 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
       : '',
       : '',
     mileage:
     mileage:
       mileage !== null && mileage !== undefined
       mileage !== null && mileage !== undefined
-        ? fillTemplate(L('mileage', 'Mileage: {mileage}'), { mileage: mileage.toLocaleString() })
+        ? fillTemplate(L('mileage', 'Mileage: {mileage}'), { mileage: nums.distance(mileage) })
         : '',
         : '',
     // The facts of the test, each already worded as its own line.
     // The facts of the test, each already worded as its own line.
     test_date: `${L('testDate', 'Date of test')}: ${testDate}`,
     test_date: `${L('testDate', 'Date of test')}: ${testDate}`,
@@ -319,7 +323,7 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
       : '',
       : '',
     odometer:
     odometer:
       mileage !== null && mileage !== undefined
       mileage !== null && mileage !== undefined
-        ? fillTemplate(L('mileage', 'Mileage: {mileage}'), { mileage: mileage.toLocaleString() })
+        ? fillTemplate(L('mileage', 'Mileage: {mileage}'), { mileage: nums.distance(mileage) })
         : '',
         : '',
     next_test_due: nextDue ? `${L('nextTestDue', 'Next test due')}: ${nextDue}` : '',
     next_test_due: nextDue ? `${L('nextTestDue', 'Next test due')}: ${nextDue}` : '',
     company_name: shopName,
     company_name: shopName,

+ 16 - 1
src/features/inventory/Lib/units.ts

@@ -10,6 +10,21 @@
  * quart bottle).
  * quart bottle).
  */
  */
 
 
+/**
+ * The system fluids and parts are measured in. Its own setting since
+ * distance and volume were split, so a British workshop can drive in miles
+ * and pour in litres; a workshop that never chose follows its distance
+ * setting, as before the split. Unset everywhere is imperial, the app's
+ * long-standing default.
+ */
+export function measureSystemIn(
+  settings: Record<string, string | undefined>
+): 'metric' | 'imperial' {
+  const own = settings['workshop.measureSystem']
+  if (own === 'metric' || own === 'imperial') return own
+  return settings['workshop.unitSystem'] === 'metric' ? 'metric' : 'imperial'
+}
+
 export const METRIC_MEASUREMENT_UNITS = ['l', 'ml', 'kg', 'g', 'm', 'cm'] as const
 export const METRIC_MEASUREMENT_UNITS = ['l', 'ml', 'kg', 'g', 'm', 'cm'] as const
 
 
 export const IMPERIAL_MEASUREMENT_UNITS = ['qt', 'gal', 'fl oz', 'lb', 'oz', 'ft', 'in'] as const
 export const IMPERIAL_MEASUREMENT_UNITS = ['qt', 'gal', 'fl oz', 'lb', 'oz', 'ft', 'in'] as const
@@ -19,7 +34,7 @@ export const IMPERIAL_MEASUREMENT_UNITS = ['qt', 'gal', 'fl oz', 'lb', 'oz', 'ft
  *
  *
  * @param localizedCountUnits comma-separated count units from the locale
  * @param localizedCountUnits comma-separated count units from the locale
  *   (`inventory.form.unitSuggestions`, e.g. "Stk,Satz")
  *   (`inventory.form.unitSuggestions`, e.g. "Stk,Satz")
- * @param unitSystem the workshop's `workshop.unitSystem` setting;
+ * @param unitSystem the workshop's measure system (see measureSystemIn);
  *   anything other than "imperial" is treated as metric
  *   anything other than "imperial" is treated as metric
  */
  */
 export function unitSuggestions(localizedCountUnits: string, unitSystem?: string): string[] {
 export function unitSuggestions(localizedCountUnits: string, unitSystem?: string): string[] {

+ 15 - 9
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -1,7 +1,7 @@
 import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
-import { formatQuantity } from '@/lib/format-quantity'
+import { documentNumbers, serviceTypeName } from '@/lib/document-numbers'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
@@ -70,6 +70,8 @@ export interface InvoicePrintInput {
   telegramQrDataUri?: string
   telegramQrDataUri?: string
   telegramLabel?: string
   telegramLabel?: string
   labels?: Record<string, string>
   labels?: Record<string, string>
+  /** The document's language, the one its labels are in; numbers are written its way. */
+  locale?: string
   /** The car's condition this visit, for a layout with Vehicle Condition on. */
   /** The car's condition this visit, for a layout with Vehicle Condition on. */
   conditionMap?: VisitConditionMap
   conditionMap?: VisitConditionMap
   /**
   /**
@@ -147,6 +149,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const labels = input.labels ?? {}
   const labels = input.labels ?? {}
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
+  const nums = documentNumbers(input.locale)
 
 
   const layout = resolveLayout(input)
   const layout = resolveLayout(input)
   const doc = layout.document ?? {}
   const doc = layout.document ?? {}
@@ -235,16 +238,16 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     mileage: data.mileage
     mileage: data.mileage
       ? `${
       ? `${
           labels.mileage
           labels.mileage
-            ? fillTemplate(labels.mileage, { mileage: data.mileage.toLocaleString() })
-            : `Mileage: ${data.mileage.toLocaleString()}`
+            ? fillTemplate(labels.mileage, { mileage: nums.distance(data.mileage) })
+            : `Mileage: ${nums.distance(data.mileage)}`
         } ${invoiceSettings?.unitSystem === 'metric' ? L('km', 'km') : L('mi', 'mi')}`
         } ${invoiceSettings?.unitSystem === 'metric' ? L('km', 'km') : L('mi', 'mi')}`
       : '',
       : '',
     service_title: data.title || '',
     service_title: data.title || '',
     // Counter sales (no vehicle) aren't a service; the default type is noise.
     // Counter sales (no vehicle) aren't a service; the default type is noise.
     service_type: data.vehicle
     service_type: data.vehicle
       ? labels.type
       ? labels.type
-        ? fillTemplate(labels.type, { type: data.type })
-        : `Type: ${data.type}`
+        ? fillTemplate(labels.type, { type: serviceTypeName(data.type, labels) })
+        : `Type: ${serviceTypeName(data.type, labels)}`
       : '',
       : '',
     tech_name: tech ? (labels.tech ? fillTemplate(labels.tech, { tech }) : `Tech: ${tech}`) : '',
     tech_name: tech ? (labels.tech ? fillTemplate(labels.tech, { tech }) : `Tech: ${tech}`) : '',
     company_name: shopDisplayName,
     company_name: shopDisplayName,
@@ -278,7 +281,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const items: DocumentData['items'] = [
   const items: DocumentData['items'] = [
     ...data.laborItems.map((l, i) => ({
     ...data.laborItems.map((l, i) => ({
       n: String(i + 1),
       n: String(i + 1),
-      qty: String(l.hours),
+      qty: nums.count(l.hours),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       desc: l.description,
       price: money(shown(l.rate, l.taxable !== false)),
       price: money(shown(l.rate, l.taxable !== false)),
@@ -287,7 +290,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     })),
     })),
     ...data.partItems.map((p, i) => ({
     ...data.partItems.map((p, i) => ({
       n: String(data.laborItems.length + i + 1),
       n: String(data.laborItems.length + i + 1),
-      qty: formatQuantity(p.quantity, p.unit),
+      qty: nums.quantity(p.quantity, p.unit),
       unit: p.unit || '',
       unit: p.unit || '',
       desc: p.name,
       desc: p.name,
       sub: p.partNumber || undefined,
       sub: p.partNumber || undefined,
@@ -300,7 +303,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
     ref: p.partNumber || '-',
     ref: p.partNumber || '-',
     desc: p.name,
     desc: p.name,
-    qty: formatQuantity(p.quantity, p.unit),
+    qty: nums.quantity(p.quantity, p.unit),
     price: money(shown(p.unitPrice, p.taxable !== false)),
     price: money(shown(p.unitPrice, p.taxable !== false)),
     total: money(shown(p.total, p.taxable !== false)),
     total: money(shown(p.total, p.taxable !== false)),
     group: partGroupKey(p.category),
     group: partGroupKey(p.category),
@@ -319,7 +322,9 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     return {
     return {
       desc: l.description,
       desc: l.description,
-      qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
+      qty: isService
+        ? `${nums.count(l.hours)} ${L('unit', 'unit')}`
+        : `${nums.count(l.hours)} ${L('hrs', 'hrs')}`,
       rate: isService
       rate: isService
         ? money(shown(l.rate, l.taxable !== false))
         ? money(shown(l.rate, l.taxable !== false))
         : labels.ratePerHour
         : labels.ratePerHour
@@ -484,6 +489,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
     attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
     warranty: warrantyForPrint(data, {
     warranty: warrantyForPrint(data, {
       labels,
       labels,
+      locale: input.locale,
       unitSystem: invoiceSettings?.unitSystem,
       unitSystem: invoiceSettings?.unitSystem,
       expires: data.warrantyExpiresAt
       expires: data.warrantyExpiresAt
         ? formatDateForPdf(data.warrantyExpiresAt, df, tz)
         ? formatDateForPdf(data.warrantyExpiresAt, df, tz)

+ 15 - 6
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -1,7 +1,7 @@
 import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
-import { formatQuantity } from '@/lib/format-quantity'
+import { documentNumbers } from '@/lib/document-numbers'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
@@ -127,6 +127,8 @@ export interface QuotePrintInput {
   otherAttachmentNames?: string[]
   otherAttachmentNames?: string[]
   customFields?: Array<{ fieldId: string; label: string; value: string; fieldType: string }>
   customFields?: Array<{ fieldId: string; label: string; value: string; fieldType: string }>
   labels?: Record<string, string>
   labels?: Record<string, string>
+  /** The document's language, the one its labels are in; numbers are written its way. */
+  locale?: string
   layoutConfig?: InvoiceLayoutConfig
   layoutConfig?: InvoiceLayoutConfig
   /** Print each line with tax included, and say how much of the subtotal is tax. */
   /** Print each line with tax included, and say how much of the subtotal is tax. */
   lineItemsInclTax?: boolean
   lineItemsInclTax?: boolean
@@ -148,6 +150,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   // The fee under the work, and a fee of nothing left out.
   // The fee under the work, and a fee of nothing left out.
   const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const labels = input.labels ?? {}
   const labels = input.labels ?? {}
+  const nums = documentNumbers(input.locale)
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
 
 
   const saved = input.layoutConfig
   const saved = input.layoutConfig
@@ -258,7 +261,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   const items: DocumentData['items'] = [
   const items: DocumentData['items'] = [
     ...data.laborItems.map((l, i) => ({
     ...data.laborItems.map((l, i) => ({
       n: String(i + 1),
       n: String(i + 1),
-      qty: String(l.hours),
+      qty: nums.count(l.hours),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       desc: l.description,
       price: money(shown(l.rate, l.taxable !== false)),
       price: money(shown(l.rate, l.taxable !== false)),
@@ -268,7 +271,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     })),
     })),
     ...data.partItems.map((p, i) => ({
     ...data.partItems.map((p, i) => ({
       n: String(data.laborItems.length + i + 1),
       n: String(data.laborItems.length + i + 1),
-      qty: formatQuantity(p.quantity, p.unit),
+      qty: nums.quantity(p.quantity, p.unit),
       unit: p.unit || '',
       unit: p.unit || '',
       desc: p.name,
       desc: p.name,
       sub: p.partNumber || undefined,
       sub: p.partNumber || undefined,
@@ -282,7 +285,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
     ref: p.partNumber || '-',
     ref: p.partNumber || '-',
     desc: p.name,
     desc: p.name,
-    qty: formatQuantity(p.quantity, p.unit),
+    qty: nums.quantity(p.quantity, p.unit),
     price: money(shown(p.unitPrice, p.taxable !== false)),
     price: money(shown(p.unitPrice, p.taxable !== false)),
     total: money(shown(p.total, p.taxable !== false)),
     total: money(shown(p.total, p.taxable !== false)),
     excluded: p.excluded,
     excluded: p.excluded,
@@ -302,7 +305,9 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     return {
     return {
       desc: l.description,
       desc: l.description,
-      qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
+      qty: isService
+        ? `${nums.count(l.hours)} ${L('unit', 'unit')}`
+        : `${nums.count(l.hours)} ${L('hrs', 'hrs')}`,
       rate: isService
       rate: isService
         ? money(shown(l.rate, l.taxable !== false))
         ? money(shown(l.rate, l.taxable !== false))
         : labels.ratePerHour
         : labels.ratePerHour
@@ -398,7 +403,11 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     totals,
     totals,
     notes: { html: data.description ?? undefined },
     notes: { html: data.description ?? undefined },
     attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
     attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
-    warranty: warrantyForPrint(data, { labels, unitSystem: input.unitSystem }),
+    warranty: warrantyForPrint(data, {
+      labels,
+      locale: input.locale,
+      unitSystem: input.unitSystem,
+    }),
     payment: [],
     payment: [],
     conditionMap: visitConditionMapForPrint(input.conditionMap, doc.margin) ?? undefined,
     conditionMap: visitConditionMapForPrint(input.conditionMap, doc.margin) ?? undefined,
     // What the inspection found, through the certificate's own blocks.
     // What the inspection found, through the certificate's own blocks.

+ 16 - 8
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -7,7 +7,7 @@ import {
   formatDateTime,
   formatDateTime,
 } from '@/lib/format'
 } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
-import { formatQuantity } from '@/lib/format-quantity'
+import { documentNumbers, serviceTypeName } from '@/lib/document-numbers'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
@@ -99,6 +99,8 @@ export interface WorkOrderPrintInput {
   template?: TemplateConfig
   template?: TemplateConfig
   torqvoiceLogoDataUri?: string
   torqvoiceLogoDataUri?: string
   labels?: Record<string, string>
   labels?: Record<string, string>
+  /** The document's language, the one its labels are in; numbers are written its way. */
+  locale?: string
 }
 }
 
 
 function fillTemplate(template: string, values: Record<string, string>): string {
 function fillTemplate(template: string, values: Record<string, string>): string {
@@ -157,6 +159,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const { workshop, invoiceSettings, template, job } = input
   const { workshop, invoiceSettings, template, job } = input
   const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const labels = input.labels ?? {}
   const labels = input.labels ?? {}
+  const nums = documentNumbers(input.locale)
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
 
 
   const layout = resolveLayout(input)
   const layout = resolveLayout(input)
@@ -228,11 +231,13 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       : '',
       : '',
     mileage: data.mileage
     mileage: data.mileage
       ? `${fillTemplate(L('mileage', 'Mileage: {mileage}'), {
       ? `${fillTemplate(L('mileage', 'Mileage: {mileage}'), {
-          mileage: data.mileage.toLocaleString(),
+          mileage: nums.distance(data.mileage),
         })} ${unit}`
         })} ${unit}`
       : '',
       : '',
     service_title: data.title || '',
     service_title: data.title || '',
-    service_type: data.vehicle ? fillTemplate(L('type', 'Type: {type}'), { type: data.type }) : '',
+    service_type: data.vehicle
+      ? fillTemplate(L('type', 'Type: {type}'), { type: serviceTypeName(data.type, labels) })
+      : '',
     tech_name: tech ? fillTemplate(L('tech', 'Tech: {tech}'), { tech }) : '',
     tech_name: tech ? fillTemplate(L('tech', 'Tech: {tech}'), { tech }) : '',
     // The facts of the job, each already worded as its own line.
     // The facts of the job, each already worded as its own line.
     status: job.statusLabel
     status: job.statusLabel
@@ -273,7 +278,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const items: DocumentData['items'] = [
   const items: DocumentData['items'] = [
     ...data.laborItems.map((l, i) => ({
     ...data.laborItems.map((l, i) => ({
       n: String(i + 1),
       n: String(i + 1),
-      qty: String(l.hours),
+      qty: nums.count(l.hours),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       desc: l.description,
       price: money(shown(l.rate, l.taxable !== false)),
       price: money(shown(l.rate, l.taxable !== false)),
@@ -281,7 +286,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
     })),
     })),
     ...data.partItems.map((p, i) => ({
     ...data.partItems.map((p, i) => ({
       n: String(data.laborItems.length + i + 1),
       n: String(data.laborItems.length + i + 1),
-      qty: formatQuantity(p.quantity, p.unit),
+      qty: nums.quantity(p.quantity, p.unit),
       unit: p.unit || '',
       unit: p.unit || '',
       desc: p.name,
       desc: p.name,
       sub: p.partNumber || undefined,
       sub: p.partNumber || undefined,
@@ -292,7 +297,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
     ref: p.partNumber || '-',
     ref: p.partNumber || '-',
     desc: p.name,
     desc: p.name,
-    qty: formatQuantity(p.quantity, p.unit),
+    qty: nums.quantity(p.quantity, p.unit),
     price: money(shown(p.unitPrice, p.taxable !== false)),
     price: money(shown(p.unitPrice, p.taxable !== false)),
     total: money(shown(p.total, p.taxable !== false)),
     total: money(shown(p.total, p.taxable !== false)),
   }))
   }))
@@ -300,7 +305,9 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     return {
     return {
       desc: l.description,
       desc: l.description,
-      qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
+      qty: isService
+        ? `${nums.count(l.hours)} ${L('unit', 'unit')}`
+        : `${nums.count(l.hours)} ${L('hrs', 'hrs')}`,
       rate: isService
       rate: isService
         ? money(shown(l.rate, l.taxable !== false))
         ? money(shown(l.rate, l.taxable !== false))
         : fillTemplate(L('ratePerHour', '{rate}/hr'), {
         : fillTemplate(L('ratePerHour', '{rate}/hr'), {
@@ -320,7 +327,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
         l.pricingType === 'service'
         l.pricingType === 'service'
           ? undefined
           ? undefined
           : l.hours
           : l.hours
-            ? `${l.hours} ${L('hrs', 'hrs')}`
+            ? `${nums.count(l.hours)} ${L('hrs', 'hrs')}`
             : undefined,
             : undefined,
     }))
     }))
 
 
@@ -433,6 +440,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
     notes: { html: data.invoiceNotes ?? undefined },
     notes: { html: data.invoiceNotes ?? undefined },
     warranty: warrantyForPrint(data, {
     warranty: warrantyForPrint(data, {
       labels,
       labels,
+      locale: input.locale,
       unitSystem: invoiceSettings?.unitSystem,
       unitSystem: invoiceSettings?.unitSystem,
       expires: data.warrantyExpiresAt ? day(data.warrantyExpiresAt) : undefined,
       expires: data.warrantyExpiresAt ? day(data.warrantyExpiresAt) : undefined,
     }),
     }),

+ 4 - 1
src/features/invoice-designer/Pdf/warrantyPrint.ts

@@ -1,3 +1,4 @@
+import { documentNumbers } from '@/lib/document-numbers'
 import { normalizeWarranty } from '@/lib/warranty'
 import { normalizeWarranty } from '@/lib/warranty'
 import type { DocumentData } from '../Spec/buildSpec'
 import type { DocumentData } from '../Spec/buildSpec'
 
 
@@ -23,6 +24,8 @@ export function warrantyForPrint(
     unitSystem?: string
     unitSystem?: string
     /** The expiry, already formatted; a quote has none. */
     /** The expiry, already formatted; a quote has none. */
     expires?: string
     expires?: string
+    /** The document's language, for the mileage limit. */
+    locale?: string
   }
   }
 ): DocumentData['warranty'] {
 ): DocumentData['warranty'] {
   const { labels } = opts
   const { labels } = opts
@@ -48,7 +51,7 @@ export function warrantyForPrint(
   }
   }
   if (warranty.warrantyMileage) {
   if (warranty.warrantyMileage) {
     const unit = opts.unitSystem === 'metric' ? labels.km || 'km' : labels.mi || 'mi'
     const unit = opts.unitSystem === 'metric' ? labels.km || 'km' : labels.mi || 'mi'
-    parts.push(`${warranty.warrantyMileage.toLocaleString()} ${unit}`)
+    parts.push(`${documentNumbers(opts.locale).distance(warranty.warrantyMileage)} ${unit}`)
   }
   }
 
 
   const duration = parts.length ? parts.join(' / ') : undefined
   const duration = parts.length ? parts.join(' / ') : undefined

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

@@ -90,6 +90,7 @@ export async function renderInvoicePdf(
     telegramQrDataUri: telegramQr?.dataUri,
     telegramQrDataUri: telegramQr?.dataUri,
     telegramLabel: labels?.telegramConnect || 'Chat with us on Telegram',
     telegramLabel: labels?.telegramConnect || 'Chat with us on Telegram',
     labels,
     labels,
+    locale,
     conditionMap,
     conditionMap,
     inspectionResults: assembly.inspectionResults,
     inspectionResults: assembly.inspectionResults,
     ...attachments,
     ...attachments,

+ 1 - 0
src/features/invoices/Pdf/buildWorkOrderPdfBuffer.ts

@@ -54,6 +54,7 @@ export async function buildWorkOrderPdfBuffer(
     template: assembly.template,
     template: assembly.template,
     torqvoiceLogoDataUri,
     torqvoiceLogoDataUri,
     labels,
     labels,
+    locale,
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
   }) as any
   }) as any
   const buffer = await renderToBuffer(element)
   const buffer = await renderToBuffer(element)

+ 44 - 15
src/features/onboarding/Actions/createOnboardingOrg.ts

@@ -1,7 +1,7 @@
 'use server'
 'use server'
 
 
 import { headers, cookies } from 'next/headers'
 import { headers, cookies } from 'next/headers'
-import { getLocale, getTranslations } from 'next-intl/server'
+import { getLocale } from 'next-intl/server'
 import { auth } from '@/lib/auth'
 import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { logAudit } from '@/lib/audit'
 import { logAudit } from '@/lib/audit'
@@ -10,10 +10,13 @@ import {
   installDefaultInspectionTemplates,
   installDefaultInspectionTemplates,
   installDefaultLaborPresets,
   installDefaultLaborPresets,
 } from '../Lib/onboardingDefaults'
 } from '../Lib/onboardingDefaults'
+import { onboardingTranslator } from '../Lib/onboardingTranslator'
 import { seedSampleData } from '../Lib/sampleData'
 import { seedSampleData } from '../Lib/sampleData'
 import { CHECKLIST_DISMISSED_KEY, SAMPLE_DATA_IDS_KEY } from '../Lib/onboardingKeys'
 import { CHECKLIST_DISMISSED_KEY, SAMPLE_DATA_IDS_KEY } from '../Lib/onboardingKeys'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { saveInitialWorkshopLocale } from '@/features/settings/Lib/initialWorkshopLocale'
 import { saveInitialWorkshopLocale } from '@/features/settings/Lib/initialWorkshopLocale'
+import { setupSettings } from '../Lib/setupSettings'
+import { starterDesignSettings } from '../Lib/starterDesign'
 import { safeTimeZone } from '@/lib/timezone'
 import { safeTimeZone } from '@/lib/timezone'
 import type { ActionResult } from '@/lib/with-auth'
 import type { ActionResult } from '@/lib/with-auth'
 import { organizationAllowance, SINGLE_WORKSHOP_MESSAGE } from '@/lib/features'
 import { organizationAllowance, SINGLE_WORKSHOP_MESSAGE } from '@/lib/features'
@@ -66,22 +69,42 @@ export async function createOnboardingOrg(
     // membership above are what onboarding must not lose, so a hiccup here
     // membership above are what onboarding must not lose, so a hiccup here
     // never fails the action — the library sync tops templates up later
     // never fails the action — the library sync tops templates up later
     // anyway.
     // anyway.
+    // The language customers get: chosen in the guided setup, else the one
+    // the owner set the workshop up in, so invoices and mails never fall
+    // back to English on their own.
+    const workshopLocale = data.setup?.locale ?? (await getLocale())
+
+    // What the owner chose, step by step. Kept apart from the first-run
+    // content below, so a hiccup in one never skips the other.
     try {
     try {
-      const zone = safeTimeZone(data.timeZone, '')
-      if (zone) {
-        await db.appSetting.create({
-          data: {
-            organizationId: org.id,
-            key: SETTING_KEYS.TIMEZONE,
-            value: zone,
-            userId: session.user.id,
-          },
-        })
+      // A new workshop starts on the starter template; its own setup wins
+      // should the two ever name the same key.
+      const rows = {
+        ...starterDesignSettings(),
+        ...(data.setup ? setupSettings(data.setup, data.timeZone) : timeZoneOnly(data.timeZone)),
       }
       }
-      const [locale, t] = await Promise.all([getLocale(), getTranslations('onboarding')])
-      // The workshop's language starts as the one its owner set it up in, so
-      // invoices and mails never fall back to English on their own.
-      await saveInitialWorkshopLocale(db, org.id, session.user.id, locale)
+      await db.appSetting.createMany({
+        data: Object.entries(rows).map(([key, value]) => ({
+          organizationId: org.id,
+          key,
+          value,
+          userId: session.user.id,
+        })),
+        skipDuplicates: true,
+      })
+      await saveInitialWorkshopLocale(db, org.id, session.user.id, workshopLocale)
+    } catch (settingsError) {
+      console.error(
+        '[createOnboardingOrg] Saving the setup failed:',
+        settingsError instanceof Error ? settingsError.message : settingsError
+      )
+    }
+
+    try {
+      // Templates, labour presets and sample jobs end up on documents, so
+      // they are written in the customers' language, not the owner's screens.
+      const t = await onboardingTranslator(workshopLocale)
+      const locale = workshopLocale
 
 
       const template = await installDefaultInspectionTemplates(org.id, locale)
       const template = await installDefaultInspectionTemplates(org.id, locale)
       await installDefaultLaborPresets(org.id, session.user.id, t)
       await installDefaultLaborPresets(org.id, session.user.id, t)
@@ -147,3 +170,9 @@ export async function createOnboardingOrg(
     return { success: false, error: message }
     return { success: false, error: message }
   }
   }
 }
 }
+
+/** An older client sends only the browser's zone; keep it as the workshop's. */
+function timeZoneOnly(timeZone: string | undefined): Record<string, string> {
+  const zone = safeTimeZone(timeZone, '')
+  return zone ? { [SETTING_KEYS.TIMEZONE]: zone } : {}
+}

+ 0 - 102
src/features/onboarding/Components/OnboardingForm.tsx

@@ -1,102 +0,0 @@
-'use client'
-
-import { safeRedirectPath } from '@/lib/safe-redirect'
-import { useState } from 'react'
-import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
-import { useGlassModal } from '@/components/glass-modal'
-import { Gauge, Loader2 } from 'lucide-react'
-import { createOnboardingOrg } from '../Actions/createOnboardingOrg'
-import { track } from '@/lib/analytics'
-
-export function OnboardingForm({ redirectTo }: { redirectTo?: string }) {
-  const t = useTranslations('onboarding.form')
-  const [workshopName, setWorkshopName] = useState('')
-  const [loadSampleData, setLoadSampleData] = useState(true)
-  const [loading, setLoading] = useState(false)
-  const router = useRouter()
-  const modal = useGlassModal()
-
-  const handleSubmit = async (e: React.FormEvent) => {
-    e.preventDefault()
-    setLoading(true)
-
-    try {
-      const result = await createOnboardingOrg({
-        workshopName,
-        loadSampleData,
-        // The workshop is where the browser is, on first run. Stored as a
-        // choice, not a guess, so scheduling has a zone from day one.
-        timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
-      })
-      if (!result.success) {
-        modal.open('error', t('setupFailed'), result.error || t('couldNotCreate'))
-      } else {
-        track('workshop:create', { sample_data: loadSampleData })
-        router.push(safeRedirectPath(redirectTo))
-        router.refresh()
-      }
-    } catch {
-      modal.open('error', t('setupFailed'), t('unexpectedError'))
-    } finally {
-      setLoading(false)
-    }
-  }
-
-  return (
-    <div className="glass relative z-10 w-full max-w-md rounded-2xl p-8 shadow-2xl">
-      <div className="mb-8 text-center">
-        <div className="mb-4 inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2">
-          <Gauge className="h-5 w-5 text-primary" />
-          <span className="gradient-text text-sm font-bold tracking-wider uppercase">
-            Torqvoice
-          </span>
-        </div>
-        <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
-        <p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
-      </div>
-
-      <form onSubmit={handleSubmit} className="space-y-4">
-        <div className="space-y-2">
-          <Label htmlFor="workshopName">{t('workshopName')}</Label>
-          <Input
-            id="workshopName"
-            type="text"
-            placeholder={t('workshopNamePlaceholder')}
-            value={workshopName}
-            onChange={(e) => setWorkshopName(e.target.value)}
-            required
-            minLength={2}
-            maxLength={100}
-            className="h-11 bg-background/50"
-          />
-        </div>
-
-        <label
-          htmlFor="loadSampleData"
-          className="flex cursor-pointer items-start justify-between gap-3 rounded-lg border bg-background/50 p-3"
-        >
-          <div className="space-y-0.5">
-            <span className="text-sm font-medium">{t('sampleDataLabel')}</span>
-            <p className="text-xs text-muted-foreground">{t('sampleDataDescription')}</p>
-          </div>
-          <Switch
-            id="loadSampleData"
-            checked={loadSampleData}
-            onCheckedChange={setLoadSampleData}
-            className="mt-0.5"
-          />
-        </label>
-
-        <Button type="submit" className="h-11 w-full" disabled={loading}>
-          {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
-          {t('submit')}
-        </Button>
-      </form>
-    </div>
-  )
-}

+ 28 - 0
src/features/onboarding/Components/setup/LaterInSettings.tsx

@@ -0,0 +1,28 @@
+'use client'
+
+import { Settings2 } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import type { ReactNode } from 'react'
+
+export type SettingsPage = 'localization' | 'workshop' | 'tax'
+
+/** "Settings → Tax", named exactly as the settings menu names it. */
+export function useSettingsPath(): (page: SettingsPage) => string {
+  const t = useTranslations('settings')
+  return (page) => `${t('title')} → ${t(`nav.items.${page}.title`)}`
+}
+
+/** Bold for the settings path inside a `t.rich` message. */
+export const settingsPathTag = (chunks: ReactNode) => (
+  <strong className="font-semibold text-foreground">{chunks}</strong>
+)
+
+/** Tells the owner where a step's choices live once the workshop is open. */
+export function LaterInSettings({ children }: { children: ReactNode }) {
+  return (
+    <p className="flex items-start gap-2 text-[13px] leading-relaxed text-muted-foreground">
+      <Settings2 className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+      <span>{children}</span>
+    </p>
+  )
+}

+ 123 - 0
src/features/onboarding/Components/setup/SearchPicker.tsx

@@ -0,0 +1,123 @@
+'use client'
+
+import { Check, ChevronDown } from 'lucide-react'
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+
+export interface PickerOption {
+  value: string
+  label: string
+  /** Extra words the search matches, such as a currency's code. */
+  keywords?: string
+}
+
+/**
+ * Ranks an option for what is typed. Whole words first, so "UK" finds the
+ * United Kingdom before Ukraine; then names starting with it, words starting
+ * with it, and anything containing it. No fuzzy matching: "UK" matching
+ * Uzbekistan letter by letter only buries the answer.
+ */
+export function pickerScore(value: string, search: string): number {
+  const q = search.trim().toLowerCase()
+  if (!q) return 1
+  const text = value.toLowerCase()
+  const words = text.split(/[\s,()/-]+/)
+  if (words.includes(q)) return 1
+  if (text.startsWith(q)) return 0.9
+  if (words.some((w) => w.startsWith(q))) return 0.7
+  if (text.includes(q)) return 0.4
+  return 0
+}
+
+/** A searchable list behind a button, for long lists: countries, currencies, zones. */
+export function SearchPicker({
+  id,
+  value,
+  options,
+  onChange,
+  placeholder,
+  searchPlaceholder,
+  empty,
+  icon,
+  className,
+  disabled,
+}: {
+  id?: string
+  value: string | null
+  options: PickerOption[]
+  onChange: (value: string) => void
+  placeholder: string
+  searchPlaceholder: string
+  empty: string
+  icon?: React.ReactNode
+  className?: string
+  disabled?: boolean
+}) {
+  const [open, setOpen] = useState(false)
+  const selected = options.find((o) => o.value === value)
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          id={id}
+          type="button"
+          disabled={disabled}
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          className={cn(
+            'h-11 w-full justify-between bg-background px-3.5 text-[15px] font-normal',
+            className
+          )}
+        >
+          <span className="flex min-w-0 items-center gap-2.5">
+            {icon}
+            <span className={cn('truncate', !selected && 'text-muted-foreground')}>
+              {selected?.label ?? placeholder}
+            </span>
+          </span>
+          <ChevronDown className="h-4 w-4 shrink-0 opacity-60" aria-hidden="true" />
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-[--radix-popover-trigger-width] min-w-64 p-0" align="start">
+        <Command filter={pickerScore}>
+          <CommandInput placeholder={searchPlaceholder} />
+          <CommandList>
+            <CommandEmpty>{empty}</CommandEmpty>
+            <CommandGroup>
+              {options.map((option) => (
+                <CommandItem
+                  key={option.value}
+                  value={`${option.label} ${option.keywords ?? ''} ${option.value}`}
+                  onSelect={() => {
+                    onChange(option.value)
+                    setOpen(false)
+                  }}
+                >
+                  <Check
+                    className={cn(
+                      'mr-2 h-4 w-4',
+                      value === option.value ? 'opacity-100' : 'opacity-0'
+                    )}
+                    aria-hidden="true"
+                  />
+                  {option.label}
+                </CommandItem>
+              ))}
+            </CommandGroup>
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 190 - 0
src/features/onboarding/Components/setup/SetupPreview.tsx

@@ -0,0 +1,190 @@
+'use client'
+
+import { Maximize2 } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { invoiceLabels } from '@/features/invoice-designer/Lib/invoiceLabels'
+import {
+  type PdfMessages,
+  withMarineDocumentLabels,
+} from '@/features/invoice-designer/Lib/marineLabels'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+import { SpecThumbnail } from '@/features/invoice-designer/Render/SpecThumbnail'
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
+import type { Locale } from '@/i18n/config'
+import { track } from '@/lib/analytics'
+import { type PreviewSample, previewInvoiceInput } from '../../Lib/previewInvoice'
+import type { SetupDraft } from '../../Lib/setupDraft'
+
+interface LanguagePack {
+  locale: Locale
+  labels: Record<string, string>
+  /** The same labels with a boat yard's words: vessel, HIN, engine hours. */
+  marineLabels: Record<string, string>
+  sample: PreviewSample
+}
+
+/** The printed labels and the sample job's prose, for one language. */
+async function loadPack(locale: Locale): Promise<LanguagePack> {
+  const [pdf, onboarding] = await Promise.all([
+    import(`../../../../../messages/${locale}/pdf.json`),
+    import(`../../../../../messages/${locale}/onboarding.json`),
+  ])
+  const labels = invoiceLabels(pdf.default, 'invoice')
+  return {
+    locale,
+    labels,
+    marineLabels: withMarineDocumentLabels(labels, pdf.default as PdfMessages),
+    sample: onboarding.default.setup.sample,
+  }
+}
+
+/** White kept beside and below the printed content, in points. */
+const GUTTER = 14
+
+/**
+ * The page cropped to what is printed and drawn as wide as its box: the side
+ * margins and the empty bottom of the page are cut. A whole A4 page in a side
+ * column prints its text at about 5px; cropped, it is nearly twice that.
+ */
+function CroppedSheet({ spec }: { spec: DocumentSpec }) {
+  const boxRef = useRef<HTMLDivElement>(null)
+  const [width, setWidth] = useState(0)
+  useEffect(() => {
+    const box = boxRef.current
+    if (!box) return
+    const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
+    observer.observe(box)
+    return () => observer.disconnect()
+  }, [])
+
+  // The visible part of the page, in points: margins cut down to a gutter.
+  const cropX = Math.max(0, Math.min(spec.page.margin.left, spec.page.margin.right) - GUTTER)
+  const cropTop = Math.max(0, spec.page.margin.top - GUTTER)
+  const scale = width ? width / (spec.page.width - cropX * 2) : 0
+
+  // Where the printed content ends, in points, so the empty rest is not drawn.
+  const [contentBottom, setContentBottom] = useState(0)
+  useLayoutEffect(() => {
+    const page = boxRef.current?.querySelector<HTMLElement>('[data-preview-page] > div > div')
+    if (!page) return
+    const measure = () => {
+      let bottom = 0
+      for (const child of Array.from(page.children) as HTMLElement[]) {
+        // A side rail of a framed design runs the full page and ends nothing.
+        if (child.offsetHeight >= spec.page.height * 0.9) continue
+        bottom = Math.max(bottom, child.offsetTop + child.offsetHeight)
+      }
+      setContentBottom(bottom)
+    }
+    measure()
+    // A logo loading or web fonts arriving change the content's height after
+    // it is drawn, so it is measured again whenever its size changes.
+    const observer = new ResizeObserver(measure)
+    for (const child of Array.from(page.children)) observer.observe(child)
+    void document.fonts?.ready.then(measure)
+    return () => observer.disconnect()
+  }, [spec, scale])
+
+  const visibleHeight =
+    Math.min(spec.page.height, (contentBottom || spec.page.height) + GUTTER) - cropTop
+
+  return (
+    <div
+      ref={boxRef}
+      className="relative overflow-hidden bg-white"
+      style={{ height: scale ? Math.round(visibleHeight * scale) : undefined }}
+    >
+      {scale > 0 && (
+        <div
+          data-preview-page=""
+          className="[&>div]:border-0!"
+          style={{ position: 'absolute', left: -cropX * scale, top: -cropTop * scale }}
+        >
+          <SpecThumbnail spec={spec} height={spec.page.height * scale} />
+        </div>
+      )}
+    </div>
+  )
+}
+
+/**
+ * The first invoice, drawn by the same builder and renderer as the real one,
+ * in the language customers get. It follows every choice as it is made, and
+ * opens at full size for a closer look.
+ */
+export function SetupPreview({ draft, caption }: { draft: SetupDraft; caption: string }) {
+  const t = useTranslations('onboarding.setup.preview')
+  const [pack, setPack] = useState<LanguagePack | null>(null)
+  const [enlarged, setEnlarged] = useState(false)
+
+  useEffect(() => {
+    let alive = true
+    loadPack(draft.locale)
+      .then((next) => alive && setPack(next))
+      .catch(() => undefined)
+    return () => {
+      alive = false
+    }
+  }, [draft.locale])
+
+  const spec = useMemo(
+    () =>
+      pack
+        ? buildInvoicePrintSpec(
+            previewInvoiceInput(
+              draft,
+              draft.kind === 'marine' ? pack.marineLabels : pack.labels,
+              pack.sample
+            )
+          )
+        : null,
+    [draft, pack]
+  )
+
+  return (
+    <aside aria-label={t('label')} className="flex min-w-0 flex-col gap-3">
+      <span className="inline-flex items-center gap-2 text-[13px] font-semibold text-muted-foreground">
+        <span className="h-2 w-2 rounded-full bg-emerald-600" aria-hidden="true" />
+        {caption}
+      </span>
+      {spec ? (
+        <button
+          type="button"
+          onClick={() => {
+            track('workshop:setup_preview_open')
+            setEnlarged(true)
+          }}
+          className="group relative block w-full cursor-zoom-in overflow-hidden rounded-lg text-left shadow-[0_18px_40px_rgba(70,52,18,0.10)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
+          lang={draft.locale}
+        >
+          <CroppedSheet spec={spec} />
+          <span className="absolute right-2.5 bottom-2.5 inline-flex items-center gap-1.5 rounded-full bg-neutral-900/80 px-2.5 py-1 text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
+            <Maximize2 className="h-3 w-3" aria-hidden="true" />
+            {t('enlarge')}
+          </span>
+        </button>
+      ) : (
+        <div
+          className="flex aspect-[1/1.414] items-center justify-center rounded-lg bg-white text-sm text-neutral-500"
+          role="status"
+        >
+          {t('loading')}
+        </div>
+      )}
+
+      <Dialog open={enlarged} onOpenChange={setEnlarged}>
+        <DialogContent className="max-h-[92vh] w-[calc(100vw-2rem)] max-w-[820px] gap-3 overflow-y-auto p-4 sm:max-w-[820px] sm:p-5">
+          <DialogTitle className="pr-10 text-[15px] font-semibold">{t('label')}</DialogTitle>
+          <DialogDescription className="sr-only">{caption}</DialogDescription>
+          {spec && (
+            <div className="overflow-hidden rounded-md" lang={draft.locale}>
+              <CroppedSheet spec={spec} />
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </aside>
+  )
+}

+ 79 - 0
src/features/onboarding/Components/setup/SetupStepper.tsx

@@ -0,0 +1,79 @@
+'use client'
+
+import { Check } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+
+export const SETUP_STEPS = ['workshop', 'region', 'tax', 'ready'] as const
+export type SetupStep = (typeof SETUP_STEPS)[number]
+
+/** Where the owner is in the four steps; a step already seen can be revisited. */
+export function SetupStepper({
+  current,
+  reached,
+  onGo,
+}: {
+  current: SetupStep
+  /** The furthest step opened so far. */
+  reached: number
+  onGo: (step: SetupStep) => void
+}) {
+  const t = useTranslations('onboarding.setup')
+  const currentIndex = SETUP_STEPS.indexOf(current)
+  return (
+    <ol aria-label={t('stepsLabel')} className="flex flex-wrap gap-1.5">
+      {SETUP_STEPS.map((step, index) => {
+        const isCurrent = index === currentIndex
+        const isDone = index < currentIndex || (index <= reached && !isCurrent)
+        const reachable = index <= reached && !isCurrent
+        const badge = (
+          <span
+            className={cn(
+              'inline-flex h-[22px] w-[22px] items-center justify-center rounded-full text-xs',
+              isCurrent && 'bg-primary text-primary-foreground',
+              isDone && 'bg-accent text-foreground',
+              !isCurrent && !isDone && 'border border-border'
+            )}
+          >
+            {isDone ? <Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" /> : index + 1}
+          </span>
+        )
+        // On a phone only the current step is named on screen; the others keep
+        // their names for screen readers.
+        const label = (
+          <span className={isCurrent ? undefined : 'sr-only sm:not-sr-only'}>
+            {t(`steps.${step}`)}
+          </span>
+        )
+        return (
+          <li key={step}>
+            {reachable ? (
+              <button
+                type="button"
+                onClick={() => onGo(step)}
+                className="flex items-center gap-2 rounded-full py-1.5 pr-1.5 pl-1.5 text-[13px] font-medium text-foreground transition-colors sm:pr-3 hover:bg-accent/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
+              >
+                {badge}
+                {label}
+              </button>
+            ) : (
+              <span
+                aria-current={isCurrent ? 'step' : undefined}
+                className={cn(
+                  'flex items-center gap-2 rounded-full py-1.5 pl-1.5 text-[13px]',
+                  isCurrent ? 'pr-3' : 'pr-1.5 sm:pr-3',
+                  isCurrent
+                    ? 'bg-foreground font-semibold text-background'
+                    : 'font-medium text-muted-foreground'
+                )}
+              >
+                {badge}
+                {label}
+              </span>
+            )}
+          </li>
+        )
+      })}
+    </ol>
+  )
+}

+ 528 - 0
src/features/onboarding/Components/setup/SetupWizard.tsx

@@ -0,0 +1,528 @@
+'use client'
+
+import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useLocale, useTranslations } from 'next-intl'
+import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import { AuthLogo } from '@/components/auth-logo'
+import { useGlassModal } from '@/components/glass-modal'
+import { Button } from '@/components/ui/button'
+import { defaultLocale, type Locale, localeNames, locales } from '@/i18n/config'
+import { registerAnalyticsProperties, track, unregisterAnalyticsProperties } from '@/lib/analytics'
+import { COUNTRY_CODES, countrySearchTerms, detectCountry } from '@/lib/country-defaults'
+import { currencyName, SELECTABLE_CURRENCIES } from '@/lib/currencies'
+import { formatCurrency } from '@/lib/format'
+import { safeRedirectPath } from '@/lib/safe-redirect'
+import { createOnboardingOrg } from '../../Actions/createOnboardingOrg'
+import {
+  initialDraft,
+  type SetupDraft,
+  dialCodeValid,
+  firstIncompleteStep,
+  setupPayload,
+  taxStepComplete,
+  withCountry,
+  withCustomerLanguage,
+  withKind,
+  withLanguage,
+  withSameLanguage,
+  type WorkKind,
+} from '../../Lib/setupDraft'
+import type { PickerOption } from './SearchPicker'
+import { SETUP_STEPS, type SetupStep, SetupStepper } from './SetupStepper'
+import { SetupPreview } from './SetupPreview'
+import { exampleDate, StepRegion } from './StepRegion'
+import { StepReady } from './StepReady'
+import { StepTax } from './StepTax'
+import { StepWorkshop } from './StepWorkshop'
+import { useScreenLanguage } from './useScreenLanguage'
+
+interface BrowserFacts {
+  timeZone: string
+  languages: string[]
+}
+
+let cachedFacts: BrowserFacts | null = null
+
+/** Where the browser says it is. Read once; the same object every time, as useSyncExternalStore wants. */
+function readBrowserFacts(): BrowserFacts {
+  cachedFacts ??= {
+    timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+    languages: [...(navigator.languages ?? [navigator.language])].filter(Boolean),
+  }
+  return cachedFacts
+}
+
+const noSubscription = () => () => undefined
+
+/**
+ * The guided setup of a new workshop: what it is and where, the language its
+ * customers get, its taxes, and a last look. Every regional choice starts
+ * from the country and stays editable, beside a preview of the first invoice
+ * as it would print. Nothing is saved until Create workshop.
+ *
+ * The first draft comes from the browser's time zone and languages, which the
+ * server cannot know, so the wizard is drawn once the page runs in the
+ * browser. Drawing it on the server too would show an empty country there
+ * and a detected one in the browser, and React would refuse to hydrate.
+ */
+export function SetupWizard({ redirectTo }: { redirectTo?: string }) {
+  const facts = useSyncExternalStore(noSubscription, readBrowserFacts, () => null)
+  if (!facts) {
+    return (
+      <div
+        className="relative z-10 mx-auto flex min-h-[60vh] w-full max-w-[1120px] items-center justify-center"
+        aria-busy="true"
+      >
+        <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
+      </div>
+    )
+  }
+  return <SetupWizardSteps redirectTo={redirectTo} facts={facts} />
+}
+
+function SetupWizardSteps({ redirectTo, facts }: { redirectTo?: string; facts: BrowserFacts }) {
+  const t = useTranslations('onboarding.setup')
+  const tRegion = useTranslations('onboarding.setup.region')
+  const tForm = useTranslations('onboarding.form')
+  const tTax = useTranslations('onboarding.setup.tax')
+  const tCommon = useTranslations('common')
+  const uiLocaleRaw = useLocale()
+  const uiLocale: Locale = (locales as readonly string[]).includes(uiLocaleRaw)
+    ? (uiLocaleRaw as Locale)
+    : defaultLocale
+  const router = useRouter()
+  const modal = useGlassModal()
+
+  const [draft, setDraft] = useState<SetupDraft>(() =>
+    initialDraft({ timeZone: facts.timeZone, languages: facts.languages, uiLocale })
+  )
+  // The zone that decided the country, kept to say so on the first step.
+  const [detectedCountry] = useState(() => detectCountry(facts.timeZone, []))
+  const detectedZone = detectedCountry ? facts.timeZone : null
+  const [step, setStep] = useState<SetupStep>('workshop')
+  const [reached, setReached] = useState(0)
+  const [saving, setSaving] = useState(false)
+  const headingRef = useRef<HTMLDivElement>(null)
+  const firstRender = useRef(true)
+
+  const patch = useCallback((p: Partial<SetupDraft>) => setDraft((d) => ({ ...d, ...p })), [])
+
+  // Analytics. Every event while the wizard is open carries the step it
+  // happened on, autocaptured rage clicks included, so a stuck step shows.
+  const startedAt = useRef(Date.now())
+  const stepOpenedAt = useRef(Date.now())
+  const countryPicked = useRef(false)
+  const blockedCount = useRef(0)
+  const secondsOnStep = () => Math.round((Date.now() - stepOpenedAt.current) / 1000)
+  // Sent once, as the wizard opens.
+  useEffect(() => {
+    track('workshop:setup_start', {
+      detected_country: draft.country ?? 'none',
+      detected_from: detectedZone ? 'time_zone' : draft.country ? 'browser_language' : 'none',
+      browser_language: facts.languages[0] ?? 'unknown',
+      time_zone: facts.timeZone,
+      screen_language: uiLocale,
+      customer_language: draft.locale,
+    })
+    return () => unregisterAnalyticsProperties(['setup_step'])
+  }, [])
+  useEffect(() => {
+    registerAnalyticsProperties({ setup_step: step })
+    stepOpenedAt.current = Date.now()
+  }, [step])
+
+  // The step whose Continue was pressed while something was missing: its
+  // fields say what, until it is put right.
+  const [blockedStep, setBlockedStep] = useState<SetupStep | null>(null)
+
+  const regionNames = useMemo(
+    () => new Intl.DisplayNames([uiLocale], { type: 'region' }),
+    [uiLocale]
+  )
+  const countryName = useCallback((code: string) => regionNames.of(code) ?? code, [regionNames])
+  const countryOptions: PickerOption[] = useMemo(
+    () =>
+      COUNTRY_CODES.map((code) => ({
+        value: code,
+        label: countryName(code),
+        keywords: countrySearchTerms(code),
+      })).sort((a, b) => a.label.localeCompare(b.label, uiLocale)),
+    [countryName, uiLocale]
+  )
+  // Every language by its own name, found by its English name and by its
+  // name in the language on screen too: "English" on a Norwegian page.
+  const languageOptions: PickerOption[] = useMemo(() => {
+    const inEnglish = new Intl.DisplayNames(['en'], { type: 'language' })
+    const onScreen = new Intl.DisplayNames([uiLocale], { type: 'language' })
+    return locales.map((l) => ({
+      value: l,
+      label: localeNames[l],
+      keywords: `${l} ${inEnglish.of(l) ?? ''} ${onScreen.of(l) ?? ''}`,
+    }))
+  }, [uiLocale])
+
+  const screens = useScreenLanguage()
+
+  /** The owner's own language: their screens switch to it, and their customers get it. */
+  const chooseLanguage = (locale: Locale) => {
+    track('workshop:setup_language_change', {
+      via: 'language_field',
+      from: uiLocale,
+      to: locale,
+      customer_from: draft.locale,
+    })
+    setDraft((d) => withLanguage(d, locale))
+    if (locale !== uiLocale) screens.switchTo(locale)
+  }
+
+  /** A language for customers alone, under the owner's. */
+  const chooseCustomerLanguage = (locale: Locale) => {
+    if (locale === draft.locale) return
+    track('workshop:setup_language_change', {
+      via: 'customer_language',
+      from: draft.locale,
+      to: locale,
+    })
+    setDraft((d) => withCustomerLanguage(d, locale))
+  }
+
+  /**
+   * A country the owner picked fills in its region, and its language too
+   * unless they picked one themselves; their screens then follow it.
+   */
+  const chooseCountry = (country: string) => {
+    if (country === draft.country) return
+    const next = withCountry(draft, country, facts.languages[0])
+    track('workshop:setup_country_change', {
+      from: draft.country ?? 'none',
+      to: country,
+      replaced: draft.country ? (countryPicked.current ? 'pick' : 'detected') : 'none',
+    })
+    countryPicked.current = true
+    setDraft(next)
+    if (next.languageSource !== 'chosen' && next.locale !== uiLocale) {
+      track('workshop:setup_language_change', {
+        via: 'country',
+        from: uiLocale,
+        to: next.locale,
+        customer_from: draft.locale,
+      })
+      screens.switchTo(next.locale)
+    }
+  }
+
+  const currencyOptions: PickerOption[] = useMemo(
+    () =>
+      SELECTABLE_CURRENCIES.map((c) => ({
+        value: c.code,
+        label: `${currencyName(c.code, uiLocale)} (${c.code})`,
+        keywords: c.code,
+      })),
+    [uiLocale]
+  )
+  const zoneOptions: PickerOption[] = useMemo(() => {
+    const zones =
+      typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
+    const all = zones.includes(draft.timezone) ? zones : [draft.timezone, ...zones]
+    return all.map((z) => ({ value: z, label: z.replaceAll('_', ' '), keywords: z }))
+  }, [draft.timezone])
+
+  const money = useCallback(
+    (value: number) => formatCurrency(value, draft.currencyCode),
+    [draft.currencyCode]
+  )
+  const currencyLabel =
+    currencyOptions.find((o) => o.value === draft.currencyCode)?.label ?? draft.currencyCode
+  const distance = draft.distance === 'imperial' ? tRegion('distanceMiles') : tRegion('distanceKm')
+  const measure =
+    draft.measure === 'imperial' ? tRegion('measureImperial') : tRegion('measureMetric')
+  const unitsValue =
+    draft.kind === 'marine'
+      ? tRegion('unitsSummary', { distance, measure })
+      : tRegion('unitsSummaryEngine', {
+          distance,
+          measure,
+          engine:
+            draft.engineSizeUnit === 'cc' ? tRegion('engineCcShort') : tRegion('engineLitresShort'),
+        })
+
+  // A new step is announced by moving focus to its heading.
+  useEffect(() => {
+    if (firstRender.current) {
+      firstRender.current = false
+      return
+    }
+    const heading = headingRef.current?.querySelector('h1')
+    if (heading) {
+      heading.setAttribute('tabindex', '-1')
+      heading.focus({ preventScroll: true })
+    }
+    window.scrollTo({ top: 0, behavior: 'smooth' })
+  }, [step])
+
+  const go = (next: SetupStep) => {
+    const index = SETUP_STEPS.indexOf(next)
+    setReached((r) => Math.max(r, index))
+    setBlockedStep(null)
+    setStep(next)
+  }
+
+  /** The step bar and the Edit links on Ready. */
+  const jump = (next: SetupStep) => {
+    if (next === step) return
+    track('workshop:setup_step', { step, action: 'jump', to: next, seconds: secondsOnStep() })
+    go(next)
+  }
+
+  const nameValid = draft.workshopName.trim().length >= 2
+  const phoneValid = dialCodeValid(draft.dialCode)
+  const canContinue =
+    step === 'workshop'
+      ? nameValid
+      : step === 'region'
+        ? phoneValid
+        : step === 'tax'
+          ? taxStepComplete(draft)
+          : nameValid
+
+  const advance = (action: 'continue' | 'skip') => {
+    const to = action === 'skip' ? 'ready' : (SETUP_STEPS[SETUP_STEPS.indexOf(step) + 1] ?? 'ready')
+    track('workshop:setup_step', { step, action, to, seconds: secondsOnStep() })
+    if (step === 'workshop' && action === 'skip') {
+      // The suggestions as they stand; the tax is left to Settings.
+      patch({ taxChoice: 'skipped' })
+      go('ready')
+      return
+    }
+    if (step === 'tax' && action === 'skip') {
+      patch({ taxChoice: 'skipped' })
+      go('ready')
+      return
+    }
+    const next = SETUP_STEPS[SETUP_STEPS.indexOf(step) + 1]
+    if (next) go(next)
+  }
+
+  const back = () => {
+    const previous = SETUP_STEPS[SETUP_STEPS.indexOf(step) - 1]
+    if (!previous) return
+    track('workshop:setup_step', { step, action: 'back', to: previous, seconds: secondsOnStep() })
+    setBlockedStep(null)
+    setStep(previous)
+  }
+
+  /**
+   * Continue on an incomplete step: say what is missing, and go to it. From
+   * Ready, `at` is the earlier step that was emptied again.
+   */
+  const block = (at: 'workshop' | 'region' | 'tax' = step as 'workshop' | 'region' | 'tax') => {
+    const reason =
+      at === 'workshop'
+        ? 'name'
+        : at === 'region'
+          ? 'phone_prefix'
+          : draft.taxRate.trim() === ''
+            ? 'tax_rate_missing'
+            : 'tax_rate_invalid'
+    blockedCount.current += 1
+    track('workshop:setup_blocked', { step, reason, attempt: blockedCount.current })
+    if (at !== step) go(at)
+    setBlockedStep(at)
+    const field = at === 'workshop' ? 'workshopName' : at === 'region' ? 'dialCode' : 'taxRate'
+    // Two frames: a step switched to here is drawn before its field exists.
+    requestAnimationFrame(() =>
+      requestAnimationFrame(() => document.getElementById(field)?.focus())
+    )
+  }
+
+  const create = async () => {
+    const incomplete = firstIncompleteStep(draft)
+    if (incomplete) {
+      block(incomplete)
+      return
+    }
+    setSaving(true)
+    try {
+      const result = await createOnboardingOrg(setupPayload(draft))
+      if (!result.success) {
+        track('workshop:setup_create_fail', { reason: 'refused' })
+        modal.open('error', tForm('setupFailed'), result.error || tForm('couldNotCreate'))
+        return
+      }
+      track('workshop:create', {
+        sample_data: draft.loadSampleData,
+        country: draft.country ?? 'unknown',
+        locale: draft.locale,
+        screen_language: uiLocale,
+        language_source: draft.languageSource,
+        currency: draft.currencyCode,
+        distance: draft.distance,
+        measure: draft.measure,
+        kind: draft.kind,
+        tax: draft.taxChoice,
+        seconds: Math.round((Date.now() - startedAt.current) / 1000),
+      })
+      router.push(safeRedirectPath(redirectTo))
+      router.refresh()
+    } catch {
+      track('workshop:setup_create_fail', { reason: 'error' })
+      modal.open('error', tForm('setupFailed'), tForm('unexpectedError'))
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  const onSubmit = (e: React.FormEvent) => {
+    e.preventDefault()
+    if (step === 'ready') void create()
+    else if (canContinue) advance('continue')
+    else block()
+  }
+
+  return (
+    <div className="relative z-10 mx-auto flex w-full max-w-[1120px] flex-col gap-7">
+      <div className="flex flex-wrap items-center justify-between gap-4">
+        {/* The brand mark, as on the sign-up page: a new workshop has no logo of its own yet. */}
+        <AuthLogo alt={tCommon('brandName')} />
+        <SetupStepper current={step} reached={reached} onGo={jump} />
+      </div>
+
+      <div className="flex flex-wrap items-start gap-8">
+        <form
+          onSubmit={onSubmit}
+          noValidate
+          className="glass flex min-w-0 flex-[999_1_520px] flex-col gap-7 rounded-2xl p-6 shadow-2xl sm:p-9"
+        >
+          {/* Focus lands on each step's heading for screen readers; it is not a control, so no ring. */}
+          <div ref={headingRef} className="[&_h1]:outline-none">
+            {step === 'workshop' && (
+              <StepWorkshop
+                draft={draft}
+                showErrors={blockedStep === 'workshop'}
+                uiLocale={uiLocale}
+                switchingScreens={screens.pending}
+                languageOptions={languageOptions}
+                // Only while the country is still the one the clock gave.
+                detectedZone={draft.country === detectedCountry ? detectedZone : null}
+                countryOptions={countryOptions}
+                countryName={countryName}
+                onLanguage={chooseLanguage}
+                onCustomerLanguage={chooseCustomerLanguage}
+                onSeparateLanguage={() => {
+                  track('workshop:setup_suggestion_edit', { field: 'customer_language' })
+                  patch({ customerLanguageSeparate: true })
+                }}
+                onSameLanguage={() => {
+                  track('workshop:setup_language_change', {
+                    via: 'customer_language_same',
+                    from: draft.locale,
+                    to: uiLocale,
+                  })
+                  setDraft((d) => withSameLanguage(d, uiLocale))
+                }}
+                onName={(workshopName) => patch({ workshopName })}
+                onKind={(kind: WorkKind) => setDraft((d) => withKind(d, kind))}
+                onCountry={chooseCountry}
+              />
+            )}
+            {step === 'region' && (
+              <StepRegion
+                draft={draft}
+                countryName={countryName}
+                currencyOptions={currencyOptions}
+                zoneOptions={zoneOptions}
+                moneyExample={money(1234.56)}
+                onChange={patch}
+              />
+            )}
+            {step === 'tax' && (
+              <StepTax
+                draft={draft}
+                showErrors={blockedStep === 'tax'}
+                countryName={countryName}
+                money={money}
+                onChange={patch}
+              />
+            )}
+            {step === 'ready' && (
+              <StepReady
+                draft={draft}
+                uiLocale={uiLocale}
+                countryName={countryName}
+                currencyLabel={currencyLabel}
+                datesValue={exampleDate(draft.dateFormat, draft.timeFormat, false, draft.timezone)}
+                unitsValue={unitsValue}
+                taxFallbackLabel={tTax('namePlaceholder')}
+                onEdit={jump}
+                onChange={patch}
+              />
+            )}
+          </div>
+
+          {/* On a phone the main button comes first at full width, the skip link
+              under it and Back last; side by side from sm. */}
+          <div className="flex flex-col-reverse gap-3 border-t border-border/60 pt-6 sm:flex-row sm:items-center sm:justify-between">
+            {step === 'workshop' ? (
+              <span className="hidden sm:block" />
+            ) : (
+              <Button
+                type="button"
+                variant="outline"
+                className="h-11 w-full px-4 sm:w-auto"
+                onClick={back}
+              >
+                <ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden="true" />
+                {t('back')}
+              </Button>
+            )}
+            <div className="flex flex-col-reverse items-stretch gap-3 sm:flex-row sm:items-center sm:gap-5">
+              {(step === 'workshop' || step === 'tax') && (
+                <Button
+                  type="button"
+                  variant="link"
+                  className="h-auto p-0 text-[13.5px] font-semibold"
+                  disabled={step === 'workshop' && !nameValid}
+                  onClick={() => advance('skip')}
+                >
+                  {step === 'workshop' ? t('workshop.skip') : tTax('skip')}
+                </Button>
+              )}
+              {step === 'ready' ? (
+                <Button
+                  type="submit"
+                  className="h-12 w-full px-6 text-[15px] font-bold sm:w-auto"
+                  disabled={saving}
+                >
+                  {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+                  {t('ready.create')}
+                  {!saving && <ArrowRight className="ml-1.5 h-4 w-4" aria-hidden="true" />}
+                </Button>
+              ) : (
+                <Button
+                  type="submit"
+                  className="h-11 w-full px-6 text-[15px] font-semibold aria-disabled:opacity-50 sm:w-auto"
+                  // Still pressable when the step is incomplete, so the press can
+                  // say what is missing; it only looks unavailable.
+                  aria-disabled={!canContinue || undefined}
+                >
+                  {t('continue')}
+                  <ArrowRight className="ml-1.5 h-4 w-4" aria-hidden="true" />
+                </Button>
+              )}
+            </div>
+          </div>
+        </form>
+
+        {/* Only beside the form: on a phone or a tablet it would sit below the
+            buttons, a long scroll away from the choices it follows. */}
+        <div className="hidden min-w-0 lg:block lg:max-w-[500px] lg:flex-[1_1_400px] xl:basis-[500px]">
+          <SetupPreview
+            draft={draft}
+            caption={step === 'workshop' ? t('preview.first') : t('preview.live')}
+          />
+        </div>
+      </div>
+    </div>
+  )
+}

+ 148 - 0
src/features/onboarding/Components/setup/StepReady.tsx

@@ -0,0 +1,148 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { type Locale, localeNames } from '@/i18n/config'
+import { parseTaxRate, type SetupDraft } from '../../Lib/setupDraft'
+import type { SetupStep } from './SetupStepper'
+
+export function StepReady({
+  draft,
+  uiLocale,
+  countryName,
+  currencyLabel,
+  datesValue,
+  unitsValue,
+  taxFallbackLabel,
+  onEdit,
+  onChange,
+}: {
+  draft: SetupDraft
+  uiLocale: Locale
+  countryName: (code: string) => string
+  currencyLabel: string
+  datesValue: string
+  unitsValue: string
+  /** What the tax is called when the owner gave it no name. */
+  taxFallbackLabel: string
+  onEdit: (step: SetupStep) => void
+  onChange: (patch: Partial<SetupDraft>) => void
+}) {
+  const t = useTranslations('onboarding.setup.ready')
+  const tWorkshop = useTranslations('onboarding.setup.workshop')
+  // One language for everything is one line; customers on another get two.
+  const oneLanguage = draft.locale === uiLocale
+  const tForm = useTranslations('onboarding.form')
+  const name = draft.workshopName.trim()
+  const kind = t(`kinds.${draft.kind}`)
+  const workshop = [name, kind, draft.country ? countryName(draft.country) : null]
+    .filter(Boolean)
+    .join(', ')
+  const rate = parseTaxRate(draft.taxRate) ?? 0
+  const label = draft.taxLabel.trim() || taxFallbackLabel
+  const tax =
+    draft.taxChoice === 'skipped'
+      ? t('taxSkipped')
+      : draft.taxChoice === 'no'
+        ? t('taxOff')
+        : t(draft.taxInclusive ? 'taxIncluding' : 'taxBefore', { label, rate })
+
+  const rows: {
+    key: string
+    label: string
+    value: React.ReactNode
+    step: SetupStep
+  }[] = [
+    { key: 'workshop', label: t('rowWorkshop'), value: workshop, step: 'workshop' },
+    {
+      key: 'customers',
+      label: oneLanguage ? tWorkshop('language') : t('rowCustomers'),
+      step: 'workshop',
+      value: oneLanguage ? (
+        localeNames[draft.locale]
+      ) : (
+        <span className="flex flex-col gap-0.5">
+          <span>{localeNames[draft.locale]}</span>
+          <span className="text-[12.5px] font-normal text-muted-foreground">
+            {t('rowScreens', { language: localeNames[uiLocale] })}
+          </span>
+        </span>
+      ),
+    },
+    {
+      key: 'money',
+      label: t('rowMoney'),
+      value: `${currencyLabel}, ${datesValue}, ${draft.timezone}`,
+      step: 'region',
+    },
+    { key: 'units', label: t('rowUnits'), value: unitsValue, step: 'region' },
+    { key: 'tax', label: t('rowTax'), value: tax, step: 'tax' },
+  ]
+
+  return (
+    <div className="flex flex-col gap-6">
+      <div className="flex flex-col gap-1.5">
+        <h1 className="text-[28px] leading-tight font-bold tracking-tight">
+          {t('title', { name: name || tForm('workshopNamePlaceholder') })}
+        </h1>
+        <p className="text-[15px] leading-relaxed text-muted-foreground">{t('subtitle')}</p>
+      </div>
+
+      <dl className="divide-y rounded-xl border bg-background">
+        {rows.map((row) => (
+          <div key={row.key} className="flex flex-wrap items-baseline gap-x-4 gap-y-1 px-4 py-3.5">
+            <dt className="w-full text-[13.5px] text-muted-foreground sm:w-36 sm:shrink-0">
+              {row.label}
+            </dt>
+            <dd className="min-w-0 flex-1 text-sm font-medium">{row.value}</dd>
+            <Button
+              type="button"
+              variant="link"
+              className="h-auto p-0 text-[13.5px] font-semibold"
+              onClick={() => onEdit(row.step)}
+              aria-label={`${t('edit')}: ${row.label}`}
+            >
+              {t('edit')}
+            </Button>
+          </div>
+        ))}
+      </dl>
+
+      <div className="flex flex-col gap-2">
+        <Label htmlFor="address" className="text-sm font-semibold">
+          {t('address')} <span className="font-normal text-muted-foreground">{t('optional')}</span>
+        </Label>
+        <Textarea
+          id="address"
+          rows={2}
+          value={draft.address}
+          onChange={(e) => onChange({ address: e.target.value })}
+          placeholder={t('addressPlaceholder')}
+          maxLength={500}
+          autoComplete="street-address"
+          className="bg-background text-[15px]"
+        />
+      </div>
+
+      <div className="flex items-start justify-between gap-4 rounded-xl border bg-background p-4">
+        <div className="flex flex-col gap-1">
+          <Label htmlFor="loadSampleData" className="text-sm font-semibold">
+            {tForm('sampleDataLabel')}
+          </Label>
+          <p className="text-[13px] leading-relaxed text-muted-foreground">
+            {tForm('sampleDataDescription')}
+          </p>
+        </div>
+        <Switch
+          id="loadSampleData"
+          checked={draft.loadSampleData}
+          onCheckedChange={(loadSampleData) => onChange({ loadSampleData })}
+          className="mt-0.5"
+        />
+      </div>
+    </div>
+  )
+}

+ 395 - 0
src/features/onboarding/Components/setup/StepRegion.tsx

@@ -0,0 +1,395 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useEffect, useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { track } from '@/lib/analytics'
+import { cn } from '@/lib/utils'
+import { dialCodeValid, SETUP_DATE_FORMATS, type SetupDraft } from '../../Lib/setupDraft'
+import { LaterInSettings, settingsPathTag, useSettingsPath } from './LaterInSettings'
+import { type PickerOption, SearchPicker } from './SearchPicker'
+
+type Row = 'currency' | 'dates' | 'timeZone' | 'units' | 'phone'
+
+/** Now, as a wall clock in the workshop's time zone. */
+function wallClock(now: Date, timeZone: string | undefined) {
+  const read = (zone: string | undefined) =>
+    new Intl.DateTimeFormat('en-US', {
+      timeZone: zone,
+      year: 'numeric',
+      month: 'numeric',
+      day: 'numeric',
+      hour: 'numeric',
+      minute: '2-digit',
+      hourCycle: 'h23',
+    }).formatToParts(now)
+  let parts: Intl.DateTimeFormatPart[]
+  try {
+    parts = read(timeZone)
+  } catch {
+    parts = read(undefined)
+  }
+  const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0)
+  return {
+    year: get('year'),
+    month: get('month') - 1,
+    day: get('day'),
+    hour: get('hour') % 24,
+    minute: get('minute'),
+  }
+}
+
+/**
+ * Today written the way a pattern writes it, for the choices and the summary,
+ * at the workshop's own time.
+ */
+export function exampleDate(
+  pattern: string,
+  timeFormat: '12h' | '24h',
+  withTime = false,
+  timeZone?: string,
+  now: Date = new Date()
+): string {
+  const d = wallClock(now, timeZone)
+  const months = [
+    'Jan',
+    'Feb',
+    'Mar',
+    'Apr',
+    'May',
+    'Jun',
+    'Jul',
+    'Aug',
+    'Sep',
+    'Oct',
+    'Nov',
+    'Dec',
+  ]
+  const longMonths = [
+    'January',
+    'February',
+    'March',
+    'April',
+    'May',
+    'June',
+    'July',
+    'August',
+    'September',
+    'October',
+    'November',
+    'December',
+  ]
+  const dd = String(d.day).padStart(2, '0')
+  const mm = String(d.month + 1).padStart(2, '0')
+  const date = pattern
+    .replace('yyyy', String(d.year))
+    .replace('MMMM', longMonths[d.month])
+    .replace('MMM', months[d.month])
+    .replace('MM', mm)
+    .replace('dd', dd)
+    .replace(/\bd\b/, String(d.day))
+  if (!withTime) return date
+  const minutes = String(d.minute).padStart(2, '0')
+  const time =
+    timeFormat === '12h'
+      ? `${d.hour % 12 || 12}:${minutes} ${d.hour < 12 ? 'AM' : 'PM'}`
+      : `${String(d.hour).padStart(2, '0')}:${minutes}`
+  return `${date}, ${time}`
+}
+
+export function StepRegion({
+  draft,
+  countryName,
+  currencyOptions,
+  zoneOptions,
+  moneyExample,
+  onChange,
+}: {
+  draft: SetupDraft
+  countryName: (code: string) => string
+  currencyOptions: PickerOption[]
+  zoneOptions: PickerOption[]
+  /** 1234.56 written in the chosen currency. */
+  moneyExample: string
+  onChange: (patch: Partial<SetupDraft>) => void
+}) {
+  const t = useTranslations('onboarding.setup.region')
+  const settingsPath = useSettingsPath()
+  const [open, setOpen] = useState<Row | null>(null)
+  // The dates row shows the time now, so it keeps up with the clock.
+  const [now, setNow] = useState(() => new Date())
+  useEffect(() => {
+    const timer = setInterval(() => setNow(new Date()), 15_000)
+    return () => clearInterval(timer)
+  }, [])
+  const currencyLabel =
+    currencyOptions.find((o) => o.value === draft.currencyCode)?.label ?? draft.currencyCode
+  const distance = draft.distance === 'imperial' ? t('distanceMiles') : t('distanceKm')
+  const measure = draft.measure === 'imperial' ? t('measureImperial') : t('measureMetric')
+  const units =
+    draft.kind === 'marine'
+      ? t('unitsSummary', { distance, measure })
+      : t('unitsSummaryEngine', {
+          distance,
+          measure,
+          engine: draft.engineSizeUnit === 'cc' ? t('engineCcShort') : t('engineLitresShort'),
+        })
+  const phoneValid = dialCodeValid(draft.dialCode)
+  // A prefix that is not one opens its row, and the row stays open once it is
+  // fixed, so it does not shut under the owner's fingers mid-typing.
+  useEffect(() => {
+    if (!phoneValid) setOpen('phone')
+  }, [phoneValid])
+
+  const rows: { key: Row; label: string; value: string; editor: React.ReactNode }[] = [
+    {
+      key: 'currency',
+      label: t('currency'),
+      value: `${currencyLabel}, ${moneyExample}`,
+      editor: (
+        <SearchPicker
+          value={draft.currencyCode}
+          options={currencyOptions}
+          onChange={(currencyCode) => onChange({ currencyCode })}
+          placeholder={t('currency')}
+          searchPlaceholder={t('currencySearch')}
+          empty={t('noCurrency')}
+        />
+      ),
+    },
+    {
+      key: 'dates',
+      label: t('dates'),
+      value: exampleDate(draft.dateFormat, draft.timeFormat, true, draft.timezone, now),
+      editor: (
+        <div className="grid gap-3 sm:grid-cols-2">
+          <div className="flex flex-col gap-1.5">
+            <Label htmlFor="dateFormat" className="text-[13px]">
+              {t('dateFormat')}
+            </Label>
+            <select
+              id="dateFormat"
+              value={draft.dateFormat}
+              onChange={(e) => onChange({ dateFormat: e.target.value })}
+              className="h-10 rounded-md border bg-background px-3 text-sm"
+            >
+              {SETUP_DATE_FORMATS.map((f) => (
+                <option key={f} value={f}>
+                  {exampleDate(f, draft.timeFormat, false, draft.timezone, now)}
+                </option>
+              ))}
+            </select>
+          </div>
+          <div className="flex flex-col gap-1.5">
+            <span className="text-[13px] font-medium" id="clockLabel">
+              {t('clock')}
+            </span>
+            <div
+              role="radiogroup"
+              aria-labelledby="clockLabel"
+              className="inline-flex gap-1 self-start rounded-lg bg-muted p-1"
+            >
+              {(['24h', '12h'] as const).map((clock) => (
+                <button
+                  key={clock}
+                  type="button"
+                  role="radio"
+                  aria-checked={draft.timeFormat === clock}
+                  onClick={() => onChange({ timeFormat: clock })}
+                  className={cn(
+                    'h-8 rounded-md px-3 text-sm',
+                    draft.timeFormat === clock
+                      ? 'bg-background font-semibold shadow-sm'
+                      : 'text-muted-foreground'
+                  )}
+                >
+                  {clock === '24h' ? t('clock24') : t('clock12')}
+                </button>
+              ))}
+            </div>
+          </div>
+        </div>
+      ),
+    },
+    {
+      key: 'timeZone',
+      label: t('timeZone'),
+      value: draft.timezone,
+      editor: (
+        <SearchPicker
+          value={draft.timezone}
+          options={zoneOptions}
+          onChange={(timezone) => onChange({ timezone })}
+          placeholder={t('timeZone')}
+          searchPlaceholder={t('zoneSearch')}
+          empty={t('noZone')}
+        />
+      ),
+    },
+    {
+      key: 'units',
+      label: t('units'),
+      value: units,
+      editor: (
+        <div className="grid gap-3 sm:grid-cols-2">
+          <Choice
+            id="distance"
+            label={t('distance')}
+            value={draft.distance}
+            options={[
+              ['metric', t('distanceKm')],
+              ['imperial', t('distanceMiles')],
+            ]}
+            onChange={(v) => onChange({ distance: v === 'imperial' ? 'imperial' : 'metric' })}
+          />
+          <Choice
+            id="measure"
+            label={t('measure')}
+            value={draft.measure}
+            options={[
+              ['metric', t('measureMetric')],
+              ['imperial', t('measureImperial')],
+            ]}
+            onChange={(v) => onChange({ measure: v === 'imperial' ? 'imperial' : 'metric' })}
+          />
+          {draft.kind !== 'marine' && (
+            <Choice
+              id="engine"
+              label={t('engine')}
+              value={draft.engineSizeUnit}
+              options={[
+                ['litres', t('engineLitres')],
+                ['cc', t('engineCc')],
+              ]}
+              onChange={(v) => onChange({ engineSizeUnit: v === 'cc' ? 'cc' : 'litres' })}
+            />
+          )}
+        </div>
+      ),
+    },
+    {
+      key: 'phone',
+      label: t('phone'),
+      value: draft.dialCode ? t('phoneValue', { code: draft.dialCode }) : t('phoneNone'),
+      editor: (
+        <div className="flex flex-col gap-1.5">
+          <Label htmlFor="dialCode" className="text-[13px]">
+            {t('phonePrefix')}
+          </Label>
+          <Input
+            id="dialCode"
+            value={draft.dialCode}
+            onChange={(e) => onChange({ dialCode: e.target.value.trim() })}
+            inputMode="tel"
+            aria-invalid={!phoneValid}
+            aria-describedby="dialCodeHint"
+            className="h-10 w-32 bg-background"
+          />
+          <p
+            id="dialCodeHint"
+            className={cn(
+              'text-[12.5px]',
+              phoneValid ? 'text-muted-foreground' : 'text-destructive'
+            )}
+          >
+            {phoneValid ? t('phoneHint') : t('phoneInvalid')}
+          </p>
+        </div>
+      ),
+    },
+  ]
+
+  return (
+    <div className="flex flex-col gap-6">
+      <div className="flex flex-col gap-1.5">
+        <h1 className="text-[28px] leading-tight font-bold tracking-tight">{t('title')}</h1>
+        <p className="text-[15px] leading-relaxed text-muted-foreground">
+          {draft.country
+            ? t('subtitle', { country: countryName(draft.country) })
+            : t('subtitleNoCountry')}
+        </p>
+      </div>
+
+      <div className="flex flex-col gap-2">
+        <span className="text-sm font-semibold" id="defaultsLabel">
+          {t('defaults')}
+        </span>
+        <dl aria-labelledby="defaultsLabel" className="divide-y rounded-xl border bg-background">
+          {rows.map((row) => {
+            // A prefix that is not one stays open, so its error is never hidden.
+            const stuck = row.key === 'phone' && !phoneValid
+            const editing = open === row.key || stuck
+            return (
+              <div key={row.key} className="flex flex-col gap-3 px-4 py-3">
+                <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
+                  <dt className="w-full text-[13.5px] text-muted-foreground sm:w-36 sm:shrink-0">
+                    {row.label}
+                  </dt>
+                  <dd className="min-w-0 flex-1 text-sm font-medium">{row.value}</dd>
+                  <Button
+                    type="button"
+                    variant="link"
+                    className="h-auto p-0 text-[13.5px] font-semibold"
+                    aria-expanded={editing}
+                    disabled={stuck}
+                    onClick={() => {
+                      if (!editing) track('workshop:setup_suggestion_edit', { field: row.key })
+                      setOpen(editing ? null : row.key)
+                    }}
+                  >
+                    {editing ? t('done') : t('change')}
+                  </Button>
+                </div>
+                {editing && <div className="pb-1">{row.editor}</div>}
+              </div>
+            )
+          })}
+        </dl>
+      </div>
+
+      <LaterInSettings>
+        {t.rich('later', {
+          localization: settingsPath('localization'),
+          workshop: settingsPath('workshop'),
+          b: settingsPathTag,
+        })}
+      </LaterInSettings>
+    </div>
+  )
+}
+
+function Choice({
+  id,
+  label,
+  value,
+  options,
+  onChange,
+}: {
+  id: string
+  label: string
+  value: string
+  options: [string, string][]
+  onChange: (value: string) => void
+}) {
+  return (
+    <div className="flex flex-col gap-1.5">
+      <Label htmlFor={id} className="text-[13px]">
+        {label}
+      </Label>
+      <select
+        id={id}
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className="h-10 rounded-md border bg-background px-3 text-sm"
+      >
+        {options.map(([v, l]) => (
+          <option key={v} value={v}>
+            {l}
+          </option>
+        ))}
+      </select>
+    </div>
+  )
+}

+ 190 - 0
src/features/onboarding/Components/setup/StepTax.tsx

@@ -0,0 +1,190 @@
+'use client'
+
+import { Check } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { cn } from '@/lib/utils'
+import { parseTaxRate, type SetupDraft } from '../../Lib/setupDraft'
+import { LaterInSettings, settingsPathTag, useSettingsPath } from './LaterInSettings'
+
+export function StepTax({
+  draft,
+  showErrors,
+  countryName,
+  money,
+  onChange,
+}: {
+  draft: SetupDraft
+  /** Continue was pressed with the rate missing: say so. */
+  showErrors: boolean
+  countryName: (code: string) => string
+  /** An amount written in the chosen currency. */
+  money: (value: number) => string
+  onChange: (patch: Partial<SetupDraft>) => void
+}) {
+  const t = useTranslations('onboarding.setup.tax')
+  const settingsPath = useSettingsPath()
+  const charges = draft.taxChoice === 'yes'
+  const rate = parseTaxRate(draft.taxRate)
+  const rateMissing = charges && showErrors && draft.taxRate.trim() === ''
+  const rateInvalid = charges && draft.taxRate.trim() !== '' && rate === null
+  const label = draft.taxLabel.trim() || t('namePlaceholder')
+  const shownRate = rate ?? 0
+  const example = `${label} (${shownRate}%)`
+
+  // The worked examples: 65 before tax, and what it comes to with it.
+  const price = 65
+  const tax = Math.round(price * shownRate) / 100
+  const total = price + tax
+
+  return (
+    <div className="flex flex-col gap-6">
+      <div className="flex flex-col gap-1.5">
+        <h1 className="text-[28px] leading-tight font-bold tracking-tight">{t('title')}</h1>
+        <p className="text-[15px] leading-relaxed text-muted-foreground">{t('subtitle')}</p>
+      </div>
+
+      <fieldset className="flex flex-col gap-2.5">
+        <legend className="mb-2.5 text-sm font-semibold">{t('charges')}</legend>
+        <div className="inline-flex gap-1 self-start rounded-xl bg-muted p-1">
+          {(['yes', 'no'] as const).map((choice) => {
+            const selected = draft.taxChoice === choice
+            return (
+              <label
+                key={choice}
+                className={cn(
+                  'inline-flex h-9 cursor-pointer items-center rounded-lg px-5 text-sm has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring',
+                  selected ? 'bg-background font-semibold shadow-sm' : 'text-muted-foreground'
+                )}
+              >
+                <input
+                  type="radio"
+                  name="taxChoice"
+                  value={choice}
+                  checked={selected}
+                  onChange={() => onChange({ taxChoice: choice })}
+                  className="sr-only"
+                />
+                {t(choice)}
+              </label>
+            )
+          })}
+        </div>
+      </fieldset>
+
+      {charges ? (
+        <>
+          <div className="grid gap-4 sm:grid-cols-2">
+            <div className="flex flex-col gap-2">
+              <Label htmlFor="taxRate" className="text-sm font-semibold">
+                {t('rate')}
+              </Label>
+              <div className="relative">
+                <Input
+                  id="taxRate"
+                  inputMode="decimal"
+                  value={draft.taxRate}
+                  onChange={(e) => onChange({ taxRate: e.target.value })}
+                  aria-invalid={rateInvalid || rateMissing}
+                  aria-describedby="taxRateHint"
+                  className="h-11 bg-background pr-9 text-[15px]"
+                />
+                <span
+                  className="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted-foreground"
+                  aria-hidden="true"
+                >
+                  %
+                </span>
+              </div>
+              <p
+                id="taxRateHint"
+                className={cn(
+                  'text-[12.5px] leading-relaxed',
+                  rateInvalid || rateMissing ? 'text-destructive' : 'text-muted-foreground'
+                )}
+              >
+                {rateInvalid ? t('rateInvalid') : rateMissing ? t('rateMissing') : t('rateHint')}
+              </p>
+            </div>
+            <div className="flex flex-col gap-2">
+              <Label htmlFor="taxName" className="text-sm font-semibold">
+                {t('name')}
+              </Label>
+              <Input
+                id="taxName"
+                value={draft.taxLabel}
+                onChange={(e) => onChange({ taxLabel: e.target.value, taxLabelEdited: true })}
+                placeholder={t('namePlaceholder')}
+                maxLength={40}
+                aria-describedby="taxNameHint"
+                className="h-11 bg-background text-[15px]"
+              />
+              <p id="taxNameHint" className="text-[12.5px] leading-relaxed text-muted-foreground">
+                {draft.country && !draft.taxLabelEdited && draft.taxLabel
+                  ? t('nameSuggested', { country: countryName(draft.country), example })
+                  : t('nameHint', { example })}
+              </p>
+            </div>
+          </div>
+
+          <fieldset className="flex flex-col gap-2.5">
+            <legend className="mb-2.5 text-sm font-semibold">{t('pricesLegend')}</legend>
+            <div className="grid gap-2.5 sm:grid-cols-2">
+              {([false, true] as const).map((inclusive) => {
+                const selected = draft.taxInclusive === inclusive
+                return (
+                  <label
+                    key={String(inclusive)}
+                    className={cn(
+                      'relative flex cursor-pointer flex-col gap-2 rounded-xl border bg-background p-4 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring',
+                      selected
+                        ? 'border-2 border-primary bg-primary/5 p-[15px]'
+                        : 'hover:border-primary/50'
+                    )}
+                  >
+                    <input
+                      type="radio"
+                      name="taxInclusive"
+                      checked={selected}
+                      onChange={() => onChange({ taxInclusive: inclusive })}
+                      className="sr-only"
+                    />
+                    <span className="flex items-center justify-between gap-2">
+                      <span className="text-[15px] font-semibold">
+                        {inclusive ? t('including') : t('before')}
+                      </span>
+                      {selected && (
+                        <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
+                          <Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" />
+                        </span>
+                      )}
+                    </span>
+                    <span className="text-[13px] leading-relaxed text-muted-foreground">
+                      {inclusive
+                        ? t('includingExample', { total: money(total), tax: money(tax), label })
+                        : t('beforeExample', {
+                            price: money(price),
+                            tax: money(tax),
+                            label,
+                            total: money(total),
+                          })}
+                    </span>
+                  </label>
+                )
+              })}
+            </div>
+          </fieldset>
+        </>
+      ) : (
+        <p className="rounded-xl border bg-background p-4 text-[13.5px] leading-relaxed text-muted-foreground">
+          {draft.taxChoice === 'skipped' ? t('skippedHint') : t('noTax')}
+        </p>
+      )}
+
+      <LaterInSettings>
+        {t.rich('later', { tax: settingsPath('tax'), b: settingsPathTag })}
+      </LaterInSettings>
+    </div>
+  )
+}

+ 247 - 0
src/features/onboarding/Components/setup/StepWorkshop.tsx

@@ -0,0 +1,247 @@
+'use client'
+
+import { Bike, Car, FileText, Globe, MapPin, Ship } from 'lucide-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 { type Locale, localeNames } from '@/i18n/config'
+import { countryDefaults } from '@/lib/country-defaults'
+import { cn } from '@/lib/utils'
+import type { SetupDraft, WorkKind } from '../../Lib/setupDraft'
+import { type PickerOption, SearchPicker } from './SearchPicker'
+
+const KINDS: { kind: WorkKind; Icon: typeof Car }[] = [
+  { kind: 'cars', Icon: Car },
+  { kind: 'motorcycles', Icon: Bike },
+  { kind: 'marine', Icon: Ship },
+]
+
+export function StepWorkshop({
+  draft,
+  showErrors,
+  uiLocale,
+  switchingScreens,
+  languageOptions,
+  onLanguage,
+  onCustomerLanguage,
+  onSeparateLanguage,
+  onSameLanguage,
+  detectedZone,
+  countryOptions,
+  countryName,
+  onName,
+  onKind,
+  onCountry,
+}: {
+  draft: SetupDraft
+  /** Continue was pressed without a name: say so. */
+  showErrors: boolean
+  /** The language on screen now. */
+  uiLocale: Locale
+  /** True while the screens are being redrawn in another language. */
+  switchingScreens: boolean
+  languageOptions: PickerOption[]
+  /** The owner's own language: screens, workshop and customers. */
+  onLanguage: (locale: Locale) => void
+  /** A language for customers alone. */
+  onCustomerLanguage: (locale: Locale) => void
+  /** Opens the customer language under the owner's. */
+  onSeparateLanguage: () => void
+  /** Customers back on the owner's language. */
+  onSameLanguage: () => void
+  /** The browser's zone when it decided the country, for the hint. */
+  detectedZone: string | null
+  countryOptions: PickerOption[]
+  countryName: (code: string) => string
+  onName: (name: string) => void
+  onKind: (kind: WorkKind) => void
+  onCountry: (country: string) => void
+}) {
+  const t = useTranslations('onboarding.setup.workshop')
+  const tRegion = useTranslations('onboarding.setup.region')
+  const country = draft.country ? countryName(draft.country) : null
+  const nameMissing = showErrors && draft.workshopName.trim().length < 2
+
+  // Said under the language: who gets it, and where it came from.
+  const fromCountry = draft.languageSource === 'country' && country
+  const spoken = draft.country ? countryDefaults(draft.country) : null
+  const missing =
+    fromCountry && spoken && !spoken.locale && draft.locale === 'en'
+      ? new Intl.DisplayNames([uiLocale], { type: 'language' }).of(spoken.language)
+      : null
+  const separate = draft.customerLanguageSeparate
+  const languageHint = separate
+    ? t('languageScreensOnly')
+    : missing && country
+      ? t('languageMissing', { language: missing, country })
+      : fromCountry
+        ? t('languageFromCountry', { country })
+        : t('languageBoth')
+
+  return (
+    <div className="flex flex-col gap-7">
+      <div className="flex flex-col gap-1.5">
+        <h1 className="text-[28px] leading-tight font-bold tracking-tight">{t('title')}</h1>
+        <p className="text-[15px] leading-relaxed text-muted-foreground">{t('subtitle')}</p>
+      </div>
+
+      <div className="flex flex-col gap-2">
+        {/* Named in English too, so someone on a borrowed computer finds it. */}
+        <Label htmlFor="language" className="text-sm font-semibold">
+          {t('language')}
+          {uiLocale !== 'en' && (
+            <span className="font-normal text-muted-foreground" lang="en">
+              {' '}
+              · Language
+            </span>
+          )}
+        </Label>
+        <SearchPicker
+          id="language"
+          value={uiLocale}
+          options={languageOptions}
+          onChange={(locale) => onLanguage(locale as Locale)}
+          placeholder={t('language')}
+          searchPlaceholder={tRegion('languageSearch')}
+          empty={tRegion('noLanguage')}
+          disabled={switchingScreens}
+          icon={<Globe className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
+        />
+        <p className="text-[13px] leading-relaxed text-muted-foreground" aria-live="polite">
+          {languageHint}
+        </p>
+
+        {/* Customers get the owner's language unless asked otherwise, and
+            the choice is offered right here rather than as a field of its own. */}
+        {separate ? (
+          <div className="mt-1.5 flex flex-col gap-2 rounded-xl border bg-background p-4">
+            <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
+              <Label htmlFor="docLanguage" className="text-sm font-semibold">
+                {tRegion('docLanguage')}
+              </Label>
+              <Button
+                type="button"
+                variant="link"
+                className="h-auto p-0 text-[13px] font-semibold"
+                onClick={onSameLanguage}
+              >
+                {t('customerLanguageSame', { language: localeNames[uiLocale] })}
+              </Button>
+            </div>
+            <SearchPicker
+              id="docLanguage"
+              value={draft.locale}
+              options={languageOptions}
+              onChange={(locale) => onCustomerLanguage(locale as Locale)}
+              placeholder={tRegion('docLanguage')}
+              searchPlaceholder={tRegion('languageSearch')}
+              empty={tRegion('noLanguage')}
+              icon={
+                <FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
+              }
+            />
+            <p className="text-[13px] leading-relaxed text-muted-foreground">
+              {fromCountry && country
+                ? `${t('customerLanguageFromCountry', { country })} ${tRegion('docLanguageHint')}`
+                : tRegion('docLanguageHint')}
+            </p>
+          </div>
+        ) : (
+          <Button
+            type="button"
+            variant="link"
+            className="h-auto self-start p-0 text-[13px] font-semibold"
+            onClick={onSeparateLanguage}
+          >
+            {t('customerLanguageDifferent')}
+          </Button>
+        )}
+      </div>
+
+      <div className="flex flex-col gap-2">
+        <Label htmlFor="workshopName" className="text-sm font-semibold">
+          {t('name')}
+        </Label>
+        <Input
+          id="workshopName"
+          value={draft.workshopName}
+          onChange={(e) => onName(e.target.value)}
+          placeholder={t('namePlaceholder')}
+          required
+          minLength={2}
+          maxLength={100}
+          autoComplete="organization"
+          aria-invalid={nameMissing || undefined}
+          aria-describedby={nameMissing ? 'workshopNameError' : undefined}
+          className="h-11 bg-background text-[15px]"
+        />
+        {nameMissing && (
+          <p id="workshopNameError" className="text-[13px] text-destructive">
+            {t('nameMissing')}
+          </p>
+        )}
+      </div>
+
+      <fieldset className="flex flex-col gap-2">
+        <legend className="mb-2 text-sm font-semibold">{t('kindLegend')}</legend>
+        {/* One row of choices, with what the chosen one means said once
+            underneath, so the step stays short. */}
+        <div className="flex flex-wrap gap-2">
+          {KINDS.map(({ kind, Icon }) => {
+            const selected = draft.kind === kind
+            return (
+              <label
+                key={kind}
+                className={cn(
+                  'inline-flex cursor-pointer items-center gap-2 rounded-full border bg-background px-3.5 py-2 text-[14px] font-medium transition-colors',
+                  'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring',
+                  selected
+                    ? 'border-primary bg-primary/10 font-semibold text-foreground'
+                    : 'text-muted-foreground hover:border-primary/50 hover:text-foreground'
+                )}
+              >
+                <input
+                  type="radio"
+                  name="kind"
+                  value={kind}
+                  checked={selected}
+                  onChange={() => onKind(kind)}
+                  className="sr-only"
+                />
+                <Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
+                {t(`kinds.${kind}.title`)}
+              </label>
+            )
+          })}
+        </div>
+        <p className="text-[13px] leading-relaxed text-muted-foreground">
+          {t(`kinds.${draft.kind}.hint`)}
+        </p>
+      </fieldset>
+
+      <div className="flex flex-col gap-2">
+        <Label htmlFor="country" className="text-sm font-semibold">
+          {t('country')}
+        </Label>
+        <SearchPicker
+          id="country"
+          value={draft.country}
+          options={countryOptions}
+          onChange={onCountry}
+          placeholder={t('countryPlaceholder')}
+          searchPlaceholder={t('countrySearch')}
+          empty={t('noCountry')}
+          icon={<MapPin className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
+        />
+        <p className="text-[13px] leading-relaxed text-muted-foreground">
+          {!country
+            ? t('countryMissing')
+            : detectedZone
+              ? t('countryDetected', { zone: detectedZone, country })
+              : t('countryChosen', { country })}
+        </p>
+      </div>
+    </div>
+  )
+}

+ 25 - 0
src/features/onboarding/Components/setup/useScreenLanguage.ts

@@ -0,0 +1,25 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useCallback, useTransition } from 'react'
+import type { Locale } from '@/i18n/config'
+import { setLocale } from '@/i18n/actions'
+
+/**
+ * Switches the language this person's screens are in, as the language menu on
+ * the sign-up page does, and redraws the page in it. What the owner has
+ * filled in so far stays: the wizard keeps its state across the refresh.
+ */
+export function useScreenLanguage() {
+  const router = useRouter()
+  const [pending, startTransition] = useTransition()
+  const switchTo = useCallback(
+    (locale: Locale) =>
+      startTransition(async () => {
+        const result = await setLocale(locale)
+        if (result.success) router.refresh()
+      }),
+    [router]
+  )
+  return { switchTo, pending }
+}

+ 17 - 0
src/features/onboarding/Lib/onboardingTranslator.ts

@@ -0,0 +1,17 @@
+import { createTranslator } from 'next-intl'
+import type { Translator } from './onboardingDefaults'
+
+/**
+ * The onboarding texts in a given language. The request's own translator
+ * always speaks the language of the owner's screens, whatever locale it is
+ * asked for, so the workshop's language is read from its own messages file:
+ * sample jobs, labour presets and parts are printed on its documents.
+ */
+export async function onboardingTranslator(locale: string): Promise<Translator> {
+  const messages = (await import(`../../../../messages/${locale}/onboarding.json`)).default
+  return createTranslator({
+    locale,
+    messages: { onboarding: messages },
+    namespace: 'onboarding',
+  } as Parameters<typeof createTranslator>[0]) as unknown as Translator
+}

+ 186 - 0
src/features/onboarding/Lib/previewInvoice.ts

@@ -0,0 +1,186 @@
+import type { InvoicePrintInput } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { calculateTotals } from '@/lib/tax'
+import { parseTaxRate, type SetupDraft } from './setupDraft'
+import { starterTemplate } from './starterDesign'
+
+/** The made-up job's prose, in the language customers get. */
+export interface PreviewSample {
+  jobTitle: string
+  serviceType: string
+  customerName: string
+  customerAddress: string
+  laborLine: string
+  partLine: string
+  /** Three more parts after the first, so the sheet looks like a real job. */
+  part2: string
+  part3: string
+  part4: string
+  /** Three more labour lines after the first. */
+  labor2: string
+  labor3: string
+  labor4: string
+  workshopName: string
+}
+
+const RATE = 65
+
+/** The sample's parts: the oil in the workshop's own measure. */
+function sampleParts(draft: SetupDraft, sample: PreviewSample) {
+  const imperial = draft.measure === 'imperial'
+  const lines = [
+    { partNumber: 'OF-1182', name: sample.partLine, quantity: 1, unit: null, unitPrice: 12.9 },
+    {
+      partNumber: 'OL-5W30',
+      name: sample.part2,
+      quantity: imperial ? 5 : 4.5,
+      unit: imperial ? 'qt' : 'l',
+      unitPrice: imperial ? 13.5 : 14.5,
+    },
+    { partNumber: 'AF-2210', name: sample.part3, quantity: 1, unit: null, unitPrice: 24 },
+    { partNumber: 'WB-650', name: sample.part4, quantity: 2, unit: null, unitPrice: 18.9 },
+  ]
+  return lines
+    .filter((line) => line.name)
+    .map((line) => ({ ...line, total: round(line.quantity * line.unitPrice) }))
+}
+
+function sampleLabor(sample: PreviewSample) {
+  const lines = [
+    { description: sample.laborLine, hours: 1.5 },
+    { description: sample.labor2, hours: 0.3 },
+    { description: sample.labor3, hours: 0.2 },
+    { description: sample.labor4, hours: 0.5 },
+  ]
+  return lines
+    .filter((line) => line.description)
+    .map((line) => ({ ...line, rate: RATE, total: round(line.hours * RATE) }))
+}
+
+function round(value: number): number {
+  return Math.round(value * 100) / 100
+}
+
+/** An address for the sample customer, made from their name: "camille.dubois@example.com". */
+function sampleEmail(name: string): string {
+  const local = name
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '')
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '.')
+    .replace(/^\.+|\.+$/g, '')
+  return `${local || 'customer'}@example.com`
+}
+
+/** The tax the preview charges, as the draft stands. */
+export function previewTax(draft: SetupDraft): { rate: number; inclusive: boolean } {
+  if (draft.taxChoice !== 'yes') return { rate: 0, inclusive: false }
+  return { rate: parseTaxRate(draft.taxRate) ?? 0, inclusive: draft.taxInclusive }
+}
+
+/**
+ * The first invoice as the setup would print it: the real document builder,
+ * fed a small sample job, the labels of the language customers get, and every
+ * choice made so far. Nothing here is drawn by hand.
+ */
+export function previewInvoiceInput(
+  draft: SetupDraft,
+  labels: Record<string, string>,
+  sample: PreviewSample,
+  today: Date = new Date()
+): InvoicePrintInput {
+  const partItems = sampleParts(draft, sample)
+  const laborItems = sampleLabor(sample)
+  const subtotal = round([...partItems, ...laborItems].reduce((sum, line) => sum + line.total, 0))
+  const tax = previewTax(draft)
+  const totals = calculateTotals({
+    subtotal,
+    discountAmount: 0,
+    taxRate: tax.rate,
+    taxInclusive: tax.inclusive,
+  })
+  const taxLabel = draft.taxChoice === 'yes' ? draft.taxLabel.trim() : ''
+  const due = new Date(today)
+  due.setDate(due.getDate() + 14)
+  const dial = draft.dialCode ? `${draft.dialCode} ` : ''
+  const customer = {
+    name: sample.customerName,
+    email: sampleEmail(sample.customerName),
+    phone: `${dial}412 55 018`,
+    address: sample.customerAddress,
+    company: null,
+  }
+  return {
+    data: {
+      id: 'setup-preview',
+      title: sample.jobTitle,
+      description: null,
+      type: sample.serviceType,
+      serviceDate: today,
+      invoiceDate: today,
+      invoiceDueDate: due,
+      shopName: null,
+      techName: null,
+      mileage: draft.kind === 'marine' ? null : 48210,
+      diagnosticNotes: null,
+      invoiceNotes: null,
+      subtotal,
+      taxRate: tax.rate,
+      taxAmount: totals.taxAmount,
+      taxInclusive: tax.inclusive,
+      totalAmount: totals.totalAmount,
+      cost: totals.totalAmount,
+      invoiceNumber: '1001',
+      partItems,
+      laborItems,
+      customer,
+      vehicle:
+        draft.kind === 'marine'
+          ? {
+              make: 'Yamaha',
+              model: 'F150',
+              year: 2021,
+              vin: null,
+              licensePlate: null,
+              mileage: 0,
+              customer,
+            }
+          : draft.kind === 'motorcycles'
+            ? {
+                make: 'Triumph',
+                model: 'Street Triple',
+                year: 2021,
+                vin: null,
+                licensePlate: 'AB12 CDE',
+                mileage: 48210,
+                customer,
+              }
+            : {
+                make: 'Volkswagen',
+                model: 'Golf',
+                year: 2021,
+                vin: null,
+                licensePlate: 'AB12 CDE',
+                mileage: 48210,
+                customer,
+              },
+    },
+    workshop: {
+      name: draft.workshopName.trim() || sample.workshopName,
+      address: draft.address.trim(),
+      phone: `${dial}22 33 44 55`,
+      email: '',
+    },
+    invoiceSettings: {
+      currencyCode: draft.currencyCode,
+      dateFormat: draft.dateFormat,
+      timeFormat: draft.timeFormat,
+      timezone: draft.timezone,
+      unitSystem: draft.distance,
+    },
+    // As the PDF routes do: the workshop's own name for its tax.
+    labels: taxLabel ? { ...labels, tax: `${taxLabel} ({rate}%)` } : labels,
+    locale: draft.locale,
+    // The template the workshop will start on, so the preview is its first invoice.
+    template: starterTemplate(),
+  }
+}

+ 221 - 0
src/features/onboarding/Lib/setupDraft.ts

@@ -0,0 +1,221 @@
+import { defaultLocale, type Locale, locales } from '@/i18n/config'
+import { countryDefaults, detectCountry, timeZoneFor } from '@/lib/country-defaults'
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
+import type { EngineSizeUnit } from '@/lib/engine-size'
+
+/**
+ * The workshop as onboarding builds it, step by step, before anything is
+ * saved. Every regional value starts from the country and stays editable.
+ */
+
+export type WorkKind = 'cars' | 'motorcycles' | 'marine'
+export type TaxChoice = 'yes' | 'no' | 'skipped'
+
+/**
+ * Where the language came from. Only one the owner picked themselves is kept
+ * when the country changes; a suggestion moves with the country.
+ */
+export type LanguageSource = 'browser' | 'country' | 'chosen'
+
+/** The language for a country whose own the app does not ship yet. */
+export const FALLBACK_LOCALE: Locale = 'en'
+
+export const SETUP_DATE_FORMATS = [
+  'dd/MM/yyyy',
+  'dd.MM.yyyy',
+  'MM/dd/yyyy',
+  'yyyy-MM-dd',
+  'MMM d, yyyy',
+  'd. MMM yyyy',
+  'd MMMM yyyy',
+] as const
+
+export interface SetupDraft {
+  workshopName: string
+  kind: WorkKind
+  country: string | null
+  /** The language customers get: the owner's own, unless they asked for another. */
+  locale: Locale
+  languageSource: LanguageSource
+  /** True when customers get a language of their own, picked under the owner's. */
+  customerLanguageSeparate: boolean
+  forceCustomerLocale: boolean
+  currencyCode: string
+  dateFormat: string
+  timeFormat: '12h' | '24h'
+  timezone: string
+  distance: 'metric' | 'imperial'
+  measure: 'metric' | 'imperial'
+  engineSizeUnit: EngineSizeUnit
+  dialCode: string
+  taxChoice: TaxChoice
+  /** As typed, so "20" and "5,5" both survive until saved. */
+  taxRate: string
+  taxLabel: string
+  /** True once the owner typed a tax name, so a later country change keeps it. */
+  taxLabelEdited: boolean
+  taxInclusive: boolean
+  address: string
+  loadSampleData: boolean
+}
+
+function asLocale(value: string | undefined): Locale | undefined {
+  return value && (locales as readonly string[]).includes(value) ? (value as Locale) : undefined
+}
+
+/**
+ * Fill the regional choices from a country. The workshop's name, its kind,
+ * address and tax answer are the owner's own and are kept; a tax name they
+ * typed is kept too, and so is a language they picked. Otherwise the language
+ * follows the country, or English where the app does not ship the country's.
+ */
+export function withCountry(
+  draft: SetupDraft,
+  country: string,
+  browserLanguage: string | undefined
+): SetupDraft {
+  const chosen = draft.languageSource === 'chosen'
+  const d = countryDefaults(country, chosen ? draft.locale : browserLanguage)
+  return {
+    ...draft,
+    country: d.country,
+    locale: chosen ? draft.locale : (d.locale ?? FALLBACK_LOCALE),
+    languageSource: chosen ? 'chosen' : 'country',
+    // A suggestion is one language for everything; the screens follow it.
+    customerLanguageSeparate: chosen ? draft.customerLanguageSeparate : false,
+    currencyCode: d.currency ?? draft.currencyCode,
+    dateFormat: d.dateFormat,
+    timeFormat: d.timeFormat,
+    distance: d.distance,
+    measure: d.measure,
+    // The workshop's clock is where the workshop is, not where the browser is.
+    timezone: timeZoneFor(d.country, draft.timezone) ?? draft.timezone,
+    dialCode: d.dialCode,
+    taxLabel: draft.taxLabelEdited ? draft.taxLabel : (d.taxLabel ?? ''),
+  }
+}
+
+/**
+ * The owner's own language, picked: their screens, the workshop and its
+ * customers get it, unless customers were given another one.
+ */
+export function withLanguage(draft: SetupDraft, locale: Locale): SetupDraft {
+  return {
+    ...draft,
+    locale: draft.customerLanguageSeparate ? draft.locale : locale,
+    languageSource: 'chosen',
+  }
+}
+
+/** A language for customers alone, picked under the owner's. */
+export function withCustomerLanguage(draft: SetupDraft, locale: Locale): SetupDraft {
+  return { ...draft, locale, customerLanguageSeparate: true, languageSource: 'chosen' }
+}
+
+/** Customers back on the owner's language. */
+export function withSameLanguage(draft: SetupDraft, ownLocale: Locale): SetupDraft {
+  return { ...draft, locale: ownLocale, customerLanguageSeparate: false }
+}
+
+/** What the kind of work decides: the marine fields, and how engines are written. */
+export function withKind(draft: SetupDraft, kind: WorkKind): SetupDraft {
+  return { ...draft, kind, engineSizeUnit: kind === 'motorcycles' ? 'cc' : 'litres' }
+}
+
+/** The first draft, from what the browser tells us about where it is. */
+export function initialDraft(args: {
+  timeZone: string
+  languages: readonly string[]
+  uiLocale: string
+}): SetupDraft {
+  const base: SetupDraft = {
+    workshopName: '',
+    kind: 'cars',
+    country: null,
+    locale: asLocale(args.uiLocale) ?? defaultLocale,
+    languageSource: 'browser',
+    customerLanguageSeparate: false,
+    forceCustomerLocale: false,
+    currencyCode: DEFAULT_CURRENCY_CODE,
+    dateFormat: 'dd/MM/yyyy',
+    timeFormat: '24h',
+    timezone: args.timeZone || 'UTC',
+    distance: 'metric',
+    measure: 'metric',
+    engineSizeUnit: 'litres',
+    dialCode: '',
+    taxChoice: 'yes',
+    taxRate: '',
+    taxLabel: '',
+    taxLabelEdited: false,
+    taxInclusive: false,
+    address: '',
+    loadSampleData: true,
+  }
+  const country = detectCountry(args.timeZone, args.languages)
+  if (!country) return base
+  // A detected country does not turn the screens; where its language is not
+  // the browser's, customers get it on their own, shown under the owner's.
+  const detected = withCountry(base, country, args.languages[0])
+  return { ...detected, customerLanguageSeparate: detected.locale !== base.locale }
+}
+
+/** The rate as a number, or null while what is typed is not one. */
+export function parseTaxRate(value: string): number | null {
+  const n = Number(value.trim().replace(',', '.'))
+  return value.trim() !== '' && Number.isFinite(n) && n >= 0 && n <= 100 ? n : null
+}
+
+/** Whether the tax step can be left as it stands. */
+export function taxStepComplete(draft: SetupDraft): boolean {
+  return draft.taxChoice !== 'yes' || parseTaxRate(draft.taxRate) !== null
+}
+
+/** A phone prefix the server accepts: empty, or + and up to four digits. */
+export function dialCodeValid(dialCode: string): boolean {
+  return dialCode === '' || /^\+\d{1,4}$/.test(dialCode)
+}
+
+/**
+ * The first step still missing something, or null when the workshop can be
+ * created. Ready can be reached from the step bar after a step was emptied
+ * again, so creating checks every step, not only the one on screen.
+ */
+export function firstIncompleteStep(draft: SetupDraft): 'workshop' | 'region' | 'tax' | null {
+  if (draft.workshopName.trim().length < 2) return 'workshop'
+  if (!dialCodeValid(draft.dialCode)) return 'region'
+  if (!taxStepComplete(draft)) return 'tax'
+  return null
+}
+
+/** What the server is sent: the draft, settled into saved values. */
+export function setupPayload(draft: SetupDraft) {
+  const charges = draft.taxChoice === 'yes'
+  return {
+    workshopName: draft.workshopName.trim(),
+    loadSampleData: draft.loadSampleData,
+    timeZone: draft.timezone,
+    setup: {
+      country: draft.country ?? undefined,
+      serviceType: draft.kind === 'marine' ? ('marine' as const) : ('automotive' as const),
+      engineSizeUnit: draft.engineSizeUnit,
+      locale: draft.locale,
+      forceCustomerLocale: draft.forceCustomerLocale,
+      currencyCode: draft.currencyCode,
+      dateFormat: draft.dateFormat,
+      timeFormat: draft.timeFormat,
+      unitSystem: draft.distance,
+      measureSystem: draft.measure,
+      dialCode: draft.dialCode,
+      tax: {
+        enabled: charges,
+        rate: charges ? (parseTaxRate(draft.taxRate) ?? 0) : 0,
+        label: charges ? draft.taxLabel.trim() : '',
+        inclusive: charges && draft.taxInclusive,
+      },
+      address: draft.address.trim(),
+    },
+  }
+}
+
+export type SetupPayload = ReturnType<typeof setupPayload>

+ 35 - 0
src/features/onboarding/Lib/setupSettings.ts

@@ -0,0 +1,35 @@
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { safeTimeZone } from '@/lib/timezone'
+import type { SetupInput } from '../Schema/onboardingSchema'
+
+/**
+ * The settings a guided setup writes, keyed as the settings pages save them,
+ * so a workshop that came through onboarding is indistinguishable from one
+ * configured by hand afterwards.
+ */
+export function setupSettings(
+  setup: SetupInput,
+  timeZone: string | undefined
+): Record<string, string> {
+  const rows: Record<string, string> = {
+    [SETTING_KEYS.SERVICE_TYPE]: setup.serviceType,
+    [SETTING_KEYS.WORKSHOP_LOCALE]: setup.locale,
+    [SETTING_KEYS.FORCE_CUSTOMER_LOCALE]: String(setup.forceCustomerLocale),
+    [SETTING_KEYS.CURRENCY_CODE]: setup.currencyCode,
+    [SETTING_KEYS.DATE_FORMAT]: setup.dateFormat,
+    [SETTING_KEYS.TIME_FORMAT]: setup.timeFormat,
+    [SETTING_KEYS.UNIT_SYSTEM]: setup.unitSystem,
+    [SETTING_KEYS.MEASURE_SYSTEM]: setup.measureSystem,
+    [SETTING_KEYS.TAX_ENABLED]: String(setup.tax.enabled),
+    [SETTING_KEYS.DEFAULT_TAX_RATE]: setup.tax.enabled ? String(setup.tax.rate) : '0',
+    [SETTING_KEYS.TAX_INCLUSIVE]: String(setup.tax.inclusive),
+  }
+  // A marine workshop's engine field is a model and power, never a displacement.
+  if (setup.serviceType !== 'marine') rows[SETTING_KEYS.ENGINE_SIZE_UNIT] = setup.engineSizeUnit
+  if (setup.dialCode) rows[SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE] = setup.dialCode
+  if (setup.tax.enabled && setup.tax.label) rows[SETTING_KEYS.TAX_LABEL] = setup.tax.label
+  if (setup.address) rows[SETTING_KEYS.WORKSHOP_ADDRESS] = setup.address
+  const zone = safeTimeZone(timeZone, '')
+  if (zone) rows[SETTING_KEYS.TIMEZONE] = zone
+  return rows
+}

+ 75 - 0
src/features/onboarding/Lib/starterDesign.ts

@@ -0,0 +1,75 @@
+import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  type InvoiceLayoutConfig,
+  invoiceLayoutConfigSchema,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/**
+ * The template a new workshop's invoices and quotes start on: Compact, the
+ * dense one-page sheet. Workshops made before this keep whatever they print
+ * now; only a workshop made by onboarding gets these settings, and it can
+ * pick any other template in Settings, Templates.
+ */
+export const STARTER_PRESET_ID = 'compact'
+
+function starterPreset() {
+  const preset = layoutPresets.find((p) => p.id === STARTER_PRESET_ID)
+  if (!preset) throw new Error(`Starter template "${STARTER_PRESET_ID}" is missing`)
+  return preset
+}
+
+/**
+ * The layout as the designer would save it, stamped so it prints as a
+ * designer layout, with the title strip on. The Compact template leaves the
+ * strip out, and a sheet without it prints no title, no number and no dates.
+ * Only new workshops get this; the template itself is left as it is, so no
+ * existing workshop changes.
+ */
+export function starterLayout(): InvoiceLayoutConfig {
+  const layout = buildLayoutFromPreset(starterPreset())
+  const ordered = [...layout.sections].sort((a, b) => a.order - b.order)
+  const title = ordered.find((s) => s.id === 'document_title')
+  const rest = ordered.filter((s) => s !== title)
+  if (title)
+    rest.splice(rest.findIndex((s) => s.id === 'header') + 1, 0, { ...title, visible: true })
+  return invoiceLayoutConfigSchema.parse({
+    ...layout,
+    sections: rest.map((s, order) => ({ ...s, order })),
+    version: DESIGNER_LAYOUT_VERSION,
+  })
+}
+
+/** The look and layout together, as the print builders take them. */
+export function starterTemplate(): TemplateConfig {
+  const { template } = starterPreset()
+  return {
+    primaryColor: template.primaryColor,
+    headerStyle: template.headerStyle,
+    fontFamily: template.fontFamily,
+    layoutConfig: starterLayout(),
+  }
+}
+
+/**
+ * The settings rows that make the starter template the workshop's own, for
+ * invoices and quotes: the same keys the designer writes when a template is
+ * saved, with the template marked as the one in use.
+ */
+export function starterDesignSettings(): Record<string, string> {
+  const { template } = starterPreset()
+  const layout = JSON.stringify(starterLayout())
+  const rows: Record<string, string> = {
+    [SETTING_KEYS.INVOICE_LAYOUT_CONFIG]: layout,
+    [SETTING_KEYS.QUOTE_LAYOUT_CONFIG]: layout,
+  }
+  for (const prefix of ['invoice', 'quote']) {
+    rows[`${prefix}.primaryColor`] = template.primaryColor
+    rows[`${prefix}.headerStyle`] = template.headerStyle
+    rows[`${prefix}.fontFamily`] = template.fontFamily
+    rows[`${prefix}.activeDesign`] = `preset:${STARTER_PRESET_ID}`
+  }
+  return rows
+}

+ 33 - 1
src/features/onboarding/Schema/onboardingSchema.ts

@@ -1,4 +1,33 @@
 import { z } from 'zod'
 import { z } from 'zod'
+import { locales } from '@/i18n/config'
+import { isCountryCode } from '@/lib/country-defaults'
+import { currencyDefinition } from '@/lib/currencies'
+import { SETUP_DATE_FORMATS } from '../Lib/setupDraft'
+
+const metricOrImperial = z.enum(['metric', 'imperial'])
+
+/** What the four setup steps decide; the action writes each into its setting. */
+export const setupSchema = z.object({
+  country: z.string().refine(isCountryCode, 'Unknown country').optional(),
+  serviceType: z.enum(['automotive', 'marine']),
+  engineSizeUnit: z.enum(['litres', 'cc']),
+  locale: z.enum(locales),
+  forceCustomerLocale: z.boolean(),
+  currencyCode: z.string().refine((code) => Boolean(currencyDefinition(code)), 'Unknown currency'),
+  dateFormat: z.enum(SETUP_DATE_FORMATS),
+  timeFormat: z.enum(['12h', '24h']),
+  unitSystem: metricOrImperial,
+  measureSystem: metricOrImperial,
+  /** "+33", or empty when the owner cleared it. */
+  dialCode: z.union([z.literal(''), z.string().regex(/^\+\d{1,4}$/, 'Use + and digits, like +44')]),
+  tax: z.object({
+    enabled: z.boolean(),
+    rate: z.number().min(0).max(100),
+    label: z.string().trim().max(40),
+    inclusive: z.boolean(),
+  }),
+  address: z.string().trim().max(500),
+})
 
 
 export const onboardingSchema = z.object({
 export const onboardingSchema = z.object({
   workshopName: z
   workshopName: z
@@ -7,8 +36,11 @@ export const onboardingSchema = z.object({
     .max(100, 'Workshop name must be at most 100 characters')
     .max(100, 'Workshop name must be at most 100 characters')
     .trim(),
     .trim(),
   loadSampleData: z.boolean().default(true),
   loadSampleData: z.boolean().default(true),
-  /** IANA zone the browser reported, stored as the workshop's explicit zone. */
+  /** IANA zone the owner confirmed, stored as the workshop's explicit zone. */
   timeZone: z.string().max(64).optional(),
   timeZone: z.string().max(64).optional(),
+  /** The guided setup. Absent from older clients, which get the defaults as before. */
+  setup: setupSchema.optional(),
 })
 })
 
 
 export type OnboardingInput = z.infer<typeof onboardingSchema>
 export type OnboardingInput = z.infer<typeof onboardingSchema>
+export type SetupInput = z.infer<typeof setupSchema>

+ 1 - 0
src/features/portal/Actions/portalActions.ts

@@ -336,6 +336,7 @@ export async function getPortalInvoiceSheet(invoiceId: string) {
       telegramQrDataUri: telegramQr?.dataUri,
       telegramQrDataUri: telegramQr?.dataUri,
       telegramLabel: labels?.telegramConnect,
       telegramLabel: labels?.telegramConnect,
       labels,
       labels,
+      locale,
       conditionMap,
       conditionMap,
       inspectionResults: assembly.inspectionResults,
       inspectionResults: assembly.inspectionResults,
     })
     })

+ 4 - 0
src/features/quotes/Components/QuotePDF.tsx

@@ -45,6 +45,7 @@ export function QuotePDF({
   pdfAttachmentNames = [],
   pdfAttachmentNames = [],
   customFields = [],
   customFields = [],
   labels = {},
   labels = {},
+  locale,
   layoutConfig,
   layoutConfig,
   lineItemsInclTax,
   lineItemsInclTax,
   conditionMap,
   conditionMap,
@@ -67,6 +68,8 @@ export function QuotePDF({
   pdfAttachmentNames?: string[]
   pdfAttachmentNames?: string[]
   customFields?: Array<{ fieldId: string; label: string; value: string; fieldType: string }>
   customFields?: Array<{ fieldId: string; label: string; value: string; fieldType: string }>
   labels?: Record<string, string>
   labels?: Record<string, string>
+  /** The document's language; numbers are written its way. */
+  locale?: string
   lineItemsInclTax?: boolean
   lineItemsInclTax?: boolean
   layoutConfig?: InvoiceLayoutConfig
   layoutConfig?: InvoiceLayoutConfig
   conditionMap?: VisitConditionMap
   conditionMap?: VisitConditionMap
@@ -90,6 +93,7 @@ export function QuotePDF({
     otherAttachmentNames: otherAttachments.map((att) => att.fileName),
     otherAttachmentNames: otherAttachments.map((att) => att.fileName),
     customFields,
     customFields,
     labels,
     labels,
+    locale,
     layoutConfig,
     layoutConfig,
     conditionMap,
     conditionMap,
     inspectionResults,
     inspectionResults,

+ 1 - 0
src/features/quotes/Pdf/buildQuotePdfBuffer.ts

@@ -202,6 +202,7 @@ export async function buildQuotePdfBuffer(
     pdfAttachmentNames: pdfAttachments.map((att) => att.fileName),
     pdfAttachmentNames: pdfAttachments.map((att) => att.fileName),
     customFields,
     customFields,
     labels,
     labels,
+    locale,
     layoutConfig,
     layoutConfig,
     conditionMap,
     conditionMap,
     inspectionResults,
     inspectionResults,

+ 5 - 1
src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx

@@ -2,7 +2,7 @@
 
 
 import { DEFAULT_CURRENCY_CODE, DEFAULT_CURRENCY_FORMAT } from '@/lib/currencies'
 import { DEFAULT_CURRENCY_CODE, DEFAULT_CURRENCY_FORMAT } from '@/lib/currencies'
 import { useMemo, useState, useEffect } from 'react'
 import { useMemo, useState, useEffect } from 'react'
-import { useMessages, useTranslations } from 'next-intl'
+import { useLocale, useMessages, useTranslations } from 'next-intl'
 import { PDFViewer } from '@react-pdf/renderer'
 import { PDFViewer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts.client'
 import '@/features/vehicles/Components/invoice-pdf/fonts.client'
 import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
 import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
@@ -246,6 +246,8 @@ export function InvoiceLayoutPreviewRenderer({
   logoUrl,
   logoUrl,
   workshop,
   workshop,
 }: InvoiceLayoutPreviewProps) {
 }: InvoiceLayoutPreviewProps) {
+  // The sample prints in the reader's language, like its labels.
+  const locale = useLocale()
   const messages = useMessages()
   const messages = useMessages()
   const t = useTranslations('settings')
   const t = useTranslations('settings')
   const serviceType = useServiceType()
   const serviceType = useServiceType()
@@ -349,6 +351,7 @@ export function InvoiceLayoutPreviewRenderer({
             template={templateConfig}
             template={templateConfig}
             customFields={dummyCf}
             customFields={dummyCf}
             labels={labels}
             labels={labels}
+            locale={locale}
             layoutConfig={config}
             layoutConfig={config}
           />
           />
         ) : (
         ) : (
@@ -361,6 +364,7 @@ export function InvoiceLayoutPreviewRenderer({
             telegramQrDataUri={telegramQrDataUri}
             telegramQrDataUri={telegramQrDataUri}
             telegramLabel={labels.telegramConnect || 'Chat with us on Telegram'}
             telegramLabel={labels.telegramConnect || 'Chat with us on Telegram'}
             labels={labels}
             labels={labels}
+            locale={locale}
           />
           />
         )}
         )}
       </PDFViewer>
       </PDFViewer>

+ 1 - 0
src/features/settings/Lib/memberReadableSettings.ts

@@ -24,6 +24,7 @@ export const MEMBER_READABLE_SETTINGS: ReadonlySet<SettingKey> = new Set<Setting
   // Money and measures, as shown on every list and document.
   // Money and measures, as shown on every list and document.
   SETTING_KEYS.CURRENCY_CODE,
   SETTING_KEYS.CURRENCY_CODE,
   SETTING_KEYS.UNIT_SYSTEM,
   SETTING_KEYS.UNIT_SYSTEM,
+  SETTING_KEYS.MEASURE_SYSTEM,
   SETTING_KEYS.ENGINE_SIZE_UNIT,
   SETTING_KEYS.ENGINE_SIZE_UNIT,
   SETTING_KEYS.DATE_FORMAT,
   SETTING_KEYS.DATE_FORMAT,
   SETTING_KEYS.TIME_FORMAT,
   SETTING_KEYS.TIME_FORMAT,

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

@@ -67,7 +67,14 @@ export const SETTING_KEYS = {
   INVOICE_LOCK_TRIGGER: 'invoice.lockTrigger',
   INVOICE_LOCK_TRIGGER: 'invoice.lockTrigger',
   QUOTE_LOCK_ENABLED: 'quote.lockEnabled',
   QUOTE_LOCK_ENABLED: 'quote.lockEnabled',
   QUOTE_LOCK_TRIGGER: 'quote.lockTrigger',
   QUOTE_LOCK_TRIGGER: 'quote.lockTrigger',
+  /** 'metric' (km) | 'imperial' (miles): distance, as mileage and intervals are written. */
   UNIT_SYSTEM: 'workshop.unitSystem',
   UNIT_SYSTEM: 'workshop.unitSystem',
+  /**
+   * 'metric' (litres, kilograms) | 'imperial' (quarts, gallons, pounds): fluids
+   * and parts. Unset follows UNIT_SYSTEM, which is how every workshop worked
+   * before the two were separate; a British shop is miles with litres.
+   */
+  MEASURE_SYSTEM: 'workshop.measureSystem',
   /** 'litres' | 'cc': how a plate lookup writes engine size. Unset means litres. */
   /** 'litres' | 'cc': how a plate lookup writes engine size. Unset means litres. */
   ENGINE_SIZE_UNIT: 'workshop.engineSizeUnit',
   ENGINE_SIZE_UNIT: 'workshop.engineSizeUnit',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',

+ 4 - 0
src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx

@@ -39,6 +39,7 @@ export function InvoicePDF({
   telegramQrDataUri,
   telegramQrDataUri,
   telegramLabel,
   telegramLabel,
   labels = {},
   labels = {},
+  locale,
   conditionMap,
   conditionMap,
   inspectionResults,
   inspectionResults,
 }: {
 }: {
@@ -57,6 +58,8 @@ export function InvoicePDF({
   telegramQrDataUri?: string
   telegramQrDataUri?: string
   telegramLabel?: string
   telegramLabel?: string
   labels?: Record<string, string>
   labels?: Record<string, string>
+  /** The document's language; numbers are written its way. */
+  locale?: string
   conditionMap?: VisitConditionMap
   conditionMap?: VisitConditionMap
   inspectionResults?: InspectionResults | null
   inspectionResults?: InspectionResults | null
 }) {
 }) {
@@ -75,6 +78,7 @@ export function InvoicePDF({
     telegramQrDataUri,
     telegramQrDataUri,
     telegramLabel,
     telegramLabel,
     labels,
     labels,
+    locale,
     conditionMap,
     conditionMap,
     inspectionResults,
     inspectionResults,
   })
   })

+ 27 - 0
src/lib/analytics.ts

@@ -29,6 +29,25 @@ export type AnalyticsEvent =
   | 'account:sign_up'
   | 'account:sign_up'
   /** The onboarding form made the person's first workshop, with or without sample data. */
   /** The onboarding form made the person's first workshop, with or without sample data. */
   | 'workshop:create'
   | 'workshop:create'
+  /** The guided setup opened, with what the browser suggested: country, languages, time zone. */
+  | 'workshop:setup_start'
+  /**
+   * The setup moved between steps: `action` is continue, skip, back or jump
+   * (the step bar or an Edit link), with the seconds spent on the step left.
+   */
+  | 'workshop:setup_step'
+  /** Continue pressed while the step was incomplete; `reason` names what was missing. */
+  | 'workshop:setup_blocked'
+  /** The language changed: `via` the language field, a country, or the customer language. */
+  | 'workshop:setup_language_change'
+  /** The owner picked a country, replacing the detected one or another pick. */
+  | 'workshop:setup_country_change'
+  /** A suggested regional value opened for editing; `field` says which. */
+  | 'workshop:setup_suggestion_edit'
+  /** The invoice preview opened at full size. */
+  | 'workshop:setup_preview_open'
+  /** Create workshop failed; the error dialog opened. */
+  | 'workshop:setup_create_fail'
   /** A customer saved from the customer form. */
   /** A customer saved from the customer form. */
   | 'customer:create'
   | 'customer:create'
   /** An invoice, quote or inspection emailed to the customer. */
   /** An invoice, quote or inspection emailed to the customer. */
@@ -43,6 +62,7 @@ type Properties = Record<string, string | number | boolean | null>
 type Pending =
 type Pending =
   | { kind: 'capture'; event: AnalyticsEvent; properties?: Properties }
   | { kind: 'capture'; event: AnalyticsEvent; properties?: Properties }
   | { kind: 'register'; properties: Properties }
   | { kind: 'register'; properties: Properties }
+  | { kind: 'unregister'; names: string[] }
   | { kind: 'identify'; userId: string; organizationId: string }
   | { kind: 'identify'; userId: string; organizationId: string }
   | { kind: 'reset' }
   | { kind: 'reset' }
 
 
@@ -72,6 +92,12 @@ export function registerAnalyticsProperties(properties: Properties): void {
   else hold({ kind: 'register', properties })
   else hold({ kind: 'register', properties })
 }
 }
 
 
+/** Stops attaching properties registered with registerAnalyticsProperties. */
+export function unregisterAnalyticsProperties(names: string[]): void {
+  if (ready) for (const name of names) posthog.unregister(name)
+  else hold({ kind: 'unregister', names })
+}
+
 /**
 /**
  * Ties this browser to the signed-in account and its workshop.
  * Ties this browser to the signed-in account and its workshop.
  *
  *
@@ -103,6 +129,7 @@ export function analyticsStarted(): void {
   ready = true
   ready = true
   for (const item of pending.splice(0)) {
   for (const item of pending.splice(0)) {
     if (item.kind === 'register') posthog.register(item.properties)
     if (item.kind === 'register') posthog.register(item.properties)
+    else if (item.kind === 'unregister') for (const name of item.names) posthog.unregister(name)
     else if (item.kind === 'identify') sendIdentify(item.userId, item.organizationId)
     else if (item.kind === 'identify') sendIdentify(item.userId, item.organizationId)
     else if (item.kind === 'reset') posthog.reset()
     else if (item.kind === 'reset') posthog.reset()
     else posthog.capture(item.event, item.properties)
     else posthog.capture(item.event, item.properties)

+ 801 - 0
src/lib/country-defaults.ts

@@ -0,0 +1,801 @@
+import { type Locale, locales } from '@/i18n/config'
+import { currencyDefinition } from '@/lib/currencies'
+
+/**
+ * What a new workshop is offered once it says which country it is in.
+ *
+ * Suggestions only: onboarding shows every one of them and the owner keeps or
+ * changes each, so nothing here is a silent guess. Facts that change by law
+ * (tax rates above all) are deliberately absent; the tax name is a label the
+ * owner can retype. Date order and the clock are read from the browser's own
+ * locale data rather than kept in a table that would drift.
+ */
+
+export type DistanceSystem = 'metric' | 'imperial'
+export type DateFormatOption = 'dd/MM/yyyy' | 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
+
+export interface CountryDefaults {
+  /** ISO 3166-1 alpha-2. */
+  country: string
+  /** The language the country writes in (BCP 47), shipped by the app or not. */
+  language: string
+  /** The app language to write documents in, when we ship the country's language. */
+  locale: Locale | undefined
+  /** ISO 4217, only when the app offers it. */
+  currency: string | undefined
+  /** Calling code with its plus, "+33". */
+  dialCode: string
+  /** Kilometres or miles. */
+  distance: DistanceSystem
+  /** Litres and kilograms, or quarts, gallons and pounds. */
+  measure: DistanceSystem
+  dateFormat: DateFormatOption
+  timeFormat: '12h' | '24h'
+  /** What the tax is called on an invoice there, when it has a common name. */
+  taxLabel: string | undefined
+}
+
+/**
+ * Country, currency, calling code and main language (BCP 47). The language
+ * picks the app locale and, with the country, how dates are written there.
+ */
+const COUNTRIES: Record<string, [currency: string, dial: string, language: string]> = {
+  // Europe
+  AD: ['EUR', '376', 'ca'],
+  AL: ['ALL', '355', 'sq'],
+  AT: ['EUR', '43', 'de'],
+  BA: ['BAM', '387', 'bs'],
+  BE: ['EUR', '32', 'nl'],
+  BG: ['EUR', '359', 'bg'],
+  BY: ['BYN', '375', 'ru'],
+  CH: ['CHF', '41', 'de'],
+  CY: ['EUR', '357', 'el'],
+  CZ: ['CZK', '420', 'cs'],
+  DE: ['EUR', '49', 'de'],
+  DK: ['DKK', '45', 'da'],
+  EE: ['EUR', '372', 'et'],
+  ES: ['EUR', '34', 'es'],
+  FI: ['EUR', '358', 'fi'],
+  FO: ['DKK', '298', 'fo'],
+  FR: ['EUR', '33', 'fr'],
+  GB: ['GBP', '44', 'en'],
+  GI: ['GIP', '350', 'en'],
+  GR: ['EUR', '30', 'el'],
+  HR: ['EUR', '385', 'hr'],
+  HU: ['HUF', '36', 'hu'],
+  IE: ['EUR', '353', 'en'],
+  IS: ['ISK', '354', 'is'],
+  IT: ['EUR', '39', 'it'],
+  LI: ['CHF', '423', 'de'],
+  LT: ['EUR', '370', 'lt'],
+  LU: ['EUR', '352', 'fr'],
+  LV: ['EUR', '371', 'lv'],
+  MC: ['EUR', '377', 'fr'],
+  MD: ['MDL', '373', 'ro'],
+  ME: ['EUR', '382', 'sr'],
+  MK: ['MKD', '389', 'mk'],
+  MT: ['EUR', '356', 'en'],
+  NL: ['EUR', '31', 'nl'],
+  NO: ['NOK', '47', 'nb'],
+  PL: ['PLN', '48', 'pl'],
+  PT: ['EUR', '351', 'pt'],
+  RO: ['RON', '40', 'ro'],
+  RS: ['RSD', '381', 'sr'],
+  RU: ['RUB', '7', 'ru'],
+  SE: ['SEK', '46', 'sv'],
+  SI: ['EUR', '386', 'sl'],
+  SK: ['EUR', '421', 'sk'],
+  SM: ['EUR', '378', 'it'],
+  TR: ['TRY', '90', 'tr'],
+  UA: ['UAH', '380', 'uk'],
+  XK: ['EUR', '383', 'sq'],
+  AX: ['EUR', '358', 'sv'],
+  GG: ['GBP', '44', 'en'],
+  IM: ['GBP', '44', 'en'],
+  JE: ['GBP', '44', 'en'],
+  SJ: ['NOK', '47', 'nb'],
+  VA: ['EUR', '39', 'it'],
+  // Americas
+  AR: ['ARS', '54', 'es'],
+  BB: ['BBD', '1', 'en'],
+  BO: ['BOB', '591', 'es'],
+  BR: ['BRL', '55', 'pt'],
+  BS: ['BSD', '1', 'en'],
+  CA: ['CAD', '1', 'en'],
+  CL: ['CLP', '56', 'es'],
+  CO: ['COP', '57', 'es'],
+  CR: ['CRC', '506', 'es'],
+  CU: ['CUP', '53', 'es'],
+  DO: ['DOP', '1', 'es'],
+  EC: ['USD', '593', 'es'],
+  GT: ['GTQ', '502', 'es'],
+  HN: ['HNL', '504', 'es'],
+  JM: ['JMD', '1', 'en'],
+  MX: ['MXN', '52', 'es'],
+  NI: ['NIO', '505', 'es'],
+  PA: ['USD', '507', 'es'],
+  PE: ['PEN', '51', 'es'],
+  PR: ['USD', '1', 'es'],
+  PY: ['PYG', '595', 'es'],
+  SV: ['USD', '503', 'es'],
+  TT: ['TTD', '1', 'en'],
+  US: ['USD', '1', 'en'],
+  UY: ['UYU', '598', 'es'],
+  VE: ['VES', '58', 'es'],
+  AG: ['XCD', '1', 'en'],
+  AI: ['XCD', '1', 'en'],
+  AW: ['AWG', '297', 'nl'],
+  BL: ['EUR', '590', 'fr'],
+  BM: ['BMD', '1', 'en'],
+  BQ: ['USD', '599', 'nl'],
+  BZ: ['BZD', '501', 'en'],
+  CW: ['XCG', '599', 'nl'],
+  DM: ['XCD', '1', 'en'],
+  FK: ['FKP', '500', 'en'],
+  GD: ['XCD', '1', 'en'],
+  GF: ['EUR', '594', 'fr'],
+  GL: ['DKK', '299', 'kl'],
+  GP: ['EUR', '590', 'fr'],
+  GY: ['GYD', '592', 'en'],
+  HT: ['HTG', '509', 'fr'],
+  KN: ['XCD', '1', 'en'],
+  KY: ['KYD', '1', 'en'],
+  LC: ['XCD', '1', 'en'],
+  MF: ['EUR', '590', 'fr'],
+  MQ: ['EUR', '596', 'fr'],
+  MS: ['XCD', '1', 'en'],
+  PM: ['EUR', '508', 'fr'],
+  SR: ['SRD', '597', 'nl'],
+  SX: ['XCG', '1', 'nl'],
+  TC: ['USD', '1', 'en'],
+  VC: ['XCD', '1', 'en'],
+  VG: ['USD', '1', 'en'],
+  VI: ['USD', '1', 'en'],
+  // Asia and Oceania
+  AE: ['AED', '971', 'ar'],
+  AF: ['AFN', '93', 'fa'],
+  AM: ['AMD', '374', 'hy'],
+  AU: ['AUD', '61', 'en'],
+  AZ: ['AZN', '994', 'az'],
+  BD: ['BDT', '880', 'bn'],
+  BH: ['BHD', '973', 'ar'],
+  CN: ['CNY', '86', 'zh'],
+  FJ: ['FJD', '679', 'en'],
+  GE: ['GEL', '995', 'ka'],
+  HK: ['HKD', '852', 'zh'],
+  ID: ['IDR', '62', 'id'],
+  IL: ['ILS', '972', 'he'],
+  IN: ['INR', '91', 'en'],
+  IQ: ['IQD', '964', 'ar'],
+  IR: ['IRR', '98', 'fa'],
+  JO: ['JOD', '962', 'ar'],
+  JP: ['JPY', '81', 'ja'],
+  KH: ['KHR', '855', 'km'],
+  KR: ['KRW', '82', 'ko'],
+  KW: ['KWD', '965', 'ar'],
+  KZ: ['KZT', '7', 'ru'],
+  LB: ['LBP', '961', 'ar'],
+  LK: ['LKR', '94', 'si'],
+  MM: ['MMK', '95', 'my'],
+  MN: ['MNT', '976', 'mn'],
+  MY: ['MYR', '60', 'ms'],
+  NP: ['NPR', '977', 'ne'],
+  NZ: ['NZD', '64', 'en'],
+  OM: ['OMR', '968', 'ar'],
+  PG: ['PGK', '675', 'en'],
+  PH: ['PHP', '63', 'en'],
+  PK: ['PKR', '92', 'en'],
+  QA: ['QAR', '974', 'ar'],
+  SA: ['SAR', '966', 'ar'],
+  SG: ['SGD', '65', 'en'],
+  TH: ['THB', '66', 'th'],
+  TW: ['TWD', '886', 'zh'],
+  UZ: ['UZS', '998', 'uz'],
+  VN: ['VND', '84', 'vi'],
+  AS: ['USD', '1', 'en'],
+  BN: ['BND', '673', 'ms'],
+  BT: ['BTN', '975', 'dz'],
+  CC: ['AUD', '61', 'en'],
+  CK: ['NZD', '682', 'en'],
+  CX: ['AUD', '61', 'en'],
+  FM: ['USD', '691', 'en'],
+  GU: ['USD', '1', 'en'],
+  KG: ['KGS', '996', 'ky'],
+  KI: ['AUD', '686', 'en'],
+  KP: ['KPW', '850', 'ko'],
+  LA: ['LAK', '856', 'lo'],
+  MH: ['USD', '692', 'en'],
+  MO: ['MOP', '853', 'zh'],
+  MP: ['USD', '1', 'en'],
+  MV: ['MVR', '960', 'dv'],
+  NC: ['XPF', '687', 'fr'],
+  NF: ['AUD', '672', 'en'],
+  NR: ['AUD', '674', 'en'],
+  NU: ['NZD', '683', 'en'],
+  PF: ['XPF', '689', 'fr'],
+  PN: ['NZD', '64', 'en'],
+  PS: ['ILS', '970', 'ar'],
+  PW: ['USD', '680', 'en'],
+  SB: ['SBD', '677', 'en'],
+  SY: ['SYP', '963', 'ar'],
+  TJ: ['TJS', '992', 'tg'],
+  TK: ['NZD', '690', 'en'],
+  TL: ['USD', '670', 'pt'],
+  TM: ['TMT', '993', 'tk'],
+  TO: ['TOP', '676', 'en'],
+  TV: ['AUD', '688', 'en'],
+  VU: ['VUV', '678', 'en'],
+  WF: ['XPF', '681', 'fr'],
+  WS: ['WST', '685', 'en'],
+  YE: ['YER', '967', 'ar'],
+  // Africa
+  AO: ['AOA', '244', 'pt'],
+  BW: ['BWP', '267', 'en'],
+  CI: ['XOF', '225', 'fr'],
+  CM: ['XAF', '237', 'fr'],
+  DZ: ['DZD', '213', 'ar'],
+  EG: ['EGP', '20', 'ar'],
+  ET: ['ETB', '251', 'am'],
+  GH: ['GHS', '233', 'en'],
+  KE: ['KES', '254', 'en'],
+  LY: ['LYD', '218', 'ar'],
+  MA: ['MAD', '212', 'ar'],
+  MG: ['MGA', '261', 'fr'],
+  MU: ['MUR', '230', 'en'],
+  MZ: ['MZN', '258', 'pt'],
+  NA: ['NAD', '264', 'en'],
+  NG: ['NGN', '234', 'en'],
+  RW: ['RWF', '250', 'en'],
+  SD: ['SDG', '249', 'ar'],
+  SN: ['XOF', '221', 'fr'],
+  TN: ['TND', '216', 'ar'],
+  TZ: ['TZS', '255', 'sw'],
+  UG: ['UGX', '256', 'en'],
+  ZA: ['ZAR', '27', 'en'],
+  ZM: ['ZMW', '260', 'en'],
+  ZW: ['USD', '263', 'en'],
+  BF: ['XOF', '226', 'fr'],
+  BI: ['BIF', '257', 'fr'],
+  BJ: ['XOF', '229', 'fr'],
+  CD: ['CDF', '243', 'fr'],
+  CF: ['XAF', '236', 'fr'],
+  CG: ['XAF', '242', 'fr'],
+  CV: ['CVE', '238', 'pt'],
+  DJ: ['DJF', '253', 'fr'],
+  ER: ['ERN', '291', 'ti'],
+  GA: ['XAF', '241', 'fr'],
+  GM: ['GMD', '220', 'en'],
+  GN: ['GNF', '224', 'fr'],
+  GQ: ['XAF', '240', 'es'],
+  GW: ['XOF', '245', 'pt'],
+  KM: ['KMF', '269', 'fr'],
+  LR: ['LRD', '231', 'en'],
+  LS: ['LSL', '266', 'en'],
+  ML: ['XOF', '223', 'fr'],
+  MR: ['MRU', '222', 'ar'],
+  MW: ['MWK', '265', 'en'],
+  NE: ['XOF', '227', 'fr'],
+  RE: ['EUR', '262', 'fr'],
+  SC: ['SCR', '248', 'en'],
+  SH: ['SHP', '290', 'en'],
+  SL: ['SLE', '232', 'en'],
+  SO: ['SOS', '252', 'so'],
+  SS: ['SSP', '211', 'en'],
+  ST: ['STN', '239', 'pt'],
+  SZ: ['SZL', '268', 'en'],
+  TD: ['XAF', '235', 'fr'],
+  TG: ['XOF', '228', 'fr'],
+  YT: ['EUR', '262', 'fr'],
+}
+
+/** Countries that drive in miles. */
+const MILES = new Set(['US', 'GB', 'GG', 'IM', 'JE', 'LR', 'MM', 'PR', 'GU', 'VI', 'AS', 'MP'])
+/** Countries that buy oil by the quart and parts by the pound. */
+const US_CUSTOMARY = new Set(['US', 'LR', 'MM', 'PR', 'GU', 'VI', 'AS', 'MP'])
+
+/** The name a sales tax goes by on an invoice, where there is one common name. */
+const TAX_LABELS: Record<string, string> = {
+  GB: 'VAT',
+  IE: 'VAT',
+  MT: 'VAT',
+  CY: 'VAT',
+  ZA: 'VAT',
+  AE: 'VAT',
+  SA: 'VAT',
+  PL: 'VAT',
+  FR: 'TVA',
+  LU: 'TVA',
+  MC: 'TVA',
+  RO: 'TVA',
+  DE: 'MwSt.',
+  AT: 'MwSt.',
+  CH: 'MWST',
+  LI: 'MWST',
+  IT: 'IVA',
+  SM: 'IVA',
+  ES: 'IVA',
+  PT: 'IVA',
+  MX: 'IVA',
+  AR: 'IVA',
+  CL: 'IVA',
+  CO: 'IVA',
+  UY: 'IVA',
+  NL: 'BTW',
+  NO: 'MVA',
+  SE: 'Moms',
+  DK: 'Moms',
+  FI: 'ALV',
+  IS: 'VSK',
+  LT: 'PVM',
+  LV: 'PVN',
+  EE: 'KM',
+  CZ: 'DPH',
+  SK: 'DPH',
+  HU: 'ÁFA',
+  HR: 'PDV',
+  SI: 'DDV',
+  BG: 'ДДС',
+  GR: 'ΦΠΑ',
+  RU: 'НДС',
+  TR: 'KDV',
+  AU: 'GST',
+  NZ: 'GST',
+  CA: 'GST',
+  SG: 'GST',
+  IN: 'GST',
+  MY: 'SST',
+  US: 'Sales tax',
+}
+
+/** Where a country speaks more than one of our languages, the label follows the language. */
+const TAX_LABELS_BY_LANGUAGE: Record<string, Record<string, string>> = {
+  BE: { fr: 'TVA', nl: 'BTW', de: 'MwSt.' },
+  CH: { fr: 'TVA', it: 'IVA', de: 'MWST' },
+}
+
+/** The countries a workshop can choose, as ISO codes. */
+export const COUNTRY_CODES: readonly string[] = Object.keys(COUNTRIES).sort()
+
+/** Names people search for that neither the English nor the local name contains. */
+const SEARCH_ALIASES: Record<string, string> = {
+  GB: 'UK England Scotland Wales Northern Ireland Great Britain Britain',
+  US: 'USA America',
+  AE: 'UAE Emirates Dubai',
+  NL: 'Holland',
+  CZ: 'Czech Republic',
+  KR: 'Korea',
+  KP: 'Korea',
+  CI: 'Ivory Coast',
+  CD: 'DRC Congo',
+  CG: 'Congo',
+  MM: 'Burma',
+  SZ: 'Swaziland',
+  TR: 'Turkey',
+  MK: 'Macedonia',
+  CV: 'Cape Verde',
+  TL: 'East Timor',
+  VA: 'Holy See Vatican',
+  BA: 'Bosnia',
+}
+
+/**
+ * What a country is found by in the picker, besides its name in the owner's
+ * own language: its code, its English name, its name in its own language and
+ * the short names people type ("UK" for Storbritannia).
+ */
+export function countrySearchTerms(code: string): string {
+  const cc = code.toUpperCase()
+  const name = (locale: string) => {
+    try {
+      return new Intl.DisplayNames([locale], { type: 'region', fallback: 'none' }).of(cc) ?? ''
+    } catch {
+      return ''
+    }
+  }
+  const local = COUNTRIES[cc] ? name(`${COUNTRIES[cc][2]}-${cc}`) : ''
+  return [cc, name('en'), local, SEARCH_ALIASES[cc] ?? ''].filter(Boolean).join(' ')
+}
+
+/**
+ * The time zone to suggest for a workshop in a country: the browser's own
+ * when it is in that country, otherwise the country's first listed zone (its
+ * capital's, or its most populous). Undefined where the table has none.
+ */
+export function timeZoneFor(country: string, browserZone: string | undefined): string | undefined {
+  const cc = country.toUpperCase()
+  if (browserZone && ZONE_COUNTRIES[browserZone] === cc) return browserZone
+  for (const [zone, code] of Object.entries(ZONE_COUNTRIES)) if (code === cc) return zone
+  // Kosovo has no zone of its own in the time zone database; it keeps Belgrade's.
+  return cc === 'XK' ? 'Europe/Belgrade' : undefined
+}
+
+export function isCountryCode(value: unknown): value is string {
+  return typeof value === 'string' && value in COUNTRIES
+}
+
+/** Our app locale for a language spoken in a country, if we ship it. */
+export function appLocaleFor(language: string, country: string): Locale | undefined {
+  const lang = language.toLowerCase().split('-')[0]
+  if (lang === 'pt') return country === 'BR' ? 'pt-BR' : 'pt-PT'
+  if (lang === 'nb' || lang === 'no' || lang === 'nn') return 'nb'
+  return (locales as readonly string[]).includes(lang) ? (lang as Locale) : undefined
+}
+
+/**
+ * The closest of the app's date formats to how dates are written in a
+ * locale: day, month or year first, and the separator used.
+ */
+export function dateFormatFor(locale: string): DateFormatOption {
+  try {
+    const parts = new Intl.DateTimeFormat(locale, {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    }).formatToParts(new Date(Date.UTC(2026, 1, 15, 12)))
+    const order = parts.filter((p) => p.type !== 'literal').map((p) => p.type)
+    const separator = parts.find((p) => p.type === 'literal')?.value.trim() ?? '/'
+    if (order[0] === 'year') return 'yyyy-MM-dd'
+    if (order[0] === 'month') return 'MM/dd/yyyy'
+    return separator === '.' ? 'dd.MM.yyyy' : 'dd/MM/yyyy'
+  } catch {
+    return 'dd/MM/yyyy'
+  }
+}
+
+/** Whether a locale reads the clock in twelve hours or twenty-four. */
+export function timeFormatFor(locale: string): '12h' | '24h' {
+  try {
+    const { hour12 } = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions()
+    return hour12 ? '12h' : '24h'
+  } catch {
+    return '24h'
+  }
+}
+
+/**
+ * Everything onboarding suggests for a country. `preferredLanguage` is the
+ * owner's own, from the browser, and wins where the country speaks it: a
+ * French-speaking Belgian workshop gets French, a Flemish one Dutch.
+ */
+export function countryDefaults(country: string, preferredLanguage?: string): CountryDefaults {
+  const cc = country.toUpperCase()
+  const [currency, dial, mainLanguage] = COUNTRIES[cc] ?? ['USD', '1', 'en']
+  const browserLanguage = preferredLanguage?.toLowerCase().split('-')[0]
+  const spoken = multilingual(cc)
+  const language =
+    browserLanguage && spoken.includes(browserLanguage) ? browserLanguage : mainLanguage
+  const formatLocale = `${language}-${cc}`
+  return {
+    country: cc,
+    language,
+    locale: appLocaleFor(language, cc),
+    currency: currencyDefinition(currency) ? currency : undefined,
+    dialCode: `+${dial}`,
+    distance: MILES.has(cc) ? 'imperial' : 'metric',
+    measure: US_CUSTOMARY.has(cc) ? 'imperial' : 'metric',
+    dateFormat: dateFormatFor(formatLocale),
+    timeFormat: timeFormatFor(formatLocale),
+    taxLabel: TAX_LABELS_BY_LANGUAGE[cc]?.[language] ?? TAX_LABELS[cc],
+  }
+}
+
+/** The languages a country commonly writes business documents in. */
+function multilingual(country: string): string[] {
+  switch (country) {
+    case 'BE':
+      return ['nl', 'fr', 'de']
+    case 'CH':
+      return ['de', 'fr', 'it']
+    case 'CA':
+      return ['en', 'fr']
+    case 'LU':
+      return ['fr', 'de']
+    case 'FI':
+      return ['fi', 'sv']
+    default:
+      return [COUNTRIES[country]?.[2] ?? 'en']
+  }
+}
+
+/**
+ * The time zones that settle a country on their own. A browser's zone is the
+ * best clue to where the workshop is: an owner can read English in Lyon, but
+ * the clock says Paris.
+ */
+const ZONE_COUNTRIES: Record<string, string> = {
+  'Europe/Andorra': 'AD',
+  'Europe/Tirane': 'AL',
+  'Europe/Vienna': 'AT',
+  'Europe/Sarajevo': 'BA',
+  'Europe/Brussels': 'BE',
+  'Europe/Sofia': 'BG',
+  'Europe/Minsk': 'BY',
+  'Europe/Zurich': 'CH',
+  'Asia/Nicosia': 'CY',
+  'Europe/Nicosia': 'CY',
+  'Europe/Prague': 'CZ',
+  'Europe/Berlin': 'DE',
+  'Europe/Copenhagen': 'DK',
+  'Europe/Tallinn': 'EE',
+  'Europe/Madrid': 'ES',
+  'Atlantic/Canary': 'ES',
+  'Europe/Helsinki': 'FI',
+  'Atlantic/Faroe': 'FO',
+  'Europe/Paris': 'FR',
+  'Europe/London': 'GB',
+  'Europe/Gibraltar': 'GI',
+  'Europe/Athens': 'GR',
+  'Europe/Zagreb': 'HR',
+  'Europe/Budapest': 'HU',
+  'Europe/Dublin': 'IE',
+  'Atlantic/Reykjavik': 'IS',
+  'Europe/Rome': 'IT',
+  'Europe/Vaduz': 'LI',
+  'Europe/Vilnius': 'LT',
+  'Europe/Luxembourg': 'LU',
+  'Europe/Riga': 'LV',
+  'Europe/Monaco': 'MC',
+  'Europe/Chisinau': 'MD',
+  'Europe/Podgorica': 'ME',
+  'Europe/Skopje': 'MK',
+  'Europe/Malta': 'MT',
+  'Europe/Amsterdam': 'NL',
+  'Europe/Oslo': 'NO',
+  'Europe/Warsaw': 'PL',
+  'Europe/Lisbon': 'PT',
+  'Atlantic/Madeira': 'PT',
+  'Atlantic/Azores': 'PT',
+  'Europe/Bucharest': 'RO',
+  'Europe/Belgrade': 'RS',
+  'Europe/Moscow': 'RU',
+  'Europe/Stockholm': 'SE',
+  'Europe/Ljubljana': 'SI',
+  'Europe/Bratislava': 'SK',
+  'Europe/San_Marino': 'SM',
+  'Europe/Istanbul': 'TR',
+  'Europe/Kyiv': 'UA',
+  'Europe/Kiev': 'UA',
+  'America/Argentina/Buenos_Aires': 'AR',
+  'America/Buenos_Aires': 'AR',
+  'America/Barbados': 'BB',
+  'America/La_Paz': 'BO',
+  'America/Sao_Paulo': 'BR',
+  'America/Nassau': 'BS',
+  'America/Toronto': 'CA',
+  'America/Vancouver': 'CA',
+  'America/Edmonton': 'CA',
+  'America/Winnipeg': 'CA',
+  'America/Halifax': 'CA',
+  'America/St_Johns': 'CA',
+  'America/Regina': 'CA',
+  'America/Montreal': 'CA',
+  'America/Santiago': 'CL',
+  'America/Bogota': 'CO',
+  'America/Costa_Rica': 'CR',
+  'America/Havana': 'CU',
+  'America/Santo_Domingo': 'DO',
+  'America/Guayaquil': 'EC',
+  'America/Guatemala': 'GT',
+  'America/Tegucigalpa': 'HN',
+  'America/Jamaica': 'JM',
+  'America/Mexico_City': 'MX',
+  'America/Monterrey': 'MX',
+  'America/Tijuana': 'MX',
+  'America/Cancun': 'MX',
+  'America/Managua': 'NI',
+  'America/Panama': 'PA',
+  'America/Lima': 'PE',
+  'America/Puerto_Rico': 'PR',
+  'America/Asuncion': 'PY',
+  'America/El_Salvador': 'SV',
+  'America/Port_of_Spain': 'TT',
+  'America/New_York': 'US',
+  'America/Chicago': 'US',
+  'America/Denver': 'US',
+  'America/Phoenix': 'US',
+  'America/Los_Angeles': 'US',
+  'America/Anchorage': 'US',
+  'Pacific/Honolulu': 'US',
+  'America/Detroit': 'US',
+  'America/Boise': 'US',
+  'America/Indiana/Indianapolis': 'US',
+  'America/Montevideo': 'UY',
+  'America/Caracas': 'VE',
+  'Asia/Dubai': 'AE',
+  'Asia/Kabul': 'AF',
+  'Asia/Yerevan': 'AM',
+  'Australia/Sydney': 'AU',
+  'Australia/Melbourne': 'AU',
+  'Australia/Brisbane': 'AU',
+  'Australia/Perth': 'AU',
+  'Australia/Adelaide': 'AU',
+  'Australia/Hobart': 'AU',
+  'Australia/Darwin': 'AU',
+  'Asia/Baku': 'AZ',
+  'Asia/Dhaka': 'BD',
+  'Asia/Bahrain': 'BH',
+  'Asia/Shanghai': 'CN',
+  'Pacific/Fiji': 'FJ',
+  'Asia/Tbilisi': 'GE',
+  'Asia/Hong_Kong': 'HK',
+  'Asia/Jakarta': 'ID',
+  'Asia/Jerusalem': 'IL',
+  'Asia/Kolkata': 'IN',
+  'Asia/Calcutta': 'IN',
+  'Asia/Baghdad': 'IQ',
+  'Asia/Tehran': 'IR',
+  'Asia/Amman': 'JO',
+  'Asia/Tokyo': 'JP',
+  'Asia/Phnom_Penh': 'KH',
+  'Asia/Seoul': 'KR',
+  'Asia/Kuwait': 'KW',
+  'Asia/Almaty': 'KZ',
+  'Asia/Beirut': 'LB',
+  'Asia/Colombo': 'LK',
+  'Asia/Yangon': 'MM',
+  'Asia/Ulaanbaatar': 'MN',
+  'Asia/Kuala_Lumpur': 'MY',
+  'Asia/Kathmandu': 'NP',
+  'Pacific/Auckland': 'NZ',
+  'Asia/Muscat': 'OM',
+  'Pacific/Port_Moresby': 'PG',
+  'Asia/Manila': 'PH',
+  'Asia/Karachi': 'PK',
+  'Asia/Qatar': 'QA',
+  'Asia/Riyadh': 'SA',
+  'Asia/Singapore': 'SG',
+  'Asia/Bangkok': 'TH',
+  'Asia/Taipei': 'TW',
+  'Asia/Tashkent': 'UZ',
+  'Asia/Ho_Chi_Minh': 'VN',
+  'Asia/Saigon': 'VN',
+  'Africa/Luanda': 'AO',
+  'Africa/Gaborone': 'BW',
+  'Africa/Abidjan': 'CI',
+  'Africa/Douala': 'CM',
+  'Africa/Algiers': 'DZ',
+  'Africa/Cairo': 'EG',
+  'Africa/Addis_Ababa': 'ET',
+  'Africa/Accra': 'GH',
+  'Africa/Nairobi': 'KE',
+  'Africa/Tripoli': 'LY',
+  'Africa/Casablanca': 'MA',
+  'Indian/Antananarivo': 'MG',
+  'Indian/Mauritius': 'MU',
+  'Africa/Maputo': 'MZ',
+  'Africa/Windhoek': 'NA',
+  'Africa/Lagos': 'NG',
+  'Africa/Kigali': 'RW',
+  'Africa/Khartoum': 'SD',
+  'Africa/Dakar': 'SN',
+  'Africa/Tunis': 'TN',
+  'Africa/Dar_es_Salaam': 'TZ',
+  'Africa/Kampala': 'UG',
+  'Africa/Johannesburg': 'ZA',
+  'Africa/Lusaka': 'ZM',
+  'Africa/Harare': 'ZW',
+  'Europe/Mariehamn': 'AX',
+  'Europe/Guernsey': 'GG',
+  'Europe/Isle_of_Man': 'IM',
+  'Europe/Jersey': 'JE',
+  'Arctic/Longyearbyen': 'SJ',
+  'Europe/Vatican': 'VA',
+  'America/Antigua': 'AG',
+  'America/Anguilla': 'AI',
+  'America/Aruba': 'AW',
+  'America/St_Barthelemy': 'BL',
+  'Atlantic/Bermuda': 'BM',
+  'America/Kralendijk': 'BQ',
+  'America/Belize': 'BZ',
+  'America/Curacao': 'CW',
+  'America/Dominica': 'DM',
+  'Atlantic/Stanley': 'FK',
+  'America/Grenada': 'GD',
+  'America/Cayenne': 'GF',
+  'America/Nuuk': 'GL',
+  'America/Godthab': 'GL',
+  'America/Guadeloupe': 'GP',
+  'America/Guyana': 'GY',
+  'America/Port-au-Prince': 'HT',
+  'America/St_Kitts': 'KN',
+  'America/Cayman': 'KY',
+  'America/St_Lucia': 'LC',
+  'America/Marigot': 'MF',
+  'America/Martinique': 'MQ',
+  'America/Montserrat': 'MS',
+  'America/Miquelon': 'PM',
+  'America/Paramaribo': 'SR',
+  'America/Lower_Princes': 'SX',
+  'America/Grand_Turk': 'TC',
+  'America/St_Vincent': 'VC',
+  'America/Tortola': 'VG',
+  'America/St_Thomas': 'VI',
+  'Pacific/Pago_Pago': 'AS',
+  'Asia/Brunei': 'BN',
+  'Asia/Thimphu': 'BT',
+  'Indian/Cocos': 'CC',
+  'Pacific/Rarotonga': 'CK',
+  'Indian/Christmas': 'CX',
+  'Pacific/Pohnpei': 'FM',
+  'Pacific/Guam': 'GU',
+  'Asia/Bishkek': 'KG',
+  'Pacific/Tarawa': 'KI',
+  'Asia/Pyongyang': 'KP',
+  'Asia/Vientiane': 'LA',
+  'Pacific/Majuro': 'MH',
+  'Asia/Macau': 'MO',
+  'Pacific/Saipan': 'MP',
+  'Indian/Maldives': 'MV',
+  'Pacific/Noumea': 'NC',
+  'Pacific/Norfolk': 'NF',
+  'Pacific/Nauru': 'NR',
+  'Pacific/Niue': 'NU',
+  'Pacific/Tahiti': 'PF',
+  'Pacific/Pitcairn': 'PN',
+  'Asia/Gaza': 'PS',
+  'Asia/Hebron': 'PS',
+  'Pacific/Palau': 'PW',
+  'Pacific/Guadalcanal': 'SB',
+  'Asia/Damascus': 'SY',
+  'Asia/Dushanbe': 'TJ',
+  'Pacific/Fakaofo': 'TK',
+  'Asia/Dili': 'TL',
+  'Asia/Ashgabat': 'TM',
+  'Pacific/Tongatapu': 'TO',
+  'Pacific/Funafuti': 'TV',
+  'Pacific/Efate': 'VU',
+  'Pacific/Wallis': 'WF',
+  'Pacific/Apia': 'WS',
+  'Asia/Aden': 'YE',
+  'Africa/Ouagadougou': 'BF',
+  'Africa/Bujumbura': 'BI',
+  'Africa/Porto-Novo': 'BJ',
+  'Africa/Kinshasa': 'CD',
+  'Africa/Lubumbashi': 'CD',
+  'Africa/Bangui': 'CF',
+  'Africa/Brazzaville': 'CG',
+  'Atlantic/Cape_Verde': 'CV',
+  'Africa/Djibouti': 'DJ',
+  'Africa/Asmara': 'ER',
+  'Africa/Libreville': 'GA',
+  'Africa/Banjul': 'GM',
+  'Africa/Conakry': 'GN',
+  'Africa/Malabo': 'GQ',
+  'Africa/Bissau': 'GW',
+  'Indian/Comoro': 'KM',
+  'Africa/Monrovia': 'LR',
+  'Africa/Maseru': 'LS',
+  'Africa/Bamako': 'ML',
+  'Africa/Nouakchott': 'MR',
+  'Africa/Blantyre': 'MW',
+  'Africa/Niamey': 'NE',
+  'Indian/Reunion': 'RE',
+  'Indian/Mahe': 'SC',
+  'Atlantic/St_Helena': 'SH',
+  'Africa/Freetown': 'SL',
+  'Africa/Mogadishu': 'SO',
+  'Africa/Juba': 'SS',
+  'Africa/Sao_Tome': 'ST',
+  'Africa/Mbabane': 'SZ',
+  'Africa/Ndjamena': 'TD',
+  'Africa/Lome': 'TG',
+  'Indian/Mayotte': 'YT',
+}
+
+/**
+ * The country the browser most likely sits in: its time zone first, then the
+ * region of its preferred languages. Null when neither says, and the owner
+ * picks.
+ */
+export function detectCountry(
+  timeZone: string | undefined,
+  languages: readonly string[]
+): string | null {
+  const fromZone = timeZone ? ZONE_COUNTRIES[timeZone] : undefined
+  if (fromZone) return fromZone
+  for (const tag of languages) {
+    const region = tag.split('-')[1]?.toUpperCase()
+    if (region && isCountryCode(region)) return region
+  }
+  return null
+}

+ 62 - 0
src/lib/document-numbers.ts

@@ -0,0 +1,62 @@
+import { formatQuantity } from './format-quantity'
+
+/**
+ * Numbers as a printed document writes them, in the document's language:
+ * "86 200 km" and "0,8 h" on a French invoice, "86,200 km" and "0.8 h" on an
+ * English one. The documents were written English-style whatever their
+ * language, because the numbers went through `toLocaleString()` and `String()`
+ * with no locale.
+ *
+ * Without a locale every number is written exactly as before, so a caller
+ * that does not know the document's language changes nothing.
+ */
+export interface DocumentNumbers {
+  /** Hours and other counts: the decimal mark only, never grouped. */
+  count(value: number): string
+  /** Mileage and engine hours on the odometer: grouped, whole. */
+  distance(value: number): string
+  /** A part's quantity with its unit, "2,5 l". */
+  quantity(value: number, unit?: string | null): string
+}
+
+/**
+ * The narrow no-break space French and others group with is missing from the
+ * standard PDF fonts; the ordinary no-break space prints the same gap.
+ */
+function printable(text: string): string {
+  return text.replace(/ /g, ' ')
+}
+
+function formatter(locale: string, options: Intl.NumberFormatOptions): Intl.NumberFormat | null {
+  try {
+    return new Intl.NumberFormat(locale, options)
+  } catch {
+    return null
+  }
+}
+
+export function documentNumbers(locale: string | undefined): DocumentNumbers {
+  if (!locale) {
+    return {
+      count: (value) => String(value),
+      distance: (value) => value.toLocaleString(),
+      quantity: (value, unit) => formatQuantity(value, unit),
+    }
+  }
+  // As many decimals as a stored quantity has: 0.125 h prints whole, since the
+  // line total is charged on it. Six still drops float noise such as 0.30000000000000004.
+  const counts = formatter(locale, { useGrouping: false, maximumFractionDigits: 6 })
+  const distances = formatter(locale, { maximumFractionDigits: 0 })
+  const count = (value: number) => (counts ? printable(counts.format(value)) : String(value))
+  return {
+    count,
+    distance: (value) => (distances ? printable(distances.format(value)) : String(value)),
+    quantity: (value, unit) => (unit ? `${count(value)} ${unit}` : count(value)),
+  }
+}
+
+/** The service type as the document's language names it, or as stored when it has no name. */
+export function serviceTypeName(type: string, labels: Record<string, string>): string {
+  const key = `serviceType${type.charAt(0).toUpperCase()}${type.slice(1)}`
+  return labels[key] || type
+}