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

Show SMS in the share dialogs even when it is not set up (#586)

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.
Bernt Christian Egeland 15 часов назад
Родитель
Сommit
f4b7d32a7a

+ 4 - 1
messages/de/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Integrationen öffnen",
+    "channelNotInPlan": "{channel} ist in Ihrem Tarif nicht enthalten."
   },
   "searchableSelect": {
     "choose": "Auswählen...",

+ 4 - 1
messages/en/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Open Integrations",
+    "channelNotInPlan": "{channel} is not included in your plan."
   },
   "searchableSelect": {
     "choose": "Choose...",

+ 4 - 1
messages/es/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Abrir Integraciones",
+    "channelNotInPlan": "{channel} no está incluido en tu plan."
   },
   "searchableSelect": {
     "choose": "Elegir...",

+ 4 - 1
messages/fr/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Ouvrir Intégrations",
+    "channelNotInPlan": "{channel} n'est pas inclus dans votre forfait."
   },
   "searchableSelect": {
     "choose": "Choisir...",

+ 4 - 1
messages/it/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Apri Integrazioni",
+    "channelNotInPlan": "{channel} non è incluso nel tuo piano."
   },
   "searchableSelect": {
     "choose": "Scegli...",

+ 4 - 1
messages/lt/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Atidaryti Integracijas",
+    "channelNotInPlan": "{channel} neįtrauktas į jūsų planą."
   },
   "searchableSelect": {
     "choose": "Pasirinkite...",

+ 4 - 1
messages/nb/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Åpne Integrasjoner",
+    "channelNotInPlan": "{channel} er ikke inkludert i planen din."
   },
   "searchableSelect": {
     "choose": "Velg...",

+ 4 - 1
messages/nl/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Integraties openen",
+    "channelNotInPlan": "{channel} zit niet in je abonnement."
   },
   "searchableSelect": {
     "choose": "Kiezen...",

+ 4 - 1
messages/pl/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Otwórz Integracje",
+    "channelNotInPlan": "{channel} nie jest objęty Twoim planem."
   },
   "searchableSelect": {
     "choose": "Wybierz...",

+ 4 - 1
messages/pt-BR/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Abrir Integrações",
+    "channelNotInPlan": "{channel} não está incluído no seu plano."
   },
   "searchableSelect": {
     "choose": "Escolher...",

+ 4 - 1
messages/pt-PT/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Abrir Integrações",
+    "channelNotInPlan": "{channel} não está incluído no seu plano."
   },
   "searchableSelect": {
     "choose": "Escolher...",

+ 4 - 1
messages/ru/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "generateFailed": "Не удалось создать ссылку. Попробуйте ещё раз.",
-    "revokeFailed": "Не удалось подтвердить отзыв ссылки. Она может по-прежнему работать для всех, у кого она есть."
+    "revokeFailed": "Не удалось подтвердить отзыв ссылки. Она может по-прежнему работать для всех, у кого она есть.",
+    "channelNotConnected": "Подключите {channel} в разделе Интеграции, чтобы отправлять этим способом.",
+    "openIntegrations": "Открыть Интеграции",
+    "channelNotInPlan": "{channel} не входит в ваш тариф."
   },
   "searchableSelect": {
     "choose": "Выберите...",

+ 4 - 1
messages/tr/common.json

@@ -227,7 +227,10 @@
   },
   "shareLink": {
     "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.",
+    "openIntegrations": "Entegrasyonları aç",
+    "channelNotInPlan": "{channel} planınıza dahil değil."
   },
   "searchableSelect": {
     "choose": "Seçin...",

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

@@ -0,0 +1,186 @@
+/**
+ * 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()
+  })
+})

+ 3 - 0
src/__tests__/features/share-link-revoke.test.tsx

@@ -44,6 +44,9 @@ vi.mock('@/features/sms/Actions/smsActions', () => ({
   sendSmsToCustomer: vi.fn(),
   getSmsTemplates: vi.fn(),
 }))
+vi.mock('@/features/messaging/Lib/useShareChannels', () => ({
+  useShareChannels: () => ({ sms: 'ready', isCloud: false }),
+}))
 vi.mock('@/features/email/Actions/emailPreferenceActions', () => ({
   getAttachPdfDefault: async () => ({ success: true, data: { attachPdf: true } }),
 }))

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

@@ -96,7 +96,6 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         distanceUnit={settings[SETTING_KEYS.UNIT_SYSTEM] === 'metric' ? 'km' : 'mi'}
         laborPresets={laborPresets}
         inventoryParts={inventoryParts}
-        smsEnabled={features?.sms ?? false}
         emailEnabled={emailEnabled}
       />
     </div>

+ 0 - 1
src/features/inspections/Components/InspectionPageClient.tsx

@@ -1166,7 +1166,6 @@ export function InspectionPageClient({
         organizationId={inspection.organizationId}
         publicToken={inspection.publicToken}
         customer={inspection.vehicle.customer}
-        smsEnabled={smsEnabled}
         emailEnabled={emailEnabled}
       />
 

+ 17 - 23
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -24,6 +24,8 @@ import {
 } 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'
@@ -40,7 +42,6 @@ interface InspectionShareDialogProps {
     email: string | null
     phone: string | null
   } | null
-  smsEnabled?: boolean
   emailEnabled?: boolean
 }
 
@@ -51,7 +52,6 @@ export function InspectionShareDialog({
   organizationId,
   publicToken,
   customer,
-  smsEnabled = false,
   emailEnabled = false,
 }: InspectionShareDialogProps) {
   const t = useTranslations('inspections.shareDialog')
@@ -66,6 +66,8 @@ export function InspectionShareDialog({
   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
@@ -132,7 +134,7 @@ export function InspectionShareDialog({
       }
     }
 
-    if (notifySms && hasPhone) {
+    if (smsReady && notifySms) {
       const tplResult = await getSmsTemplates().catch(() => null)
       const tplData = tplResult?.success && tplResult.data ? tplResult.data : null
       const tpl =
@@ -166,7 +168,7 @@ export function InspectionShareDialog({
     if (results.length > 0 && !failed) onOpenChange(false)
   }
 
-  const canNotify = shareUrl && customer && (notifyEmail || notifySms)
+  const canNotify = shareUrl && customer && (notifyEmail || (smsReady && notifySms))
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
@@ -196,7 +198,7 @@ export function InspectionShareDialog({
               </div>
 
               {/* Notify customer */}
-              {customer && (emailEnabled || smsEnabled) && (
+              {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">
@@ -228,24 +230,16 @@ export function InspectionShareDialog({
                         />
                       </div>
                     )}
-                    {smsEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-sms-inspection"
-                          checked={notifySms}
-                          onCheckedChange={(v) => setNotifySms(v === true)}
-                          disabled={!hasPhone}
-                        />
-                        <Label
-                          htmlFor="notify-sms-inspection"
-                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <MessageSquare className="h-3.5 w-3.5" />
-                          {t('sms')}
-                          {!hasPhone && <span className="text-xs">{t('noPhone')}</span>}
-                        </Label>
-                      </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. */}

+ 40 - 0
src/features/messaging/Actions/getShareChannels.ts

@@ -0,0 +1,40 @@
+'use server'
+
+import { isCloudInstance } from '@/lib/cloud-instance'
+import { getFeatures } from '@/lib/features'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { getOrgSmsProvider } from '@/lib/sms'
+import { withAuth } from '@/lib/with-auth'
+
+/**
+ * Why a channel can or cannot carry a share link right now. The plan comes
+ * first: a workshop whose plan has no SMS gains nothing from being told to
+ * connect a provider.
+ */
+export type ShareChannelStatus = 'ready' | 'not_connected' | 'not_in_plan'
+
+/**
+ * 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
+ * learns the option exists and where to turn it on.
+ */
+export async function getShareChannels() {
+  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'
+      }
+
+      return { sms, isCloud: isCloudInstance() }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}

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

@@ -0,0 +1,88 @@
+'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>
+  )
+}

+ 34 - 0
src/features/messaging/Lib/useShareChannels.ts

@@ -0,0 +1,34 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+import { z } from 'zod'
+import { getShareChannels } from '../Actions/getShareChannels'
+
+const statusSchema = z.enum(['ready', 'not_connected', 'not_in_plan'])
+
+const shareChannelsSchema = z.object({
+  sms: statusSchema,
+  isCloud: z.boolean(),
+})
+
+export type ShareChannels = z.infer<typeof shareChannelsSchema>
+
+/**
+ * The state of each channel a share dialog offers, asked when the dialog
+ * opens. Undefined until the answer is in, and a channel stays unticked for
+ * that moment rather than promising something it may not be able to do.
+ */
+export function useShareChannels(open: boolean): ShareChannels | undefined {
+  const { data } = useQuery({
+    queryKey: ['share-channels'],
+    queryFn: async () => {
+      const res = await getShareChannels()
+      if (!res.success) throw new Error(res.error)
+      return shareChannelsSchema.parse(res.data)
+    },
+    enabled: open,
+    staleTime: 60_000,
+    retry: false,
+  })
+  return data
+}

+ 0 - 3
src/features/quotes/Components/QuotePageClient.tsx

@@ -102,7 +102,6 @@ export function QuotePageClient({
   distanceUnit,
   laborPresets = [],
   inventoryParts = [],
-  smsEnabled = false,
   emailEnabled = false,
   imageAttachments = [],
   documentAttachments = [],
@@ -133,7 +132,6 @@ export function QuotePageClient({
   distanceUnit: string
   laborPresets?: LaborPresetOption[]
   inventoryParts?: InventoryPartOption[]
-  smsEnabled?: boolean
   emailEnabled?: boolean
   imageAttachments?: QuoteAttachment[]
   documentAttachments?: QuoteAttachment[]
@@ -619,7 +617,6 @@ export function QuotePageClient({
               initialToken={quote.publicToken}
               onSent={handleQuoteSent}
               customer={quote.customer}
-              smsEnabled={smsEnabled}
               emailEnabled={emailEnabled}
             />
 

+ 17 - 23
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -17,6 +17,8 @@ import {
 } 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'
@@ -35,7 +37,6 @@ interface QuoteShareDialogProps {
     email: string | null
     phone: string | null
   } | null
-  smsEnabled?: boolean
   emailEnabled?: boolean
 }
 
@@ -47,7 +48,6 @@ export function QuoteShareDialog({
   initialToken,
   onSent,
   customer,
-  smsEnabled = false,
   emailEnabled = false,
 }: QuoteShareDialogProps) {
   const [publicToken, setPublicToken] = useState<string | null>(initialToken)
@@ -63,6 +63,8 @@ export function QuoteShareDialog({
 
   const hasEmail = !!customer?.email
   const hasPhone = !!customer?.phone
+  const channels = useShareChannels(open)
+  const smsReady = channels?.sms === 'ready' && hasPhone
 
   const publicUrl =
     publicToken && organizationId
@@ -130,7 +132,7 @@ export function QuoteShareDialog({
       }
     }
 
-    if (notifySms && hasPhone) {
+    if (smsReady && notifySms) {
       const tplResult = await getSmsTemplates()
       const tplData = tplResult.success && tplResult.data ? tplResult.data : null
       const tpl =
@@ -168,7 +170,7 @@ export function QuoteShareDialog({
     if (results.length > 0 && !failed) onOpenChange(false)
   }
 
-  const canNotify = publicUrl && customer && (notifyEmail || notifySms)
+  const canNotify = publicUrl && customer && (notifyEmail || (smsReady && notifySms))
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
@@ -200,7 +202,7 @@ export function QuoteShareDialog({
               </div>
 
               {/* Notify customer */}
-              {customer && (emailEnabled || smsEnabled) && (
+              {customer && (
                 <div className="space-y-3 rounded-lg border p-3">
                   <p className="text-sm font-medium">
                     {t('shareDialog.notifyTitle', { name: customer.name })}
@@ -234,24 +236,16 @@ export function QuoteShareDialog({
                         />
                       </div>
                     )}
-                    {smsEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-sms-quote"
-                          checked={notifySms}
-                          onCheckedChange={(v) => setNotifySms(v === true)}
-                          disabled={!hasPhone}
-                        />
-                        <Label
-                          htmlFor="notify-sms-quote"
-                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <MessageSquare className="h-3.5 w-3.5" />
-                          {t('shareDialog.sms')}
-                          {!hasPhone && <span className="text-xs">{t('shareDialog.noPhone')}</span>}
-                        </Label>
-                      </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. */}

+ 17 - 23
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -14,6 +14,8 @@ import { toast } from 'sonner'
 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'
@@ -32,7 +34,6 @@ interface ShareDialogProps {
     email: string | null
     phone: string | null
   } | null
-  smsEnabled?: boolean
   emailEnabled?: boolean
 }
 
@@ -44,7 +45,6 @@ export function ShareDialog({
   initialToken,
   onSent,
   customer,
-  smsEnabled = false,
   emailEnabled = false,
 }: ShareDialogProps) {
   const t = useTranslations('service.share')
@@ -60,6 +60,8 @@ export function ShareDialog({
 
   const hasEmail = !!customer?.email
   const hasPhone = !!customer?.phone
+  const channels = useShareChannels(open)
+  const smsReady = channels?.sms === 'ready' && hasPhone
 
   const publicUrl =
     publicToken && organizationId
@@ -127,7 +129,7 @@ export function ShareDialog({
       }
     }
 
-    if (notifySms && hasPhone) {
+    if (smsReady && notifySms) {
       const tplResult = await getSmsTemplates()
       const tplData = tplResult.success && tplResult.data ? tplResult.data : null
       const tpl =
@@ -167,7 +169,7 @@ export function ShareDialog({
     if (results.length > 0 && !failed) onOpenChange(false)
   }
 
-  const canNotify = publicUrl && customer && (notifyEmail || notifySms)
+  const canNotify = publicUrl && customer && (notifyEmail || (smsReady && notifySms))
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
@@ -199,7 +201,7 @@ export function ShareDialog({
               </div>
 
               {/* Notify customer */}
-              {customer && (emailEnabled || smsEnabled) && (
+              {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">
@@ -231,24 +233,16 @@ export function ShareDialog({
                         />
                       </div>
                     )}
-                    {smsEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-sms"
-                          checked={notifySms}
-                          onCheckedChange={(v) => setNotifySms(v === true)}
-                          disabled={!hasPhone}
-                        />
-                        <Label
-                          htmlFor="notify-sms"
-                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <MessageSquare className="h-3.5 w-3.5" />
-                          {t('sms')}
-                          {!hasPhone && <span className="text-xs">{t('noPhone')}</span>}
-                        </Label>
-                      </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. */}

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

@@ -821,7 +821,6 @@ export function ServicePageClient({
               initialToken={record.publicToken}
               onSent={handleInvoiceSent}
               customer={customer}
-              smsEnabled={smsEnabled}
               emailEnabled={emailEnabled}
             />