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

Send quotes, invoices and inspections from one Send dialog (#591)

* Show SMS in the share dialogs even when it is not set up

The invoice, quote and inspection share dialogs left the SMS box out when
the plan had no SMS, and offered it when the plan had SMS but no provider
was connected, so the send failed. The box is now always there: usable when
a text can go out, otherwise disabled with the reason and a link to
Integrations or to the plans.

* Send quotes, invoices and inspections on WhatsApp from the share dialog

The Notify box in the three share dialogs gets a WhatsApp option next to
Email and SMS. It sends the same wording as the text message, with the
link. Like SMS it is always shown: disabled with the reason when WhatsApp
is not connected, not in the plan, or the customer is out of reach under
the 24 hour rule with no approved template.

* Send quotes, invoices and inspections from one Send dialog

The Email button and the Notify box inside Share did the same job twice.
One Send dialog now covers email, SMS and WhatsApp, each with an address
or number that starts as the customer's and can be typed over, so a job
with no customer on file can still be sent. The link always goes with the
email; the PDF is an extra. Share is left with the link: copy and revoke.

A text or WhatsApp message to a typed number is built on the server from
the workshop's template and the document's link.

* Keep the link in every send, and close the gaps the review found

A text template without the link tag now gets the link added, and a
WhatsApp template with no message variable is refused outside the 24
hours instead of delivering no document. The first share link is minted
once, so two sends cannot hand out different ones. Email is shown as
turned off when the workshop switched it off, and an unticked email row
no longer holds up the other channels. Any member who can send can see
which channels work. The WhatsApp window check looks up the customer's
number in the shape replies are stored in.
Bernt Christian Egeland 12 часов назад
Родитель
Сommit
794af43fcc
84 измененных файлов с 2191 добавлено и 1550 удалено
  1. 2 2
      e2e/specs/condition-map/inspection.spec.ts
  2. 5 5
      e2e/specs/email/sending.spec.ts
  3. 4 4
      e2e/specs/invoices/pdf-parity.spec.ts
  4. 4 4
      e2e/specs/quotes/document.spec.ts
  5. 13 12
      e2e/specs/quotes/lock.spec.ts
  6. 28 3
      messages/de/common.json
  7. 2 11
      messages/de/inspections.json
  8. 1 11
      messages/de/quotes.json
  9. 1 11
      messages/de/service.json
  10. 28 3
      messages/en/common.json
  11. 2 11
      messages/en/inspections.json
  12. 1 11
      messages/en/quotes.json
  13. 1 11
      messages/en/service.json
  14. 28 3
      messages/es/common.json
  15. 2 11
      messages/es/inspections.json
  16. 1 11
      messages/es/quotes.json
  17. 1 11
      messages/es/service.json
  18. 28 3
      messages/fr/common.json
  19. 2 11
      messages/fr/inspections.json
  20. 1 11
      messages/fr/quotes.json
  21. 1 11
      messages/fr/service.json
  22. 28 3
      messages/it/common.json
  23. 2 11
      messages/it/inspections.json
  24. 1 11
      messages/it/quotes.json
  25. 1 11
      messages/it/service.json
  26. 28 3
      messages/lt/common.json
  27. 2 11
      messages/lt/inspections.json
  28. 1 11
      messages/lt/quotes.json
  29. 1 11
      messages/lt/service.json
  30. 28 3
      messages/nb/common.json
  31. 2 11
      messages/nb/inspections.json
  32. 1 11
      messages/nb/quotes.json
  33. 1 11
      messages/nb/service.json
  34. 28 3
      messages/nl/common.json
  35. 2 11
      messages/nl/inspections.json
  36. 1 11
      messages/nl/quotes.json
  37. 1 11
      messages/nl/service.json
  38. 28 3
      messages/pl/common.json
  39. 2 11
      messages/pl/inspections.json
  40. 1 11
      messages/pl/quotes.json
  41. 1 11
      messages/pl/service.json
  42. 28 3
      messages/pt-BR/common.json
  43. 2 11
      messages/pt-BR/inspections.json
  44. 1 11
      messages/pt-BR/quotes.json
  45. 1 11
      messages/pt-BR/service.json
  46. 28 3
      messages/pt-PT/common.json
  47. 2 11
      messages/pt-PT/inspections.json
  48. 1 11
      messages/pt-PT/quotes.json
  49. 1 11
      messages/pt-PT/service.json
  50. 28 3
      messages/ru/common.json
  51. 2 11
      messages/ru/inspections.json
  52. 1 11
      messages/ru/quotes.json
  53. 1 11
      messages/ru/service.json
  54. 28 3
      messages/tr/common.json
  55. 2 11
      messages/tr/inspections.json
  56. 1 11
      messages/tr/quotes.json
  57. 1 11
      messages/tr/service.json
  58. 5 0
      src/__tests__/features/email/inspection-email-certificate.test.ts
  59. 347 0
      src/__tests__/features/messaging/send-document-dialog.test.tsx
  60. 281 0
      src/__tests__/features/messaging/send-document-link.test.ts
  61. 0 186
      src/__tests__/features/messaging/share-dialog-sms-option.test.tsx
  62. 5 1
      src/__tests__/features/share-link-revoke.test.tsx
  63. 3 1
      src/__tests__/features/vehicles/add-observations-to-work-order.test.tsx
  64. 61 0
      src/__tests__/lib/share-token.test.ts
  65. 0 3
      src/app/(authenticated)/quotes/[id]/page.tsx
  66. 17 32
      src/features/email/Actions/emailActions.ts
  67. 0 45
      src/features/email/Components/AttachPdfOption.tsx
  68. 0 110
      src/features/email/Components/SendEmailDialog.tsx
  69. 33 2
      src/features/inspections/Components/InspectionPageClient.tsx
  70. 1 147
      src/features/inspections/Components/InspectionShareDialog.tsx
  71. 37 17
      src/features/messaging/Actions/getShareChannels.ts
  72. 287 0
      src/features/messaging/Actions/sendDocumentLink.ts
  73. 0 88
      src/features/messaging/Components/NotifyChannelOption.tsx
  74. 443 0
      src/features/messaging/Components/SendDocumentDialog.tsx
  75. 38 1
      src/features/messaging/Lib/useShareChannels.ts
  76. 17 25
      src/features/quotes/Components/QuotePageClient.tsx
  77. 1 153
      src/features/quotes/Components/QuoteShareDialog.tsx
  78. 13 70
      src/features/sms/Actions/smsActions.ts
  79. 78 0
      src/features/sms/Lib/deliverSms.server.ts
  80. 1 153
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  81. 2 2
      src/features/vehicles/Components/service-page/ServiceHeaderActions.tsx
  82. 17 17
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  83. 11 2
      src/features/whatsapp/Actions/whatsappActions.ts
  84. 49 0
      src/lib/share-token.server.ts

+ 2 - 2
e2e/specs/condition-map/inspection.spec.ts

@@ -79,8 +79,8 @@ async function complete(page: Page) {
   const confirm = page.getByRole('alertdialog', { name: 'Complete this inspection?' })
   const confirm = page.getByRole('alertdialog', { name: 'Complete this inspection?' })
   await confirm.getByRole('button', { name: 'Complete', exact: true }).click()
   await confirm.getByRole('button', { name: 'Complete', exact: true }).click()
   await expect(page.getByText('Inspection completed').first()).toBeVisible()
   await expect(page.getByText('Inspection completed').first()).toBeVisible()
-  // Completing offers to share the certificate straight away; not today.
-  const share = page.getByRole('dialog').filter({ hasText: 'Share a read-only view' })
+  // Completing offers to send the certificate straight away; not today.
+  const share = page.getByRole('dialog', { name: 'Send inspection' })
   const offered = await share
   const offered = await share
     .waitFor({ state: 'visible', timeout: 5_000 })
     .waitFor({ state: 'visible', timeout: 5_000 })
     .then(() => true)
     .then(() => true)

+ 5 - 5
e2e/specs/email/sending.spec.ts

@@ -42,12 +42,12 @@ function leftoverTags(...parts: string[]): string[] {
 async function emailInvoice(page: Page, to: string, attachPdf: boolean): Promise<void> {
 async function emailInvoice(page: Page, to: string, attachPdf: boolean): Promise<void> {
   await page.goto(jobUrl)
   await page.goto(jobUrl)
   await expect(async () => {
   await expect(async () => {
-    await page.getByRole('button', { name: 'Email', exact: true }).click()
-    await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+    await page.getByRole('button', { name: 'Send', exact: true }).click()
+    await expect(page.locator('#send-email-to')).toBeVisible({ timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
   }).toPass({ timeout: 30_000 })
 
 
-  await page.locator('#email').fill(to)
-  const attach = page.locator('#attach-pdf-send')
+  await page.locator('#send-email-to').fill(to)
+  const attach = page.locator('#send-attach-pdf')
   await expect(attach).toBeVisible()
   await expect(attach).toBeVisible()
   // The workshop's default answers this, so it is set rather than clicked.
   // The workshop's default answers this, so it is set rather than clicked.
   if ((await attach.getAttribute('data-state')) !== (attachPdf ? 'checked' : 'unchecked')) {
   if ((await attach.getAttribute('data-state')) !== (attachPdf ? 'checked' : 'unchecked')) {
@@ -55,7 +55,7 @@ async function emailInvoice(page: Page, to: string, attachPdf: boolean): Promise
   }
   }
   await expect(attach).toHaveAttribute('data-state', attachPdf ? 'checked' : 'unchecked')
   await expect(attach).toHaveAttribute('data-state', attachPdf ? 'checked' : 'unchecked')
 
 
-  await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+  await page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click()
 }
 }
 
 
 test.beforeAll(async ({ browser }) => {
 test.beforeAll(async ({ browser }) => {

+ 4 - 4
e2e/specs/invoices/pdf-parity.spec.ts

@@ -160,11 +160,11 @@ test.describe('the same invoice however it is handed over', () => {
     await page.goto(jobUrl)
     await page.goto(jobUrl)
 
 
     await expect(async () => {
     await expect(async () => {
-      await page.getByRole('button', { name: 'Email', exact: true }).click()
-      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+      await page.getByRole('button', { name: 'Send', exact: true }).click()
+      await expect(page.locator('#send-email-to')).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
-    await page.locator('#email').fill(CUSTOMER)
-    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+    await page.locator('#send-email-to').fill(CUSTOMER)
+    await page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click()
 
 
     const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
     const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
     const file = attachment(mail, /\.pdf$/)
     const file = attachment(mail, /\.pdf$/)

+ 4 - 4
e2e/specs/quotes/document.spec.ts

@@ -136,11 +136,11 @@ test.describe('a quote as the customer receives it', () => {
     await page.goto(quoteUrl)
     await page.goto(quoteUrl)
 
 
     await expect(async () => {
     await expect(async () => {
-      await page.getByRole('button', { name: 'Email', exact: true }).click()
-      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+      await page.getByRole('button', { name: 'Send', exact: true }).click()
+      await expect(page.locator('#send-email-to')).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
-    await page.locator('#email').fill(CUSTOMER)
-    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+    await page.locator('#send-email-to').fill(CUSTOMER)
+    await page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click()
 
 
     const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
     const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
     const file = attachment(mail, /\.pdf$/)
     const file = attachment(mail, /\.pdf$/)

+ 13 - 12
e2e/specs/quotes/lock.spec.ts

@@ -85,11 +85,11 @@ test.describe('a locked quote can still become a work order', () => {
     // Sent the way the workshop in the report sent theirs.
     // Sent the way the workshop in the report sent theirs.
     await clearMailbox()
     await clearMailbox()
     await expect(async () => {
     await expect(async () => {
-      await page.getByRole('button', { name: 'Email', exact: true }).click()
-      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+      await page.getByRole('button', { name: 'Send', exact: true }).click()
+      await expect(page.locator('#send-email-to')).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
-    await page.locator('#email').fill(CUSTOMER)
-    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+    await page.locator('#send-email-to').fill(CUSTOMER)
+    await page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click()
     await waitForMail(CUSTOMER, { timeout: 30_000 })
     await waitForMail(CUSTOMER, { timeout: 30_000 })
 
 
     await page.goto(quoteUrl)
     await page.goto(quoteUrl)
@@ -131,17 +131,18 @@ test.describe('a locked quote can still become a work order', () => {
     await saveQuote(page)
     await saveQuote(page)
     const shareUrl = await quoteShareLink(page)
     const shareUrl = await quoteShareLink(page)
 
 
-    // Telling the customer from the Share dialog closes it once the mail has
-    // gone, instead of leaving a finished dialog on screen.
+    // Sending from the Send dialog closes it once the mail has gone, instead
+    // of leaving a finished dialog on screen. The address is the customer's
+    // own, already filled in and ticked.
     await clearMailbox()
     await clearMailbox()
-    const shareDialog = page.getByRole('dialog', { name: 'Share Quote' })
+    const sendDialog = page.getByRole('dialog', { name: 'Send quote' })
     await expect(async () => {
     await expect(async () => {
-      await page.getByRole('button', { name: 'Share', exact: true }).click()
-      await expect(shareDialog).toBeVisible({ timeout: 2_000 })
+      await page.getByRole('button', { name: 'Send', exact: true }).click()
+      await expect(sendDialog).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
-    await shareDialog.locator('#notify-email-quote').click()
-    await shareDialog.getByRole('button', { name: 'Send Notification', exact: true }).click()
-    await expect(shareDialog).toBeHidden({ timeout: 30_000 })
+    await expect(sendDialog.locator('#send-email')).toHaveAttribute('data-state', 'checked')
+    await sendDialog.getByRole('button', { name: 'Send', exact: true }).click()
+    await expect(sendDialog).toBeHidden({ timeout: 30_000 })
     await waitForMail(SEEDED_CUSTOMER_EMAIL, { timeout: 30_000 })
     await waitForMail(SEEDED_CUSTOMER_EMAIL, { timeout: 30_000 })
 
 
     // Sharing sends it, and under this trigger a sent quote is still open.
     // Sharing sends it, and under this trigger a sent quote is still open.

+ 28 - 3
messages/de/common.json

@@ -156,8 +156,6 @@
     "preview": "Vorschau",
     "preview": "Vorschau",
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
   },
   },
-  "attachPdf": "PDF anhängen",
-  "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet.",
   "sentCopy": {
   "sentCopy": {
     "note": "Gesendet am {date}. Änderungen hier kommen auf die Rechnung, wenn sie erneut gesendet wird.",
     "note": "Gesendet am {date}. Änderungen hier kommen auf die Rechnung, wenn sie erneut gesendet wird.",
     "confirmTitle": "Diese Rechnung wurde bereits gesendet",
     "confirmTitle": "Diese Rechnung wurde bereits gesendet",
@@ -225,12 +223,39 @@
     "title": "Torqvoice ist nicht erreichbar",
     "title": "Torqvoice ist nicht erreichbar",
     "description": "Ihre letzte Änderung wurde möglicherweise nicht gespeichert. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut."
     "description": "Ihre letzte Änderung wurde möglicherweise nicht gespeichert. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Angebot senden",
+    "titleInvoice": "Rechnung senden",
+    "titleInspection": "Inspektion senden",
+    "description": "Der Kunde erhält einen Link, um es anzusehen und herunterzuladen.",
+    "email": "E-Mail",
+    "emailAddress": "E-Mail-Adresse",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "PDF zusätzlich anhängen",
+    "sms": "SMS",
+    "smsNumber": "Telefonnummer für SMS",
+    "whatsappNumber": "Telefonnummer für WhatsApp",
+    "phonePlaceholder": "Telefonnummer",
+    "message": "Nachricht (optional, wird der E-Mail hinzugefügt)",
+    "messagePlaceholder": "Persönliche Nachricht hinzufügen...",
+    "copyLink": "Link kopieren",
+    "send": "Senden",
+    "emailSent": "E-Mail gesendet",
+    "failedEmail": "E-Mail konnte nicht gesendet werden",
+    "smsSent": "SMS gesendet",
+    "failedSms": "SMS konnte nicht gesendet werden"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Link konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
     "generateFailed": "Link konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
     "revokeFailed": "Der Widerruf des Links konnte nicht bestätigt werden. Er funktioniert möglicherweise noch für alle, die ihn haben.",
     "revokeFailed": "Der Widerruf des Links konnte nicht bestätigt werden. Er funktioniert möglicherweise noch für alle, die ihn haben.",
     "channelNotConnected": "Verbinden Sie {channel} unter Integrationen, um auf diesem Weg zu senden.",
     "channelNotConnected": "Verbinden Sie {channel} unter Integrationen, um auf diesem Weg zu senden.",
     "openIntegrations": "Integrationen öffnen",
     "openIntegrations": "Integrationen öffnen",
-    "channelNotInPlan": "{channel} ist in Ihrem Tarif nicht enthalten."
+    "channelNotInPlan": "{channel} ist in Ihrem Tarif nicht enthalten.",
+    "channelTurnedOff": "Der Versand per {channel} ist für diese Werkstatt deaktiviert.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp gesendet",
+    "failedWhatsapp": "WhatsApp-Nachricht konnte nicht gesendet werden",
+    "whatsappWindowClosed": "Dieser Kunde hat Ihnen in den letzten 24 Stunden nicht über WhatsApp geschrieben, und es ist keine genehmigte Vorlage eingerichtet, um ihn zu erreichen."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Auswählen...",
     "choose": "Auswählen...",

+ 2 - 11
messages/de/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Abgeschlossen",
     "statusCompleted": "Abgeschlossen",
     "complete": "Abschließen",
     "complete": "Abschließen",
     "reopen": "Wieder öffnen",
     "reopen": "Wieder öffnen",
+    "send": "Senden",
     "share": "Teilen",
     "share": "Teilen",
     "save": "Speichern",
     "save": "Speichern",
     "saving": "Speichert…",
     "saving": "Speichert…",
@@ -460,18 +461,8 @@
     "copied": "Link in die Zwischenablage kopiert",
     "copied": "Link in die Zwischenablage kopiert",
     "generated": "Freigabelink erstellt",
     "generated": "Freigabelink erstellt",
     "revoked": "Freigabelink zurückgezogen",
     "revoked": "Freigabelink zurückgezogen",
-    "notifyTitle": "{name} benachrichtigen",
-    "email": "E-Mail",
-    "noEmail": "(keine E-Mail hinterlegt)",
-    "sms": "SMS",
-    "noPhone": "(keine Telefonnummer hinterlegt)",
-    "sendNotification": "Benachrichtigung senden",
     "revokeLink": "Link widerrufen",
     "revokeLink": "Link widerrufen",
-    "generateLink": "Öffentlichen Link erstellen",
-    "emailSent": "E-Mail gesendet",
-    "failedEmail": "E-Mail konnte nicht gesendet werden",
-    "smsSent": "SMS gesendet",
-    "failedSms": "SMS konnte nicht gesendet werden"
+    "generateLink": "Öffentlichen Link erstellen"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Fotos zur Prüfung",
     "pageTitle": "Fotos zur Prüfung",

+ 1 - 11
messages/de/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Angebot",
     "quote": "Angebot",
     "save": "Speichern",
     "save": "Speichern",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-Mail",
+    "send": "Senden",
     "share": "Teilen",
     "share": "Teilen",
     "delete": "Löschen",
     "delete": "Löschen",
     "deleteTitle": "Angebot löschen",
     "deleteTitle": "Angebot löschen",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Angebot teilen",
     "title": "Angebot teilen",
     "description": "Erstellen Sie einen öffentlichen Link, über den jeder dieses Angebot ansehen und herunterladen kann, ohne sich anzumelden.",
     "description": "Erstellen Sie einen öffentlichen Link, über den jeder dieses Angebot ansehen und herunterladen kann, ohne sich anzumelden.",
-    "notifyTitle": "{name} benachrichtigen",
-    "email": "E-Mail",
-    "noEmail": "(keine E-Mail hinterlegt)",
-    "sms": "SMS",
-    "noPhone": "(keine Telefonnummer hinterlegt)",
-    "sendNotification": "Benachrichtigung senden",
     "revokeLink": "Link widerrufen",
     "revokeLink": "Link widerrufen",
     "generateLink": "Öffentlichen Link erstellen",
     "generateLink": "Öffentlichen Link erstellen",
-    "emailSent": "E-Mail gesendet",
-    "failedEmail": "E-Mail konnte nicht gesendet werden",
-    "smsSent": "SMS gesendet",
-    "failedSms": "SMS konnte nicht gesendet werden",
     "copyLink": "Link kopieren"
     "copyLink": "Link kopieren"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/de/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Speichern",
     "save": "Speichern",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-Mail",
+    "send": "Senden",
     "share": "Teilen",
     "share": "Teilen",
     "notify": "Benachrichtigen",
     "notify": "Benachrichtigen",
     "delete": "Löschen",
     "delete": "Löschen",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Rechnung teilen",
     "title": "Rechnung teilen",
     "description": "Generieren Sie einen öffentlichen Link, der es jedem ermöglicht, diese Rechnung anzusehen und herunterzuladen, ohne sich anzumelden.",
     "description": "Generieren Sie einen öffentlichen Link, der es jedem ermöglicht, diese Rechnung anzusehen und herunterzuladen, ohne sich anzumelden.",
-    "notifyTitle": "{name} benachrichtigen",
-    "email": "E-Mail",
-    "noEmail": "(keine E-Mail vorhanden)",
-    "sms": "SMS",
-    "noPhone": "(keine Telefonnummer vorhanden)",
-    "sendNotification": "Benachrichtigung senden",
     "revokeLink": "Link widerrufen",
     "revokeLink": "Link widerrufen",
     "generateLink": "Öffentlichen Link generieren",
     "generateLink": "Öffentlichen Link generieren",
-    "emailSent": "E-Mail gesendet",
-    "failedEmail": "E-Mail konnte nicht gesendet werden",
-    "smsSent": "SMS gesendet",
-    "failedSms": "SMS konnte nicht gesendet werden",
     "copyLink": "Link kopieren"
     "copyLink": "Link kopieren"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/en/common.json

@@ -156,8 +156,6 @@
     "preview": "Preview",
     "preview": "Preview",
     "failed": "Could not generate the PDF preview."
     "failed": "Could not generate the PDF preview."
   },
   },
-  "attachPdf": "Attach PDF",
-  "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it.",
   "sentCopy": {
   "sentCopy": {
     "note": "Sent {date}. Changes here go on the invoice when it is sent again.",
     "note": "Sent {date}. Changes here go on the invoice when it is sent again.",
     "confirmTitle": "This invoice has already been sent",
     "confirmTitle": "This invoice has already been sent",
@@ -225,12 +223,39 @@
     "title": "Couldn't reach Torqvoice",
     "title": "Couldn't reach Torqvoice",
     "description": "Your last change may not have been saved. Check your connection and try again."
     "description": "Your last change may not have been saved. Check your connection and try again."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Send quote",
+    "titleInvoice": "Send invoice",
+    "titleInspection": "Send inspection",
+    "description": "The customer gets a link to view and download it.",
+    "email": "Email",
+    "emailAddress": "Email address",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Attach the PDF as well",
+    "sms": "SMS",
+    "smsNumber": "Phone number for SMS",
+    "whatsappNumber": "Phone number for WhatsApp",
+    "phonePlaceholder": "Phone number",
+    "message": "Message (optional, added to the email)",
+    "messagePlaceholder": "Add a personal message...",
+    "copyLink": "Copy link",
+    "send": "Send",
+    "emailSent": "Email sent",
+    "failedEmail": "Failed to send email",
+    "smsSent": "SMS sent",
+    "failedSms": "Failed to send SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Couldn't create the link. Try again.",
     "generateFailed": "Couldn't create the link. Try again.",
     "revokeFailed": "Couldn't confirm the link was revoked. It may still work for anyone who has it.",
     "revokeFailed": "Couldn't confirm the link was revoked. It may still work for anyone who has it.",
     "channelNotConnected": "Connect {channel} under Integrations to send this way.",
     "channelNotConnected": "Connect {channel} under Integrations to send this way.",
     "openIntegrations": "Open Integrations",
     "openIntegrations": "Open Integrations",
-    "channelNotInPlan": "{channel} is not included in your plan."
+    "channelNotInPlan": "{channel} is not included in your plan.",
+    "channelTurnedOff": "{channel} sending is turned off for this workshop.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp sent",
+    "failedWhatsapp": "Failed to send WhatsApp message",
+    "whatsappWindowClosed": "This customer has not written to you on WhatsApp in the last 24 hours, and no approved template is set up to reach them."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Choose...",
     "choose": "Choose...",

+ 2 - 11
messages/en/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Completed",
     "statusCompleted": "Completed",
     "complete": "Complete",
     "complete": "Complete",
     "reopen": "Reopen",
     "reopen": "Reopen",
+    "send": "Send",
     "share": "Share",
     "share": "Share",
     "save": "Save",
     "save": "Save",
     "saving": "Saving…",
     "saving": "Saving…",
@@ -460,18 +461,8 @@
     "copied": "Link copied to clipboard",
     "copied": "Link copied to clipboard",
     "generated": "Share link generated",
     "generated": "Share link generated",
     "revoked": "Share link revoked",
     "revoked": "Share link revoked",
-    "notifyTitle": "Notify {name}",
-    "email": "Email",
-    "noEmail": "(no email on file)",
-    "sms": "SMS",
-    "noPhone": "(no phone on file)",
-    "sendNotification": "Send Notification",
     "revokeLink": "Revoke Link",
     "revokeLink": "Revoke Link",
-    "generateLink": "Generate Public Link",
-    "emailSent": "Email sent",
-    "failedEmail": "Failed to send email",
-    "smsSent": "SMS sent",
-    "failedSms": "Failed to send SMS"
+    "generateLink": "Generate Public Link"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Inspection photos",
     "pageTitle": "Inspection photos",

+ 1 - 11
messages/en/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Quote",
     "quote": "Quote",
     "save": "Save",
     "save": "Save",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Email",
+    "send": "Send",
     "share": "Share",
     "share": "Share",
     "delete": "Delete",
     "delete": "Delete",
     "deleteTitle": "Delete Quote",
     "deleteTitle": "Delete Quote",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Share Quote",
     "title": "Share Quote",
     "description": "Generate a public link that allows anyone to view and download this quote without signing in.",
     "description": "Generate a public link that allows anyone to view and download this quote without signing in.",
-    "notifyTitle": "Notify {name}",
-    "email": "Email",
-    "noEmail": "(no email on file)",
-    "sms": "SMS",
-    "noPhone": "(no phone on file)",
-    "sendNotification": "Send Notification",
     "revokeLink": "Revoke Link",
     "revokeLink": "Revoke Link",
     "generateLink": "Generate Public Link",
     "generateLink": "Generate Public Link",
-    "emailSent": "Email sent",
-    "failedEmail": "Failed to send email",
-    "smsSent": "SMS sent",
-    "failedSms": "Failed to send SMS",
     "copyLink": "Copy link"
     "copyLink": "Copy link"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/en/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Save",
     "save": "Save",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Email",
+    "send": "Send",
     "share": "Share",
     "share": "Share",
     "notify": "Notify",
     "notify": "Notify",
     "delete": "Delete",
     "delete": "Delete",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Share Invoice",
     "title": "Share Invoice",
     "description": "Generate a public link that allows anyone to view and download this invoice without signing in.",
     "description": "Generate a public link that allows anyone to view and download this invoice without signing in.",
-    "notifyTitle": "Notify {name}",
-    "email": "Email",
-    "noEmail": "(no email on file)",
-    "sms": "SMS",
-    "noPhone": "(no phone on file)",
-    "sendNotification": "Send Notification",
     "revokeLink": "Revoke Link",
     "revokeLink": "Revoke Link",
     "generateLink": "Generate Public Link",
     "generateLink": "Generate Public Link",
-    "emailSent": "Email sent",
-    "failedEmail": "Failed to send email",
-    "smsSent": "SMS sent",
-    "failedSms": "Failed to send SMS",
     "copyLink": "Copy link"
     "copyLink": "Copy link"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/es/common.json

@@ -156,8 +156,6 @@
     "preview": "Vista previa",
     "preview": "Vista previa",
     "failed": "No se pudo generar la vista previa del PDF."
     "failed": "No se pudo generar la vista previa del PDF."
   },
   },
-  "attachPdf": "Adjuntar PDF",
-  "attachPdfHint": "Desactívalo para enviar un enlace en su lugar y ver cuándo lo abre el cliente.",
   "sentCopy": {
   "sentCopy": {
     "note": "Enviada el {date}. Los cambios hechos aquí pasan a la factura cuando se vuelva a enviar.",
     "note": "Enviada el {date}. Los cambios hechos aquí pasan a la factura cuando se vuelva a enviar.",
     "confirmTitle": "Esta factura ya se ha enviado",
     "confirmTitle": "Esta factura ya se ha enviado",
@@ -225,12 +223,39 @@
     "title": "No se pudo conectar con Torqvoice",
     "title": "No se pudo conectar con Torqvoice",
     "description": "Puede que tu último cambio no se haya guardado. Revisa tu conexión e inténtalo de nuevo."
     "description": "Puede que tu último cambio no se haya guardado. Revisa tu conexión e inténtalo de nuevo."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Enviar presupuesto",
+    "titleInvoice": "Enviar factura",
+    "titleInspection": "Enviar inspección",
+    "description": "El cliente recibe un enlace para verlo y descargarlo.",
+    "email": "Correo Electrónico",
+    "emailAddress": "Dirección de correo electrónico",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Adjuntar también el PDF",
+    "sms": "SMS",
+    "smsNumber": "Número de teléfono para SMS",
+    "whatsappNumber": "Número de teléfono para WhatsApp",
+    "phonePlaceholder": "Número de teléfono",
+    "message": "Mensaje (opcional, se añade al correo electrónico)",
+    "messagePlaceholder": "Añade un mensaje personal...",
+    "copyLink": "Copiar enlace",
+    "send": "Enviar",
+    "emailSent": "Correo electrónico enviado",
+    "failedEmail": "Error al enviar correo electrónico",
+    "smsSent": "SMS enviado",
+    "failedSms": "Error al enviar SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "No se pudo crear el enlace. Inténtalo de nuevo.",
     "generateFailed": "No se pudo crear el enlace. Inténtalo de nuevo.",
     "revokeFailed": "No se pudo confirmar que el enlace se revocó. Puede que siga funcionando para cualquiera que lo tenga.",
     "revokeFailed": "No se pudo confirmar que el enlace se revocó. Puede que siga funcionando para cualquiera que lo tenga.",
     "channelNotConnected": "Conecta {channel} en Integraciones para enviar por esta vía.",
     "channelNotConnected": "Conecta {channel} en Integraciones para enviar por esta vía.",
     "openIntegrations": "Abrir Integraciones",
     "openIntegrations": "Abrir Integraciones",
-    "channelNotInPlan": "{channel} no está incluido en tu plan."
+    "channelNotInPlan": "{channel} no está incluido en tu plan.",
+    "channelTurnedOff": "El envío por {channel} está desactivado para este taller.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp enviado",
+    "failedWhatsapp": "Error al enviar el mensaje de WhatsApp",
+    "whatsappWindowClosed": "Este cliente no te ha escrito por WhatsApp en las últimas 24 horas y no hay ninguna plantilla aprobada configurada para contactarlo."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Elegir...",
     "choose": "Elegir...",

+ 2 - 11
messages/es/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Cerrada",
     "statusCompleted": "Cerrada",
     "complete": "Cerrar",
     "complete": "Cerrar",
     "reopen": "Reabrir",
     "reopen": "Reabrir",
+    "send": "Enviar",
     "share": "Compartir",
     "share": "Compartir",
     "save": "Guardar",
     "save": "Guardar",
     "saving": "Guardando…",
     "saving": "Guardando…",
@@ -460,18 +461,8 @@
     "copied": "Enlace copiado al portapapeles",
     "copied": "Enlace copiado al portapapeles",
     "generated": "Enlace de compartición creado",
     "generated": "Enlace de compartición creado",
     "revoked": "Enlace de compartición revocado",
     "revoked": "Enlace de compartición revocado",
-    "notifyTitle": "Notificar a {name}",
-    "email": "Correo electrónico",
-    "noEmail": "(sin correo registrado)",
-    "sms": "SMS",
-    "noPhone": "(sin teléfono registrado)",
-    "sendNotification": "Enviar notificación",
     "revokeLink": "Revocar enlace",
     "revokeLink": "Revocar enlace",
-    "generateLink": "Generar enlace público",
-    "emailSent": "Correo enviado",
-    "failedEmail": "Error al enviar el correo",
-    "smsSent": "SMS enviado",
-    "failedSms": "Error al enviar el SMS"
+    "generateLink": "Generar enlace público"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Fotos de la inspección",
     "pageTitle": "Fotos de la inspección",

+ 1 - 11
messages/es/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Presupuesto",
     "quote": "Presupuesto",
     "save": "Guardar",
     "save": "Guardar",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Correo electrónico",
+    "send": "Enviar",
     "share": "Compartir",
     "share": "Compartir",
     "delete": "Eliminar",
     "delete": "Eliminar",
     "deleteTitle": "Eliminar presupuesto",
     "deleteTitle": "Eliminar presupuesto",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Compartir presupuesto",
     "title": "Compartir presupuesto",
     "description": "Genere un enlace público que permita a cualquier persona ver y descargar este presupuesto sin iniciar sesión.",
     "description": "Genere un enlace público que permita a cualquier persona ver y descargar este presupuesto sin iniciar sesión.",
-    "notifyTitle": "Notificar a {name}",
-    "email": "Correo electrónico",
-    "noEmail": "(sin correo registrado)",
-    "sms": "SMS",
-    "noPhone": "(sin teléfono registrado)",
-    "sendNotification": "Enviar notificación",
     "revokeLink": "Revocar enlace",
     "revokeLink": "Revocar enlace",
     "generateLink": "Generar enlace público",
     "generateLink": "Generar enlace público",
-    "emailSent": "Correo enviado",
-    "failedEmail": "Error al enviar el correo",
-    "smsSent": "SMS enviado",
-    "failedSms": "Error al enviar el SMS",
     "copyLink": "Copiar enlace"
     "copyLink": "Copiar enlace"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/es/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Guardar",
     "save": "Guardar",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Correo Electrónico",
+    "send": "Enviar",
     "share": "Compartir",
     "share": "Compartir",
     "notify": "Notificar",
     "notify": "Notificar",
     "delete": "Eliminar",
     "delete": "Eliminar",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Compartir Factura",
     "title": "Compartir Factura",
     "description": "Genere un enlace público que permita a cualquiera ver y descargar esta factura sin iniciar sesión.",
     "description": "Genere un enlace público que permita a cualquiera ver y descargar esta factura sin iniciar sesión.",
-    "notifyTitle": "Notificar a {name}",
-    "email": "Correo Electrónico",
-    "noEmail": "(sin correo electrónico registrado)",
-    "sms": "SMS",
-    "noPhone": "(sin teléfono registrado)",
-    "sendNotification": "Enviar Notificación",
     "revokeLink": "Revocar Enlace",
     "revokeLink": "Revocar Enlace",
     "generateLink": "Generar Enlace Público",
     "generateLink": "Generar Enlace Público",
-    "emailSent": "Correo electrónico enviado",
-    "failedEmail": "Error al enviar correo electrónico",
-    "smsSent": "SMS enviado",
-    "failedSms": "Error al enviar SMS",
     "copyLink": "Copiar enlace"
     "copyLink": "Copiar enlace"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/fr/common.json

@@ -156,8 +156,6 @@
     "preview": "Aperçu",
     "preview": "Aperçu",
     "failed": "Impossible de générer l'aperçu du PDF."
     "failed": "Impossible de générer l'aperçu du PDF."
   },
   },
-  "attachPdf": "Joindre le PDF",
-  "attachPdfHint": "Désactivez pour envoyer un lien à la place, afin de voir quand le client l'ouvre.",
   "sentCopy": {
   "sentCopy": {
     "note": "Envoyée le {date}. Les modifications faites ici passent sur la facture lorsqu'elle est renvoyée.",
     "note": "Envoyée le {date}. Les modifications faites ici passent sur la facture lorsqu'elle est renvoyée.",
     "confirmTitle": "Cette facture a déjà été envoyée",
     "confirmTitle": "Cette facture a déjà été envoyée",
@@ -225,12 +223,39 @@
     "title": "Impossible de joindre Torqvoice",
     "title": "Impossible de joindre Torqvoice",
     "description": "Votre dernière modification n'a peut-être pas été enregistrée. Vérifiez votre connexion et réessayez."
     "description": "Votre dernière modification n'a peut-être pas été enregistrée. Vérifiez votre connexion et réessayez."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Envoyer le devis",
+    "titleInvoice": "Envoyer la facture",
+    "titleInspection": "Envoyer l'inspection",
+    "description": "Le client reçoit un lien pour le consulter et le télécharger.",
+    "email": "Email",
+    "emailAddress": "Adresse email",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Joindre aussi le PDF",
+    "sms": "SMS",
+    "smsNumber": "Numéro de téléphone pour le SMS",
+    "whatsappNumber": "Numéro de téléphone pour WhatsApp",
+    "phonePlaceholder": "Numéro de téléphone",
+    "message": "Message (facultatif, ajouté à l'email)",
+    "messagePlaceholder": "Ajoutez un message personnel...",
+    "copyLink": "Copier le lien",
+    "send": "Envoyer",
+    "emailSent": "Email envoyé",
+    "failedEmail": "Impossible d'envoyer l'email",
+    "smsSent": "SMS envoyé",
+    "failedSms": "Impossible d'envoyer le SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Impossible de créer le lien. Veuillez réessayer.",
     "generateFailed": "Impossible de créer le lien. Veuillez réessayer.",
     "revokeFailed": "Impossible de confirmer la révocation du lien. Il fonctionne peut-être encore pour toute personne qui l'a.",
     "revokeFailed": "Impossible de confirmer la révocation du lien. Il fonctionne peut-être encore pour toute personne qui l'a.",
     "channelNotConnected": "Connectez {channel} dans Intégrations pour envoyer par ce moyen.",
     "channelNotConnected": "Connectez {channel} dans Intégrations pour envoyer par ce moyen.",
     "openIntegrations": "Ouvrir Intégrations",
     "openIntegrations": "Ouvrir Intégrations",
-    "channelNotInPlan": "{channel} n'est pas inclus dans votre forfait."
+    "channelNotInPlan": "{channel} n'est pas inclus dans votre forfait.",
+    "channelTurnedOff": "L'envoi par {channel} est désactivé pour cet atelier.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp envoyé",
+    "failedWhatsapp": "Impossible d'envoyer le message WhatsApp",
+    "whatsappWindowClosed": "Ce client ne vous a pas écrit sur WhatsApp au cours des dernières 24 heures, et aucun modèle approuvé n'est configuré pour le contacter."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Choisir...",
     "choose": "Choisir...",

+ 2 - 11
messages/fr/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Terminée",
     "statusCompleted": "Terminée",
     "complete": "Clôturer",
     "complete": "Clôturer",
     "reopen": "Rouvrir",
     "reopen": "Rouvrir",
+    "send": "Envoyer",
     "share": "Partager",
     "share": "Partager",
     "save": "Enregistrer",
     "save": "Enregistrer",
     "saving": "Enregistrement…",
     "saving": "Enregistrement…",
@@ -460,18 +461,8 @@
     "copied": "Lien copié dans le presse-papiers",
     "copied": "Lien copié dans le presse-papiers",
     "generated": "Lien de partage créé",
     "generated": "Lien de partage créé",
     "revoked": "Lien de partage révoqué",
     "revoked": "Lien de partage révoqué",
-    "notifyTitle": "Notifier {name}",
-    "email": "E-mail",
-    "noEmail": "(aucun e-mail enregistré)",
-    "sms": "SMS",
-    "noPhone": "(aucun téléphone enregistré)",
-    "sendNotification": "Envoyer la notification",
     "revokeLink": "Révoquer le lien",
     "revokeLink": "Révoquer le lien",
-    "generateLink": "Générer un lien public",
-    "emailSent": "E-mail envoyé",
-    "failedEmail": "Échec de l'envoi de l'e-mail",
-    "smsSent": "SMS envoyé",
-    "failedSms": "Échec de l'envoi du SMS"
+    "generateLink": "Générer un lien public"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Photos de l'inspection",
     "pageTitle": "Photos de l'inspection",

+ 1 - 11
messages/fr/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Devis",
     "quote": "Devis",
     "save": "Enregistrer",
     "save": "Enregistrer",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Envoyer",
     "share": "Partager",
     "share": "Partager",
     "delete": "Supprimer",
     "delete": "Supprimer",
     "deleteTitle": "Supprimer le devis",
     "deleteTitle": "Supprimer le devis",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Partager le devis",
     "title": "Partager le devis",
     "description": "Générez un lien public permettant à quiconque de consulter et télécharger ce devis sans se connecter.",
     "description": "Générez un lien public permettant à quiconque de consulter et télécharger ce devis sans se connecter.",
-    "notifyTitle": "Notifier {name}",
-    "email": "E-mail",
-    "noEmail": "(aucun e-mail enregistré)",
-    "sms": "SMS",
-    "noPhone": "(aucun téléphone enregistré)",
-    "sendNotification": "Envoyer la notification",
     "revokeLink": "Révoquer le lien",
     "revokeLink": "Révoquer le lien",
     "generateLink": "Générer un lien public",
     "generateLink": "Générer un lien public",
-    "emailSent": "E-mail envoyé",
-    "failedEmail": "Échec de l'envoi de l'e-mail",
-    "smsSent": "SMS envoyé",
-    "failedSms": "Échec de l'envoi du SMS",
     "copyLink": "Copier le lien"
     "copyLink": "Copier le lien"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/fr/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Enregistrer",
     "save": "Enregistrer",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Email",
+    "send": "Envoyer",
     "share": "Partager",
     "share": "Partager",
     "notify": "Notifier",
     "notify": "Notifier",
     "delete": "Supprimer",
     "delete": "Supprimer",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Partager la Facture",
     "title": "Partager la Facture",
     "description": "Générer un lien public qui permet à n'importe qui de consulter et télécharger cette facture sans se connecter.",
     "description": "Générer un lien public qui permet à n'importe qui de consulter et télécharger cette facture sans se connecter.",
-    "notifyTitle": "Notifier {name}",
-    "email": "Email",
-    "noEmail": "(pas d'email en dossier)",
-    "sms": "SMS",
-    "noPhone": "(pas de numéro en dossier)",
-    "sendNotification": "Envoyer une Notification",
     "revokeLink": "Révoquer le Lien",
     "revokeLink": "Révoquer le Lien",
     "generateLink": "Générer un Lien Public",
     "generateLink": "Générer un Lien Public",
-    "emailSent": "Email envoyé",
-    "failedEmail": "Impossible d'envoyer l'email",
-    "smsSent": "SMS envoyé",
-    "failedSms": "Impossible d'envoyer le SMS",
     "copyLink": "Copier le lien"
     "copyLink": "Copier le lien"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/it/common.json

@@ -156,8 +156,6 @@
     "preview": "Anteprima",
     "preview": "Anteprima",
     "failed": "Impossibile generare l'anteprima del PDF."
     "failed": "Impossibile generare l'anteprima del PDF."
   },
   },
-  "attachPdf": "Allega PDF",
-  "attachPdfHint": "Disattiva per inviare invece un link, così vedi quando il cliente lo apre.",
   "sentCopy": {
   "sentCopy": {
     "note": "Inviata il {date}. Le modifiche fatte qui entrano nella fattura quando viene inviata di nuovo.",
     "note": "Inviata il {date}. Le modifiche fatte qui entrano nella fattura quando viene inviata di nuovo.",
     "confirmTitle": "Questa fattura è già stata inviata",
     "confirmTitle": "Questa fattura è già stata inviata",
@@ -225,12 +223,39 @@
     "title": "Impossibile raggiungere Torqvoice",
     "title": "Impossibile raggiungere Torqvoice",
     "description": "L'ultima modifica potrebbe non essere stata salvata. Controlla la connessione e riprova."
     "description": "L'ultima modifica potrebbe non essere stata salvata. Controlla la connessione e riprova."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Invia preventivo",
+    "titleInvoice": "Invia fattura",
+    "titleInspection": "Invia ispezione",
+    "description": "Il cliente riceve un link per visualizzarlo e scaricarlo.",
+    "email": "Email",
+    "emailAddress": "Indirizzo email",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Allega anche il PDF",
+    "sms": "SMS",
+    "smsNumber": "Numero di telefono per SMS",
+    "whatsappNumber": "Numero di telefono per WhatsApp",
+    "phonePlaceholder": "Numero di telefono",
+    "message": "Messaggio (facoltativo, aggiunto all'email)",
+    "messagePlaceholder": "Aggiungi un messaggio personale...",
+    "copyLink": "Copia link",
+    "send": "Invia",
+    "emailSent": "Email inviata",
+    "failedEmail": "Impossibile inviare email",
+    "smsSent": "SMS inviato",
+    "failedSms": "Impossibile inviare SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Impossibile creare il link. Riprova.",
     "generateFailed": "Impossibile creare il link. Riprova.",
     "revokeFailed": "Impossibile confermare la revoca del link. Potrebbe funzionare ancora per chiunque lo abbia.",
     "revokeFailed": "Impossibile confermare la revoca del link. Potrebbe funzionare ancora per chiunque lo abbia.",
     "channelNotConnected": "Collega {channel} in Integrazioni per inviare in questo modo.",
     "channelNotConnected": "Collega {channel} in Integrazioni per inviare in questo modo.",
     "openIntegrations": "Apri Integrazioni",
     "openIntegrations": "Apri Integrazioni",
-    "channelNotInPlan": "{channel} non è incluso nel tuo piano."
+    "channelNotInPlan": "{channel} non è incluso nel tuo piano.",
+    "channelTurnedOff": "L'invio tramite {channel} è disattivato per questa officina.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp inviato",
+    "failedWhatsapp": "Impossibile inviare il messaggio WhatsApp",
+    "whatsappWindowClosed": "Questo cliente non ti ha scritto su WhatsApp nelle ultime 24 ore e non è configurato alcun modello approvato per contattarlo."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Scegli...",
     "choose": "Scegli...",

+ 2 - 11
messages/it/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Chiusa",
     "statusCompleted": "Chiusa",
     "complete": "Chiudi",
     "complete": "Chiudi",
     "reopen": "Riapri",
     "reopen": "Riapri",
+    "send": "Invia",
     "share": "Condividi",
     "share": "Condividi",
     "save": "Salva",
     "save": "Salva",
     "saving": "Salvataggio…",
     "saving": "Salvataggio…",
@@ -460,18 +461,8 @@
     "copied": "Link copiato negli appunti",
     "copied": "Link copiato negli appunti",
     "generated": "Link di condivisione creato",
     "generated": "Link di condivisione creato",
     "revoked": "Link di condivisione revocato",
     "revoked": "Link di condivisione revocato",
-    "notifyTitle": "Notifica {name}",
-    "email": "E-mail",
-    "noEmail": "(nessuna e-mail registrata)",
-    "sms": "SMS",
-    "noPhone": "(nessun telefono registrato)",
-    "sendNotification": "Invia notifica",
     "revokeLink": "Revoca link",
     "revokeLink": "Revoca link",
-    "generateLink": "Genera link pubblico",
-    "emailSent": "E-mail inviata",
-    "failedEmail": "Invio e-mail non riuscito",
-    "smsSent": "SMS inviato",
-    "failedSms": "Invio SMS non riuscito"
+    "generateLink": "Genera link pubblico"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Foto dell'ispezione",
     "pageTitle": "Foto dell'ispezione",

+ 1 - 11
messages/it/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Preventivo",
     "quote": "Preventivo",
     "save": "Salva",
     "save": "Salva",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Invia",
     "share": "Condividi",
     "share": "Condividi",
     "delete": "Elimina",
     "delete": "Elimina",
     "deleteTitle": "Elimina preventivo",
     "deleteTitle": "Elimina preventivo",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Condividi preventivo",
     "title": "Condividi preventivo",
     "description": "Genera un link pubblico che consente a chiunque di visualizzare e scaricare questo preventivo senza effettuare l'accesso.",
     "description": "Genera un link pubblico che consente a chiunque di visualizzare e scaricare questo preventivo senza effettuare l'accesso.",
-    "notifyTitle": "Notifica {name}",
-    "email": "E-mail",
-    "noEmail": "(nessuna e-mail registrata)",
-    "sms": "SMS",
-    "noPhone": "(nessun telefono registrato)",
-    "sendNotification": "Invia notifica",
     "revokeLink": "Revoca link",
     "revokeLink": "Revoca link",
     "generateLink": "Genera link pubblico",
     "generateLink": "Genera link pubblico",
-    "emailSent": "E-mail inviata",
-    "failedEmail": "Invio e-mail non riuscito",
-    "smsSent": "SMS inviato",
-    "failedSms": "Invio SMS non riuscito",
     "copyLink": "Copia link"
     "copyLink": "Copia link"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/it/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Salva",
     "save": "Salva",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Email",
+    "send": "Invia",
     "share": "Condividi",
     "share": "Condividi",
     "notify": "Notifica",
     "notify": "Notifica",
     "delete": "Elimina",
     "delete": "Elimina",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Condividi Fattura",
     "title": "Condividi Fattura",
     "description": "Genera un collegamento pubblico che consente a chiunque di visualizzare e scaricare questa fattura senza accedere.",
     "description": "Genera un collegamento pubblico che consente a chiunque di visualizzare e scaricare questa fattura senza accedere.",
-    "notifyTitle": "Notifica {name}",
-    "email": "Email",
-    "noEmail": "(nessun email in archivio)",
-    "sms": "SMS",
-    "noPhone": "(nessun telefono in archivio)",
-    "sendNotification": "Invia Notifica",
     "revokeLink": "Revoca Collegamento",
     "revokeLink": "Revoca Collegamento",
     "generateLink": "Genera Collegamento Pubblico",
     "generateLink": "Genera Collegamento Pubblico",
-    "emailSent": "Email inviata",
-    "failedEmail": "Impossibile inviare email",
-    "smsSent": "SMS inviato",
-    "failedSms": "Impossibile inviare SMS",
     "copyLink": "Copia link"
     "copyLink": "Copia link"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/lt/common.json

@@ -156,8 +156,6 @@
     "preview": "Peržiūra",
     "preview": "Peržiūra",
     "failed": "Nepavyko sugeneruoti PDF peržiūros."
     "failed": "Nepavyko sugeneruoti PDF peržiūros."
   },
   },
-  "attachPdf": "Pridėti PDF",
-  "attachPdfHint": "Išjunkite, kad būtų siunčiama nuoroda, tada matysite, kada klientas ją atidarė.",
   "sentCopy": {
   "sentCopy": {
     "note": "Išsiųsta {date}. Čia atlikti pakeitimai pateks į sąskaitą, kai ji bus išsiųsta dar kartą.",
     "note": "Išsiųsta {date}. Čia atlikti pakeitimai pateks į sąskaitą, kai ji bus išsiųsta dar kartą.",
     "confirmTitle": "Ši sąskaita jau išsiųsta",
     "confirmTitle": "Ši sąskaita jau išsiųsta",
@@ -225,12 +223,39 @@
     "title": "Nepavyko pasiekti Torqvoice",
     "title": "Nepavyko pasiekti Torqvoice",
     "description": "Paskutinis pakeitimas galėjo būti neišsaugotas. Patikrinkite ryšį ir bandykite dar kartą."
     "description": "Paskutinis pakeitimas galėjo būti neišsaugotas. Patikrinkite ryšį ir bandykite dar kartą."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Siųsti pasiūlymą",
+    "titleInvoice": "Siųsti sąskaitą",
+    "titleInspection": "Siųsti apžiūrą",
+    "description": "Klientas gauna nuorodą, kurią atidaręs gali peržiūrėti ir atsisiųsti dokumentą.",
+    "email": "El. paštas",
+    "emailAddress": "El. pašto adresas",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Taip pat pridėti PDF",
+    "sms": "SMS",
+    "smsNumber": "Telefono numeris SMS žinutei",
+    "whatsappNumber": "Telefono numeris WhatsApp",
+    "phonePlaceholder": "Telefono numeris",
+    "message": "Žinutė (neprivaloma, pridedama prie laiško)",
+    "messagePlaceholder": "Pridėkite asmeninę žinutę...",
+    "copyLink": "Kopijuoti nuorodą",
+    "send": "Siųsti",
+    "emailSent": "Laiškas išsiųstas",
+    "failedEmail": "Nepavyko išsiųsti laiško",
+    "smsSent": "SMS išsiųsta",
+    "failedSms": "Nepavyko išsiųsti SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Nepavyko sukurti nuorodos. Bandykite dar kartą.",
     "generateFailed": "Nepavyko sukurti nuorodos. Bandykite dar kartą.",
     "revokeFailed": "Nepavyko patvirtinti, kad nuoroda atšaukta. Ji vis dar gali veikti visiems, kurie ją turi.",
     "revokeFailed": "Nepavyko patvirtinti, kad nuoroda atšaukta. Ji vis dar gali veikti visiems, kurie ją turi.",
     "channelNotConnected": "Prijunkite {channel} skiltyje Integracijos, kad galėtumėte siųsti šiuo būdu.",
     "channelNotConnected": "Prijunkite {channel} skiltyje Integracijos, kad galėtumėte siųsti šiuo būdu.",
     "openIntegrations": "Atidaryti Integracijas",
     "openIntegrations": "Atidaryti Integracijas",
-    "channelNotInPlan": "{channel} neįtrauktas į jūsų planą."
+    "channelNotInPlan": "{channel} neįtrauktas į jūsų planą.",
+    "channelTurnedOff": "Siuntimas per {channel} šiose dirbtuvėse yra išjungtas.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp išsiųsta",
+    "failedWhatsapp": "Nepavyko išsiųsti WhatsApp žinutės",
+    "whatsappWindowClosed": "Šis klientas per pastarąsias 24 valandas jums nerašė per WhatsApp, o patvirtinto šablono jam pasiekti nesukurta."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Pasirinkite...",
     "choose": "Pasirinkite...",

+ 2 - 11
messages/lt/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Baigta",
     "statusCompleted": "Baigta",
     "complete": "Užbaigti",
     "complete": "Užbaigti",
     "reopen": "Atverti iš naujo",
     "reopen": "Atverti iš naujo",
+    "send": "Siųsti",
     "share": "Bendrinti",
     "share": "Bendrinti",
     "save": "Įrašyti",
     "save": "Įrašyti",
     "saving": "Įrašoma…",
     "saving": "Įrašoma…",
@@ -460,18 +461,8 @@
     "copied": "Nuoroda nukopijuota į iškarpinę",
     "copied": "Nuoroda nukopijuota į iškarpinę",
     "generated": "Bendrinimo nuoroda sukurta",
     "generated": "Bendrinimo nuoroda sukurta",
     "revoked": "Bendrinimo nuoroda atšaukta",
     "revoked": "Bendrinimo nuoroda atšaukta",
-    "notifyTitle": "Pranešti {name}",
-    "email": "El. paštas",
-    "noEmail": "(nėra el. pašto)",
-    "sms": "SMS",
-    "noPhone": "(nėra telefono)",
-    "sendNotification": "Siųsti pranešimą",
     "revokeLink": "Atšaukti nuorodą",
     "revokeLink": "Atšaukti nuorodą",
-    "generateLink": "Generuoti viešą nuorodą",
-    "emailSent": "Laiškas išsiųstas",
-    "failedEmail": "Nepavyko išsiųsti laiško",
-    "smsSent": "SMS išsiųsta",
-    "failedSms": "Nepavyko išsiųsti SMS"
+    "generateLink": "Generuoti viešą nuorodą"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Apžiūros nuotraukos",
     "pageTitle": "Apžiūros nuotraukos",

+ 1 - 11
messages/lt/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Pasiūlymas",
     "quote": "Pasiūlymas",
     "save": "Išsaugoti",
     "save": "Išsaugoti",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "El. paštas",
+    "send": "Siųsti",
     "share": "Dalintis",
     "share": "Dalintis",
     "delete": "Ištrinti",
     "delete": "Ištrinti",
     "deleteTitle": "Ištrinti pasiūlymą",
     "deleteTitle": "Ištrinti pasiūlymą",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Dalintis pasiūlymu",
     "title": "Dalintis pasiūlymu",
     "description": "Sugeneruokite viešą nuorodą, leidžiančią bet kam peržiūrėti ir atsisiųsti šį pasiūlymą be prisijungimo.",
     "description": "Sugeneruokite viešą nuorodą, leidžiančią bet kam peržiūrėti ir atsisiųsti šį pasiūlymą be prisijungimo.",
-    "notifyTitle": "Pranešti {name}",
-    "email": "El. paštas",
-    "noEmail": "(nėra el. pašto)",
-    "sms": "SMS",
-    "noPhone": "(nėra telefono)",
-    "sendNotification": "Siųsti pranešimą",
     "revokeLink": "Atšaukti nuorodą",
     "revokeLink": "Atšaukti nuorodą",
     "generateLink": "Generuoti viešą nuorodą",
     "generateLink": "Generuoti viešą nuorodą",
-    "emailSent": "Laiškas išsiųstas",
-    "failedEmail": "Nepavyko išsiųsti laiško",
-    "smsSent": "SMS išsiųsta",
-    "failedSms": "Nepavyko išsiųsti SMS",
     "copyLink": "Kopijuoti nuorodą"
     "copyLink": "Kopijuoti nuorodą"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/lt/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Išsaugoti",
     "save": "Išsaugoti",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "El. paštas",
+    "send": "Siųsti",
     "share": "Dalintis",
     "share": "Dalintis",
     "notify": "Pranešti",
     "notify": "Pranešti",
     "delete": "Ištrinti",
     "delete": "Ištrinti",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Dalintis sąskaita",
     "title": "Dalintis sąskaita",
     "description": "Sugeneruokite viešą nuorodą, leidžiančią bet kam peržiūrėti ir atsisiųsti šią sąskaitą be prisijungimo.",
     "description": "Sugeneruokite viešą nuorodą, leidžiančią bet kam peržiūrėti ir atsisiųsti šią sąskaitą be prisijungimo.",
-    "notifyTitle": "Pranešti {name}",
-    "email": "El. paštas",
-    "noEmail": "(nėra el. pašto)",
-    "sms": "SMS",
-    "noPhone": "(nėra telefono)",
-    "sendNotification": "Siųsti pranešimą",
     "revokeLink": "Atšaukti nuorodą",
     "revokeLink": "Atšaukti nuorodą",
     "generateLink": "Generuoti viešą nuorodą",
     "generateLink": "Generuoti viešą nuorodą",
-    "emailSent": "Laiškas išsiųstas",
-    "failedEmail": "Nepavyko išsiųsti laiško",
-    "smsSent": "SMS išsiųsta",
-    "failedSms": "Nepavyko išsiųsti SMS",
     "copyLink": "Kopijuoti nuorodą"
     "copyLink": "Kopijuoti nuorodą"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/nb/common.json

@@ -156,8 +156,6 @@
     "preview": "Forhåndsvisning",
     "preview": "Forhåndsvisning",
     "failed": "Kunne ikke generere PDF-forhåndsvisningen."
     "failed": "Kunne ikke generere PDF-forhåndsvisningen."
   },
   },
-  "attachPdf": "Legg ved PDF",
-  "attachPdfHint": "Slå av for å sende en lenke i stedet, så ser du når kunden åpner den.",
   "sentCopy": {
   "sentCopy": {
     "note": "Sendt {date}. Endringer her kommer på fakturaen når den sendes på nytt.",
     "note": "Sendt {date}. Endringer her kommer på fakturaen når den sendes på nytt.",
     "confirmTitle": "Denne fakturaen er allerede sendt",
     "confirmTitle": "Denne fakturaen er allerede sendt",
@@ -225,12 +223,39 @@
     "title": "Får ikke kontakt med Torqvoice",
     "title": "Får ikke kontakt med Torqvoice",
     "description": "Den siste endringen din ble kanskje ikke lagret. Sjekk tilkoblingen og prøv igjen."
     "description": "Den siste endringen din ble kanskje ikke lagret. Sjekk tilkoblingen og prøv igjen."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Send tilbud",
+    "titleInvoice": "Send faktura",
+    "titleInspection": "Send inspeksjon",
+    "description": "Kunden får en lenke for å se og laste den ned.",
+    "email": "E-post",
+    "emailAddress": "E-postadresse",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Legg ved PDF-en også",
+    "sms": "SMS",
+    "smsNumber": "Telefonnummer for SMS",
+    "whatsappNumber": "Telefonnummer for WhatsApp",
+    "phonePlaceholder": "Telefonnummer",
+    "message": "Melding (valgfritt, legges til i e-posten)",
+    "messagePlaceholder": "Legg til en personlig melding...",
+    "copyLink": "Kopier lenke",
+    "send": "Send",
+    "emailSent": "E-post sendt",
+    "failedEmail": "Kunne ikke sende e-post",
+    "smsSent": "SMS sendt",
+    "failedSms": "Kunne ikke sende SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Kunne ikke opprette lenken. Prøv igjen.",
     "generateFailed": "Kunne ikke opprette lenken. Prøv igjen.",
     "revokeFailed": "Kunne ikke bekrefte at lenken ble tilbakekalt. Den kan fortsatt virke for alle som har den.",
     "revokeFailed": "Kunne ikke bekrefte at lenken ble tilbakekalt. Den kan fortsatt virke for alle som har den.",
     "channelNotConnected": "Koble til {channel} under Integrasjoner for å sende på denne måten.",
     "channelNotConnected": "Koble til {channel} under Integrasjoner for å sende på denne måten.",
     "openIntegrations": "Åpne Integrasjoner",
     "openIntegrations": "Åpne Integrasjoner",
-    "channelNotInPlan": "{channel} er ikke inkludert i planen din."
+    "channelNotInPlan": "{channel} er ikke inkludert i planen din.",
+    "channelTurnedOff": "Sending med {channel} er slått av for dette verkstedet.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp sendt",
+    "failedWhatsapp": "Kunne ikke sende WhatsApp-melding",
+    "whatsappWindowClosed": "Denne kunden har ikke skrevet til deg på WhatsApp de siste 24 timene, og ingen godkjent mal er satt opp for å nå dem."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Velg...",
     "choose": "Velg...",

+ 2 - 11
messages/nb/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Fullført",
     "statusCompleted": "Fullført",
     "complete": "Fullfør",
     "complete": "Fullfør",
     "reopen": "Gjenåpne",
     "reopen": "Gjenåpne",
+    "send": "Send",
     "share": "Del",
     "share": "Del",
     "save": "Lagre",
     "save": "Lagre",
     "saving": "Lagrer…",
     "saving": "Lagrer…",
@@ -460,18 +461,8 @@
     "copied": "Lenken er kopiert",
     "copied": "Lenken er kopiert",
     "generated": "Delingslenke opprettet",
     "generated": "Delingslenke opprettet",
     "revoked": "Delingslenke trukket tilbake",
     "revoked": "Delingslenke trukket tilbake",
-    "notifyTitle": "Varsle {name}",
-    "email": "E-post",
-    "noEmail": "(ingen e-post registrert)",
-    "sms": "SMS",
-    "noPhone": "(ingen telefon registrert)",
-    "sendNotification": "Send varsel",
     "revokeLink": "Tilbakekall lenke",
     "revokeLink": "Tilbakekall lenke",
-    "generateLink": "Generer offentlig lenke",
-    "emailSent": "E-post sendt",
-    "failedEmail": "Kunne ikke sende e-post",
-    "smsSent": "SMS sendt",
-    "failedSms": "Kunne ikke sende SMS"
+    "generateLink": "Generer offentlig lenke"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Bilder til kontrollen",
     "pageTitle": "Bilder til kontrollen",

+ 1 - 11
messages/nb/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Tilbud",
     "quote": "Tilbud",
     "save": "Lagre",
     "save": "Lagre",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-post",
+    "send": "Send",
     "share": "Del",
     "share": "Del",
     "delete": "Slett",
     "delete": "Slett",
     "deleteTitle": "Slett tilbud",
     "deleteTitle": "Slett tilbud",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Del tilbud",
     "title": "Del tilbud",
     "description": "Generer en offentlig lenke som lar hvem som helst se og laste ned dette tilbudet uten å logge inn.",
     "description": "Generer en offentlig lenke som lar hvem som helst se og laste ned dette tilbudet uten å logge inn.",
-    "notifyTitle": "Varsle {name}",
-    "email": "E-post",
-    "noEmail": "(ingen e-post registrert)",
-    "sms": "SMS",
-    "noPhone": "(ingen telefon registrert)",
-    "sendNotification": "Send varsel",
     "revokeLink": "Tilbakekall lenke",
     "revokeLink": "Tilbakekall lenke",
     "generateLink": "Generer offentlig lenke",
     "generateLink": "Generer offentlig lenke",
-    "emailSent": "E-post sendt",
-    "failedEmail": "Kunne ikke sende e-post",
-    "smsSent": "SMS sendt",
-    "failedSms": "Kunne ikke sende SMS",
     "copyLink": "Kopier lenke"
     "copyLink": "Kopier lenke"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/nb/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Lagre",
     "save": "Lagre",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-post",
+    "send": "Send",
     "share": "Del",
     "share": "Del",
     "notify": "Varsle",
     "notify": "Varsle",
     "delete": "Slett",
     "delete": "Slett",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Del faktura",
     "title": "Del faktura",
     "description": "Generer en offentlig lenke som lar hvem som helst se og laste ned denne fakturaen uten å logge inn.",
     "description": "Generer en offentlig lenke som lar hvem som helst se og laste ned denne fakturaen uten å logge inn.",
-    "notifyTitle": "Varsle {name}",
-    "email": "E-post",
-    "noEmail": "(ingen e-post på arkiv)",
-    "sms": "SMS",
-    "noPhone": "(ingen telefon på arkiv)",
-    "sendNotification": "Send varsling",
     "revokeLink": "Tilbakekall lenke",
     "revokeLink": "Tilbakekall lenke",
     "generateLink": "Generer offentlig lenke",
     "generateLink": "Generer offentlig lenke",
-    "emailSent": "E-post sendt",
-    "failedEmail": "Kunne ikke sende e-post",
-    "smsSent": "SMS sendt",
-    "failedSms": "Kunne ikke sende SMS",
     "copyLink": "Kopier lenke"
     "copyLink": "Kopier lenke"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/nl/common.json

@@ -156,8 +156,6 @@
     "preview": "Voorbeeld",
     "preview": "Voorbeeld",
     "failed": "Kon het PDF-voorbeeld niet genereren."
     "failed": "Kon het PDF-voorbeeld niet genereren."
   },
   },
-  "attachPdf": "Pdf toevoegen",
-  "attachPdfHint": "Zet dit uit om in plaats daarvan een link te sturen, zodat je ziet wanneer de klant hem opent.",
   "sentCopy": {
   "sentCopy": {
     "note": "Verzonden op {date}. Wijzigingen hier komen op de factuur wanneer die opnieuw wordt verzonden.",
     "note": "Verzonden op {date}. Wijzigingen hier komen op de factuur wanneer die opnieuw wordt verzonden.",
     "confirmTitle": "Deze factuur is al verzonden",
     "confirmTitle": "Deze factuur is al verzonden",
@@ -225,12 +223,39 @@
     "title": "Torqvoice is niet bereikbaar",
     "title": "Torqvoice is niet bereikbaar",
     "description": "Je laatste wijziging is mogelijk niet opgeslagen. Controleer je verbinding en probeer het opnieuw."
     "description": "Je laatste wijziging is mogelijk niet opgeslagen. Controleer je verbinding en probeer het opnieuw."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Offerte versturen",
+    "titleInvoice": "Factuur versturen",
+    "titleInspection": "Inspectie versturen",
+    "description": "De klant krijgt een link om het te bekijken en te downloaden.",
+    "email": "E-mail",
+    "emailAddress": "E-mailadres",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Ook de pdf bijvoegen",
+    "sms": "SMS",
+    "smsNumber": "Telefoonnummer voor SMS",
+    "whatsappNumber": "Telefoonnummer voor WhatsApp",
+    "phonePlaceholder": "Telefoonnummer",
+    "message": "Bericht (optioneel, wordt aan de e-mail toegevoegd)",
+    "messagePlaceholder": "Voeg een persoonlijk bericht toe...",
+    "copyLink": "Link kopiëren",
+    "send": "Versturen",
+    "emailSent": "E-mail verzonden",
+    "failedEmail": "E-mail verzenden mislukt",
+    "smsSent": "SMS verzonden",
+    "failedSms": "SMS verzenden mislukt"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Kon de link niet aanmaken. Probeer het opnieuw.",
     "generateFailed": "Kon de link niet aanmaken. Probeer het opnieuw.",
     "revokeFailed": "Kon niet bevestigen dat de link is ingetrokken. Hij werkt mogelijk nog voor iedereen die hem heeft.",
     "revokeFailed": "Kon niet bevestigen dat de link is ingetrokken. Hij werkt mogelijk nog voor iedereen die hem heeft.",
     "channelNotConnected": "Koppel {channel} onder Integraties om op deze manier te verzenden.",
     "channelNotConnected": "Koppel {channel} onder Integraties om op deze manier te verzenden.",
     "openIntegrations": "Integraties openen",
     "openIntegrations": "Integraties openen",
-    "channelNotInPlan": "{channel} zit niet in je abonnement."
+    "channelNotInPlan": "{channel} zit niet in je abonnement.",
+    "channelTurnedOff": "Verzenden via {channel} is uitgeschakeld voor deze werkplaats.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp verzonden",
+    "failedWhatsapp": "WhatsApp-bericht verzenden mislukt",
+    "whatsappWindowClosed": "Deze klant heeft je de afgelopen 24 uur niet via WhatsApp geschreven en er is geen goedgekeurde sjabloon ingesteld om hem te bereiken."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Kiezen...",
     "choose": "Kiezen...",

+ 2 - 11
messages/nl/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Afgerond",
     "statusCompleted": "Afgerond",
     "complete": "Afronden",
     "complete": "Afronden",
     "reopen": "Heropenen",
     "reopen": "Heropenen",
+    "send": "Versturen",
     "share": "Delen",
     "share": "Delen",
     "save": "Opslaan",
     "save": "Opslaan",
     "saving": "Opslaan…",
     "saving": "Opslaan…",
@@ -460,18 +461,8 @@
     "copied": "Link gekopieerd naar klembord",
     "copied": "Link gekopieerd naar klembord",
     "generated": "Deellink aangemaakt",
     "generated": "Deellink aangemaakt",
     "revoked": "Deellink ingetrokken",
     "revoked": "Deellink ingetrokken",
-    "notifyTitle": "{name} op de hoogte stellen",
-    "email": "E-mail",
-    "noEmail": "(geen e-mail geregistreerd)",
-    "sms": "SMS",
-    "noPhone": "(geen telefoonnummer geregistreerd)",
-    "sendNotification": "Melding verzenden",
     "revokeLink": "Link intrekken",
     "revokeLink": "Link intrekken",
-    "generateLink": "Openbare link genereren",
-    "emailSent": "E-mail verzonden",
-    "failedEmail": "E-mail verzenden mislukt",
-    "smsSent": "SMS verzonden",
-    "failedSms": "SMS verzenden mislukt"
+    "generateLink": "Openbare link genereren"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Foto's bij de keuring",
     "pageTitle": "Foto's bij de keuring",

+ 1 - 11
messages/nl/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Offerte",
     "quote": "Offerte",
     "save": "Opslaan",
     "save": "Opslaan",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Versturen",
     "share": "Delen",
     "share": "Delen",
     "delete": "Verwijderen",
     "delete": "Verwijderen",
     "deleteTitle": "Offerte verwijderen",
     "deleteTitle": "Offerte verwijderen",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Offerte delen",
     "title": "Offerte delen",
     "description": "Genereer een openbare link waarmee iedereen deze offerte kan bekijken en downloaden zonder in te loggen.",
     "description": "Genereer een openbare link waarmee iedereen deze offerte kan bekijken en downloaden zonder in te loggen.",
-    "notifyTitle": "{name} op de hoogte stellen",
-    "email": "E-mail",
-    "noEmail": "(geen e-mail geregistreerd)",
-    "sms": "SMS",
-    "noPhone": "(geen telefoonnummer geregistreerd)",
-    "sendNotification": "Melding verzenden",
     "revokeLink": "Link intrekken",
     "revokeLink": "Link intrekken",
     "generateLink": "Openbare link genereren",
     "generateLink": "Openbare link genereren",
-    "emailSent": "E-mail verzonden",
-    "failedEmail": "E-mail verzenden mislukt",
-    "smsSent": "SMS verzonden",
-    "failedSms": "SMS verzenden mislukt",
     "copyLink": "Link kopiëren"
     "copyLink": "Link kopiëren"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/nl/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Opslaan",
     "save": "Opslaan",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Versturen",
     "share": "Delen",
     "share": "Delen",
     "notify": "Melden",
     "notify": "Melden",
     "delete": "Verwijderen",
     "delete": "Verwijderen",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Factuur delen",
     "title": "Factuur delen",
     "description": "Genereer een openbare koppeling waarmee iedereen deze factuur kan bekijken en downloaden zonder in te loggen.",
     "description": "Genereer een openbare koppeling waarmee iedereen deze factuur kan bekijken en downloaden zonder in te loggen.",
-    "notifyTitle": "Stuur bericht naar {name}",
-    "email": "E-mail",
-    "noEmail": "(geen e-mailadres op bestand)",
-    "sms": "SMS",
-    "noPhone": "(geen telefoonnummer op bestand)",
-    "sendNotification": "Melding verzenden",
     "revokeLink": "Koppeling intrekken",
     "revokeLink": "Koppeling intrekken",
     "generateLink": "Openbare koppeling genereren",
     "generateLink": "Openbare koppeling genereren",
-    "emailSent": "E-mail verzonden",
-    "failedEmail": "E-mail verzenden mislukt",
-    "smsSent": "SMS verzonden",
-    "failedSms": "SMS verzenden mislukt",
     "copyLink": "Link kopiëren"
     "copyLink": "Link kopiëren"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/pl/common.json

@@ -156,8 +156,6 @@
     "preview": "Podgląd",
     "preview": "Podgląd",
     "failed": "Nie udało się wygenerować podglądu PDF."
     "failed": "Nie udało się wygenerować podglądu PDF."
   },
   },
-  "attachPdf": "Dołącz PDF",
-  "attachPdfHint": "Wyłącz, aby wysłać zamiast tego link i widzieć, kiedy klient go otworzy.",
   "sentCopy": {
   "sentCopy": {
     "note": "Wysłano {date}. Zmiany wprowadzone tutaj trafią na fakturę, gdy zostanie wysłana ponownie.",
     "note": "Wysłano {date}. Zmiany wprowadzone tutaj trafią na fakturę, gdy zostanie wysłana ponownie.",
     "confirmTitle": "Ta faktura została już wysłana",
     "confirmTitle": "Ta faktura została już wysłana",
@@ -225,12 +223,39 @@
     "title": "Nie można połączyć się z Torqvoice",
     "title": "Nie można połączyć się z Torqvoice",
     "description": "Ostatnia zmiana mogła nie zostać zapisana. Sprawdź połączenie i spróbuj ponownie."
     "description": "Ostatnia zmiana mogła nie zostać zapisana. Sprawdź połączenie i spróbuj ponownie."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Wyślij wycenę",
+    "titleInvoice": "Wyślij fakturę",
+    "titleInspection": "Wyślij inspekcję",
+    "description": "Klient otrzyma link, aby go wyświetlić i pobrać.",
+    "email": "Email",
+    "emailAddress": "Adres email",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Dołącz również plik PDF",
+    "sms": "SMS",
+    "smsNumber": "Numer telefonu do SMS",
+    "whatsappNumber": "Numer telefonu do WhatsApp",
+    "phonePlaceholder": "Numer telefonu",
+    "message": "Wiadomość (opcjonalnie, dodawana do emaila)",
+    "messagePlaceholder": "Dodaj osobistą wiadomość...",
+    "copyLink": "Kopiuj link",
+    "send": "Wyślij",
+    "emailSent": "Email wysłany",
+    "failedEmail": "Nie udało się wysłać email",
+    "smsSent": "SMS wysłany",
+    "failedSms": "Nie udało się wysłać SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Nie udało się utworzyć linku. Spróbuj ponownie.",
     "generateFailed": "Nie udało się utworzyć linku. Spróbuj ponownie.",
     "revokeFailed": "Nie udało się potwierdzić unieważnienia linku. Może nadal działać dla każdego, kto go ma.",
     "revokeFailed": "Nie udało się potwierdzić unieważnienia linku. Może nadal działać dla każdego, kto go ma.",
     "channelNotConnected": "Podłącz {channel} w Integracjach, aby wysyłać w ten sposób.",
     "channelNotConnected": "Podłącz {channel} w Integracjach, aby wysyłać w ten sposób.",
     "openIntegrations": "Otwórz Integracje",
     "openIntegrations": "Otwórz Integracje",
-    "channelNotInPlan": "{channel} nie jest objęty Twoim planem."
+    "channelNotInPlan": "{channel} nie jest objęty Twoim planem.",
+    "channelTurnedOff": "Wysyłanie przez {channel} jest wyłączone w tym warsztacie.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp wysłany",
+    "failedWhatsapp": "Nie udało się wysłać wiadomości WhatsApp",
+    "whatsappWindowClosed": "Ten klient nie napisał do Ciebie na WhatsAppie w ciągu ostatnich 24 godzin, a nie skonfigurowano zatwierdzonego szablonu, aby się z nim skontaktować."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Wybierz...",
     "choose": "Wybierz...",

+ 2 - 11
messages/pl/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Zakończona",
     "statusCompleted": "Zakończona",
     "complete": "Zakończ",
     "complete": "Zakończ",
     "reopen": "Otwórz ponownie",
     "reopen": "Otwórz ponownie",
+    "send": "Wyślij",
     "share": "Udostępnij",
     "share": "Udostępnij",
     "save": "Zapisz",
     "save": "Zapisz",
     "saving": "Zapisywanie…",
     "saving": "Zapisywanie…",
@@ -460,18 +461,8 @@
     "copied": "Link skopiowany do schowka",
     "copied": "Link skopiowany do schowka",
     "generated": "Link do udostępniania utworzony",
     "generated": "Link do udostępniania utworzony",
     "revoked": "Link do udostępniania wycofany",
     "revoked": "Link do udostępniania wycofany",
-    "notifyTitle": "Powiadom {name}",
-    "email": "E-mail",
-    "noEmail": "(brak adresu e-mail)",
-    "sms": "SMS",
-    "noPhone": "(brak numeru telefonu)",
-    "sendNotification": "Wyślij powiadomienie",
     "revokeLink": "Unieważnij link",
     "revokeLink": "Unieważnij link",
-    "generateLink": "Wygeneruj publiczny link",
-    "emailSent": "E-mail wysłany",
-    "failedEmail": "Nie udało się wysłać e-maila",
-    "smsSent": "SMS wysłany",
-    "failedSms": "Nie udało się wysłać SMS-a"
+    "generateLink": "Wygeneruj publiczny link"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Zdjęcia do przeglądu",
     "pageTitle": "Zdjęcia do przeglądu",

+ 1 - 11
messages/pl/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Wycena",
     "quote": "Wycena",
     "save": "Zapisz",
     "save": "Zapisz",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Wyślij",
     "share": "Udostępnij",
     "share": "Udostępnij",
     "delete": "Usuń",
     "delete": "Usuń",
     "deleteTitle": "Usuń wycenę",
     "deleteTitle": "Usuń wycenę",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Udostępnij wycenę",
     "title": "Udostępnij wycenę",
     "description": "Wygeneruj publiczny link, który pozwoli każdemu przeglądać i pobierać tę wycenę bez logowania.",
     "description": "Wygeneruj publiczny link, który pozwoli każdemu przeglądać i pobierać tę wycenę bez logowania.",
-    "notifyTitle": "Powiadom {name}",
-    "email": "E-mail",
-    "noEmail": "(brak adresu e-mail)",
-    "sms": "SMS",
-    "noPhone": "(brak numeru telefonu)",
-    "sendNotification": "Wyślij powiadomienie",
     "revokeLink": "Unieważnij link",
     "revokeLink": "Unieważnij link",
     "generateLink": "Wygeneruj publiczny link",
     "generateLink": "Wygeneruj publiczny link",
-    "emailSent": "E-mail wysłany",
-    "failedEmail": "Nie udało się wysłać e-maila",
-    "smsSent": "SMS wysłany",
-    "failedSms": "Nie udało się wysłać SMS-a",
     "copyLink": "Kopiuj link"
     "copyLink": "Kopiuj link"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/pl/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Zapisz",
     "save": "Zapisz",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Email",
+    "send": "Wyślij",
     "share": "Udostępnij",
     "share": "Udostępnij",
     "notify": "Powiadom",
     "notify": "Powiadom",
     "delete": "Usuń",
     "delete": "Usuń",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Udostępnij Fakturę",
     "title": "Udostępnij Fakturę",
     "description": "Wygeneruj publiczny link, który pozwala każdemu wyświetlać i pobierać tę fakturę bez logowania.",
     "description": "Wygeneruj publiczny link, który pozwala każdemu wyświetlać i pobierać tę fakturę bez logowania.",
-    "notifyTitle": "Powiadom {name}",
-    "email": "Email",
-    "noEmail": "(brak adresu email)",
-    "sms": "SMS",
-    "noPhone": "(brak numeru telefonu)",
-    "sendNotification": "Wyślij Powiadomienie",
     "revokeLink": "Odwołaj Link",
     "revokeLink": "Odwołaj Link",
     "generateLink": "Wygeneruj Publiczny Link",
     "generateLink": "Wygeneruj Publiczny Link",
-    "emailSent": "Email wysłany",
-    "failedEmail": "Nie udało się wysłać email",
-    "smsSent": "SMS wysłany",
-    "failedSms": "Nie udało się wysłać SMS",
     "copyLink": "Kopiuj link"
     "copyLink": "Kopiuj link"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/pt-BR/common.json

@@ -156,8 +156,6 @@
     "preview": "Pré-visualização",
     "preview": "Pré-visualização",
     "failed": "Não foi possível gerar a pré-visualização do PDF."
     "failed": "Não foi possível gerar a pré-visualização do PDF."
   },
   },
-  "attachPdf": "Anexar PDF",
-  "attachPdfHint": "Desligue para enviar um link, assim você vê quando o cliente abre.",
   "sentCopy": {
   "sentCopy": {
     "note": "Enviada em {date}. As alterações feitas aqui entram na fatura quando ela for enviada novamente.",
     "note": "Enviada em {date}. As alterações feitas aqui entram na fatura quando ela for enviada novamente.",
     "confirmTitle": "Esta fatura já foi enviada",
     "confirmTitle": "Esta fatura já foi enviada",
@@ -225,12 +223,39 @@
     "title": "Não foi possível conectar ao Torqvoice",
     "title": "Não foi possível conectar ao Torqvoice",
     "description": "Sua última alteração pode não ter sido salva. Verifique sua conexão e tente novamente."
     "description": "Sua última alteração pode não ter sido salva. Verifique sua conexão e tente novamente."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Enviar orçamento",
+    "titleInvoice": "Enviar fatura",
+    "titleInspection": "Enviar inspeção",
+    "description": "O cliente recebe um link para visualizar e baixar o documento.",
+    "email": "Email",
+    "emailAddress": "Endereço de email",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Anexar também o PDF",
+    "sms": "SMS",
+    "smsNumber": "Número de telefone para SMS",
+    "whatsappNumber": "Número de telefone para WhatsApp",
+    "phonePlaceholder": "Número de telefone",
+    "message": "Mensagem (opcional, adicionada ao email)",
+    "messagePlaceholder": "Adicione uma mensagem pessoal...",
+    "copyLink": "Copiar link",
+    "send": "Enviar",
+    "emailSent": "Email enviado",
+    "failedEmail": "Falha ao enviar email",
+    "smsSent": "SMS enviado",
+    "failedSms": "Falha ao enviar SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Não foi possível criar o link. Tente novamente.",
     "generateFailed": "Não foi possível criar o link. Tente novamente.",
     "revokeFailed": "Não foi possível confirmar que o link foi revogado. Ele pode continuar funcionando para quem o tiver.",
     "revokeFailed": "Não foi possível confirmar que o link foi revogado. Ele pode continuar funcionando para quem o tiver.",
     "channelNotConnected": "Conecte {channel} em Integrações para enviar por este meio.",
     "channelNotConnected": "Conecte {channel} em Integrações para enviar por este meio.",
     "openIntegrations": "Abrir Integrações",
     "openIntegrations": "Abrir Integrações",
-    "channelNotInPlan": "{channel} não está incluído no seu plano."
+    "channelNotInPlan": "{channel} não está incluído no seu plano.",
+    "channelTurnedOff": "O envio por {channel} está desativado nesta oficina.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp enviado",
+    "failedWhatsapp": "Falha ao enviar mensagem de WhatsApp",
+    "whatsappWindowClosed": "Este cliente não escreveu para você no WhatsApp nas últimas 24 horas, e nenhum modelo aprovado está configurado para contatá-lo."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Escolher...",
     "choose": "Escolher...",

+ 2 - 11
messages/pt-BR/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Concluída",
     "statusCompleted": "Concluída",
     "complete": "Concluir",
     "complete": "Concluir",
     "reopen": "Reabrir",
     "reopen": "Reabrir",
+    "send": "Enviar",
     "share": "Compartilhar",
     "share": "Compartilhar",
     "save": "Salvar",
     "save": "Salvar",
     "saving": "Salvando…",
     "saving": "Salvando…",
@@ -460,18 +461,8 @@
     "copied": "Link copiado para a área de transferência",
     "copied": "Link copiado para a área de transferência",
     "generated": "Link de compartilhamento criado",
     "generated": "Link de compartilhamento criado",
     "revoked": "Link de compartilhamento revogado",
     "revoked": "Link de compartilhamento revogado",
-    "notifyTitle": "Notificar {name}",
-    "email": "E-mail",
-    "noEmail": "(sem e-mail cadastrado)",
-    "sms": "SMS",
-    "noPhone": "(sem telefone cadastrado)",
-    "sendNotification": "Enviar notificação",
     "revokeLink": "Revogar link",
     "revokeLink": "Revogar link",
-    "generateLink": "Gerar link público",
-    "emailSent": "E-mail enviado",
-    "failedEmail": "Falha ao enviar o e-mail",
-    "smsSent": "SMS enviado",
-    "failedSms": "Falha ao enviar o SMS"
+    "generateLink": "Gerar link público"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Fotos da inspeção",
     "pageTitle": "Fotos da inspeção",

+ 1 - 11
messages/pt-BR/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Orçamento",
     "quote": "Orçamento",
     "save": "Salvar",
     "save": "Salvar",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Enviar",
     "share": "Compartilhar",
     "share": "Compartilhar",
     "delete": "Excluir",
     "delete": "Excluir",
     "deleteTitle": "Excluir orçamento",
     "deleteTitle": "Excluir orçamento",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Compartilhar orçamento",
     "title": "Compartilhar orçamento",
     "description": "Gere um link público que permite a qualquer pessoa visualizar e baixar este orçamento sem fazer login.",
     "description": "Gere um link público que permite a qualquer pessoa visualizar e baixar este orçamento sem fazer login.",
-    "notifyTitle": "Notificar {name}",
-    "email": "E-mail",
-    "noEmail": "(sem e-mail cadastrado)",
-    "sms": "SMS",
-    "noPhone": "(sem telefone cadastrado)",
-    "sendNotification": "Enviar notificação",
     "revokeLink": "Revogar link",
     "revokeLink": "Revogar link",
     "generateLink": "Gerar link público",
     "generateLink": "Gerar link público",
-    "emailSent": "E-mail enviado",
-    "failedEmail": "Falha ao enviar o e-mail",
-    "smsSent": "SMS enviado",
-    "failedSms": "Falha ao enviar o SMS",
     "copyLink": "Copiar link"
     "copyLink": "Copiar link"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/pt-BR/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Salvar",
     "save": "Salvar",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Email",
+    "send": "Enviar",
     "share": "Compartilhar",
     "share": "Compartilhar",
     "notify": "Notificar",
     "notify": "Notificar",
     "delete": "Deletar",
     "delete": "Deletar",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Compartilhar Fatura",
     "title": "Compartilhar Fatura",
     "description": "Gere um link público que permita qualquer pessoa visualizar e baixar esta fatura sem fazer login.",
     "description": "Gere um link público que permita qualquer pessoa visualizar e baixar esta fatura sem fazer login.",
-    "notifyTitle": "Notificar {name}",
-    "email": "Email",
-    "noEmail": "(sem email registrado)",
-    "sms": "SMS",
-    "noPhone": "(sem telefone registrado)",
-    "sendNotification": "Enviar Notificação",
     "revokeLink": "Revogar Link",
     "revokeLink": "Revogar Link",
     "generateLink": "Gerar Link Público",
     "generateLink": "Gerar Link Público",
-    "emailSent": "Email enviado",
-    "failedEmail": "Falha ao enviar email",
-    "smsSent": "SMS enviado",
-    "failedSms": "Falha ao enviar SMS",
     "copyLink": "Copiar link"
     "copyLink": "Copiar link"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/pt-PT/common.json

@@ -156,8 +156,6 @@
     "preview": "Pré-visualizar",
     "preview": "Pré-visualizar",
     "failed": "Não foi possível gerar a pré-visualização do PDF."
     "failed": "Não foi possível gerar a pré-visualização do PDF."
   },
   },
-  "attachPdf": "Anexar PDF",
-  "attachPdfHint": "Desative para enviar um link em vez do anexo e assim saber quando o cliente o abre.",
   "sentCopy": {
   "sentCopy": {
     "note": "Enviada a {date}. As alterações feitas aqui entram na fatura quando for enviada novamente.",
     "note": "Enviada a {date}. As alterações feitas aqui entram na fatura quando for enviada novamente.",
     "confirmTitle": "Esta fatura já foi enviada",
     "confirmTitle": "Esta fatura já foi enviada",
@@ -225,12 +223,39 @@
     "title": "Não foi possível contactar o Torqvoice",
     "title": "Não foi possível contactar o Torqvoice",
     "description": "A sua última alteração pode não ter sido guardada. Verifique a sua ligação e tente novamente."
     "description": "A sua última alteração pode não ter sido guardada. Verifique a sua ligação e tente novamente."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Enviar orçamento",
+    "titleInvoice": "Enviar fatura",
+    "titleInspection": "Enviar inspeção",
+    "description": "O cliente recebe uma ligação para ver e transferir o documento.",
+    "email": "E-mail",
+    "emailAddress": "Endereço de e-mail",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Anexar também o PDF",
+    "sms": "SMS",
+    "smsNumber": "Número de telefone para SMS",
+    "whatsappNumber": "Número de telefone para WhatsApp",
+    "phonePlaceholder": "Número de telefone",
+    "message": "Mensagem (opcional, acrescentada ao e-mail)",
+    "messagePlaceholder": "Acrescente uma mensagem pessoal...",
+    "copyLink": "Copiar link",
+    "send": "Enviar",
+    "emailSent": "E-mail enviado",
+    "failedEmail": "Não foi possível enviar o e-mail",
+    "smsSent": "SMS enviado",
+    "failedSms": "Não foi possível enviar o SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Não foi possível criar o link. Tente novamente.",
     "generateFailed": "Não foi possível criar o link. Tente novamente.",
     "revokeFailed": "Não foi possível confirmar que o link foi revogado. Pode continuar a funcionar para quem o tiver.",
     "revokeFailed": "Não foi possível confirmar que o link foi revogado. Pode continuar a funcionar para quem o tiver.",
     "channelNotConnected": "Ligue {channel} em Integrações para enviar desta forma.",
     "channelNotConnected": "Ligue {channel} em Integrações para enviar desta forma.",
     "openIntegrations": "Abrir Integrações",
     "openIntegrations": "Abrir Integrações",
-    "channelNotInPlan": "{channel} não está incluído no seu plano."
+    "channelNotInPlan": "{channel} não está incluído no seu plano.",
+    "channelTurnedOff": "O envio por {channel} está desativado nesta oficina.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp enviado",
+    "failedWhatsapp": "Não foi possível enviar a mensagem de WhatsApp",
+    "whatsappWindowClosed": "Este cliente não lhe escreveu no WhatsApp nas últimas 24 horas, e não está configurado nenhum modelo aprovado para o contactar."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Escolher...",
     "choose": "Escolher...",

+ 2 - 11
messages/pt-PT/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Concluída",
     "statusCompleted": "Concluída",
     "complete": "Concluir",
     "complete": "Concluir",
     "reopen": "Reabrir",
     "reopen": "Reabrir",
+    "send": "Enviar",
     "share": "Partilhar",
     "share": "Partilhar",
     "save": "Guardar",
     "save": "Guardar",
     "saving": "A guardar…",
     "saving": "A guardar…",
@@ -460,18 +461,8 @@
     "copied": "Link copiado para a área de transferência",
     "copied": "Link copiado para a área de transferência",
     "generated": "Link de partilha criado",
     "generated": "Link de partilha criado",
     "revoked": "Link de partilha revogado",
     "revoked": "Link de partilha revogado",
-    "notifyTitle": "Notificar {name}",
-    "email": "E-mail",
-    "noEmail": "(sem e-mail registado)",
-    "sms": "SMS",
-    "noPhone": "(sem telefone registado)",
-    "sendNotification": "Enviar notificação",
     "revokeLink": "Revogar link",
     "revokeLink": "Revogar link",
-    "generateLink": "Gerar link público",
-    "emailSent": "E-mail enviado",
-    "failedEmail": "Não foi possível enviar o e-mail",
-    "smsSent": "SMS enviado",
-    "failedSms": "Não foi possível enviar o SMS"
+    "generateLink": "Gerar link público"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Fotografias da inspeção",
     "pageTitle": "Fotografias da inspeção",

+ 1 - 11
messages/pt-PT/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Orçamento",
     "quote": "Orçamento",
     "save": "Guardar",
     "save": "Guardar",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Enviar",
     "share": "Partilhar",
     "share": "Partilhar",
     "delete": "Eliminar",
     "delete": "Eliminar",
     "deleteTitle": "Eliminar orçamento",
     "deleteTitle": "Eliminar orçamento",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Partilhar orçamento",
     "title": "Partilhar orçamento",
     "description": "Gere um link público que permite a qualquer pessoa ver e transferir este orçamento sem iniciar sessão.",
     "description": "Gere um link público que permite a qualquer pessoa ver e transferir este orçamento sem iniciar sessão.",
-    "notifyTitle": "Notificar {name}",
-    "email": "E-mail",
-    "noEmail": "(sem e-mail registado)",
-    "sms": "SMS",
-    "noPhone": "(sem telefone registado)",
-    "sendNotification": "Enviar notificação",
     "revokeLink": "Revogar link",
     "revokeLink": "Revogar link",
     "generateLink": "Gerar link público",
     "generateLink": "Gerar link público",
-    "emailSent": "E-mail enviado",
-    "failedEmail": "Não foi possível enviar o e-mail",
-    "smsSent": "SMS enviado",
-    "failedSms": "Não foi possível enviar o SMS",
     "copyLink": "Copiar link"
     "copyLink": "Copiar link"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/pt-PT/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Guardar",
     "save": "Guardar",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-mail",
+    "send": "Enviar",
     "share": "Partilhar",
     "share": "Partilhar",
     "notify": "Notificar",
     "notify": "Notificar",
     "delete": "Eliminar",
     "delete": "Eliminar",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Partilhar fatura",
     "title": "Partilhar fatura",
     "description": "Gere um link público que permite a qualquer pessoa ver e transferir esta fatura sem iniciar sessão.",
     "description": "Gere um link público que permite a qualquer pessoa ver e transferir esta fatura sem iniciar sessão.",
-    "notifyTitle": "Notificar {name}",
-    "email": "E-mail",
-    "noEmail": "(sem e-mail registado)",
-    "sms": "SMS",
-    "noPhone": "(sem telefone registado)",
-    "sendNotification": "Enviar notificação",
     "revokeLink": "Revogar link",
     "revokeLink": "Revogar link",
     "generateLink": "Gerar link público",
     "generateLink": "Gerar link público",
-    "emailSent": "E-mail enviado",
-    "failedEmail": "Não foi possível enviar o e-mail",
-    "smsSent": "SMS enviado",
-    "failedSms": "Não foi possível enviar o SMS",
     "copyLink": "Copiar link"
     "copyLink": "Copiar link"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/ru/common.json

@@ -156,8 +156,6 @@
     "preview": "Предпросмотр",
     "preview": "Предпросмотр",
     "failed": "Не удалось создать предпросмотр PDF."
     "failed": "Не удалось создать предпросмотр PDF."
   },
   },
-  "attachPdf": "Прикрепить PDF",
-  "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл.",
   "sentCopy": {
   "sentCopy": {
     "note": "Отправлен {date}. Изменения здесь попадут в счёт, когда он будет отправлен повторно.",
     "note": "Отправлен {date}. Изменения здесь попадут в счёт, когда он будет отправлен повторно.",
     "confirmTitle": "Этот счёт уже отправлен",
     "confirmTitle": "Этот счёт уже отправлен",
@@ -225,12 +223,39 @@
     "title": "Не удалось связаться с Torqvoice",
     "title": "Не удалось связаться с Torqvoice",
     "description": "Последнее изменение могло не сохраниться. Проверьте подключение и повторите попытку."
     "description": "Последнее изменение могло не сохраниться. Проверьте подключение и повторите попытку."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Отправить предложение",
+    "titleInvoice": "Отправить счёт",
+    "titleInspection": "Отправить осмотр",
+    "description": "Клиент получит ссылку для просмотра и скачивания.",
+    "email": "Эл. почта",
+    "emailAddress": "Адрес эл. почты",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "Также приложить PDF",
+    "sms": "SMS",
+    "smsNumber": "Номер телефона для SMS",
+    "whatsappNumber": "Номер телефона для WhatsApp",
+    "phonePlaceholder": "Номер телефона",
+    "message": "Сообщение (необязательно, добавляется в письмо)",
+    "messagePlaceholder": "Добавьте личное сообщение...",
+    "copyLink": "Копировать ссылку",
+    "send": "Отправить",
+    "emailSent": "Письмо отправлено",
+    "failedEmail": "Не удалось отправить письмо",
+    "smsSent": "SMS отправлено",
+    "failedSms": "Не удалось отправить SMS"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Не удалось создать ссылку. Попробуйте ещё раз.",
     "generateFailed": "Не удалось создать ссылку. Попробуйте ещё раз.",
     "revokeFailed": "Не удалось подтвердить отзыв ссылки. Она может по-прежнему работать для всех, у кого она есть.",
     "revokeFailed": "Не удалось подтвердить отзыв ссылки. Она может по-прежнему работать для всех, у кого она есть.",
     "channelNotConnected": "Подключите {channel} в разделе Интеграции, чтобы отправлять этим способом.",
     "channelNotConnected": "Подключите {channel} в разделе Интеграции, чтобы отправлять этим способом.",
     "openIntegrations": "Открыть Интеграции",
     "openIntegrations": "Открыть Интеграции",
-    "channelNotInPlan": "{channel} не входит в ваш тариф."
+    "channelNotInPlan": "{channel} не входит в ваш тариф.",
+    "channelTurnedOff": "Отправка через {channel} отключена для этой мастерской.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp отправлено",
+    "failedWhatsapp": "Не удалось отправить сообщение WhatsApp",
+    "whatsappWindowClosed": "Этот клиент не писал вам в WhatsApp за последние 24 часа, а одобренный шаблон для связи с ним не настроен."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Выберите...",
     "choose": "Выберите...",

+ 2 - 11
messages/ru/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Завершён",
     "statusCompleted": "Завершён",
     "complete": "Завершить",
     "complete": "Завершить",
     "reopen": "Открыть заново",
     "reopen": "Открыть заново",
+    "send": "Отправить",
     "share": "Поделиться",
     "share": "Поделиться",
     "save": "Сохранить",
     "save": "Сохранить",
     "saving": "Сохранение…",
     "saving": "Сохранение…",
@@ -460,18 +461,8 @@
     "copied": "Ссылка скопирована в буфер обмена",
     "copied": "Ссылка скопирована в буфер обмена",
     "generated": "Ссылка для доступа создана",
     "generated": "Ссылка для доступа создана",
     "revoked": "Ссылка для доступа отозвана",
     "revoked": "Ссылка для доступа отозвана",
-    "notifyTitle": "Уведомить {name}",
-    "email": "Эл. почта",
-    "noEmail": "(эл. почта не указана)",
-    "sms": "SMS",
-    "noPhone": "(телефон не указан)",
-    "sendNotification": "Отправить уведомление",
     "revokeLink": "Отозвать ссылку",
     "revokeLink": "Отозвать ссылку",
-    "generateLink": "Создать публичную ссылку",
-    "emailSent": "Письмо отправлено",
-    "failedEmail": "Не удалось отправить письмо",
-    "smsSent": "SMS отправлено",
-    "failedSms": "Не удалось отправить SMS"
+    "generateLink": "Создать публичную ссылку"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Фото к осмотру",
     "pageTitle": "Фото к осмотру",

+ 1 - 11
messages/ru/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Предложение",
     "quote": "Предложение",
     "save": "Сохранить",
     "save": "Сохранить",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Эл. почта",
+    "send": "Отправить",
     "share": "Поделиться",
     "share": "Поделиться",
     "delete": "Удалить",
     "delete": "Удалить",
     "deleteTitle": "Удалить предложение",
     "deleteTitle": "Удалить предложение",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Поделиться предложением",
     "title": "Поделиться предложением",
     "description": "Создайте публичную ссылку, по которой любой сможет просмотреть и скачать это предложение без входа в систему.",
     "description": "Создайте публичную ссылку, по которой любой сможет просмотреть и скачать это предложение без входа в систему.",
-    "notifyTitle": "Уведомить {name}",
-    "email": "Эл. почта",
-    "noEmail": "(эл. почта не указана)",
-    "sms": "SMS",
-    "noPhone": "(телефон не указан)",
-    "sendNotification": "Отправить уведомление",
     "revokeLink": "Отозвать ссылку",
     "revokeLink": "Отозвать ссылку",
     "generateLink": "Создать публичную ссылку",
     "generateLink": "Создать публичную ссылку",
-    "emailSent": "Письмо отправлено",
-    "failedEmail": "Не удалось отправить письмо",
-    "smsSent": "SMS отправлено",
-    "failedSms": "Не удалось отправить SMS",
     "copyLink": "Копировать ссылку"
     "copyLink": "Копировать ссылку"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/ru/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Сохранить",
     "save": "Сохранить",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "Эл. почта",
+    "send": "Отправить",
     "share": "Поделиться",
     "share": "Поделиться",
     "notify": "Уведомить",
     "notify": "Уведомить",
     "delete": "Удалить",
     "delete": "Удалить",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Поделиться счётом",
     "title": "Поделиться счётом",
     "description": "Создайте публичную ссылку, по которой любой сможет просмотреть и скачать этот счёт без входа в систему.",
     "description": "Создайте публичную ссылку, по которой любой сможет просмотреть и скачать этот счёт без входа в систему.",
-    "notifyTitle": "Уведомить {name}",
-    "email": "Эл. почта",
-    "noEmail": "(эл. почта не указана)",
-    "sms": "SMS",
-    "noPhone": "(телефон не указан)",
-    "sendNotification": "Отправить уведомление",
     "revokeLink": "Отозвать ссылку",
     "revokeLink": "Отозвать ссылку",
     "generateLink": "Создать публичную ссылку",
     "generateLink": "Создать публичную ссылку",
-    "emailSent": "Письмо отправлено",
-    "failedEmail": "Не удалось отправить письмо",
-    "smsSent": "SMS отправлено",
-    "failedSms": "Не удалось отправить SMS",
     "copyLink": "Копировать ссылку"
     "copyLink": "Копировать ссылку"
   },
   },
   "sidebar": {
   "sidebar": {

+ 28 - 3
messages/tr/common.json

@@ -156,8 +156,6 @@
     "preview": "Önizleme",
     "preview": "Önizleme",
     "failed": "PDF önizlemesi oluşturulamadı."
     "failed": "PDF önizlemesi oluşturulamadı."
   },
   },
-  "attachPdf": "PDF ekle",
-  "attachPdfHint": "Bunun yerine bağlantı göndermek için kapatın; böylece müşterinin ne zaman açtığını görürsünüz.",
   "sentCopy": {
   "sentCopy": {
     "note": "{date} tarihinde gönderildi. Buradaki değişiklikler fatura yeniden gönderildiğinde faturaya yansır.",
     "note": "{date} tarihinde gönderildi. Buradaki değişiklikler fatura yeniden gönderildiğinde faturaya yansır.",
     "confirmTitle": "Bu fatura zaten gönderildi",
     "confirmTitle": "Bu fatura zaten gönderildi",
@@ -225,12 +223,39 @@
     "title": "Torqvoice'a ulaşılamadı",
     "title": "Torqvoice'a ulaşılamadı",
     "description": "Son değişikliğiniz kaydedilmemiş olabilir. Bağlantınızı kontrol edip tekrar deneyin."
     "description": "Son değişikliğiniz kaydedilmemiş olabilir. Bağlantınızı kontrol edip tekrar deneyin."
   },
   },
+  "sendDocument": {
+    "titleQuote": "Teklif gönder",
+    "titleInvoice": "Fatura gönder",
+    "titleInspection": "Muayene gönder",
+    "description": "Müşteri, görüntülemek ve indirmek için bir bağlantı alır.",
+    "email": "E-posta",
+    "emailAddress": "E-posta adresi",
+    "emailPlaceholder": "customer@example.com",
+    "attachPdf": "PDF'yi de ekle",
+    "sms": "SMS",
+    "smsNumber": "SMS için telefon numarası",
+    "whatsappNumber": "WhatsApp için telefon numarası",
+    "phonePlaceholder": "Telefon numarası",
+    "message": "Mesaj (isteğe bağlı, e-postaya eklenir)",
+    "messagePlaceholder": "Kişisel bir mesaj ekleyin...",
+    "copyLink": "Bağlantıyı kopyala",
+    "send": "Gönder",
+    "emailSent": "E-posta gönderildi",
+    "failedEmail": "E-posta gönderilemedi",
+    "smsSent": "SMS gönderildi",
+    "failedSms": "SMS gönderilemedi"
+  },
   "shareLink": {
   "shareLink": {
     "generateFailed": "Bağlantı oluşturulamadı. Tekrar deneyin.",
     "generateFailed": "Bağlantı oluşturulamadı. Tekrar deneyin.",
     "revokeFailed": "Bağlantının iptali doğrulanamadı. Bağlantıya sahip olan herkes onu hâlâ açabilir.",
     "revokeFailed": "Bağlantının iptali doğrulanamadı. Bağlantıya sahip olan herkes onu hâlâ açabilir.",
     "channelNotConnected": "Bu yolla göndermek için {channel} hizmetini Entegrasyonlar altında bağlayın.",
     "channelNotConnected": "Bu yolla göndermek için {channel} hizmetini Entegrasyonlar altında bağlayın.",
     "openIntegrations": "Entegrasyonları aç",
     "openIntegrations": "Entegrasyonları aç",
-    "channelNotInPlan": "{channel} planınıza dahil değil."
+    "channelNotInPlan": "{channel} planınıza dahil değil.",
+    "channelTurnedOff": "{channel} ile gönderim bu atölye için kapalıdır.",
+    "whatsapp": "WhatsApp",
+    "whatsappSent": "WhatsApp gönderildi",
+    "failedWhatsapp": "WhatsApp mesajı gönderilemedi",
+    "whatsappWindowClosed": "Bu müşteri son 24 saat içinde size WhatsApp üzerinden yazmadı ve ona ulaşmak için onaylı bir şablon ayarlanmamış."
   },
   },
   "searchableSelect": {
   "searchableSelect": {
     "choose": "Seçin...",
     "choose": "Seçin...",

+ 2 - 11
messages/tr/inspections.json

@@ -53,6 +53,7 @@
     "statusCompleted": "Tamamlandı",
     "statusCompleted": "Tamamlandı",
     "complete": "Tamamla",
     "complete": "Tamamla",
     "reopen": "Yeniden aç",
     "reopen": "Yeniden aç",
+    "send": "Gönder",
     "share": "Paylaş",
     "share": "Paylaş",
     "save": "Kaydet",
     "save": "Kaydet",
     "saving": "Kaydediliyor…",
     "saving": "Kaydediliyor…",
@@ -460,18 +461,8 @@
     "copied": "Bağlantı panoya kopyalandı",
     "copied": "Bağlantı panoya kopyalandı",
     "generated": "Paylaşım bağlantısı oluşturuldu",
     "generated": "Paylaşım bağlantısı oluşturuldu",
     "revoked": "Paylaşım bağlantısı geri alındı",
     "revoked": "Paylaşım bağlantısı geri alındı",
-    "notifyTitle": "{name} Bilgilendir",
-    "email": "E-posta",
-    "noEmail": "(kayıtlı e-posta yok)",
-    "sms": "SMS",
-    "noPhone": "(kayıtlı telefon yok)",
-    "sendNotification": "Bildirim Gönder",
     "revokeLink": "Bağlantıyı İptal Et",
     "revokeLink": "Bağlantıyı İptal Et",
-    "generateLink": "Herkese Açık Bağlantı Oluştur",
-    "emailSent": "E-posta gönderildi",
-    "failedEmail": "E-posta gönderilemedi",
-    "smsSent": "SMS gönderildi",
-    "failedSms": "SMS gönderilemedi"
+    "generateLink": "Herkese Açık Bağlantı Oluştur"
   },
   },
   "phone": {
   "phone": {
     "pageTitle": "Muayene fotoğrafları",
     "pageTitle": "Muayene fotoğrafları",

+ 1 - 11
messages/tr/quotes.json

@@ -22,7 +22,7 @@
     "quote": "Teklif",
     "quote": "Teklif",
     "save": "Kaydet",
     "save": "Kaydet",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-posta",
+    "send": "Gönder",
     "share": "Paylaş",
     "share": "Paylaş",
     "delete": "Sil",
     "delete": "Sil",
     "deleteTitle": "Teklifi Sil",
     "deleteTitle": "Teklifi Sil",
@@ -199,18 +199,8 @@
   "shareDialog": {
   "shareDialog": {
     "title": "Teklifi Paylaş",
     "title": "Teklifi Paylaş",
     "description": "Herkesin giriş yapmadan bu teklifi görüntüleyip indirebileceği bir herkese açık bağlantı oluşturun.",
     "description": "Herkesin giriş yapmadan bu teklifi görüntüleyip indirebileceği bir herkese açık bağlantı oluşturun.",
-    "notifyTitle": "{name} Bilgilendir",
-    "email": "E-posta",
-    "noEmail": "(kayıtlı e-posta yok)",
-    "sms": "SMS",
-    "noPhone": "(kayıtlı telefon yok)",
-    "sendNotification": "Bildirim Gönder",
     "revokeLink": "Bağlantıyı İptal Et",
     "revokeLink": "Bağlantıyı İptal Et",
     "generateLink": "Herkese Açık Bağlantı Oluştur",
     "generateLink": "Herkese Açık Bağlantı Oluştur",
-    "emailSent": "E-posta gönderildi",
-    "failedEmail": "E-posta gönderilemedi",
-    "smsSent": "SMS gönderildi",
-    "failedSms": "SMS gönderilemedi",
     "copyLink": "Bağlantıyı kopyala"
     "copyLink": "Bağlantıyı kopyala"
   },
   },
   "images": {
   "images": {

+ 1 - 11
messages/tr/service.json

@@ -35,7 +35,7 @@
     },
     },
     "save": "Kaydet",
     "save": "Kaydet",
     "pdf": "PDF",
     "pdf": "PDF",
-    "email": "E-posta",
+    "send": "Gönder",
     "share": "Paylaş",
     "share": "Paylaş",
     "notify": "Bildir",
     "notify": "Bildir",
     "delete": "Sil",
     "delete": "Sil",
@@ -385,18 +385,8 @@
   "share": {
   "share": {
     "title": "Faturayı Paylaş",
     "title": "Faturayı Paylaş",
     "description": "Herkesin oturum açmadan bu faturayı görüntülemesine ve indirmesine izin veren herkese açık bir bağlantı oluştur.",
     "description": "Herkesin oturum açmadan bu faturayı görüntülemesine ve indirmesine izin veren herkese açık bir bağlantı oluştur.",
-    "notifyTitle": "{name} Bilgilendir",
-    "email": "E-posta",
-    "noEmail": "(dosyada e-posta yok)",
-    "sms": "SMS",
-    "noPhone": "(dosyada telefon yok)",
-    "sendNotification": "Bildirim Gönder",
     "revokeLink": "Bağlantıyı İptal Et",
     "revokeLink": "Bağlantıyı İptal Et",
     "generateLink": "Herkese Açık Bağlantı Oluştur",
     "generateLink": "Herkese Açık Bağlantı Oluştur",
-    "emailSent": "E-posta gönderildi",
-    "failedEmail": "E-posta gönderilemedi",
-    "smsSent": "SMS gönderildi",
-    "failedSms": "SMS gönderilemedi",
     "copyLink": "Bağlantıyı kopyala"
     "copyLink": "Bağlantıyı kopyala"
   },
   },
   "sidebar": {
   "sidebar": {

+ 5 - 0
src/__tests__/features/email/inspection-email-certificate.test.ts

@@ -54,6 +54,9 @@ vi.mock('@/features/email/Lib/sendTemplatedMail', () => ({
   sendTemplatedMail: (org: string, options: unknown) => sendTemplatedMail(org, options),
   sendTemplatedMail: (org: string, options: unknown) => sendTemplatedMail(org, options),
 }))
 }))
 
 
+// The link's own rules (kept when it exists, minted once) have their own test.
+vi.mock('@/lib/share-token.server', () => ({ ensureShareToken: async () => 'minted' }))
+
 const { sendInspectionEmail } = await import('@/features/email/Actions/emailActions')
 const { sendInspectionEmail } = await import('@/features/email/Actions/emailActions')
 
 
 beforeEach(() => {
 beforeEach(() => {
@@ -98,6 +101,8 @@ describe('the inspection email', () => {
     // The file keeps the name the mail has always given it.
     // The file keeps the name the mail has always given it.
     expect(options.attachments[0].filename).toBe('Inspection-2011 BMW 5-Serie F11.pdf')
     expect(options.attachments[0].filename).toBe('Inspection-2011 BMW 5-Serie F11.pdf')
     expect(options.attachments[0].content.toString()).toBe('%PDF designed')
     expect(options.attachments[0].content.toString()).toBe('%PDF designed')
+    // The link goes with the mail as well: the PDF is an extra, not instead.
+    expect(options.context.shareLink).toBe('https://app.example/share/inspection/org/minted')
   })
   })
 
 
   it('builds nothing for a link-only mail, and mints the link', async () => {
   it('builds nothing for a link-only mail, and mints the link', async () => {

+ 347 - 0
src/__tests__/features/messaging/send-document-dialog.test.tsx

@@ -0,0 +1,347 @@
+/**
+ * One dialog sends a quote, an invoice or an inspection: by email, text or
+ * WhatsApp, each to the customer's address or number, or to one typed in for
+ * a job whose customer has not been entered yet. A channel the workshop has
+ * not set up is still shown, disabled, with the way to turn it on.
+ */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getShareChannels = vi.fn()
+const getWhatsappWindowState = vi.fn()
+const sendQuoteLink = vi.fn()
+const sendInvoiceLink = vi.fn()
+const sendInspectionLink = vi.fn()
+
+// A Radix checkbox inside a form measures itself; jsdom has no ResizeObserver.
+class NoResize {
+  observe = vi.fn()
+  unobserve = vi.fn()
+  disconnect = vi.fn()
+}
+vi.stubGlobal('ResizeObserver', NoResize)
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/lib/analytics', () => ({ track: vi.fn() }))
+vi.mock('@/features/messaging/Actions/getShareChannels', () => ({
+  getShareChannels: async () => getShareChannels(),
+}))
+vi.mock('@/features/messaging/Actions/sendDocumentLink', () => ({
+  sendQuoteLink: async (input: unknown) => sendQuoteLink(input),
+  sendInvoiceLink: async (input: unknown) => sendInvoiceLink(input),
+  sendInspectionLink: async (input: unknown) => sendInspectionLink(input),
+}))
+vi.mock('@/features/whatsapp/Actions/whatsappActions', () => ({
+  getWhatsappWindowState: async (id: string) => getWhatsappWindowState(id),
+}))
+vi.mock('@/features/email/Actions/emailPreferenceActions', () => ({
+  getAttachPdfDefault: async () => ({ success: true, data: { attachPdf: false } }),
+}))
+
+import {
+  SendDocumentDialog,
+  type SendDocumentKind,
+} from '@/features/messaging/Components/SendDocumentDialog'
+
+const customer = { id: 'c1', name: 'Ola Nordmann', email: 'ola@nordmann.no', phone: '+4790000000' }
+type Status = 'ready' | 'not_connected' | 'not_in_plan' | 'turned_off'
+
+function channels(
+  over: Partial<Record<'email' | 'sms' | 'whatsapp', Status>> = {},
+  isCloud = true
+) {
+  getShareChannels.mockResolvedValue({
+    success: true,
+    data: { email: 'ready', sms: 'ready', whatsapp: 'ready', isCloud, ...over },
+  })
+}
+
+function whatsappWindow(open: boolean, hasTemplate: boolean, templateCarriesMessage = hasTemplate) {
+  getWhatsappWindowState.mockResolvedValue({
+    success: true,
+    data: {
+      configured: true,
+      open,
+      hasTemplate,
+      templateCarriesMessage,
+      hasMediaTemplate: false,
+      lastInboundAt: null,
+    },
+  })
+}
+
+const sendEmail = vi.fn()
+const onOpenChange = vi.fn()
+const onSent = vi.fn()
+
+async function show(
+  options: { kind?: SendDocumentKind; customer?: typeof customer | null; shareUrl?: string } = {}
+) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+  await act(async () => {
+    render(
+      <QueryClientProvider client={client}>
+        <SendDocumentDialog
+          open
+          onOpenChange={onOpenChange}
+          kind={options.kind ?? 'invoice'}
+          documentId="doc1"
+          shareUrl={options.shareUrl}
+          customer={options.customer === undefined ? customer : options.customer}
+          sendEmail={sendEmail}
+          onSent={onSent}
+        />
+      </QueryClientProvider>
+    )
+  })
+  // The rows unlock once the workshop's channels are known.
+  await vi.waitFor(() => expect(screen.getByLabelText('Email address')).toBeEnabled())
+}
+
+const box = (name: RegExp) => screen.getByRole('checkbox', { name })
+const sendButton = () => screen.getByRole('button', { name: 'Send' })
+async function send() {
+  await act(async () => {
+    fireEvent.click(sendButton())
+  })
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  channels()
+  whatsappWindow(true, false)
+  sendEmail.mockResolvedValue({ success: true })
+  for (const action of [sendQuoteLink, sendInvoiceLink, sendInspectionLink]) {
+    action.mockResolvedValue({ success: true })
+  }
+})
+
+describe('sending to the customer on file', () => {
+  it('starts with the customer’s address and number, email ticked', async () => {
+    await show()
+
+    expect(screen.getByRole('dialog', { name: 'Send invoice' })).toBeInTheDocument()
+    expect(screen.getByLabelText('Email address')).toHaveValue('ola@nordmann.no')
+    expect(screen.getByLabelText('Phone number for SMS')).toHaveValue('+4790000000')
+    expect(screen.getByLabelText('Phone number for WhatsApp')).toHaveValue('+4790000000')
+    expect(box(/Email/)).toBeChecked()
+    expect(box(/SMS/)).not.toBeChecked()
+    expect(box(/WhatsApp/)).not.toBeChecked()
+  })
+
+  it('emails the document with the message, and closes', async () => {
+    await show()
+    fireEvent.change(screen.getByLabelText(/Message/), { target: { value: 'Ready on Monday' } })
+    await send()
+
+    expect(sendEmail).toHaveBeenCalledWith('ola@nordmann.no', 'Ready on Monday', false)
+    expect(sendInvoiceLink).not.toHaveBeenCalled()
+    expect(toast.success).toHaveBeenCalledWith('Email sent')
+    expect(onSent).toHaveBeenCalled()
+    expect(onOpenChange).toHaveBeenCalledWith(false)
+  })
+
+  it('attaches the PDF as well when asked', async () => {
+    await show()
+    fireEvent.click(box(/Attach the PDF as well/))
+    await send()
+
+    expect(sendEmail).toHaveBeenCalledWith('ola@nordmann.no', undefined, true)
+  })
+
+  it('sends by text and WhatsApp together, without an email', async () => {
+    await show()
+    fireEvent.click(box(/Email/))
+    fireEvent.click(box(/SMS/))
+    fireEvent.click(box(/WhatsApp/))
+    await send()
+
+    expect(sendEmail).not.toHaveBeenCalled()
+    expect(sendInvoiceLink).toHaveBeenCalledWith({ id: 'doc1', channel: 'sms', to: '+4790000000' })
+    expect(sendInvoiceLink).toHaveBeenCalledWith({
+      id: 'doc1',
+      channel: 'whatsapp',
+      to: '+4790000000',
+    })
+    expect(toast.success).toHaveBeenCalledWith('SMS sent & WhatsApp sent')
+  })
+
+  it.each([
+    ['quote', 'Send quote', sendQuoteLink],
+    ['inspection', 'Send inspection', sendInspectionLink],
+  ] as const)('sends a %s through its own action', async (kind, title, action) => {
+    await show({ kind })
+    expect(screen.getByRole('dialog', { name: title })).toBeInTheDocument()
+    fireEvent.click(box(/SMS/))
+    fireEvent.click(box(/Email/))
+    await send()
+
+    expect(action).toHaveBeenCalledWith({ id: 'doc1', channel: 'sms', to: '+4790000000' })
+  })
+
+  it('shows the link the document already has', async () => {
+    await show({ shareUrl: 'https://app.test/share/invoice/org1/tok' })
+    expect(screen.getByText('https://app.test/share/invoice/org1/tok')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Copy link' })).toBeInTheDocument()
+  })
+})
+
+describe('sending when the customer’s details are not on file', () => {
+  it('has nothing to send to until an address or a number is typed', async () => {
+    await show({ customer: null })
+
+    expect(screen.getByLabelText('Email address')).toHaveValue('')
+    expect(box(/Email/)).not.toBeChecked()
+    expect(sendButton()).toBeDisabled()
+  })
+
+  it('texts the number that was typed', async () => {
+    await show({ customer: null })
+    fireEvent.change(screen.getByLabelText('Phone number for SMS'), {
+      target: { value: '912 345 678' },
+    })
+
+    // Typing who it goes to is choosing the channel.
+    expect(box(/SMS/)).toBeChecked()
+    await send()
+    expect(sendInvoiceLink).toHaveBeenCalledWith({ id: 'doc1', channel: 'sms', to: '912 345 678' })
+    expect(sendEmail).not.toHaveBeenCalled()
+  })
+
+  it('emails an address typed over the customer’s', async () => {
+    await show()
+    fireEvent.change(screen.getByLabelText('Email address'), {
+      target: { value: 'fleet@example.com' },
+    })
+    await send()
+
+    expect(sendEmail).toHaveBeenCalledWith('fleet@example.com', undefined, false)
+  })
+})
+
+describe('a channel that cannot send', () => {
+  it('shows SMS disabled with the way to connect a provider', async () => {
+    channels({ sms: 'not_connected' })
+    await show()
+
+    expect(screen.getByText('Connect SMS under Integrations to send this way.')).toBeInTheDocument()
+    expect(box(/SMS/)).toBeDisabled()
+    expect(screen.getByLabelText('Phone number for SMS')).toBeDisabled()
+    expect(screen.getByRole('link', { name: 'Open Integrations' })).toHaveAttribute(
+      'href',
+      '/settings/integrations'
+    )
+  })
+
+  it('points a cloud workshop without WhatsApp in its plan to the plans', async () => {
+    channels({ whatsapp: 'not_in_plan' }, true)
+    await show()
+
+    expect(screen.getByText('WhatsApp is not included in your plan.')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute(
+      'href',
+      '/settings/subscription'
+    )
+  })
+
+  it('points a self-hosted workshop to the licence page', async () => {
+    channels({ sms: 'not_in_plan' }, false)
+    await show()
+
+    expect(screen.getByRole('link', { name: 'Activate License' })).toHaveAttribute(
+      'href',
+      '/settings/license'
+    )
+  })
+
+  it('explains WhatsApp’s 24 hour rule for the customer’s number, and lifts it for another', async () => {
+    whatsappWindow(false, false)
+    await show()
+
+    expect(
+      await screen.findByText(
+        'This customer has not written to you on WhatsApp in the last 24 hours, and no approved template is set up to reach them.'
+      )
+    ).toBeInTheDocument()
+    expect(box(/WhatsApp/)).toBeDisabled()
+
+    // Another number has no conversation on file to judge by: it is tried.
+    fireEvent.change(screen.getByLabelText('Phone number for WhatsApp'), {
+      target: { value: '+4791111111' },
+    })
+    expect(box(/WhatsApp/)).toBeEnabled()
+    expect(box(/WhatsApp/)).toBeChecked()
+  })
+
+  it('offers WhatsApp outside the 24 hours when a template can carry it', async () => {
+    whatsappWindow(false, true)
+    await show()
+
+    await vi.waitFor(() => expect(getWhatsappWindowState).toHaveBeenCalledWith('c1'))
+    expect(box(/WhatsApp/)).toBeEnabled()
+  })
+})
+
+describe('what the review of the first version found', () => {
+  it('does not offer WhatsApp through a template with no room for the link', async () => {
+    whatsappWindow(false, true, false)
+    await show()
+
+    await vi.waitFor(() => expect(box(/WhatsApp/)).toBeDisabled())
+  })
+
+  it('shows email disabled, and says so, when the workshop has turned email off', async () => {
+    channels({ email: 'turned_off' })
+    await show({ customer: null }).catch(() => undefined)
+
+    expect(
+      await screen.findByText('Email sending is turned off for this workshop.')
+    ).toBeInTheDocument()
+    expect(box(/Email/)).toBeDisabled()
+  })
+
+  it('checks the address as an address only while email is ticked', async () => {
+    await show()
+    expect(screen.getByLabelText('Email address')).toHaveAttribute('type', 'email')
+
+    // Unticked, a half-typed address must not hold up a text.
+    fireEvent.click(box(/Email/))
+    expect(screen.getByLabelText('Email address')).toHaveAttribute('type', 'text')
+  })
+})
+
+describe('a send that fails', () => {
+  it('says why and keeps the dialog open with the channel still ticked', async () => {
+    sendInvoiceLink.mockResolvedValue({ success: false, error: 'Number not on WhatsApp' })
+    await show()
+    fireEvent.click(box(/Email/))
+    fireEvent.click(box(/WhatsApp/))
+    await send()
+
+    expect(toast.error).toHaveBeenCalledWith('Number not on WhatsApp')
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+    expect(box(/WhatsApp/)).toBeChecked()
+  })
+
+  it('still sends the other channels and reports the one that went', async () => {
+    sendEmail.mockResolvedValue({ success: false, error: 'Mailbox full' })
+    await show()
+    fireEvent.click(box(/SMS/))
+    await send()
+
+    expect(toast.error).toHaveBeenCalledWith('Mailbox full')
+    expect(toast.success).toHaveBeenCalledWith('SMS sent')
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+  })
+
+  it('treats a dropped connection as a failure, not a crash', async () => {
+    sendEmail.mockRejectedValue(new TypeError('Failed to fetch'))
+    await show()
+    await send()
+
+    expect(toast.error).toHaveBeenCalledWith('Failed to send email')
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+  })
+})

+ 281 - 0
src/__tests__/features/messaging/send-document-link.test.ts

@@ -0,0 +1,281 @@
+/**
+ * A document's link can be texted or sent on WhatsApp to a number typed in
+ * the send dialog, for a job whose customer is not on file yet. The number
+ * can be anyone's, so what it receives is built on the server from the
+ * workshop's template and the document's own link, never taken from the
+ * browser, and a link the customer already holds is never replaced.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const quote = { findFirst: vi.fn(), update: vi.fn().mockResolvedValue({}) }
+const serviceRecord = { findFirst: vi.fn(), update: vi.fn().mockResolvedValue({}) }
+const inspection = { findFirst: vi.fn(), update: vi.fn().mockResolvedValue({}) }
+const appSetting = { findFirst: vi.fn() }
+vi.mock('@/lib/db', () => ({
+  db: {
+    quote,
+    serviceRecord,
+    inspection,
+    appSetting,
+    organization: { findUnique: async () => ({ name: 'Fjord Auto' }) },
+    user: { findUnique: async () => ({ name: 'Kari' }) },
+  },
+}))
+
+// The real behaviour: a throw becomes a returned error.
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (fn: (ctx: { organizationId: string; userId: string }) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn({ organizationId: 'org1', userId: 'user1' }) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+// The link's own rules (kept when it exists, minted once) have their own test.
+const ensureShareToken = vi.fn()
+vi.mock('@/lib/share-token.server', () => ({
+  ensureShareToken: (...args: unknown[]) => ensureShareToken(...args),
+}))
+vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn() }))
+vi.mock('@/lib/app-url', () => ({ getAppBaseUrl: () => 'https://app.test' }))
+
+const requireFeature = vi.fn()
+vi.mock('@/lib/features', () => ({
+  requireFeature: (org: string, feature: string) => requireFeature(org, feature),
+}))
+
+const deliverSms = vi.fn()
+vi.mock('@/features/sms/Lib/deliverSms.server', () => ({
+  deliverSms: (org: string, input: unknown) => deliverSms(org, input),
+}))
+// Numbers with a country code pass; anything else cannot be dialled.
+vi.mock('@/lib/sms', () => ({
+  normalizeOrgPhone: async (_org: string, phone: string) =>
+    phone.trim().startsWith('+') ? phone.replace(/\s/g, '') : null,
+}))
+
+const sendOrgWhatsapp = vi.fn()
+const getWhatsappConfig = vi.fn()
+const isWithinServiceWindow = vi.fn()
+vi.mock('@/lib/whatsapp', () => {
+  class WhatsappWindowClosedError extends Error {}
+  return {
+    WhatsappWindowClosedError,
+    sendOrgWhatsapp: (org: string, input: unknown) => sendOrgWhatsapp(org, input),
+    getWhatsappConfig: () => getWhatsappConfig(),
+    isWithinServiceWindow: () => isWithinServiceWindow(),
+  }
+})
+
+const markInvoiceSent = vi.fn()
+const markQuoteSent = vi.fn()
+vi.mock('@/lib/document-lock.server', () => ({
+  markInvoiceSent: (...args: unknown[]) => markInvoiceSent(...args),
+  markQuoteSent: (...args: unknown[]) => markQuoteSent(...args),
+}))
+const issueInvoice = vi.fn()
+vi.mock('@/features/invoices/Lib/issueInvoice', () => ({
+  issueInvoice: (...args: unknown[]) => issueInvoice(...args),
+}))
+
+const { sendQuoteLink, sendInvoiceLink, sendInspectionLink } = await import(
+  '@/features/messaging/Actions/sendDocumentLink'
+)
+
+const OLA = { id: 'c1', name: 'Ola Nordmann', phone: '+47 900 00 000' }
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  appSetting.findFirst.mockResolvedValue(null)
+  requireFeature.mockResolvedValue(undefined)
+  deliverSms.mockResolvedValue({ id: 'sms1', to: '+4790000000' })
+  sendOrgWhatsapp.mockResolvedValue({ messageId: 'wa1', usedTemplate: false })
+  ensureShareToken.mockResolvedValue('held')
+  getWhatsappConfig.mockResolvedValue({ template: null })
+  isWithinServiceWindow.mockResolvedValue(true)
+})
+
+describe('texting a quote', () => {
+  it('texts the quote’s link to the number, in a shape that can be dialled', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+47 900 00 000' })
+
+    expect(result.success).toBe(true)
+    expect(ensureShareToken).toHaveBeenCalledWith('quote', 'q1', 'org1')
+    const sent = deliverSms.mock.calls[0][1]
+    expect(sent.to).toBe('+4790000000')
+    expect(sent.body).toContain('https://app.test/share/quote/org1/held')
+    expect(sent).toMatchObject({ relatedEntityType: 'quote', relatedEntityId: 'q1' })
+  })
+
+  it('adds the link when the workshop’s template left it out', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+    appSetting.findFirst.mockResolvedValue({ value: 'Hi {customer_name}, your quote is ready.' })
+
+    await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+47 900 00 000' })
+
+    expect(deliverSms.mock.calls[0][1].body).toBe(
+      'Hi Ola Nordmann, your quote is ready. https://app.test/share/quote/org1/held'
+    )
+  })
+
+  it('writes the text with the workshop’s own template', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+    appSetting.findFirst.mockResolvedValue({
+      value: 'Hi {customer_name}, {company_name} here: {share_link} /{current_user}',
+    })
+
+    await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+47 900 00 000' })
+
+    expect(deliverSms.mock.calls[0][1].body).toBe(
+      'Hi Ola Nordmann, Fjord Auto here: https://app.test/share/quote/org1/held /Kari'
+    )
+  })
+
+  it('files the text in the customer’s conversation when it went to their number', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+
+    await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+4790000000' })
+
+    expect(deliverSms.mock.calls[0][1].customerId).toBe('c1')
+  })
+
+  it('keeps a text to another number out of the customer’s conversation', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+
+    await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+4791111111' })
+
+    expect(deliverSms.mock.calls[0][1]).toMatchObject({ to: '+4791111111', customerId: null })
+  })
+
+  it('texts a typed number for a quote with no customer at all', async () => {
+    quote.findFirst.mockResolvedValue({ customer: null })
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+4791111111' })
+
+    expect(result.success).toBe(true)
+    expect(deliverSms.mock.calls[0][1]).toMatchObject({ to: '+4791111111', customerId: null })
+  })
+
+  it('says what to fix when the number cannot be dialled', async () => {
+    quote.findFirst.mockResolvedValue({ customer: null })
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'sms', to: '912 345 678' })
+
+    expect(result).toMatchObject({ success: false })
+    expect(result.error).toContain('"912 345 678" is not a phone number we can text')
+    expect(deliverSms).not.toHaveBeenCalled()
+  })
+
+  it('refuses a quote from another workshop', async () => {
+    quote.findFirst.mockResolvedValue(null)
+
+    const result = await sendQuoteLink({ id: 'theirs', channel: 'sms', to: '+4791111111' })
+
+    expect(quote.findFirst.mock.calls[0][0].where).toEqual({ id: 'theirs', organizationId: 'org1' })
+    expect(result).toMatchObject({ success: false, error: 'Quote not found' })
+    expect(deliverSms).not.toHaveBeenCalled()
+  })
+
+  it('sends nothing when the plan has no SMS', async () => {
+    requireFeature.mockRejectedValue(new Error('gated'))
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'sms', to: '+4791111111' })
+
+    expect(requireFeature).toHaveBeenCalledWith('org1', 'sms')
+    expect(result.success).toBe(false)
+    expect(quote.findFirst).not.toHaveBeenCalled()
+  })
+})
+
+describe('sending on WhatsApp', () => {
+  it('sends the same link and counts the quote as sent', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'whatsapp', to: '+47 900 00 000' })
+
+    expect(result.success).toBe(true)
+    expect(requireFeature).toHaveBeenCalledWith('org1', 'whatsapp')
+    expect(sendOrgWhatsapp.mock.calls[0][1]).toMatchObject({
+      to: '+47 900 00 000',
+      customerId: 'c1',
+      relatedEntityType: 'quote',
+      relatedEntityId: 'q1',
+    })
+    expect(sendOrgWhatsapp.mock.calls[0][1].body).toContain('/share/quote/org1/held')
+    expect(markQuoteSent).toHaveBeenCalledWith('q1', 'org1')
+  })
+
+  it('does not count the quote as sent when WhatsApp refuses it', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+    sendOrgWhatsapp.mockRejectedValue(new Error('Number not on WhatsApp'))
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'whatsapp', to: '+4791111111' })
+
+    expect(result).toMatchObject({
+      success: false,
+      error: 'Could not send WhatsApp message: Number not on WhatsApp',
+    })
+    expect(markQuoteSent).not.toHaveBeenCalled()
+  })
+})
+
+describe('WhatsApp outside the 24 hours', () => {
+  it('goes out through a template that can carry the link', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+    isWithinServiceWindow.mockResolvedValue(false)
+    getWhatsappConfig.mockResolvedValue({ template: { tokens: ['customer', 'message'] } })
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'whatsapp', to: '+47 900 00 000' })
+
+    expect(result.success).toBe(true)
+    expect(sendOrgWhatsapp).toHaveBeenCalled()
+  })
+
+  it('is refused when the template has no room for the link, and nothing counts as sent', async () => {
+    quote.findFirst.mockResolvedValue({ customer: OLA })
+    isWithinServiceWindow.mockResolvedValue(false)
+    getWhatsappConfig.mockResolvedValue({ template: { tokens: ['customer'] } })
+
+    const result = await sendQuoteLink({ id: 'q1', channel: 'whatsapp', to: '+47 900 00 000' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toContain('no message variable to carry the link')
+    expect(sendOrgWhatsapp).not.toHaveBeenCalled()
+    expect(markQuoteSent).not.toHaveBeenCalled()
+  })
+})
+
+describe('invoices and inspections', () => {
+  it('issues the invoice before its link goes out, to the vehicle’s customer', async () => {
+    serviceRecord.findFirst.mockResolvedValue({
+      vehicleId: 'v1',
+      customer: null,
+      vehicle: { customer: OLA },
+    })
+
+    const result = await sendInvoiceLink({ id: 'r1', channel: 'whatsapp', to: '+47 900 00 000' })
+
+    expect(result.success).toBe(true)
+    expect(issueInvoice).toHaveBeenCalledWith('r1', 'org1', 'sent')
+    // Issued first, so the link shows the copy that is kept.
+    expect(issueInvoice.mock.invocationCallOrder[0]).toBeLessThan(
+      ensureShareToken.mock.invocationCallOrder[0]
+    )
+    expect(sendOrgWhatsapp.mock.calls[0][1].body).toContain('/share/invoice/org1/held')
+    expect(sendOrgWhatsapp.mock.calls[0][1].customerId).toBe('c1')
+    expect(markInvoiceSent).toHaveBeenCalledWith('r1', 'org1', { alreadyIssued: true })
+  })
+
+  it('texts an inspection’s link', async () => {
+    inspection.findFirst.mockResolvedValue({ vehicle: { customer: OLA } })
+
+    const result = await sendInspectionLink({ id: 'i1', channel: 'sms', to: '+47 900 00 000' })
+
+    expect(result.success).toBe(true)
+    expect(deliverSms.mock.calls[0][1].body).toContain('/share/inspection/org1/held')
+  })
+})

+ 0 - 186
src/__tests__/features/messaging/share-dialog-sms-option.test.tsx

@@ -1,186 +0,0 @@
-/**
- * The share dialogs used to leave SMS out when the workshop could not send
- * one, so nobody learned that a quote or an invoice can go out by text. Now
- * the option is always there: ticked and sent when SMS works, and disabled
- * with the reason and the way to fix it when it does not.
- */
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { act, fireEvent, render, screen } from '@testing-library/react'
-import type { ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const getShareChannels = vi.fn()
-const sendSmsToCustomer = vi.fn()
-
-vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) }))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
-vi.mock('@/lib/analytics', () => ({ track: vi.fn() }))
-vi.mock('@/features/messaging/Actions/getShareChannels', () => ({
-  getShareChannels: async () => getShareChannels(),
-}))
-vi.mock('@/features/quotes/Actions/quoteShareActions', () => ({
-  revokeQuotePublicLink: vi.fn(),
-  generateQuotePublicLink: vi.fn(),
-}))
-vi.mock('@/features/vehicles/Actions/serviceActions', () => ({
-  revokePublicLink: vi.fn(),
-  generatePublicLink: vi.fn(),
-}))
-vi.mock('@/features/inspections/Actions/inspectionShareActions', () => ({
-  revokeInspectionPublicLink: vi.fn(),
-  generateInspectionPublicLink: vi.fn(),
-}))
-vi.mock('@/features/email/Actions/emailActions', () => ({
-  sendQuoteEmail: vi.fn(),
-  sendInvoiceEmail: vi.fn(),
-  sendInspectionEmail: vi.fn(),
-}))
-vi.mock('@/features/sms/Actions/smsActions', () => ({
-  sendSmsToCustomer: async (input: unknown) => sendSmsToCustomer(input),
-  getSmsTemplates: async () => ({
-    success: true,
-    data: { templates: {}, companyName: 'Fjord Auto', currentUser: 'Kari' },
-  }),
-}))
-vi.mock('@/features/email/Actions/emailPreferenceActions', () => ({
-  getAttachPdfDefault: async () => ({ success: true, data: { attachPdf: true } }),
-}))
-
-import { InspectionShareDialog } from '@/features/inspections/Components/InspectionShareDialog'
-import { QuoteShareDialog } from '@/features/quotes/Components/QuoteShareDialog'
-import { ShareDialog } from '@/features/vehicles/Components/service-detail/ShareDialog'
-
-const customer = { id: 'c1', name: 'Ola Nordmann', email: null, phone: '+4790000000' }
-
-function channels(sms: 'ready' | 'not_connected' | 'not_in_plan', isCloud = false) {
-  getShareChannels.mockResolvedValue({ success: true, data: { sms, isCloud } })
-}
-
-async function show(dialog: ReactNode) {
-  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
-  await act(async () => {
-    render(<QueryClientProvider client={client}>{dialog}</QueryClientProvider>)
-  })
-}
-
-const invoice = (c = customer) => (
-  <ShareDialog
-    open
-    onOpenChange={vi.fn()}
-    recordId="r1"
-    organizationId="org1"
-    initialToken="tok"
-    customer={c}
-  />
-)
-
-beforeEach(() => {
-  vi.clearAllMocks()
-})
-
-describe('SMS in the invoice share dialog', () => {
-  it('sends the link by text when SMS is set up', async () => {
-    channels('ready')
-    sendSmsToCustomer.mockResolvedValue({ success: true })
-    await show(invoice())
-
-    const sms = await screen.findByRole('checkbox', { name: /SMS/ })
-    expect(sms).toBeEnabled()
-    fireEvent.click(sms)
-    await act(async () => {
-      fireEvent.click(screen.getByRole('button', { name: 'Send Notification' }))
-    })
-
-    expect(sendSmsToCustomer).toHaveBeenCalledWith(
-      expect.objectContaining({
-        customerId: 'c1',
-        body: expect.stringContaining('/share/invoice/org1/tok'),
-      })
-    )
-  })
-
-  it('shows SMS disabled with the way to connect a provider', async () => {
-    channels('not_connected')
-    await show(invoice())
-
-    expect(
-      await screen.findByText('Connect SMS under Integrations to send this way.')
-    ).toBeInTheDocument()
-    expect(screen.getByRole('checkbox', { name: /SMS/ })).toBeDisabled()
-    expect(screen.getByRole('link', { name: 'Open Integrations' })).toHaveAttribute(
-      'href',
-      '/settings/integrations'
-    )
-    expect(screen.getByRole('button', { name: 'Send Notification' })).toBeDisabled()
-  })
-
-  it('points a cloud workshop without SMS in its plan to the plans', async () => {
-    channels('not_in_plan', true)
-    await show(invoice())
-
-    expect(await screen.findByText('SMS is not included in your plan.')).toBeInTheDocument()
-    expect(screen.getByRole('checkbox', { name: /SMS/ })).toBeDisabled()
-    expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute(
-      'href',
-      '/settings/subscription'
-    )
-  })
-
-  it('points a self-hosted workshop without SMS to the licence page', async () => {
-    channels('not_in_plan', false)
-    await show(invoice())
-
-    expect(await screen.findByRole('link', { name: 'Activate License' })).toHaveAttribute(
-      'href',
-      '/settings/license'
-    )
-  })
-
-  it('says the customer has no phone when that is what is missing', async () => {
-    channels('ready')
-    await show(invoice({ ...customer, phone: null as unknown as string }))
-
-    expect(await screen.findByText('(no phone on file)')).toBeInTheDocument()
-    expect(screen.getByRole('checkbox', { name: /SMS/ })).toBeDisabled()
-  })
-})
-
-describe('SMS in the quote and inspection share dialogs', () => {
-  it('shows the quote dialog the same hint', async () => {
-    channels('not_connected')
-    await show(
-      <QuoteShareDialog
-        open
-        onOpenChange={vi.fn()}
-        quoteId="q1"
-        organizationId="org1"
-        initialToken="tok"
-        customer={customer}
-      />
-    )
-
-    expect(
-      await screen.findByText('Connect SMS under Integrations to send this way.')
-    ).toBeInTheDocument()
-    expect(screen.getByRole('checkbox', { name: /SMS/ })).toBeDisabled()
-  })
-
-  it('shows the inspection dialog the same hint', async () => {
-    channels('not_connected')
-    await show(
-      <InspectionShareDialog
-        open
-        onOpenChange={vi.fn()}
-        inspectionId="i1"
-        organizationId="org1"
-        publicToken="tok"
-        customer={customer}
-      />
-    )
-
-    expect(
-      await screen.findByText('Connect SMS under Integrations to send this way.')
-    ).toBeInTheDocument()
-    expect(screen.getByRole('checkbox', { name: /SMS/ })).toBeDisabled()
-  })
-})

+ 5 - 1
src/__tests__/features/share-link-revoke.test.tsx

@@ -45,7 +45,11 @@ vi.mock('@/features/sms/Actions/smsActions', () => ({
   getSmsTemplates: vi.fn(),
   getSmsTemplates: vi.fn(),
 }))
 }))
 vi.mock('@/features/messaging/Lib/useShareChannels', () => ({
 vi.mock('@/features/messaging/Lib/useShareChannels', () => ({
-  useShareChannels: () => ({ sms: 'ready', isCloud: false }),
+  useShareChannels: () => ({ sms: 'ready', whatsapp: 'not_connected', isCloud: false }),
+  useWhatsappReach: () => undefined,
+}))
+vi.mock('@/features/whatsapp/Actions/whatsappActions', () => ({
+  sendWhatsappToCustomer: vi.fn(),
 }))
 }))
 vi.mock('@/features/email/Actions/emailPreferenceActions', () => ({
 vi.mock('@/features/email/Actions/emailPreferenceActions', () => ({
   getAttachPdfDefault: async () => ({ success: true, data: { attachPdf: true } }),
   getAttachPdfDefault: async () => ({ success: true, data: { attachPdf: true } }),

+ 3 - 1
src/__tests__/features/vehicles/add-observations-to-work-order.test.tsx

@@ -60,7 +60,9 @@ vi.mock('@/features/vehicles/Components/service-page/InvoiceDesignMenu', () => (
 vi.mock('@/features/vehicles/Components/service-page/PayCodeButton', () => ({
 vi.mock('@/features/vehicles/Components/service-page/PayCodeButton', () => ({
   PayCodeButton: () => null,
   PayCodeButton: () => null,
 }))
 }))
-vi.mock('@/features/email/Components/SendEmailDialog', () => ({ SendEmailDialog: () => null }))
+vi.mock('@/features/messaging/Components/SendDocumentDialog', () => ({
+  SendDocumentDialog: () => null,
+}))
 vi.mock('@/features/vehicles/Components/service-detail/ImageCarousel', () => ({
 vi.mock('@/features/vehicles/Components/service-detail/ImageCarousel', () => ({
   ImageCarousel: () => null,
   ImageCarousel: () => null,
 }))
 }))

+ 61 - 0
src/__tests__/lib/share-token.test.ts

@@ -0,0 +1,61 @@
+/**
+ * A document's share link is minted once. Two sends racing for the first link
+ * must end up handing out the same one, and a link the customer already holds
+ * is never replaced.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('server-only', () => ({}))
+
+function table() {
+  return { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findFirst: vi.fn() }
+}
+const quote = table()
+const serviceRecord = table()
+const inspection = table()
+vi.mock('@/lib/db', () => ({ db: { quote, serviceRecord, inspection } }))
+
+const { ensureShareToken } = await import('@/lib/share-token.server')
+
+beforeEach(() => {
+  vi.clearAllMocks()
+})
+
+describe('a document’s share link', () => {
+  it('is only written while the document has none, inside its own workshop', async () => {
+    quote.findFirst.mockResolvedValue({ publicToken: 'minted' })
+
+    await ensureShareToken('quote', 'q1', 'org1')
+
+    expect(quote.updateMany.mock.calls[0][0].where).toEqual({
+      id: 'q1',
+      organizationId: 'org1',
+      publicToken: null,
+    })
+  })
+
+  it('is the one that was stored, not the one this send tried to write', async () => {
+    // Another send got there first: the write matched nothing.
+    quote.updateMany.mockResolvedValue({ count: 0 })
+    quote.findFirst.mockResolvedValue({ publicToken: 'held-by-customer' })
+
+    expect(await ensureShareToken('quote', 'q1', 'org1')).toBe('held-by-customer')
+  })
+
+  it('is null for a document this workshop does not have', async () => {
+    serviceRecord.updateMany.mockResolvedValue({ count: 0 })
+    serviceRecord.findFirst.mockResolvedValue(null)
+
+    expect(await ensureShareToken('invoice', 'theirs', 'org1')).toBeNull()
+  })
+
+  it.each([
+    ['invoice', serviceRecord],
+    ['inspection', inspection],
+  ] as const)('is minted for an %s the same way', async (kind, model) => {
+    model.findFirst.mockResolvedValue({ publicToken: 'tok' })
+
+    expect(await ensureShareToken(kind, 'd1', 'org1')).toBe('tok')
+    expect(model.updateMany.mock.calls[0][0].where.publicToken).toBeNull()
+  })
+})

+ 0 - 3
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -13,7 +13,6 @@ import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPrese
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getQuoteLockState } from '@/lib/document-lock.server'
 import { getQuoteLockState } from '@/lib/document-lock.server'
-import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { readLineTaxSettings, TAX_APPLIES_SETTING_KEYS } from '@/features/settings/Lib/workshopTax'
 import { readLineTaxSettings, TAX_APPLIES_SETTING_KEYS } from '@/features/settings/Lib/workshopTax'
@@ -41,7 +40,6 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
 
 
   const orgId = authContext?.organizationId
   const orgId = authContext?.organizationId
   const features = orgId ? await getFeatures(orgId) : null
   const features = orgId ? await getFeatures(orgId) : null
-  const emailEnabled = orgId ? await canEmailCustomers(orgId) : false
   const lockState = orgId
   const lockState = orgId
     ? await getQuoteLockState(id, orgId)
     ? await getQuoteLockState(id, orgId)
     : { locked: false, reason: null, unlockedAt: null }
     : { locked: false, reason: null, unlockedAt: null }
@@ -96,7 +94,6 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         distanceUnit={settings[SETTING_KEYS.UNIT_SYSTEM] === 'metric' ? 'km' : 'mi'}
         distanceUnit={settings[SETTING_KEYS.UNIT_SYSTEM] === 'metric' ? 'km' : 'mi'}
         laborPresets={laborPresets}
         laborPresets={laborPresets}
         inventoryParts={inventoryParts}
         inventoryParts={inventoryParts}
-        emailEnabled={emailEnabled}
       />
       />
     </div>
     </div>
   )
   )

+ 17 - 32
src/features/email/Actions/emailActions.ts

@@ -17,7 +17,7 @@ import {
 } from '@/features/inspections/Pdf/customerCertificatePdf'
 } from '@/features/inspections/Pdf/customerCertificatePdf'
 import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { getAppBaseUrl } from '@/lib/app-url'
 import { getAppBaseUrl } from '@/lib/app-url'
-import { randomUUID } from 'crypto'
+import { ensureShareToken } from '@/lib/share-token.server'
 import { resolveAttachPdf } from '@/features/email/Lib/documentEmail'
 import { resolveAttachPdf } from '@/features/email/Lib/documentEmail'
 import type { VehicleContext } from '@/features/email/Lib/emailContext'
 import type { VehicleContext } from '@/features/email/Lib/emailContext'
 import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
 import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
@@ -92,7 +92,7 @@ export async function sendQuoteEmail(input: {
   quoteId: string
   quoteId: string
   recipientEmail: string
   recipientEmail: string
   message?: string
   message?: string
-  /** Send the PDF, or the share link instead. Unset follows the setting. */
+  /** Attach the PDF as well as the link. Unset follows the setting. */
   attachPdf?: boolean
   attachPdf?: boolean
 }) {
 }) {
   return withAuth(
   return withAuth(
@@ -147,15 +147,10 @@ export async function sendQuoteEmail(input: {
         : null
         : null
       const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`
       const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`
 
 
-      // The quote mail carried no link at all before, which left a link-only
-      // send with nothing in it. One is minted for a quote never shared.
-      const token = quote.publicToken ?? (attachPdf ? null : randomUUID())
-      if (token && token !== quote.publicToken) {
-        await db.quote.update({
-          where: { id: quoteId },
-          data: { publicToken: token, sharedAt: new Date() },
-        })
-      }
+      // The link always goes with the mail, with or without the PDF: it is
+      // where the customer accepts the quote and where the workshop sees it
+      // was opened. One is minted for a quote never shared.
+      const token = await ensureShareToken('quote', quoteId, organizationId)
       const publicLink = token ? `${getAppBaseUrl()}/share/quote/${organizationId}/${token}` : null
       const publicLink = token ? `${getAppBaseUrl()}/share/quote/${organizationId}/${token}` : null
 
 
       await withPlatformMailAllowance(organizationId, sendingMode, async () =>
       await withPlatformMailAllowance(organizationId, sendingMode, async () =>
@@ -273,7 +268,7 @@ export async function sendInvoiceEmail(input: {
   serviceRecordId: string
   serviceRecordId: string
   recipientEmail: string
   recipientEmail: string
   message?: string
   message?: string
-  /** Send the PDF, or the share link instead. Unset follows the setting. */
+  /** Attach the PDF as well as the link. Unset follows the setting. */
   attachPdf?: boolean
   attachPdf?: boolean
 }) {
 }) {
   return withAuth(
   return withAuth(
@@ -326,16 +321,12 @@ export async function sendInvoiceEmail(input: {
         : null
         : null
       const invoiceNum = invoiceNumberOf(record)
       const invoiceNum = invoiceNumberOf(record)
 
 
-      // Without the PDF the link is the whole mail, so one is minted here for
-      // a document that has never been shared. sharedAt goes with it, the way
-      // the share dialog sets it; sentAt is markInvoiceSent's job below.
-      const token = owned.publicToken ?? (attachPdf ? null : randomUUID())
-      if (token && token !== owned.publicToken) {
-        await db.serviceRecord.update({
-          where: { id: serviceRecordId },
-          data: { publicToken: token, sharedAt: new Date() },
-        })
-      }
+      // The link always goes with the mail, with or without the PDF: it is
+      // where the customer pays and where the workshop sees it was opened.
+      // One is minted here for a document that has never been shared.
+      // sharedAt goes with it, the way the share dialog sets it; sentAt is
+      // markInvoiceSent's job below.
+      const token = await ensureShareToken('invoice', serviceRecordId, organizationId)
       const publicLink = token
       const publicLink = token
         ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${token}`
         ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${token}`
         : null
         : null
@@ -403,7 +394,7 @@ export async function sendInspectionEmail(input: {
   inspectionId: string
   inspectionId: string
   recipientEmail: string
   recipientEmail: string
   message?: string
   message?: string
-  /** Send the PDF, or the share link instead. Unset follows the setting. */
+  /** Attach the PDF as well as the link. Unset follows the setting. */
   attachPdf?: boolean
   attachPdf?: boolean
 }) {
 }) {
   return withAuth(
   return withAuth(
@@ -462,15 +453,9 @@ export async function sendInspectionEmail(input: {
         pdfBuffer = Buffer.from(certificate.body)
         pdfBuffer = Buffer.from(certificate.body)
       }
       }
 
 
-      // Without the PDF the link is the whole mail, so one is minted for an
-      // inspection that has never been shared.
-      const token = inspection.publicToken ?? (attachPdf ? null : randomUUID())
-      if (token && token !== inspection.publicToken) {
-        await db.inspection.update({
-          where: { id: inspectionId },
-          data: { publicToken: token },
-        })
-      }
+      // The link always goes with the mail, with or without the PDF. One is
+      // minted for an inspection that has never been shared.
+      const token = await ensureShareToken('inspection', inspectionId, organizationId)
       const publicLink = token
       const publicLink = token
         ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${token}`
         ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${token}`
         : null
         : null

+ 0 - 45
src/features/email/Components/AttachPdfOption.tsx

@@ -1,9 +1,6 @@
 'use client'
 'use client'
 
 
 import { useCallback, useEffect, useRef, useState } from 'react'
 import { useCallback, useEffect, useRef, useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
 import { getAttachPdfDefault } from '@/features/email/Actions/emailPreferenceActions'
 import { getAttachPdfDefault } from '@/features/email/Actions/emailPreferenceActions'
 
 
 /**
 /**
@@ -43,45 +40,3 @@ export function useAttachPdf(open: boolean) {
 
 
   return [attachPdf, setAttachPdf] as const
   return [attachPdf, setAttachPdf] as const
 }
 }
-
-/**
- * Sits under the email option in every send dialog. Off means the customer
- * gets the link instead, which is the only send that can be seen to have been
- * opened.
- *
- * Stays in place and goes dim when email is not the channel, rather than
- * appearing on the tick: a control that pops into existence moves everything
- * under it, including the row the cursor is already travelling towards. It
- * reads clearly enough disabled because it sits indented under the checkbox
- * that governs it.
- */
-export function AttachPdfOption({
-  id,
-  checked,
-  onCheckedChange,
-  disabled = false,
-}: {
-  id: string
-  checked: boolean
-  onCheckedChange: (checked: boolean) => void
-  disabled?: boolean
-}) {
-  const t = useTranslations('common')
-
-  return (
-    <div className={`space-y-1 ${disabled ? 'opacity-50' : ''}`}>
-      <div className="flex items-center gap-2">
-        <Checkbox
-          id={id}
-          checked={checked}
-          onCheckedChange={(v) => onCheckedChange(v === true)}
-          disabled={disabled}
-        />
-        <Label htmlFor={id} className="text-sm">
-          {t('attachPdf')}
-        </Label>
-      </div>
-      <p className="text-xs text-muted-foreground">{t('attachPdfHint')}</p>
-    </div>
-  )
-}

+ 0 - 110
src/features/email/Components/SendEmailDialog.tsx

@@ -1,110 +0,0 @@
-'use client'
-
-import { handleGated } from '@/components/upgrade-gate'
-import type { GatedFeature } from '@/lib/with-auth'
-import { useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Loader2, Mail, Send } from 'lucide-react'
-import { useGlassModal } from '@/components/glass-modal'
-import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
-
-interface SendEmailDialogProps {
-  open: boolean
-  onOpenChange: (open: boolean) => void
-  defaultEmail?: string
-  entityLabel: string
-  onSend: (
-    email: string,
-    message?: string,
-    attachPdf?: boolean
-  ) => Promise<{ success: boolean; error?: string; gated?: GatedFeature }>
-}
-
-export function SendEmailDialog({
-  open,
-  onOpenChange,
-  defaultEmail = '',
-  entityLabel,
-  onSend,
-}: SendEmailDialogProps) {
-  const modal = useGlassModal()
-  const [sending, setSending] = useState(false)
-  const [email, setEmail] = useState(defaultEmail)
-  const [message, setMessage] = useState('')
-  const [attachPdf, setAttachPdf] = useAttachPdf(open)
-
-  const handleSend = async (e: React.FormEvent) => {
-    e.preventDefault()
-    if (!email) return
-    setSending(true)
-    const result = await onSend(email, message || undefined, attachPdf)
-    if (result.success) {
-      modal.open('success', 'Email Sent', `${entityLabel} has been sent to ${email}`)
-      onOpenChange(false)
-      setMessage('')
-    } else if (handleGated(result)) {
-      onOpenChange(false)
-    } else {
-      modal.open('error', 'Error', result.error || 'Failed to send email')
-    }
-    setSending(false)
-  }
-
-  return (
-    <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-md" aria-describedby={undefined}>
-        <DialogHeader>
-          <DialogTitle className="flex items-center gap-2">
-            <Mail className="h-5 w-5" />
-            Send {entityLabel}
-          </DialogTitle>
-        </DialogHeader>
-        <form onSubmit={handleSend} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="email">Recipient Email</Label>
-            <Input
-              id="email"
-              type="email"
-              placeholder="customer@example.com"
-              value={email}
-              onChange={(e) => setEmail(e.target.value)}
-              required
-            />
-          </div>
-          <div className="space-y-2">
-            <Label htmlFor="message">Message (optional)</Label>
-            <Textarea
-              id="message"
-              placeholder="Add a personal message..."
-              value={message}
-              onChange={(e) => setMessage(e.target.value)}
-              rows={3}
-            />
-          </div>
-          <AttachPdfOption
-            id="attach-pdf-send"
-            checked={attachPdf}
-            onCheckedChange={setAttachPdf}
-          />
-          <div className="flex justify-end gap-2">
-            <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
-              Cancel
-            </Button>
-            <Button type="submit" disabled={sending || !email}>
-              {sending ? (
-                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-              ) : (
-                <Send className="mr-2 h-4 w-4" />
-              )}
-              Send Email
-            </Button>
-          </div>
-        </form>
-      </DialogContent>
-    </Dialog>
-  )
-}

+ 33 - 2
src/features/inspections/Components/InspectionPageClient.tsx

@@ -36,6 +36,7 @@ import {
   Save,
   Save,
   Settings2,
   Settings2,
   Wrench,
   Wrench,
+  Send,
   Share2,
   Share2,
   Trash2,
   Trash2,
   Eye,
   Eye,
@@ -49,6 +50,8 @@ import {
   createQuoteFromInspection,
   createQuoteFromInspection,
 } from '../Actions/inspectionActions'
 } from '../Actions/inspectionActions'
 import { InspectionShareDialog } from './InspectionShareDialog'
 import { InspectionShareDialog } from './InspectionShareDialog'
+import { SendDocumentDialog } from '@/features/messaging/Components/SendDocumentDialog'
+import { sendInspectionEmail } from '@/features/email/Actions/emailActions'
 import { InspectionNotesCard } from './InspectionNotesCard'
 import { InspectionNotesCard } from './InspectionNotesCard'
 import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
 import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
 import { WorkOrderFromInspectionDialog } from './WorkOrderFromInspectionDialog'
 import { WorkOrderFromInspectionDialog } from './WorkOrderFromInspectionDialog'
@@ -270,6 +273,7 @@ export function InspectionPageClient({
   const [showReopenDialog, setShowReopenDialog] = useState(false)
   const [showReopenDialog, setShowReopenDialog] = useState(false)
   const [showDeleteDialog, setShowDeleteDialog] = useState(false)
   const [showDeleteDialog, setShowDeleteDialog] = useState(false)
   const [showShareDialog, setShowShareDialog] = useState(false)
   const [showShareDialog, setShowShareDialog] = useState(false)
+  const [showSendDialog, setShowSendDialog] = useState(false)
   const [isCreatingQuote, setIsCreatingQuote] = useState(false)
   const [isCreatingQuote, setIsCreatingQuote] = useState(false)
   const [isCreatingWorkOrder, setIsCreatingWorkOrder] = useState(false)
   const [isCreatingWorkOrder, setIsCreatingWorkOrder] = useState(false)
   const [showWorkOrderDialog, setShowWorkOrderDialog] = useState(false)
   const [showWorkOrderDialog, setShowWorkOrderDialog] = useState(false)
@@ -441,7 +445,8 @@ export function InspectionPageClient({
       if (done?.success) {
       if (done?.success) {
         toast.success(t('completed'))
         toast.success(t('completed'))
         setShowCompleteDialog(false)
         setShowCompleteDialog(false)
-        setShowShareDialog(true)
+        // A finished inspection is usually what the customer is waiting for.
+        setShowSendDialog(true)
         router.refresh()
         router.refresh()
       } else {
       } else {
         toast.error(done?.error || t('completeFailed'))
         toast.error(done?.error || t('completeFailed'))
@@ -557,6 +562,12 @@ export function InspectionPageClient({
               onClick={() => setShowPreview(true)}
               onClick={() => setShowPreview(true)}
               data-testid="inspection-preview"
               data-testid="inspection-preview"
             />
             />
+            <BarButton
+              variant="outline"
+              icon={Send}
+              label={t('send')}
+              onClick={() => setShowSendDialog(true)}
+            />
             <BarButton
             <BarButton
               variant="outline"
               variant="outline"
               icon={Share2}
               icon={Share2}
@@ -1165,8 +1176,28 @@ export function InspectionPageClient({
         inspectionId={inspection.id}
         inspectionId={inspection.id}
         organizationId={inspection.organizationId}
         organizationId={inspection.organizationId}
         publicToken={inspection.publicToken}
         publicToken={inspection.publicToken}
+      />
+
+      <SendDocumentDialog
+        open={showSendDialog}
+        onOpenChange={setShowSendDialog}
+        kind="inspection"
+        documentId={inspection.id}
+        shareUrl={
+          inspection.publicToken
+            ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/inspection/${inspection.organizationId}/${inspection.publicToken}`
+            : null
+        }
         customer={inspection.vehicle.customer}
         customer={inspection.vehicle.customer}
-        emailEnabled={emailEnabled}
+        sendEmail={(email, message, attachPdf) =>
+          sendInspectionEmail({
+            inspectionId: inspection.id,
+            recipientEmail: email,
+            message,
+            attachPdf,
+          })
+        }
+        onSent={() => router.refresh()}
       />
       />
 
 
       <MediaLightbox
       <MediaLightbox

+ 1 - 147
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -1,7 +1,5 @@
 'use client'
 'use client'
 
 
-import { track } from '@/lib/analytics'
-import { handleGated } from '@/components/upgrade-gate'
 import { useState, useTransition } from 'react'
 import { useState, useTransition } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import {
 import {
@@ -13,22 +11,13 @@ import {
 } from '@/components/ui/dialog'
 } from '@/components/ui/dialog'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import { Check, Copy, Link2, Loader2, Mail, MessageSquare, Unlink } from 'lucide-react'
+import { Check, Copy, Link2, Loader2, Unlink } from 'lucide-react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import {
 import {
   generateInspectionPublicLink,
   generateInspectionPublicLink,
   revokeInspectionPublicLink,
   revokeInspectionPublicLink,
 } from '../Actions/inspectionShareActions'
 } from '../Actions/inspectionShareActions'
-import { sendSmsToCustomer, getSmsTemplates } from '@/features/sms/Actions/smsActions'
-import { sendInspectionEmail } from '@/features/email/Actions/emailActions'
-import { NotifyChannelOption } from '@/features/messaging/Components/NotifyChannelOption'
-import { useShareChannels } from '@/features/messaging/Lib/useShareChannels'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
-import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 
 interface InspectionShareDialogProps {
 interface InspectionShareDialogProps {
   open: boolean
   open: boolean
@@ -36,13 +25,6 @@ interface InspectionShareDialogProps {
   inspectionId: string
   inspectionId: string
   organizationId: string
   organizationId: string
   publicToken: string | null
   publicToken: string | null
-  customer?: {
-    id: string
-    name: string
-    email: string | null
-    phone: string | null
-  } | null
-  emailEnabled?: boolean
 }
 }
 
 
 export function InspectionShareDialog({
 export function InspectionShareDialog({
@@ -51,8 +33,6 @@ export function InspectionShareDialog({
   inspectionId,
   inspectionId,
   organizationId,
   organizationId,
   publicToken,
   publicToken,
-  customer,
-  emailEnabled = false,
 }: InspectionShareDialogProps) {
 }: InspectionShareDialogProps) {
   const t = useTranslations('inspections.shareDialog')
   const t = useTranslations('inspections.shareDialog')
   const tc = useTranslations('common')
   const tc = useTranslations('common')
@@ -60,15 +40,6 @@ export function InspectionShareDialog({
   const [isPending, startTransition] = useTransition()
   const [isPending, startTransition] = useTransition()
   const [copied, setCopied] = useState(false)
   const [copied, setCopied] = useState(false)
   const [token, setToken] = useState(publicToken)
   const [token, setToken] = useState(publicToken)
-  const [notifySms, setNotifySms] = useState(false)
-  const [notifyEmail, setNotifyEmail] = useState(false)
-  const [attachPdf, setAttachPdf] = useAttachPdf(open)
-  const [sending, setSending] = useState(false)
-
-  const hasPhone = !!customer?.phone
-  const channels = useShareChannels(open)
-  const smsReady = channels?.sms === 'ready' && hasPhone
-  const hasEmail = !!customer?.email
 
 
   const shareUrl = token
   const shareUrl = token
     ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/inspection/${organizationId}/${token}`
     ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/inspection/${organizationId}/${token}`
@@ -111,65 +82,6 @@ export function InspectionShareDialog({
     setTimeout(() => setCopied(false), 2000)
     setTimeout(() => setCopied(false), 2000)
   }
   }
 
 
-  const handleNotify = async () => {
-    if (!shareUrl || !customer) return
-    setSending(true)
-
-    const results: string[] = []
-    let failed = false
-
-    if (notifyEmail && hasEmail) {
-      const res = await sendInspectionEmail({
-        inspectionId,
-        recipientEmail: customer.email!,
-        attachPdf,
-      }).catch(() => null)
-      if (res?.success) {
-        track('document:email_send', { document: 'inspection' })
-        results.push(t('emailSent'))
-        setNotifyEmail(false)
-      } else {
-        failed = true
-        if (!res || !handleGated(res)) toast.error(res?.error || t('failedEmail'))
-      }
-    }
-
-    if (smsReady && notifySms) {
-      const tplResult = await getSmsTemplates().catch(() => null)
-      const tplData = tplResult?.success && tplResult.data ? tplResult.data : null
-      const tpl =
-        tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY] ||
-        SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]
-      const body = interpolateSmsTemplate(tpl || '', {
-        share_link: shareUrl,
-        customer_name: customer.name,
-        company_name: tplData?.companyName || '',
-        current_user: tplData?.currentUser || '',
-      })
-      const res = await sendSmsToCustomer({
-        customerId: customer.id,
-        body,
-        relatedEntityType: 'inspection',
-        relatedEntityId: inspectionId,
-      }).catch(() => null)
-      if (res?.success) {
-        results.push(t('smsSent'))
-        setNotifySms(false)
-      } else {
-        failed = true
-        if (!res || !handleGated(res)) toast.error(res?.error || t('failedSms'))
-      }
-    }
-
-    if (results.length > 0) toast.success(results.join(' & '))
-    setSending(false)
-    // Done once everything ticked has gone. A channel that failed stays ticked
-    // and the dialog stays open, so it can be tried again.
-    if (results.length > 0 && !failed) onOpenChange(false)
-  }
-
-  const canNotify = shareUrl && customer && (notifyEmail || (smsReady && notifySms))
-
   return (
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="sm:max-w-md">
       <DialogContent className="sm:max-w-md">
@@ -197,64 +109,6 @@ export function InspectionShareDialog({
                 </Button>
                 </Button>
               </div>
               </div>
 
 
-              {/* Notify customer */}
-              {customer && (
-                <div className="space-y-3 rounded-lg border p-3">
-                  <p className="text-sm font-medium">{t('notifyTitle', { name: customer.name })}</p>
-                  <div className="space-y-2">
-                    {emailEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-email-inspection"
-                          checked={notifyEmail}
-                          onCheckedChange={(v) => setNotifyEmail(v === true)}
-                          disabled={!hasEmail}
-                        />
-                        <Label
-                          htmlFor="notify-email-inspection"
-                          className={`flex items-center gap-1.5 text-sm ${!hasEmail ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <Mail className="h-3.5 w-3.5" />
-                          {t('email')}
-                          {!hasEmail && <span className="text-xs">{t('noEmail')}</span>}
-                        </Label>
-                      </div>
-                    )}
-                    {emailEnabled && (
-                      <div className="pl-6">
-                        <AttachPdfOption
-                          id="attach-pdf-inspection"
-                          checked={attachPdf}
-                          onCheckedChange={setAttachPdf}
-                          disabled={!notifyEmail || !hasEmail}
-                        />
-                      </div>
-                    )}
-                    <NotifyChannelOption
-                      id="notify-sms-inspection"
-                      icon={<MessageSquare className="h-3.5 w-3.5" />}
-                      label={t('sms')}
-                      checked={notifySms}
-                      onCheckedChange={setNotifySms}
-                      status={channels?.sms}
-                      isCloud={channels?.isCloud ?? false}
-                      missingContact={hasPhone ? null : t('noPhone')}
-                    />
-                  </div>
-                  {/* Always here, disabled until a channel is ticked: it used to
-                      appear on the first tick and shove the dialog around. */}
-                  <Button
-                    size="sm"
-                    onClick={handleNotify}
-                    disabled={sending || !canNotify}
-                    className="w-full"
-                  >
-                    {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                    {t('sendNotification')}
-                  </Button>
-                </div>
-              )}
-
               <Button
               <Button
                 variant="outline"
                 variant="outline"
                 className="w-full text-destructive"
                 className="w-full text-destructive"

+ 37 - 17
src/features/messaging/Actions/getShareChannels.ts

@@ -1,9 +1,11 @@
 'use server'
 'use server'
 
 
+import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
 import { isCloudInstance } from '@/lib/cloud-instance'
 import { isCloudInstance } from '@/lib/cloud-instance'
+import { db } from '@/lib/db'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getOrgSmsProvider } from '@/lib/sms'
 import { getOrgSmsProvider } from '@/lib/sms'
+import { isWhatsappConfigured } from '@/lib/whatsapp'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 
 
 /**
 /**
@@ -11,30 +13,48 @@ import { withAuth } from '@/lib/with-auth'
  * first: a workshop whose plan has no SMS gains nothing from being told to
  * first: a workshop whose plan has no SMS gains nothing from being told to
  * connect a provider.
  * connect a provider.
  */
  */
-export type ShareChannelStatus = 'ready' | 'not_connected' | 'not_in_plan'
+export type ShareChannelStatus = 'ready' | 'not_connected' | 'not_in_plan' | 'turned_off'
 
 
 /**
 /**
  * What the share dialogs need to draw their channels: every channel is shown,
  * What the share dialogs need to draw their channels: every channel is shown,
  * and one that cannot send says why instead of being left out, so a workshop
  * and one that cannot send says why instead of being left out, so a workshop
  * learns the option exists and where to turn it on.
  * learns the option exists and where to turn it on.
+ *
+ * Open to every member: it says only what the workshop has set up, nothing
+ * about any customer, and someone allowed to send a quote must be able to
+ * see which ways it can go.
  */
  */
 export async function getShareChannels() {
 export async function getShareChannels() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const features = await getFeatures(organizationId)
+  return withAuth(async ({ organizationId }) => {
+    const features = await getFeatures(organizationId)
 
 
-      let sms: ShareChannelStatus = 'not_in_plan'
-      if (features.sms) {
-        const provider = await getOrgSmsProvider(organizationId).catch(() => null)
-        sms = provider ? 'ready' : 'not_connected'
-      }
+    let sms: ShareChannelStatus = 'not_in_plan'
+    if (features.sms) {
+      const provider = await getOrgSmsProvider(organizationId).catch(() => null)
+      sms = provider ? 'ready' : 'not_connected'
+    }
 
 
-      return { sms, isCloud: isCloudInstance() }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
-      ],
+    let whatsapp: ShareChannelStatus = 'not_in_plan'
+    if (features.whatsapp) {
+      const configured = await isWhatsappConfigured(organizationId).catch(() => false)
+      whatsapp = configured ? 'ready' : 'not_connected'
     }
     }
-  )
+
+    // Every plan may mail its customers. The one refusal is a workshop that
+    // connected its own mail provider on a plan that no longer includes it.
+    // The workshop's own switch is asked first: with email turned off the
+    // send is refused whatever the plan says.
+    const emailSwitch = await db.appSetting.findUnique({
+      where: { organizationId_key: { organizationId, key: 'workshop.emailEnabled' } },
+      select: { value: true },
+    })
+    const email: ShareChannelStatus =
+      emailSwitch?.value === 'false'
+        ? 'turned_off'
+        : (await canEmailCustomers(organizationId))
+          ? 'ready'
+          : 'not_in_plan'
+
+    return { email, sms, whatsapp, isCloud: isCloudInstance() }
+  })
 }
 }

+ 287 - 0
src/features/messaging/Actions/sendDocumentLink.ts

@@ -0,0 +1,287 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { deliverSms } from '@/features/sms/Lib/deliverSms.server'
+import { getAppBaseUrl } from '@/lib/app-url'
+import type { AuditEvent } from '@/lib/audit'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
+import { requireFeature } from '@/lib/features'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { ensureShareToken } from '@/lib/share-token.server'
+import { normalizeOrgPhone } from '@/lib/sms'
+import { interpolateSmsTemplate, SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
+import {
+  getWhatsappConfig,
+  isWithinServiceWindow,
+  sendOrgWhatsapp,
+  WhatsappWindowClosedError,
+} from '@/lib/whatsapp'
+import { withAuth } from '@/lib/with-auth'
+
+const inputSchema = z.object({
+  id: z.string().min(1),
+  channel: z.enum(['sms', 'whatsapp']),
+  /** As typed in the send dialog: the customer's number, or any other. */
+  to: z.string().trim().min(3).max(40),
+})
+
+export type SendDocumentLinkInput = z.input<typeof inputSchema>
+
+type DocumentKind = 'quote' | 'invoice' | 'inspection'
+
+interface LinkedDocument {
+  token: string
+  customer: { id: string; name: string; phone: string | null } | null
+  /** Where the document's own page lives, to refresh after the send. */
+  path: string
+}
+
+const TEMPLATE_KEY: Record<DocumentKind, string> = {
+  quote: SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
+  invoice: SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+  inspection: SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+}
+
+const customerSelect = { id: true, name: true, phone: true } as const
+
+/**
+ * The document with a share link on it, minting one for a document that was
+ * never shared, the same way emailing it does. An existing link is kept: a
+ * new one would kill the copy the customer already holds.
+ */
+async function documentWithLink(
+  kind: DocumentKind,
+  id: string,
+  organizationId: string
+): Promise<LinkedDocument> {
+  let customer: LinkedDocument['customer']
+  let path: string
+  let notFound: string
+
+  if (kind === 'quote') {
+    const quote = await db.quote.findFirst({
+      where: { id, organizationId },
+      select: { customer: { select: customerSelect } },
+    })
+    notFound = 'Quote not found'
+    if (!quote) throw new Error(notFound)
+    customer = quote.customer
+    path = `/quotes/${id}`
+  } else if (kind === 'invoice') {
+    const record = await db.serviceRecord.findFirst({
+      where: { id, organizationId },
+      select: {
+        vehicleId: true,
+        customer: { select: customerSelect },
+        // A record on a vehicle carries no customer of its own; the
+        // vehicle's is who the invoice goes to.
+        vehicle: { select: { customer: { select: customerSelect } } },
+      },
+    })
+    notFound = 'Service record not found'
+    if (!record) throw new Error(notFound)
+    // Issued before the link exists, so what it shows is the copy that is kept.
+    const { issueInvoice } = await import('@/features/invoices/Lib/issueInvoice')
+    await issueInvoice(id, organizationId, 'sent')
+    customer = record.customer ?? record.vehicle?.customer ?? null
+    path = record.vehicleId ? `/vehicles/${record.vehicleId}/service/${id}` : `/sales/${id}`
+  } else {
+    const inspection = await db.inspection.findFirst({
+      where: { id, organizationId },
+      select: { vehicle: { select: { customer: { select: customerSelect } } } },
+    })
+    notFound = 'Inspection not found'
+    if (!inspection) throw new Error(notFound)
+    customer = inspection.vehicle?.customer ?? null
+    path = `/inspections/${id}`
+  }
+
+  const token = await ensureShareToken(kind, id, organizationId)
+  if (!token) throw new Error(notFound)
+  return { token, customer, path }
+}
+
+/** The workshop's own wording for "your document is ready", with the link in it. */
+async function linkMessage(
+  kind: DocumentKind,
+  organizationId: string,
+  userId: string,
+  link: string,
+  customerName: string
+) {
+  const key = TEMPLATE_KEY[kind]
+  const [setting, org, user] = await Promise.all([
+    db.appSetting.findFirst({ where: { organizationId, key }, select: { value: true } }),
+    db.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
+    db.user.findUnique({ where: { id: userId }, select: { name: true } }),
+  ])
+  const body = interpolateSmsTemplate(setting?.value || SMS_TEMPLATE_DEFAULTS[key] || '', {
+    share_link: link,
+    customer_name: customerName,
+    company_name: org?.name || '',
+    current_user: user?.name || '',
+  })
+  // A workshop may have written its template without the link tag. The link
+  // is the document, so it goes out either way: a message that only says
+  // "your quote is ready" would count as sent with nothing in it.
+  return body.includes(link) ? body : `${body} ${link}`.trim()
+}
+
+/**
+ * Sends a document's link by text or WhatsApp to the number in the dialog.
+ *
+ * The message is built here, from the workshop's template and the document's
+ * own link, never taken from the browser: the number can be anyone's, so what
+ * it receives must not be free text.
+ */
+async function sendLink(
+  kind: DocumentKind,
+  raw: SendDocumentLinkInput,
+  organizationId: string,
+  userId: string
+) {
+  demoGuard()
+  const input = inputSchema.parse(raw)
+  await requireFeature(organizationId, input.channel)
+
+  const document = await documentWithLink(kind, input.id, organizationId)
+  const link = `${getAppBaseUrl()}/share/${kind}/${organizationId}/${document.token}`
+  const body = await linkMessage(kind, organizationId, userId, link, document.customer?.name ?? '')
+
+  const normalized = await normalizeOrgPhone(organizationId, input.to)
+  // The message joins the customer's conversation only when it went to the
+  // customer's own number. A typed one stands alone under that number.
+  const customerNumber = document.customer?.phone
+    ? await normalizeOrgPhone(organizationId, document.customer.phone)
+    : null
+  const customerId =
+    normalized && customerNumber === normalized ? (document.customer?.id ?? null) : null
+
+  let messageId: string
+  if (input.channel === 'sms') {
+    if (!normalized) {
+      throw new Error(
+        `"${input.to}" is not a phone number we can text. ` +
+          'Add the country code, or set a default country in Settings → Localization.'
+      )
+    }
+    const sent = await deliverSms(organizationId, {
+      to: normalized,
+      body,
+      customerId,
+      relatedEntityType: kind,
+      relatedEntityId: input.id,
+    })
+    messageId = sent.id
+  } else {
+    // Outside WhatsApp's 24 hours the message goes out as the workshop's
+    // approved template, and only a template with the message variable has
+    // room for the link. One without it would deliver its fixed wording and
+    // no document, so the send is refused before anything is counted as sent.
+    const [config, open] = await Promise.all([
+      getWhatsappConfig(organizationId),
+      isWithinServiceWindow(organizationId, normalized ?? input.to),
+    ])
+    if (config && !open && config.template && !config.template.tokens.includes('message')) {
+      throw new Error(
+        'This customer has not written to you on WhatsApp in the last 24 hours, and your approved template has no message variable to carry the link. Add the message variable to the template in Settings, or ask the customer to send you a message first.'
+      )
+    }
+    try {
+      const sent = await sendOrgWhatsapp(organizationId, {
+        to: input.to,
+        body,
+        customerId: customerId ?? undefined,
+        relatedEntityType: kind,
+        relatedEntityId: input.id,
+      })
+      messageId = sent.messageId
+    } catch (error) {
+      // The 24 hour rule is WhatsApp's, and keeps its own wording.
+      if (error instanceof WhatsappWindowClosedError) throw error
+      const message = error instanceof Error ? error.message : 'Unknown error'
+      throw new Error(`Could not send WhatsApp message: ${message}`)
+    }
+    // A text stamps the document as sent inside deliverSms; WhatsApp carries
+    // the same link, so it counts the same way.
+    if (kind === 'invoice') {
+      await markInvoiceSent(input.id, organizationId, { alreadyIssued: true })
+    } else if (kind === 'quote') {
+      await markQuoteSent(input.id, organizationId)
+    }
+  }
+
+  revalidatePath(document.path)
+  return {
+    messageId,
+    channel: input.channel,
+    token: document.token,
+    to: normalized ?? input.to,
+    name: document.customer?.name ?? input.to,
+  }
+}
+
+type SendResult = Awaited<ReturnType<typeof sendLink>>
+
+function auditSend({ result }: { result: SendResult }): AuditEvent {
+  return result.channel === 'sms'
+    ? {
+        action: 'sms.send',
+        entity: 'SmsMessage',
+        entityId: result.messageId,
+        details: { key: 'sms_send', params: { name: result.name, phone: result.to } },
+        metadata: { messageId: result.messageId },
+      }
+    : {
+        action: 'whatsapp.send',
+        entity: 'WhatsappMessage',
+        entityId: result.messageId,
+        details: { key: 'whatsapp_send', params: { name: result.name } },
+        metadata: { messageId: result.messageId },
+      }
+}
+
+const sendsToCustomers = { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }
+
+export async function sendQuoteLink(input: SendDocumentLinkInput) {
+  return withAuth(
+    ({ organizationId, userId }) => sendLink('quote', input, organizationId, userId),
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES },
+        sendsToCustomers,
+      ],
+      audit: auditSend,
+    }
+  )
+}
+
+export async function sendInvoiceLink(input: SendDocumentLinkInput) {
+  return withAuth(
+    ({ organizationId, userId }) => sendLink('invoice', input, organizationId, userId),
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+        sendsToCustomers,
+      ],
+      audit: auditSend,
+    }
+  )
+}
+
+export async function sendInspectionLink(input: SendDocumentLinkInput) {
+  return withAuth(
+    ({ organizationId, userId }) => sendLink('inspection', input, organizationId, userId),
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS },
+        sendsToCustomers,
+      ],
+      audit: auditSend,
+    }
+  )
+}

+ 0 - 88
src/features/messaging/Components/NotifyChannelOption.tsx

@@ -1,88 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-import Link from 'next/link'
-import { useTranslations } from 'next-intl'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import type { ShareChannelStatus } from '../Actions/getShareChannels'
-
-interface NotifyChannelOptionProps {
-  id: string
-  icon: ReactNode
-  /** The channel's name as the workshop knows it: "SMS". */
-  label: string
-  checked: boolean
-  onCheckedChange: (checked: boolean) => void
-  /** Undefined while the dialog is still asking. */
-  status: ShareChannelStatus | undefined
-  isCloud: boolean
-  /** Said after the label when the customer cannot be reached this way. */
-  missingContact?: string | null
-}
-
-/**
- * One channel in a share dialog's "Notify" box.
- *
- * Always drawn. A channel the workshop has not set up is disabled and says
- * what to do about it, with the way there: hidden, nobody found out that a
- * quote or an invoice could go out by text at all.
- */
-export function NotifyChannelOption({
-  id,
-  icon,
-  label,
-  checked,
-  onCheckedChange,
-  status,
-  isCloud,
-  missingContact,
-}: NotifyChannelOptionProps) {
-  const t = useTranslations('common.shareLink')
-  const ts = useTranslations('settings.featureLocked')
-
-  const usable = status === 'ready' && !missingContact
-  const hintId = `${id}-hint`
-  const hasHint = status === 'not_connected' || status === 'not_in_plan'
-
-  return (
-    <div className="space-y-1">
-      <div className="flex items-center gap-2">
-        <Checkbox
-          id={id}
-          checked={usable && checked}
-          onCheckedChange={(v) => onCheckedChange(v === true)}
-          disabled={!usable}
-          aria-describedby={hasHint ? hintId : undefined}
-        />
-        <Label
-          htmlFor={id}
-          className={`flex items-center gap-1.5 text-sm ${usable ? '' : 'text-muted-foreground/50'}`}
-        >
-          {icon}
-          {label}
-          {!hasHint && missingContact && <span className="text-xs">{missingContact}</span>}
-        </Label>
-      </div>
-      {status === 'not_connected' && (
-        <p id={hintId} className="pl-6 text-xs text-muted-foreground">
-          {t('channelNotConnected', { channel: label })}{' '}
-          <Link href="/settings/integrations" className="font-medium text-primary hover:underline">
-            {t('openIntegrations')}
-          </Link>
-        </p>
-      )}
-      {status === 'not_in_plan' && (
-        <p id={hintId} className="pl-6 text-xs text-muted-foreground">
-          {t('channelNotInPlan', { channel: label })}{' '}
-          <Link
-            href={isCloud ? '/settings/subscription' : '/settings/license'}
-            className="font-medium text-primary hover:underline"
-          >
-            {isCloud ? ts('upgradePlan') : ts('activateLicense')}
-          </Link>
-        </p>
-      )}
-    </div>
-  )
-}

+ 443 - 0
src/features/messaging/Components/SendDocumentDialog.tsx

@@ -0,0 +1,443 @@
+'use client'
+
+import { useEffect, useState, type ReactNode } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Check, Copy, Link2, Loader2, Mail, MessageCircle, MessageSquare, Send } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { handleGated } from '@/components/upgrade-gate'
+import { useAttachPdf } from '@/features/email/Components/AttachPdfOption'
+import { track } from '@/lib/analytics'
+import type { GatedFeature } from '@/lib/with-auth'
+import type { ShareChannelStatus } from '../Actions/getShareChannels'
+import { sendInspectionLink, sendInvoiceLink, sendQuoteLink } from '../Actions/sendDocumentLink'
+import { useShareChannels, useWhatsappReach } from '../Lib/useShareChannels'
+
+export type SendDocumentKind = 'quote' | 'invoice' | 'inspection'
+
+type SendResult = { success: boolean; error?: string; gated?: GatedFeature }
+
+interface SendDocumentDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  kind: SendDocumentKind
+  documentId: string
+  /** The document's link when it already has one, shown with a copy button. */
+  shareUrl?: string | null
+  customer?: {
+    id: string
+    name: string
+    email: string | null
+    phone: string | null
+  } | null
+  /** The page's own email action: each document builds its mail differently. */
+  sendEmail: (to: string, message: string | undefined, attachPdf: boolean) => Promise<SendResult>
+  /** Called once the document has actually gone out on at least one channel. */
+  onSent?: () => void
+}
+
+const SEND_LINK = {
+  quote: sendQuoteLink,
+  invoice: sendInvoiceLink,
+  inspection: sendInspectionLink,
+} as const
+
+const TITLE = {
+  quote: 'titleQuote',
+  invoice: 'titleInvoice',
+  inspection: 'titleInspection',
+} as const
+
+/**
+ * The one place a quote, an invoice or an inspection is sent from.
+ *
+ * Every channel carries the same thing, the document's link, so they sit in
+ * one dialog as rows to tick. Each row's address or number starts as the
+ * customer's and can be typed over, because a car often arrives before its
+ * owner's details do. A channel the workshop has not set up is still shown,
+ * disabled, with what to do about it.
+ */
+export function SendDocumentDialog({
+  open,
+  onOpenChange,
+  kind,
+  documentId,
+  shareUrl,
+  customer,
+  sendEmail,
+  onSent,
+}: SendDocumentDialogProps) {
+  const t = useTranslations('common.sendDocument')
+  const ts = useTranslations('common.shareLink')
+  const tb = useTranslations('common.buttons')
+  const channels = useShareChannels(open)
+
+  const [emailOn, setEmailOn] = useState(false)
+  const [email, setEmail] = useState('')
+  const [attachPdf, setAttachPdf] = useAttachPdf(open)
+  const [smsOn, setSmsOn] = useState(false)
+  const [smsTo, setSmsTo] = useState('')
+  const [whatsappOn, setWhatsappOn] = useState(false)
+  const [whatsappTo, setWhatsappTo] = useState('')
+  const [message, setMessage] = useState('')
+  const [sending, setSending] = useState(false)
+  const [copied, setCopied] = useState(false)
+
+  // Filled from the customer each time the dialog opens, so a number typed
+  // for one send is not still there for the next.
+  const customerEmail = customer?.email ?? ''
+  const customerPhone = customer?.phone ?? ''
+  useEffect(() => {
+    if (!open) return
+    setEmail(customerEmail)
+    setEmailOn(customerEmail !== '')
+    setSmsTo(customerPhone)
+    setSmsOn(false)
+    setWhatsappTo(customerPhone)
+    setWhatsappOn(false)
+    setMessage('')
+  }, [open, customerEmail, customerPhone])
+
+  const emailReady = channels?.email === 'ready'
+  const smsReady = channels?.sms === 'ready'
+  const whatsappSetUp = channels?.whatsapp === 'ready'
+  // WhatsApp's 24 hour rule can be checked ahead only for the customer's own
+  // number, where the conversation is on file. A typed number is tried, and
+  // WhatsApp's answer is shown if it refuses.
+  const toCustomerNumber = customerPhone !== '' && whatsappTo.trim() === customerPhone
+  const whatsappReach = useWhatsappReach(open && whatsappSetUp && toCustomerNumber, customer?.id)
+  const whatsappBlocked = toCustomerNumber && whatsappReach === false
+  const whatsappReady = whatsappSetUp && !whatsappBlocked
+
+  const wantsEmail = emailReady && emailOn && email.trim() !== ''
+  const wantsSms = smsReady && smsOn && smsTo.trim() !== ''
+  const wantsWhatsapp = whatsappReady && whatsappOn && whatsappTo.trim() !== ''
+  const canSend = wantsEmail || wantsSms || wantsWhatsapp
+
+  const handleCopy = () => {
+    if (!shareUrl) return
+    navigator.clipboard.writeText(shareUrl)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  const handleSend = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!canSend || sending) return
+    setSending(true)
+
+    const sent: string[] = []
+    let failed = false
+    // An action that cannot reach the server throws instead of answering, and
+    // is told like any other failure so the other channels still get their go.
+    const settle = (result: SendResult | null, done: string, fallback: string) => {
+      if (result?.success) {
+        sent.push(done)
+        return true
+      }
+      failed = true
+      if (!result || !handleGated(result)) toast.error(result?.error || fallback)
+      return false
+    }
+
+    if (wantsEmail) {
+      const result = await sendEmail(email.trim(), message.trim() || undefined, attachPdf).catch(
+        () => null
+      )
+      if (settle(result, t('emailSent'), t('failedEmail'))) {
+        track('document:email_send', { document: kind })
+        setEmailOn(false)
+      }
+    }
+    if (wantsSms) {
+      const result = await SEND_LINK[kind]({
+        id: documentId,
+        channel: 'sms',
+        to: smsTo.trim(),
+      }).catch(() => null)
+      if (settle(result, t('smsSent'), t('failedSms'))) setSmsOn(false)
+    }
+    if (wantsWhatsapp) {
+      const result = await SEND_LINK[kind]({
+        id: documentId,
+        channel: 'whatsapp',
+        to: whatsappTo.trim(),
+      }).catch(() => null)
+      if (settle(result, ts('whatsappSent'), ts('failedWhatsapp'))) setWhatsappOn(false)
+    }
+
+    if (sent.length > 0) {
+      toast.success(sent.join(' & '))
+      onSent?.()
+    }
+    setSending(false)
+    // Done once everything ticked has gone. A channel that failed stays ticked
+    // and the dialog stays open, so it can be tried again.
+    if (sent.length > 0 && !failed) onOpenChange(false)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-md">
+        <form onSubmit={handleSend} className="contents">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <Send className="h-5 w-5" />
+              {t(TITLE[kind])}
+            </DialogTitle>
+            <DialogDescription>{t('description')}</DialogDescription>
+          </DialogHeader>
+          <DialogBody className="space-y-4">
+            <div className="space-y-3 rounded-lg border p-3">
+              <ChannelRow
+                id="send-email"
+                icon={<Mail className="h-3.5 w-3.5" />}
+                label={t('email')}
+                checked={emailOn}
+                onCheckedChange={setEmailOn}
+                status={channels?.email}
+                isCloud={channels?.isCloud ?? false}
+                value={email}
+                onValueChange={setEmail}
+                // Checked as an address only while it is one being sent to: an
+                // unticked row must not hold up the other channels.
+                inputType={emailOn ? 'email' : 'text'}
+                inputLabel={t('emailAddress')}
+                placeholder={t('emailPlaceholder')}
+              >
+                <div className="flex items-center gap-2 pl-6">
+                  <Checkbox
+                    id="send-attach-pdf"
+                    checked={attachPdf}
+                    onCheckedChange={(v) => setAttachPdf(v === true)}
+                    disabled={!emailReady || !emailOn}
+                  />
+                  <Label
+                    htmlFor="send-attach-pdf"
+                    className={`text-sm ${emailReady && emailOn ? '' : 'text-muted-foreground/50'}`}
+                  >
+                    {t('attachPdf')}
+                  </Label>
+                </div>
+              </ChannelRow>
+              <ChannelRow
+                id="send-sms"
+                icon={<MessageSquare className="h-3.5 w-3.5" />}
+                label={t('sms')}
+                checked={smsOn}
+                onCheckedChange={setSmsOn}
+                status={channels?.sms}
+                isCloud={channels?.isCloud ?? false}
+                value={smsTo}
+                onValueChange={setSmsTo}
+                inputType="tel"
+                inputLabel={t('smsNumber')}
+                placeholder={t('phonePlaceholder')}
+              />
+              <ChannelRow
+                id="send-whatsapp"
+                icon={<MessageCircle className="h-3.5 w-3.5" />}
+                label={ts('whatsapp')}
+                checked={whatsappOn}
+                onCheckedChange={setWhatsappOn}
+                status={channels?.whatsapp}
+                isCloud={channels?.isCloud ?? false}
+                value={whatsappTo}
+                onValueChange={setWhatsappTo}
+                inputType="tel"
+                inputLabel={t('whatsappNumber')}
+                placeholder={t('phonePlaceholder')}
+                blockedReason={whatsappBlocked ? ts('whatsappWindowClosed') : null}
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="send-message">{t('message')}</Label>
+              <Textarea
+                id="send-message"
+                placeholder={t('messagePlaceholder')}
+                value={message}
+                onChange={(e) => setMessage(e.target.value)}
+                rows={2}
+                disabled={!wantsEmail}
+              />
+            </div>
+
+            {shareUrl && (
+              <div className="flex items-center gap-2 text-xs text-muted-foreground">
+                <Link2 className="h-3.5 w-3.5 shrink-0" />
+                <span className="min-w-0 flex-1 truncate font-mono" title={shareUrl}>
+                  {shareUrl}
+                </span>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="icon"
+                  className="h-7 w-7 shrink-0"
+                  onClick={handleCopy}
+                  aria-label={t('copyLink')}
+                >
+                  {copied ? (
+                    <Check className="h-3.5 w-3.5 text-emerald-500" />
+                  ) : (
+                    <Copy className="h-3.5 w-3.5" />
+                  )}
+                </Button>
+              </div>
+            )}
+          </DialogBody>
+          <DialogFooter>
+            <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+              {tb('cancel')}
+            </Button>
+            <Button type="submit" disabled={sending || !canSend}>
+              {sending ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Send className="mr-2 h-4 w-4" />
+              )}
+              {t('send')}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+interface ChannelRowProps {
+  id: string
+  icon: ReactNode
+  /** The channel's name as the workshop knows it: "SMS". */
+  label: string
+  checked: boolean
+  onCheckedChange: (checked: boolean) => void
+  /** Undefined while the dialog is still asking. */
+  status: ShareChannelStatus | undefined
+  isCloud: boolean
+  value: string
+  onValueChange: (value: string) => void
+  inputType: 'email' | 'tel' | 'text'
+  inputLabel: string
+  placeholder: string
+  /** Why this recipient cannot be reached on a channel that otherwise works. */
+  blockedReason?: string | null
+  children?: ReactNode
+}
+
+/**
+ * One channel: a tick, its name and who it goes to.
+ *
+ * Always drawn. A channel the workshop has not set up is disabled and says
+ * what to do about it, with the way there: hidden, nobody found out that a
+ * quote or an invoice could go out by text at all.
+ */
+function ChannelRow({
+  id,
+  icon,
+  label,
+  checked,
+  onCheckedChange,
+  status,
+  isCloud,
+  value,
+  onValueChange,
+  inputType,
+  inputLabel,
+  placeholder,
+  blockedReason,
+  children,
+}: ChannelRowProps) {
+  const t = useTranslations('common.shareLink')
+  const tl = useTranslations('settings.featureLocked')
+
+  const ready = status === 'ready'
+  const usable = ready && !blockedReason
+  const hintId = `${id}-hint`
+  const hasHint =
+    status === 'not_connected' ||
+    status === 'not_in_plan' ||
+    status === 'turned_off' ||
+    !!blockedReason
+
+  return (
+    <div className="space-y-1.5">
+      <div className="flex items-center gap-2">
+        <Checkbox
+          id={id}
+          checked={usable && checked}
+          onCheckedChange={(v) => onCheckedChange(v === true)}
+          disabled={!usable}
+          aria-describedby={hasHint ? hintId : undefined}
+        />
+        <Label
+          htmlFor={id}
+          className={`flex w-24 shrink-0 items-center gap-1.5 text-sm ${usable ? '' : 'text-muted-foreground/50'}`}
+        >
+          {icon}
+          {label}
+        </Label>
+        <Input
+          type={inputType}
+          value={value}
+          onChange={(e) => {
+            onValueChange(e.target.value)
+            // Typing who it goes to is choosing the channel. Judged on the
+            // channel alone: a block on the old recipient says nothing about
+            // the one being typed.
+            if (ready && e.target.value.trim() !== '') onCheckedChange(true)
+          }}
+          placeholder={placeholder}
+          id={`${id}-to`}
+          aria-label={inputLabel}
+          disabled={!ready}
+          className="h-8 min-w-0 flex-1"
+        />
+      </div>
+      {ready && blockedReason && (
+        <p id={hintId} className="pl-6 text-xs text-muted-foreground">
+          {blockedReason}
+        </p>
+      )}
+      {status === 'not_connected' && (
+        <p id={hintId} className="pl-6 text-xs text-muted-foreground">
+          {t('channelNotConnected', { channel: label })}{' '}
+          <Link href="/settings/integrations" className="font-medium text-primary hover:underline">
+            {t('openIntegrations')}
+          </Link>
+        </p>
+      )}
+      {status === 'turned_off' && (
+        <p id={hintId} className="pl-6 text-xs text-muted-foreground">
+          {t('channelTurnedOff', { channel: label })}
+        </p>
+      )}
+      {status === 'not_in_plan' && (
+        <p id={hintId} className="pl-6 text-xs text-muted-foreground">
+          {t('channelNotInPlan', { channel: label })}{' '}
+          <Link
+            href={isCloud ? '/settings/subscription' : '/settings/license'}
+            className="font-medium text-primary hover:underline"
+          >
+            {isCloud ? tl('upgradePlan') : tl('activateLicense')}
+          </Link>
+        </p>
+      )}
+      {children}
+    </div>
+  )
+}

+ 38 - 1
src/features/messaging/Lib/useShareChannels.ts

@@ -2,12 +2,15 @@
 
 
 import { useQuery } from '@tanstack/react-query'
 import { useQuery } from '@tanstack/react-query'
 import { z } from 'zod'
 import { z } from 'zod'
+import { getWhatsappWindowState } from '@/features/whatsapp/Actions/whatsappActions'
 import { getShareChannels } from '../Actions/getShareChannels'
 import { getShareChannels } from '../Actions/getShareChannels'
 
 
-const statusSchema = z.enum(['ready', 'not_connected', 'not_in_plan'])
+const statusSchema = z.enum(['ready', 'not_connected', 'not_in_plan', 'turned_off'])
 
 
 const shareChannelsSchema = z.object({
 const shareChannelsSchema = z.object({
+  email: statusSchema,
   sms: statusSchema,
   sms: statusSchema,
+  whatsapp: statusSchema,
   isCloud: z.boolean(),
   isCloud: z.boolean(),
 })
 })
 
 
@@ -32,3 +35,37 @@ export function useShareChannels(open: boolean): ShareChannels | undefined {
   })
   })
   return data
   return data
 }
 }
+
+const windowSchema = z.object({
+  configured: z.boolean(),
+  open: z.boolean(),
+  hasTemplate: z.boolean(),
+  templateCarriesMessage: z.boolean(),
+})
+
+/**
+ * Whether a WhatsApp message can reach this customer right now.
+ *
+ * WhatsApp lets a business write freely only for 24 hours after the customer
+ * last wrote; after that an approved template is the only way through. The
+ * dialog asks before offering the channel, so the workshop hears about the
+ * rule while choosing how to send, not from a failed send. Undefined until
+ * the answer is in.
+ */
+export function useWhatsappReach(enabled: boolean, customerId: string | undefined) {
+  const { data } = useQuery({
+    queryKey: ['whatsapp-window', customerId],
+    queryFn: async () => {
+      const res = await getWhatsappWindowState(customerId as string)
+      if (!res.success) throw new Error(res.error)
+      return windowSchema.parse(res.data)
+    },
+    enabled: enabled && !!customerId,
+    // Short: the customer writing back is what opens the window.
+    staleTime: 15_000,
+    retry: false,
+  })
+  if (!data) return undefined
+  // A link needs a template with the message variable to ride in.
+  return data.configured && (data.open || (data.hasTemplate && data.templateCarriesMessage))
+}

+ 17 - 25
src/features/quotes/Components/QuotePageClient.tsx

@@ -1,6 +1,5 @@
 'use client'
 'use client'
 
 
-import { track } from '@/lib/analytics'
 import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
 import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
 import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
 import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
@@ -25,7 +24,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
 import { SearchableSelect } from '@/components/ui/searchable-select'
 import { SearchableSelect } from '@/components/ui/searchable-select'
 import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
 import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
 import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
 import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
-import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
+import { SendDocumentDialog } from '@/features/messaging/Components/SendDocumentDialog'
 import { revokeQuotePublicLink } from '@/features/quotes/Actions/quoteShareActions'
 import { revokeQuotePublicLink } from '@/features/quotes/Actions/quoteShareActions'
 import { QuoteShareDialog } from '@/features/quotes/Components/QuoteShareDialog'
 import { QuoteShareDialog } from '@/features/quotes/Components/QuoteShareDialog'
 import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
 import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
@@ -39,7 +38,7 @@ import {
   FileText,
   FileText,
   Globe,
   Globe,
   Loader2,
   Loader2,
-  Mail,
+  Send,
   Paperclip,
   Paperclip,
   Save,
   Save,
   Trash2,
   Trash2,
@@ -102,7 +101,6 @@ export function QuotePageClient({
   distanceUnit,
   distanceUnit,
   laborPresets = [],
   laborPresets = [],
   inventoryParts = [],
   inventoryParts = [],
-  emailEnabled = false,
   imageAttachments = [],
   imageAttachments = [],
   documentAttachments = [],
   documentAttachments = [],
   maxImages,
   maxImages,
@@ -132,7 +130,6 @@ export function QuotePageClient({
   distanceUnit: string
   distanceUnit: string
   laborPresets?: LaborPresetOption[]
   laborPresets?: LaborPresetOption[]
   inventoryParts?: InventoryPartOption[]
   inventoryParts?: InventoryPartOption[]
-  emailEnabled?: boolean
   imageAttachments?: QuoteAttachment[]
   imageAttachments?: QuoteAttachment[]
   documentAttachments?: QuoteAttachment[]
   documentAttachments?: QuoteAttachment[]
   maxImages?: number
   maxImages?: number
@@ -431,9 +428,9 @@ export function QuotePageClient({
                       {state.saving ? (
                       {state.saving ? (
                         <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
                         <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
                       ) : (
                       ) : (
-                        <Mail className="mr-1 h-3.5 w-3.5" />
+                        <Send className="mr-1 h-3.5 w-3.5" />
                       )}
                       )}
-                      {t('page.email')}
+                      {t('page.send')}
                     </Button>
                     </Button>
                     <Button
                     <Button
                       variant="outline"
                       variant="outline"
@@ -589,24 +586,21 @@ export function QuotePageClient({
               url={`/api/protected/quotes/${quote.id}/pdf`}
               url={`/api/protected/quotes/${quote.id}/pdf`}
             />
             />
 
 
-            <SendEmailDialog
+            <SendDocumentDialog
               open={state.showEmailDialog}
               open={state.showEmailDialog}
               onOpenChange={state.setShowEmailDialog}
               onOpenChange={state.setShowEmailDialog}
-              defaultEmail={quote.customer?.email || ''}
-              entityLabel={t('page.entityLabel')}
-              onSend={async (email, message, attachPdf) => {
-                const result = await sendQuoteEmail({
-                  quoteId: quote.id,
-                  recipientEmail: email,
-                  message,
-                  attachPdf,
-                })
-                if (result.success) {
-                  track('document:email_send', { document: 'quote' })
-                  handleQuoteSent()
-                }
-                return result
-              }}
+              kind="quote"
+              documentId={quote.id}
+              shareUrl={
+                quote.publicToken
+                  ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/quote/${organizationId}/${quote.publicToken}`
+                  : null
+              }
+              customer={quote.customer}
+              sendEmail={(email, message, attachPdf) =>
+                sendQuoteEmail({ quoteId: quote.id, recipientEmail: email, message, attachPdf })
+              }
+              onSent={handleQuoteSent}
             />
             />
 
 
             <QuoteShareDialog
             <QuoteShareDialog
@@ -616,8 +610,6 @@ export function QuotePageClient({
               organizationId={organizationId}
               organizationId={organizationId}
               initialToken={quote.publicToken}
               initialToken={quote.publicToken}
               onSent={handleQuoteSent}
               onSent={handleQuoteSent}
-              customer={quote.customer}
-              emailEnabled={emailEnabled}
             />
             />
 
 
             <Dialog open={state.showConvertDialog} onOpenChange={state.setShowConvertDialog}>
             <Dialog open={state.showConvertDialog} onOpenChange={state.setShowConvertDialog}>

+ 1 - 153
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -1,27 +1,16 @@
 'use client'
 'use client'
 
 
-import { track } from '@/lib/analytics'
-import { handleGated } from '@/components/upgrade-gate'
 import { useState } from 'react'
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Check, Copy, Link2, Loader2, Mail, MessageSquare } from 'lucide-react'
+import { Check, Copy, Link2, Loader2 } from 'lucide-react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import {
 import {
   generateQuotePublicLink,
   generateQuotePublicLink,
   revokeQuotePublicLink,
   revokeQuotePublicLink,
 } from '@/features/quotes/Actions/quoteShareActions'
 } from '@/features/quotes/Actions/quoteShareActions'
-import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
-import { sendSmsToCustomer, getSmsTemplates } from '@/features/sms/Actions/smsActions'
-import { NotifyChannelOption } from '@/features/messaging/Components/NotifyChannelOption'
-import { useShareChannels } from '@/features/messaging/Lib/useShareChannels'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
-import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 
 interface QuoteShareDialogProps {
 interface QuoteShareDialogProps {
   open: boolean
   open: boolean
@@ -31,13 +20,6 @@ interface QuoteShareDialogProps {
   initialToken: string | null
   initialToken: string | null
   /** Called once the quote has actually gone to the customer. */
   /** Called once the quote has actually gone to the customer. */
   onSent?: () => void
   onSent?: () => void
-  customer?: {
-    id: string
-    name: string
-    email: string | null
-    phone: string | null
-  } | null
-  emailEnabled?: boolean
 }
 }
 
 
 export function QuoteShareDialog({
 export function QuoteShareDialog({
@@ -47,25 +29,14 @@ export function QuoteShareDialog({
   organizationId,
   organizationId,
   initialToken,
   initialToken,
   onSent,
   onSent,
-  customer,
-  emailEnabled = false,
 }: QuoteShareDialogProps) {
 }: QuoteShareDialogProps) {
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [revoking, setRevoking] = useState(false)
   const [revoking, setRevoking] = useState(false)
   const [copied, setCopied] = useState(false)
   const [copied, setCopied] = useState(false)
-  const [notifyEmail, setNotifyEmail] = useState(false)
-  const [attachPdf, setAttachPdf] = useAttachPdf(open)
-  const [notifySms, setNotifySms] = useState(false)
-  const [sending, setSending] = useState(false)
   const t = useTranslations('quotes')
   const t = useTranslations('quotes')
   const tc = useTranslations('common')
   const tc = useTranslations('common')
 
 
-  const hasEmail = !!customer?.email
-  const hasPhone = !!customer?.phone
-  const channels = useShareChannels(open)
-  const smsReady = channels?.sms === 'ready' && hasPhone
-
   const publicUrl =
   const publicUrl =
     publicToken && organizationId
     publicToken && organizationId
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/quote/${organizationId}/${publicToken}`
       ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/quote/${organizationId}/${publicToken}`
@@ -109,69 +80,6 @@ export function QuoteShareDialog({
     }
     }
   }
   }
 
 
-  const handleNotify = async () => {
-    if (!publicUrl || !customer) return
-    setSending(true)
-
-    const results: string[] = []
-    let failed = false
-
-    if (notifyEmail && hasEmail) {
-      const res = await sendQuoteEmail({
-        quoteId,
-        recipientEmail: customer.email!,
-        attachPdf,
-      })
-      if (res.success) {
-        track('document:email_send', { document: 'quote' })
-        results.push(t('shareDialog.emailSent'))
-        setNotifyEmail(false)
-      } else {
-        failed = true
-        if (!handleGated(res)) toast.error(res.error || t('shareDialog.failedEmail'))
-      }
-    }
-
-    if (smsReady && notifySms) {
-      const tplResult = await getSmsTemplates()
-      const tplData = tplResult.success && tplResult.data ? tplResult.data : null
-      const tpl =
-        tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY] ||
-        SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY]
-      const body = interpolateSmsTemplate(tpl || '', {
-        share_link: publicUrl,
-        customer_name: customer.name,
-        company_name: tplData?.companyName || '',
-        current_user: tplData?.currentUser || '',
-      })
-      const res = await sendSmsToCustomer({
-        customerId: customer.id,
-        body,
-        relatedEntityType: 'quote',
-        relatedEntityId: quoteId,
-      })
-      if (res.success) {
-        results.push(t('shareDialog.smsSent'))
-        setNotifySms(false)
-      } else {
-        failed = true
-        if (!handleGated(res)) toast.error(res.error || t('shareDialog.failedSms'))
-      }
-    }
-
-    if (results.length > 0) {
-      toast.success(results.join(' & '))
-      // Either channel re-issues the quote, and the lock may have engaged.
-      onSent?.()
-    }
-    setSending(false)
-    // Done once everything ticked has gone. A channel that failed stays ticked
-    // and the dialog stays open, so it can be tried again.
-    if (results.length > 0 && !failed) onOpenChange(false)
-  }
-
-  const canNotify = publicUrl && customer && (notifyEmail || (smsReady && notifySms))
-
   return (
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="max-w-md" aria-describedby={undefined}>
       <DialogContent className="max-w-md" aria-describedby={undefined}>
@@ -201,66 +109,6 @@ export function QuoteShareDialog({
                 </Button>
                 </Button>
               </div>
               </div>
 
 
-              {/* Notify customer */}
-              {customer && (
-                <div className="space-y-3 rounded-lg border p-3">
-                  <p className="text-sm font-medium">
-                    {t('shareDialog.notifyTitle', { name: customer.name })}
-                  </p>
-                  <div className="space-y-2">
-                    {emailEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-email-quote"
-                          checked={notifyEmail}
-                          onCheckedChange={(v) => setNotifyEmail(v === true)}
-                          disabled={!hasEmail}
-                        />
-                        <Label
-                          htmlFor="notify-email-quote"
-                          className={`flex items-center gap-1.5 text-sm ${!hasEmail ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <Mail className="h-3.5 w-3.5" />
-                          {t('shareDialog.email')}
-                          {!hasEmail && <span className="text-xs">{t('shareDialog.noEmail')}</span>}
-                        </Label>
-                      </div>
-                    )}
-                    {emailEnabled && (
-                      <div className="pl-6">
-                        <AttachPdfOption
-                          id="attach-pdf-quote"
-                          checked={attachPdf}
-                          onCheckedChange={setAttachPdf}
-                          disabled={!notifyEmail || !hasEmail}
-                        />
-                      </div>
-                    )}
-                    <NotifyChannelOption
-                      id="notify-sms-quote"
-                      icon={<MessageSquare className="h-3.5 w-3.5" />}
-                      label={t('shareDialog.sms')}
-                      checked={notifySms}
-                      onCheckedChange={setNotifySms}
-                      status={channels?.sms}
-                      isCloud={channels?.isCloud ?? false}
-                      missingContact={hasPhone ? null : t('shareDialog.noPhone')}
-                    />
-                  </div>
-                  {/* Always here, disabled until a channel is ticked: it used to
-                      appear on the first tick and shove the dialog around. */}
-                  <Button
-                    size="sm"
-                    onClick={handleNotify}
-                    disabled={sending || !canNotify}
-                    className="w-full"
-                  >
-                    {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                    {t('shareDialog.sendNotification')}
-                  </Button>
-                </div>
-              )}
-
               <Button
               <Button
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"

+ 13 - 70
src/features/sms/Actions/smsActions.ts

@@ -2,18 +2,13 @@
 
 
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
-import {
-  sendOrgSms,
-  getOrgSmsPhoneNumber,
-  getPlatformSmsAllowance,
-  normalizeOrgPhone,
-} from '@/lib/sms'
+import { getPlatformSmsAllowance, normalizeOrgPhone } from '@/lib/sms'
 import { requireFeature } from '@/lib/features'
 import { requireFeature } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
 import { SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
-import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
+import { deliverSms } from '../Lib/deliverSms.server'
 
 
 export async function sendSmsToCustomer(input: {
 export async function sendSmsToCustomer(input: {
   customerId: string
   customerId: string
@@ -34,9 +29,6 @@ export async function sendSmsToCustomer(input: {
       if (!customer) throw new Error('Customer not found')
       if (!customer) throw new Error('Customer not found')
       if (!customer.phone) throw new Error('Customer has no phone number')
       if (!customer.phone) throw new Error('Customer has no phone number')
 
 
-      const fromNumber = await getOrgSmsPhoneNumber(organizationId)
-      if (!fromNumber) throw new Error('SMS phone number is not configured')
-
       // Normalize the destination phone using the workshop's default
       // Normalize the destination phone using the workshop's default
       // country code so the audit-log row records the same E.164 number
       // country code so the audit-log row records the same E.164 number
       // the provider will receive. sendOrgSms re-normalizes internally as
       // the provider will receive. sendOrgSms re-normalizes internally as
@@ -50,68 +42,19 @@ export async function sendSmsToCustomer(input: {
         )
         )
       }
       }
 
 
-      // Create the message record first
-      const message = await db.smsMessage.create({
-        data: {
-          direction: 'outbound',
-          fromNumber,
-          toNumber: normalizedTo,
-          body: input.body,
-          status: 'queued',
-          organizationId,
-          customerId: customer.id,
-          relatedEntityType: input.relatedEntityType,
-          relatedEntityId: input.relatedEntityId,
-        },
+      const sent = await deliverSms(organizationId, {
+        to: normalizedTo,
+        body: input.body,
+        customerId: customer.id,
+        relatedEntityType: input.relatedEntityType,
+        relatedEntityId: input.relatedEntityId,
       })
       })
 
 
-      try {
-        const result = await sendOrgSms(organizationId, {
-          to: normalizedTo,
-          body: input.body,
-        })
-
-        await db.smsMessage.update({
-          where: { id: message.id },
-          data: {
-            status: 'sent',
-            providerMsgId: result.providerMsgId,
-          },
-        })
-
-        // These entity types are set only by the share dialogs, whose SMS
-        // carries the document's link: texting it is a way of sending the
-        // document, so it counts for "lock when sent". Plain conversations
-        // and reminders carry no entity type and stamp nothing.
-        if (input.relatedEntityType === 'invoice' && input.relatedEntityId) {
-          await markInvoiceSent(input.relatedEntityId, organizationId)
-        } else if (input.relatedEntityType === 'quote' && input.relatedEntityId) {
-          await markQuoteSent(input.relatedEntityId, organizationId)
-        }
-
-        return {
-          id: message.id,
-          status: 'sent' as const,
-          customerPhone: result.to,
-          customerName: customer.name,
-        }
-      } catch (error) {
-        // The plan saying no is not a failed send: nothing was attempted, so
-        // no failed message is left in the thread, and the refusal goes up as
-        // it is so the screen can offer the upgrade instead of an error.
-        if (error instanceof Error && error.name === 'FeatureGatedError') {
-          await db.smsMessage.delete({ where: { id: message.id } }).catch(() => null)
-          throw error
-        }
-
-        const errorMessage = error instanceof Error ? error.message : 'Unknown error'
-
-        await db.smsMessage.update({
-          where: { id: message.id },
-          data: { status: 'failed', errorMessage },
-        })
-
-        throw new Error(`Failed to send SMS: ${errorMessage}`)
+      return {
+        id: sent.id,
+        status: 'sent' as const,
+        customerPhone: sent.to,
+        customerName: customer.name,
       }
       }
     },
     },
     {
     {

+ 78 - 0
src/features/sms/Lib/deliverSms.server.ts

@@ -0,0 +1,78 @@
+import 'server-only'
+import { db } from '@/lib/db'
+import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
+import { getOrgSmsPhoneNumber, sendOrgSms } from '@/lib/sms'
+
+export interface DeliverSmsInput {
+  /** Already normalised to the E.164 number the provider will receive. */
+  to: string
+  body: string
+  /** Whose conversation the text belongs in, when it belongs in one. */
+  customerId?: string | null
+  relatedEntityType?: string
+  relatedEntityId?: string
+}
+
+/**
+ * Sends one text and keeps a record of it, sent or failed.
+ *
+ * Shared by the two ways a text leaves a workshop: to a customer on file, and
+ * to a number typed into a send dialog for a job that has no customer yet.
+ */
+export async function deliverSms(organizationId: string, input: DeliverSmsInput) {
+  const fromNumber = await getOrgSmsPhoneNumber(organizationId)
+  if (!fromNumber) throw new Error('SMS phone number is not configured')
+
+  // Create the message record first
+  const message = await db.smsMessage.create({
+    data: {
+      direction: 'outbound',
+      fromNumber,
+      toNumber: input.to,
+      body: input.body,
+      status: 'queued',
+      organizationId,
+      customerId: input.customerId ?? null,
+      relatedEntityType: input.relatedEntityType,
+      relatedEntityId: input.relatedEntityId,
+    },
+  })
+
+  try {
+    const result = await sendOrgSms(organizationId, { to: input.to, body: input.body })
+
+    await db.smsMessage.update({
+      where: { id: message.id },
+      data: { status: 'sent', providerMsgId: result.providerMsgId },
+    })
+
+    // These entity types are set only by the send dialogs, whose SMS
+    // carries the document's link: texting it is a way of sending the
+    // document, so it counts for "lock when sent". Plain conversations
+    // and reminders carry no entity type and stamp nothing.
+    if (input.relatedEntityType === 'invoice' && input.relatedEntityId) {
+      await markInvoiceSent(input.relatedEntityId, organizationId)
+    } else if (input.relatedEntityType === 'quote' && input.relatedEntityId) {
+      await markQuoteSent(input.relatedEntityId, organizationId)
+    }
+
+    return { id: message.id, to: result.to }
+  } catch (error) {
+    // The plan saying no is not a failed send: nothing was attempted, so
+    // no failed message is left in the thread, and the refusal goes up as
+    // it is so the screen can offer the upgrade instead of an error.
+    if (error instanceof Error && error.name === 'FeatureGatedError') {
+      await db.smsMessage.delete({ where: { id: message.id } }).catch(() => null)
+      throw error
+    }
+
+    const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+
+    await db.smsMessage.update({
+      where: { id: message.id },
+      data: { status: 'failed', errorMessage },
+    })
+
+    throw new Error(`Failed to send SMS: ${errorMessage}`)
+  }
+}

+ 1 - 153
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -1,24 +1,13 @@
 'use client'
 'use client'
 
 
-import { track } from '@/lib/analytics'
-import { handleGated } from '@/components/upgrade-gate'
 import { useState } from 'react'
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Check, Copy, Link2, Loader2, Mail, MessageSquare } from 'lucide-react'
+import { Check, Copy, Link2, Loader2 } from 'lucide-react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { generatePublicLink, revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
 import { generatePublicLink, revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
-import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
-import { sendSmsToCustomer, getSmsTemplates } from '@/features/sms/Actions/smsActions'
-import { NotifyChannelOption } from '@/features/messaging/Components/NotifyChannelOption'
-import { useShareChannels } from '@/features/messaging/Lib/useShareChannels'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
-import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 
 interface ShareDialogProps {
 interface ShareDialogProps {
   open: boolean
   open: boolean
@@ -28,13 +17,6 @@ interface ShareDialogProps {
   initialToken: string | null
   initialToken: string | null
   /** Called once the invoice has actually gone to the customer. */
   /** Called once the invoice has actually gone to the customer. */
   onSent?: () => void
   onSent?: () => void
-  customer?: {
-    id: string
-    name: string
-    email: string | null
-    phone: string | null
-  } | null
-  emailEnabled?: boolean
 }
 }
 
 
 export function ShareDialog({
 export function ShareDialog({
@@ -44,8 +26,6 @@ export function ShareDialog({
   organizationId,
   organizationId,
   initialToken,
   initialToken,
   onSent,
   onSent,
-  customer,
-  emailEnabled = false,
 }: ShareDialogProps) {
 }: ShareDialogProps) {
   const t = useTranslations('service.share')
   const t = useTranslations('service.share')
   const tc = useTranslations('common')
   const tc = useTranslations('common')
@@ -53,15 +33,6 @@ export function ShareDialog({
   const [generatingLink, setGeneratingLink] = useState(false)
   const [generatingLink, setGeneratingLink] = useState(false)
   const [revoking, setRevoking] = useState(false)
   const [revoking, setRevoking] = useState(false)
   const [copied, setCopied] = useState(false)
   const [copied, setCopied] = useState(false)
-  const [notifyEmail, setNotifyEmail] = useState(false)
-  const [attachPdf, setAttachPdf] = useAttachPdf(open)
-  const [notifySms, setNotifySms] = useState(false)
-  const [sending, setSending] = useState(false)
-
-  const hasEmail = !!customer?.email
-  const hasPhone = !!customer?.phone
-  const channels = useShareChannels(open)
-  const smsReady = channels?.sms === 'ready' && hasPhone
 
 
   const publicUrl =
   const publicUrl =
     publicToken && organizationId
     publicToken && organizationId
@@ -106,71 +77,6 @@ export function ShareDialog({
     }
     }
   }
   }
 
 
-  const handleNotify = async () => {
-    if (!publicUrl || !customer) return
-    setSending(true)
-
-    const results: string[] = []
-    let failed = false
-
-    if (notifyEmail && hasEmail) {
-      const res = await sendInvoiceEmail({
-        serviceRecordId: recordId,
-        recipientEmail: customer.email!,
-        attachPdf,
-      })
-      if (res.success) {
-        track('document:email_send', { document: 'invoice' })
-        results.push(t('emailSent'))
-        setNotifyEmail(false)
-      } else {
-        failed = true
-        if (!handleGated(res)) toast.error(res.error || t('failedEmail'))
-      }
-    }
-
-    if (smsReady && notifySms) {
-      const tplResult = await getSmsTemplates()
-      const tplData = tplResult.success && tplResult.data ? tplResult.data : null
-      const tpl =
-        tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY] ||
-        SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY]
-      const body = interpolateSmsTemplate(tpl || '', {
-        share_link: publicUrl,
-        customer_name: customer.name,
-        company_name: tplData?.companyName || '',
-        current_user: tplData?.currentUser || '',
-      })
-      const res = await sendSmsToCustomer({
-        customerId: customer.id,
-        body,
-        relatedEntityType: 'invoice',
-        relatedEntityId: recordId,
-      })
-      if (res.success) {
-        results.push(t('smsSent'))
-        setNotifySms(false)
-      } else {
-        failed = true
-        if (!handleGated(res)) toast.error(res.error || t('failedSms'))
-      }
-    }
-
-    if (results.length > 0) {
-      toast.success(results.join(' & '))
-      // Both channels count as sending the invoice — email stamps sentAt on
-      // the server, and the SMS carries the link — so the lock may have just
-      // engaged and the page needs to hear about it.
-      onSent?.()
-    }
-    setSending(false)
-    // Done once everything ticked has gone. A channel that failed stays ticked
-    // and the dialog stays open, so it can be tried again.
-    if (results.length > 0 && !failed) onOpenChange(false)
-  }
-
-  const canNotify = publicUrl && customer && (notifyEmail || (smsReady && notifySms))
-
   return (
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="max-w-md" aria-describedby={undefined}>
       <DialogContent className="max-w-md" aria-describedby={undefined}>
@@ -200,64 +106,6 @@ export function ShareDialog({
                 </Button>
                 </Button>
               </div>
               </div>
 
 
-              {/* Notify customer */}
-              {customer && (
-                <div className="space-y-3 rounded-lg border p-3">
-                  <p className="text-sm font-medium">{t('notifyTitle', { name: customer.name })}</p>
-                  <div className="space-y-2">
-                    {emailEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-email"
-                          checked={notifyEmail}
-                          onCheckedChange={(v) => setNotifyEmail(v === true)}
-                          disabled={!hasEmail}
-                        />
-                        <Label
-                          htmlFor="notify-email"
-                          className={`flex items-center gap-1.5 text-sm ${!hasEmail ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <Mail className="h-3.5 w-3.5" />
-                          {t('email')}
-                          {!hasEmail && <span className="text-xs">{t('noEmail')}</span>}
-                        </Label>
-                      </div>
-                    )}
-                    {emailEnabled && (
-                      <div className="pl-6">
-                        <AttachPdfOption
-                          id="attach-pdf-invoice"
-                          checked={attachPdf}
-                          onCheckedChange={setAttachPdf}
-                          disabled={!notifyEmail || !hasEmail}
-                        />
-                      </div>
-                    )}
-                    <NotifyChannelOption
-                      id="notify-sms"
-                      icon={<MessageSquare className="h-3.5 w-3.5" />}
-                      label={t('sms')}
-                      checked={notifySms}
-                      onCheckedChange={setNotifySms}
-                      status={channels?.sms}
-                      isCloud={channels?.isCloud ?? false}
-                      missingContact={hasPhone ? null : t('noPhone')}
-                    />
-                  </div>
-                  {/* Always here, disabled until a channel is ticked: it used to
-                      appear on the first tick and shove the dialog around. */}
-                  <Button
-                    size="sm"
-                    onClick={handleNotify}
-                    disabled={sending || !canNotify}
-                    className="w-full"
-                  >
-                    {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                    {t('sendNotification')}
-                  </Button>
-                </div>
-              )}
-
               <Button
               <Button
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"

+ 2 - 2
src/features/vehicles/Components/service-page/ServiceHeaderActions.tsx

@@ -17,7 +17,7 @@ import {
   Download,
   Download,
   Eye,
   Eye,
   Globe,
   Globe,
-  Mail,
+  Send,
   MessageSquare,
   MessageSquare,
   MoreVertical,
   MoreVertical,
   Save,
   Save,
@@ -126,7 +126,7 @@ export function ServiceHeaderActions({
           loading={downloading}
           loading={downloading}
           onClick={onDownloadPDF}
           onClick={onDownloadPDF}
         />
         />
-        <IconActionButton label={t('email')} icon={Mail} onClick={onShowEmail} />
+        <IconActionButton label={t('send')} icon={Send} onClick={onShowEmail} />
         <IconActionButton label={t('share')} icon={Globe} onClick={onShowShare} />
         <IconActionButton label={t('share')} icon={Globe} onClick={onShowShare} />
         {meetingUrl && (
         {meetingUrl && (
           <IconActionButton
           <IconActionButton

+ 17 - 17
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -20,7 +20,7 @@ import {
   updateServiceStatus,
   updateServiceStatus,
 } from '@/features/vehicles/Actions/serviceActions'
 } from '@/features/vehicles/Actions/serviceActions'
 import { useConfirm } from '@/components/confirm-dialog'
 import { useConfirm } from '@/components/confirm-dialog'
-import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
+import { SendDocumentDialog } from '@/features/messaging/Components/SendDocumentDialog'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 
 
 import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ImageCarousel } from '../service-detail/ImageCarousel'
@@ -790,27 +790,29 @@ export function ServicePageClient({
               url={`/api/protected/services/${record.id}/work-order-pdf`}
               url={`/api/protected/services/${record.id}/work-order-pdf`}
             />
             />
 
 
-            <SendEmailDialog
+            <SendDocumentDialog
               open={actions.showEmailDialog}
               open={actions.showEmailDialog}
               onOpenChange={actions.setShowEmailDialog}
               onOpenChange={actions.setShowEmailDialog}
-              defaultEmail={customer?.email || ''}
-              entityLabel={t('invoice.entityLabel')}
-              onSend={async (email, message, attachPdf) => {
-                const result = await sendInvoiceEmail({
+              kind="invoice"
+              documentId={record.id}
+              shareUrl={
+                record.publicToken
+                  ? `${typeof window !== 'undefined' ? window.location.origin : ''}/share/invoice/${organizationId}/${record.publicToken}`
+                  : null
+              }
+              customer={customer}
+              sendEmail={(email, message, attachPdf) =>
+                sendInvoiceEmail({
                   serviceRecordId: record.id,
                   serviceRecordId: record.id,
                   recipientEmail: email,
                   recipientEmail: email,
                   message,
                   message,
                   attachPdf,
                   attachPdf,
                 })
                 })
-                // Deliberately not awaited: the email dialog should show "sent" the
-                // moment it is, not sit spinning behind the "mark completed"
-                // confirmation that handleInvoiceSent may raise.
-                if (result.success) {
-                  track('document:email_send', { document: 'invoice' })
-                  void handleInvoiceSent()
-                }
-                return result
-              }}
+              }
+              // Deliberately not awaited: the dialog should say "sent" the
+              // moment it is, not sit behind the "mark completed" confirmation
+              // that handleInvoiceSent may raise.
+              onSent={() => void handleInvoiceSent()}
             />
             />
 
 
             <ShareDialog
             <ShareDialog
@@ -820,8 +822,6 @@ export function ServicePageClient({
               organizationId={organizationId}
               organizationId={organizationId}
               initialToken={record.publicToken}
               initialToken={record.publicToken}
               onSent={handleInvoiceSent}
               onSent={handleInvoiceSent}
-              customer={customer}
-              emailEnabled={emailEnabled}
             />
             />
 
 
             {customer && (
             {customer && (

+ 11 - 2
src/features/whatsapp/Actions/whatsappActions.ts

@@ -5,6 +5,7 @@ import { withAuth } from '@/lib/with-auth'
 import { getFeatures, requireFeature } from '@/lib/features'
 import { getFeatures, requireFeature } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
+import { normalizeOrgPhone } from '@/lib/sms'
 import { releaseFiles } from '@/lib/files/manager'
 import { releaseFiles } from '@/lib/files/manager'
 import {
 import {
   getWhatsappConfig,
   getWhatsappConfig,
@@ -36,15 +37,20 @@ export async function getWhatsappWindowState(customerId: string) {
           configured: false,
           configured: false,
           open: false,
           open: false,
           hasTemplate: false,
           hasTemplate: false,
+          templateCarriesMessage: false,
           hasMediaTemplate: false,
           hasMediaTemplate: false,
           lastInboundAt: null,
           lastInboundAt: null,
         }
         }
       }
       }
 
 
+      // Inbound messages are stored under the number as WhatsApp reports it,
+      // so the customer's number is put in that shape before looking: one
+      // saved as a local number would otherwise never match its own replies.
+      const phone = (await normalizeOrgPhone(organizationId, customer.phone)) ?? customer.phone
       const [config, open, last] = await Promise.all([
       const [config, open, last] = await Promise.all([
         getWhatsappConfig(organizationId),
         getWhatsappConfig(organizationId),
-        isWithinServiceWindow(organizationId, customer.phone),
-        lastInboundAt(organizationId, customer.phone),
+        isWithinServiceWindow(organizationId, phone),
+        lastInboundAt(organizationId, phone),
       ])
       ])
 
 
       // Whether a template exists decides what the compose box may promise: a
       // Whether a template exists decides what the compose box may promise: a
@@ -54,6 +60,9 @@ export async function getWhatsappWindowState(customerId: string) {
         configured: config !== null,
         configured: config !== null,
         open,
         open,
         hasTemplate: config?.template != null,
         hasTemplate: config?.template != null,
+        // Whether that template has room for words of the workshop's own,
+        // which is where a document's link travels.
+        templateCarriesMessage: config?.template?.tokens.includes('message') ?? false,
         hasMediaTemplate: config?.mediaTemplate != null,
         hasMediaTemplate: config?.mediaTemplate != null,
         lastInboundAt: last,
         lastInboundAt: last,
       }
       }

+ 49 - 0
src/lib/share-token.server.ts

@@ -0,0 +1,49 @@
+import 'server-only'
+import { randomUUID } from 'crypto'
+import { db } from '@/lib/db'
+
+export type SharedDocumentKind = 'quote' | 'invoice' | 'inspection'
+
+/**
+ * The document's share link token, minting one for a document that was never
+ * shared.
+ *
+ * The write only lands while the token is still empty, and the token is read
+ * back afterwards. Two sends racing for a document's first link would
+ * otherwise each write their own, and the one that lost would have delivered
+ * a link the other had just replaced. An existing token is never touched: the
+ * customer may already hold it.
+ */
+export async function ensureShareToken(
+  kind: SharedDocumentKind,
+  id: string,
+  organizationId: string
+): Promise<string | null> {
+  const where = { id, organizationId, publicToken: null }
+  const token = randomUUID()
+  if (kind === 'quote') {
+    await db.quote.updateMany({ where, data: { publicToken: token, sharedAt: new Date() } })
+    const row = await db.quote.findFirst({
+      where: { id, organizationId },
+      select: { publicToken: true },
+    })
+    return row?.publicToken ?? null
+  }
+  if (kind === 'invoice') {
+    await db.serviceRecord.updateMany({
+      where,
+      data: { publicToken: token, sharedAt: new Date() },
+    })
+    const row = await db.serviceRecord.findFirst({
+      where: { id, organizationId },
+      select: { publicToken: true },
+    })
+    return row?.publicToken ?? null
+  }
+  await db.inspection.updateMany({ where, data: { publicToken: token } })
+  const row = await db.inspection.findFirst({
+    where: { id, organizationId },
+    select: { publicToken: true },
+  })
+  return row?.publicToken ?? null
+}