Procházet zdrojové kódy

Link the invoice QR to the customer, answer a bare /start, show the connect QR on the Telegram tab, count unread in the tab title (#383)

Bernt Christian Egeland před 2 týdny
rodič
revize
70a88b594a

+ 28 - 3
src/__tests__/features/invoices/telegram-qr.test.ts

@@ -21,6 +21,7 @@ vi.mock('@/lib/telegram', () => ({
 
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import {
+  documentCustomerId,
   telegramBotLink,
   telegramQrForPrint,
   telegramQrWanted,
@@ -57,14 +58,23 @@ describe('telegramQrForPrint', () => {
     expect(await telegramQrForPrint('org1', layoutWithTelegram(true))).toBeNull()
   })
 
-  it('encodes the connected bot link when the block is on', async () => {
+  it('encodes the bot link that names the customer, so scanning links them', async () => {
+    // A bare bot link opens the bot and links nobody: a customer who scanned
+    // it stayed unknown, and their message went nowhere. The code on a
+    // document carries the customer the document is for.
     lookup = async () => 'eigeland_bot'
-    const qr = await telegramQrForPrint('org1', layoutWithTelegram(true))
-    expect(qr?.link).toBe('https://t.me/eigeland_bot')
+    const qr = await telegramQrForPrint('org1', layoutWithTelegram(true), 'cust_42')
+    expect(qr?.link).toBe('https://t.me/eigeland_bot?start=cust_42')
     expect(qr?.dataUri.startsWith('data:image/png;base64,')).toBe(true)
     expect(lookups).toEqual(['org1'])
   })
 
+  it('falls back to the plain bot link for a document with no customer', async () => {
+    lookup = async () => 'eigeland_bot'
+    const qr = await telegramQrForPrint('org1', layoutWithTelegram(true), null)
+    expect(qr?.link).toBe('https://t.me/eigeland_bot')
+  })
+
   it('never lets a broken integration cost the document', async () => {
     const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined)
     lookup = async () => {
@@ -75,6 +85,21 @@ describe('telegramQrForPrint', () => {
     quiet.mockRestore()
   })
 
+  it('names the customer in the link, and only then', () => {
+    expect(telegramBotLink('shop_bot', 'cust_1')).toBe('https://t.me/shop_bot?start=cust_1')
+    expect(telegramBotLink('shop_bot', null)).toBe('https://t.me/shop_bot')
+    expect(telegramBotLink('shop_bot', '')).toBe('https://t.me/shop_bot')
+  })
+
+  it('takes the customer from the document, or from the car it is on', () => {
+    expect(documentCustomerId({ customerId: 'direct', vehicle: { customerId: 'owner' } })).toBe(
+      'direct'
+    )
+    expect(documentCustomerId({ customerId: null, vehicle: { customerId: 'owner' } })).toBe('owner')
+    expect(documentCustomerId({ customerId: null, vehicle: null })).toBeNull()
+    expect(documentCustomerId({})).toBeNull()
+  })
+
   it('reads a username with or without the @', () => {
     expect(telegramBotLink('@shop_bot')).toBe('https://t.me/shop_bot')
     expect(telegramBotLink('shop_bot')).toBe('https://t.me/shop_bot')

+ 68 - 0
src/__tests__/features/notifications/unread-tab-title.test.tsx

@@ -0,0 +1,68 @@
+/**
+ * The unread count in the browser tab: "(3) Messages · Torqvoice" while
+ * something is unread, the plain title once nothing is, and never a badge on
+ * top of a badge when the title is rewritten by a navigation.
+ */
+import { act, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { badgedTitle, useUnreadTabTitle } from '@/features/notifications/hooks/useUnreadTabTitle'
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
+
+function Badge() {
+  useUnreadTabTitle()
+  return null
+}
+
+describe('badgedTitle', () => {
+  it('prefixes the count and strips an old one first', () => {
+    expect(badgedTitle('Messages', 3)).toBe('(3) Messages')
+    expect(badgedTitle('(2) Messages', 3)).toBe('(3) Messages')
+    expect(badgedTitle('(3) Messages', 0)).toBe('Messages')
+    expect(badgedTitle('Messages', 0)).toBe('Messages')
+    expect(badgedTitle('Messages', 250)).toBe('(99+) Messages')
+    expect(badgedTitle('(99+) Messages', 1)).toBe('(1) Messages')
+  })
+
+  it('leaves a title that happens to start with brackets alone', () => {
+    expect(badgedTitle('(draft) Quote', 2)).toBe('(2) (draft) Quote')
+  })
+})
+
+describe('useUnreadTabTitle', () => {
+  afterEach(() => {
+    act(() => useNotificationStore.getState().setNotifications([], 0))
+    document.title = ''
+  })
+
+  it('follows the store, and takes the badge with it on unmount', () => {
+    document.head.appendChild(document.createElement('title'))
+    document.title = 'Dashboard'
+    const view = render(<Badge />)
+    expect(document.title).toBe('Dashboard')
+
+    act(() => useNotificationStore.getState().setNotifications([], 2))
+    expect(document.title).toBe('(2) Dashboard')
+
+    act(() => useNotificationStore.getState().setNotifications([], 0))
+    expect(document.title).toBe('Dashboard')
+
+    act(() => useNotificationStore.getState().setNotifications([], 5))
+    expect(document.title).toBe('(5) Dashboard')
+    view.unmount()
+    expect(document.title).toBe('Dashboard')
+  })
+
+  it('re-applies the badge after a navigation rewrites the title', async () => {
+    document.title = 'Dashboard'
+    render(<Badge />)
+    act(() => useNotificationStore.getState().setNotifications([], 4))
+    expect(document.title).toBe('(4) Dashboard')
+
+    // What Next does on a route change: a new title, badge gone.
+    document.title = 'Messages'
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 0))
+    })
+    expect(document.title).toBe('(4) Messages')
+  })
+})

+ 138 - 0
src/__tests__/features/telegram/webhook-start.test.ts

@@ -0,0 +1,138 @@
+// @vitest-environment node
+/**
+ * What the Telegram webhook does with the first thing a customer sends.
+ *
+ * A customer arrives one of two ways: through a link that names them, which
+ * sends `/start <customerId>` and ties their chat to that customer, or by
+ * opening the bot by name, which sends a bare `/start`. The second used to be
+ * filed as a message from nobody and raised a notification that led to the
+ * integrations page; now the bot tells them what to do and files nothing.
+ * After linking, an ordinary message lands on the customer and its
+ * notification leads to the inbox.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { db, sendTelegramMessage, notify } = vi.hoisted(() => ({
+  db: {
+    customer: { findFirst: vi.fn(), update: vi.fn() },
+    telegramMessage: { create: vi.fn() },
+  },
+  sendTelegramMessage: vi.fn(),
+  notify: vi.fn(),
+}))
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/lib/telegram', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/telegram')>()),
+  getOrgTelegramWebhookSecret: async () => 'hook-secret',
+  sendTelegramMessage,
+}))
+vi.mock('@/lib/notify', () => ({ notify }))
+
+import { POST } from '@/app/api/webhooks/telegram/[organizationId]/route'
+import { BARE_START_REPLY } from '@/lib/telegram'
+
+const ORG = 'org_1'
+
+function update(text: string, chatId = 4242) {
+  return new Request(`http://app.test/api/webhooks/telegram/${ORG}`, {
+    method: 'POST',
+    headers: {
+      'content-type': 'application/json',
+      'x-telegram-bot-api-secret-token': 'hook-secret',
+    },
+    body: JSON.stringify({
+      message: { message_id: 7, chat: { id: chatId, first_name: 'Jane' }, text },
+    }),
+  })
+}
+
+const params = Promise.resolve({ organizationId: ORG })
+
+describe('the Telegram webhook', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    db.telegramMessage.create.mockResolvedValue({ id: 'msg_1' })
+  })
+
+  it('links the chat to the customer named in a deep link, and says so', async () => {
+    db.customer.findFirst.mockResolvedValue({ id: 'cust_42', name: 'Jane Cooper' })
+    db.customer.update.mockResolvedValue({})
+
+    const response = await POST(update('/start cust_42'), { params })
+
+    expect(response.status).toBe(200)
+    expect(db.customer.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'cust_42', organizationId: ORG } })
+    )
+    expect(db.customer.update).toHaveBeenCalledWith({
+      where: { id: 'cust_42' },
+      data: { telegramChatId: '4242' },
+    })
+    expect(sendTelegramMessage).toHaveBeenCalledWith(
+      ORG,
+      expect.objectContaining({ chatId: '4242', text: expect.stringContaining('now linked') })
+    )
+    expect(db.telegramMessage.create, 'a link is not a message').not.toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
+
+  it('tells a stranger who opened the bot by name how to connect, and files nothing', async () => {
+    const response = await POST(update('/start'), { params })
+
+    expect(response.status).toBe(200)
+    expect(sendTelegramMessage).toHaveBeenCalledWith(ORG, {
+      chatId: '4242',
+      text: BARE_START_REPLY,
+    })
+    expect(db.customer.update).not.toHaveBeenCalled()
+    expect(db.telegramMessage.create).not.toHaveBeenCalled()
+    expect(notify, 'nothing for the workshop to act on').not.toHaveBeenCalled()
+  })
+
+  it('does not link a chat to a customer of another workshop', async () => {
+    db.customer.findFirst.mockResolvedValue(null)
+
+    await POST(update('/start cust_of_someone_else'), { params })
+
+    expect(db.customer.update).not.toHaveBeenCalled()
+    expect(sendTelegramMessage).not.toHaveBeenCalled()
+  })
+
+  it('files a message from a linked chat on the customer, with a link to the inbox', async () => {
+    db.customer.findFirst.mockResolvedValue({ id: 'cust_42', name: 'Jane Cooper' })
+
+    const response = await POST(update('Is the car ready?'), { params })
+
+    expect(response.status).toBe(200)
+    expect(db.customer.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { organizationId: ORG, telegramChatId: '4242' } })
+    )
+    expect(db.telegramMessage.create).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        direction: 'inbound',
+        chatId: '4242',
+        body: 'Is the car ready?',
+        customerId: 'cust_42',
+        organizationId: ORG,
+      }),
+    })
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({
+        type: 'telegram_inbound',
+        message: 'Jane Cooper: Is the car ready?',
+        entityUrl: '/messages?tab=telegram&customerId=cust_42',
+      })
+    )
+  })
+
+  it('drops a delivery that does not carry the webhook secret', async () => {
+    const request = new Request(`http://app.test/api/webhooks/telegram/${ORG}`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ message: { message_id: 1, chat: { id: 1 }, text: 'hi' } }),
+    })
+    const response = await POST(request, { params })
+    expect(response.status).toBe(200)
+    expect(db.telegramMessage.create).not.toHaveBeenCalled()
+  })
+})

+ 2 - 0
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -708,6 +708,8 @@ export function CustomerDetailClient({
               <TelegramConversation
                 customerId={customer.id}
                 customerName={customer.name}
+                customerEmail={customer.email}
+                botUsername={telegramBotUsername}
                 telegramChatId={telegramChatId}
                 initialMessages={telegramMessages}
                 initialNextCursor={telegramNextCursor}

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

@@ -10,7 +10,7 @@ import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
-import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
+import { documentCustomerId, telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
 import { getAppBaseUrl } from '@/lib/app-url'
 
@@ -116,7 +116,7 @@ export default async function PublicInvoicePage({
   // shared copy and the download are the same document.
   const [torqvoiceLogoDataUri, telegramQr] = await Promise.all([
     features.brandingRemoved ? undefined : getTorqvoiceLogoDataUri(),
-    telegramQrForPrint(orgId, assembly.layoutConfig),
+    telegramQrForPrint(orgId, assembly.layoutConfig, documentCustomerId(assembly.record)),
   ])
 
   const spec = buildInvoicePrintSpec({

+ 16 - 3
src/app/api/webhooks/telegram/[organizationId]/route.ts

@@ -1,6 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
-import { getOrgTelegramWebhookSecret, sendTelegramMessage } from '@/lib/telegram'
+import { BARE_START_REPLY, getOrgTelegramWebhookSecret, sendTelegramMessage } from '@/lib/telegram'
 import { notify } from '@/lib/notify'
 import { safeEqual } from '@/lib/webhook-signatures'
 
@@ -57,10 +57,15 @@ export async function POST(
     const telegramMessageId = String(msg.message_id)
 
     // Handle /start deep-link command: /start {customerId}
-    if (text.startsWith('/start ')) {
-      const customerId = text.slice(7).trim()
+    if (text === '/start' || text.startsWith('/start ')) {
+      const customerId = text.slice('/start'.length).trim()
       if (customerId) {
         await handleStartCommand(organizationId, chatId, customerId, msg.chat.first_name)
+      } else {
+        // Somebody opened the bot by name rather than through a link that
+        // names them, so there is nobody to link them to. Not a message to
+        // file or to raise a notification for; say what to do instead.
+        await handleBareStart(organizationId, chatId)
       }
       return NextResponse.json({ ok: true })
     }
@@ -109,6 +114,14 @@ export async function POST(
   }
 }
 
+async function handleBareStart(organizationId: string, chatId: string) {
+  try {
+    await sendTelegramMessage(organizationId, { chatId, text: BARE_START_REPLY })
+  } catch (error) {
+    console.error('[webhook/telegram] Could not answer a bare /start:', error)
+  }
+}
+
 async function handleStartCommand(
   organizationId: string,
   chatId: string,

+ 2 - 0
src/features/invoices/Lib/assembleInvoicePrint.ts

@@ -68,6 +68,8 @@ const RECORD_INCLUDE = {
   customer: { select: PARTY_SELECT },
   vehicle: {
     select: {
+      // Whose car it is, for the Telegram code that links the scanner to them.
+      customerId: true,
       make: true,
       model: true,
       year: true,

+ 24 - 7
src/features/invoices/Lib/telegramQr.ts

@@ -22,9 +22,24 @@ export function telegramQrWanted(layout: Pick<InvoiceLayoutConfig, 'sections'>):
   return layout.sections.some((s) => s.id === TELEGRAM_QR_SECTION && s.visible)
 }
 
-/** The t.me link a bot's username resolves to. */
-export function telegramBotLink(username: string): string {
-  return `https://t.me/${username.replace(/^@/, '')}`
+/**
+ * The t.me link a bot's username resolves to. With a customer, it is the deep
+ * link that ties the scanner's chat to that customer: pressing Start then
+ * sends `/start <customerId>`, which the webhook links. Without one the bot
+ * only opens, and a bare `/start` links nobody, which is what the invoice
+ * used to print and why a customer scanning it stayed unknown.
+ */
+export function telegramBotLink(username: string, customerId?: string | null): string {
+  const base = `https://t.me/${username.replace(/^@/, '')}`
+  return customerId ? `${base}?start=${encodeURIComponent(customerId)}` : base
+}
+
+/** The customer a document is for: its own, or the owner of the vehicle it is on. */
+export function documentCustomerId(record: {
+  customerId?: string | null
+  vehicle?: { customerId?: string | null } | null
+}): string | null {
+  return record.customerId ?? record.vehicle?.customerId ?? null
 }
 
 export interface TelegramQr {
@@ -33,19 +48,21 @@ export interface TelegramQr {
 }
 
 /**
- * The code to print for this organisation's sheet, or null when the design
- * leaves the block off or no bot is connected. A failing lookup or encoder
+ * The code to print for this organisation's sheet, linking the scanner to
+ * the document's customer, or null when the design leaves the block off or
+ * no bot is connected. A failing lookup or encoder
  * costs the code, never the document.
  */
 export async function telegramQrForPrint(
   organizationId: string,
-  layout: Pick<InvoiceLayoutConfig, 'sections'>
+  layout: Pick<InvoiceLayoutConfig, 'sections'>,
+  customerId?: string | null
 ): Promise<TelegramQr | null> {
   if (!telegramQrWanted(layout)) return null
   try {
     const username = await getOrgTelegramBotUsername(organizationId)
     if (!username) return null
-    const link = telegramBotLink(username)
+    const link = telegramBotLink(username, customerId)
     return { link, dataUri: await generateQrDataUri(link, 200) }
   } catch (error) {
     console.error('[telegram] Could not build the invoice QR code:', error)

+ 6 - 2
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -33,7 +33,7 @@ import {
   type InvoicePrintAssembly,
   invoiceNumberOf,
 } from '../Lib/assembleInvoicePrint'
-import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
+import { documentCustomerId, telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { getAppBaseUrl } from '@/lib/app-url'
 
 /** The job's own files, printed into the workshop's copy only. */
@@ -67,7 +67,11 @@ export async function renderInvoicePdf(
     ? `${getAppBaseUrl()}/portal/${org?.portalSlug || orgId}`
     : undefined
 
-  const telegramQr = await telegramQrForPrint(orgId, layoutConfig)
+  const telegramQr = await telegramQrForPrint(
+    orgId,
+    layoutConfig,
+    documentCustomerId(assembly.record)
+  )
 
   const element = React.createElement(InvoicePDF, {
     data: assembly.data,

+ 2 - 0
src/features/notifications/Components/NotificationInitializer.tsx

@@ -1,8 +1,10 @@
 'use client'
 
 import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket'
+import { useUnreadTabTitle } from '../hooks/useUnreadTabTitle'
 
 export function NotificationInitializer() {
   useNotificationWebSocket()
+  useUnreadTabTitle()
   return null
 }

+ 40 - 0
src/features/notifications/hooks/useUnreadTabTitle.ts

@@ -0,0 +1,40 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useNotificationStore } from '../store/notificationStore'
+
+const BADGE = /^\(\d+\+?\) /
+
+/** The browser tab's title with the unread count in front, or without one at zero. */
+export function badgedTitle(title: string, unreadCount: number): string {
+  const bare = title.replace(BADGE, '')
+  if (unreadCount <= 0) return bare
+  return `(${unreadCount > 99 ? '99+' : unreadCount}) ${bare}`
+}
+
+/**
+ * Puts the unread count in the browser tab, the way a mail client does, so a
+ * message that arrives while the workshop is on another tab is seen. Next
+ * rewrites the title on every navigation, so the badge is re-applied
+ * whenever the title changes, not only when the count does.
+ */
+export function useUnreadTabTitle(): void {
+  const unreadCount = useNotificationStore((state) => state.unreadCount)
+
+  useEffect(() => {
+    const apply = () => {
+      const wanted = badgedTitle(document.title, unreadCount)
+      if (document.title !== wanted) document.title = wanted
+    }
+    apply()
+
+    const title = document.querySelector('title')
+    if (!title) return
+    const observer = new MutationObserver(apply)
+    observer.observe(title, { childList: true, characterData: true, subtree: true })
+    return () => {
+      observer.disconnect()
+      document.title = badgedTitle(document.title, 0)
+    }
+  }, [unreadCount])
+}

+ 6 - 2
src/features/portal/Actions/portalActions.ts

@@ -13,7 +13,7 @@ import { headers } from 'next/headers'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint } from '@/features/invoices/Lib/assembleInvoicePrint'
-import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
+import { documentCustomerId, telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
@@ -307,7 +307,11 @@ export async function getPortalInvoiceSheet(invoiceId: string) {
     const [labels, features, telegramQr] = await Promise.all([
       loadPrintLabels(locale, assembly.labelSettings),
       getFeatures(organizationId),
-      telegramQrForPrint(organizationId, assembly.layoutConfig),
+      telegramQrForPrint(
+        organizationId,
+        assembly.layoutConfig,
+        documentCustomerId(assembly.record)
+      ),
     ])
     const torqvoiceLogoDataUri = features.brandingRemoved
       ? undefined

+ 32 - 3
src/features/telegram/Components/TelegramConversation.tsx

@@ -15,6 +15,7 @@ import {
   AlertDialogTitle,
 } from '@/components/ui/alert-dialog'
 import { Loader2, Send, ChevronUp } from 'lucide-react'
+import { TelegramConnectCard } from './TelegramQrCode'
 import { cn } from '@/lib/utils'
 import {
   sendTelegramToCustomer,
@@ -30,6 +31,13 @@ interface TelegramConversationProps {
   customerId: string
   customerName: string
   telegramChatId: string | null
+  /**
+   * The bot's username, when the caller has it: the customer's page does,
+   * and shows the connect QR in place of the empty state; the inbox only
+   * opens linked chats and leaves it out.
+   */
+  botUsername?: string | null
+  customerEmail?: string | null
   initialMessages: TelegramMessage[]
   initialNextCursor: string | null
   className?: string
@@ -39,6 +47,8 @@ export function TelegramConversation({
   customerId,
   customerName,
   telegramChatId,
+  botUsername,
+  customerEmail,
   initialMessages,
   initialNextCursor,
   className,
@@ -163,10 +173,29 @@ export function TelegramConversation({
   }
 
   if (!telegramChatId) {
+    // Nothing to read yet, so the space shows how to change that: the same
+    // QR and link as the header button, large enough to scan across a desk.
     return (
-      <div className="flex flex-col items-center justify-center py-12">
-        <Send className="mb-3 h-10 w-10 text-muted-foreground/40" />
-        <p className="text-sm text-muted-foreground">{t('notLinked')}</p>
+      <div
+        className="flex flex-col items-center justify-center px-4 py-8"
+        data-testid="telegram-not-linked"
+      >
+        {botUsername ? (
+          <>
+            <p className="mb-2 text-center text-sm text-muted-foreground">{t('notLinked')}</p>
+            <TelegramConnectCard
+              botUsername={botUsername}
+              customerId={customerId}
+              customerName={customerName}
+              customerEmail={customerEmail}
+            />
+          </>
+        ) : (
+          <>
+            <Send className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm text-muted-foreground">{t('notLinked')}</p>
+          </>
+        )}
       </div>
     )
   }

+ 62 - 31
src/features/telegram/Components/TelegramQrCode.tsx

@@ -8,7 +8,13 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
 import { Check, Copy, Mail, Send } from 'lucide-react'
 import { toast } from 'sonner'
 
-export function TelegramQrCode({
+/**
+ * The QR code and link a customer uses to connect, with the ways to hand it
+ * over. Shown in a dialog from the customer's header, and inline on the
+ * Telegram tab while the customer has not connected yet, so the desk can
+ * turn the screen round rather than hunt for a button.
+ */
+export function TelegramConnectCard({
   botUsername,
   customerId,
   customerName,
@@ -20,7 +26,6 @@ export function TelegramQrCode({
   customerEmail?: string | null
 }) {
   const t = useTranslations('telegram.qr')
-  const [open, setOpen] = useState(false)
   const [copied, setCopied] = useState(false)
 
   const deepLink = `https://t.me/${botUsername}?start=${customerId}`
@@ -32,6 +37,54 @@ export function TelegramQrCode({
     setTimeout(() => setCopied(false), 2000)
   }
 
+  return (
+    <div className="flex w-full max-w-sm flex-col items-center gap-4 py-4">
+      <div className="rounded-xl bg-white p-4">
+        <QRCodeSVG value={deepLink} size={200} />
+      </div>
+      <p className="text-center text-sm text-muted-foreground">
+        {t('description', { name: customerName })}
+      </p>
+      <div className="flex w-full gap-2">
+        <Button variant="outline" onClick={handleCopy} className="flex-1">
+          {copied ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
+          {copied ? t('copied') : t('copyLink')}
+        </Button>
+        {customerEmail && (
+          <Button
+            variant="outline"
+            className="flex-1"
+            onClick={() => {
+              const subject = encodeURIComponent(t('emailSubject'))
+              const body = encodeURIComponent(
+                t('emailBody', { name: customerName, link: deepLink })
+              )
+              window.open(`mailto:${customerEmail}?subject=${subject}&body=${body}`)
+            }}
+          >
+            <Mail className="mr-2 h-4 w-4" />
+            {t('sendEmail')}
+          </Button>
+        )}
+      </div>
+    </div>
+  )
+}
+
+export function TelegramQrCode({
+  botUsername,
+  customerId,
+  customerName,
+  customerEmail,
+}: {
+  botUsername: string
+  customerId: string
+  customerName: string
+  customerEmail?: string | null
+}) {
+  const t = useTranslations('telegram.qr')
+  const [open, setOpen] = useState(false)
+
   return (
     <>
       <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
@@ -44,35 +97,13 @@ export function TelegramQrCode({
           <DialogHeader>
             <DialogTitle className="text-center">{t('title')}</DialogTitle>
           </DialogHeader>
-          <div className="flex flex-col items-center gap-4 py-4">
-            <div className="rounded-xl bg-white p-4">
-              <QRCodeSVG value={deepLink} size={200} />
-            </div>
-            <p className="text-center text-sm text-muted-foreground">
-              {t('description', { name: customerName })}
-            </p>
-            <div className="flex w-full gap-2">
-              <Button variant="outline" onClick={handleCopy} className="flex-1">
-                {copied ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
-                {copied ? t('copied') : t('copyLink')}
-              </Button>
-              {customerEmail && (
-                <Button
-                  variant="outline"
-                  className="flex-1"
-                  onClick={() => {
-                    const subject = encodeURIComponent(t('emailSubject'))
-                    const body = encodeURIComponent(
-                      t('emailBody', { name: customerName, link: deepLink })
-                    )
-                    window.open(`mailto:${customerEmail}?subject=${subject}&body=${body}`)
-                  }}
-                >
-                  <Mail className="mr-2 h-4 w-4" />
-                  {t('sendEmail')}
-                </Button>
-              )}
-            </div>
+          <div className="flex justify-center">
+            <TelegramConnectCard
+              botUsername={botUsername}
+              customerId={customerId}
+              customerName={customerName}
+              customerEmail={customerEmail}
+            />
           </div>
         </DialogContent>
       </Dialog>

+ 8 - 0
src/lib/telegram.ts

@@ -28,6 +28,14 @@ export async function getOrgTelegramBotUsername(organizationId: string): Promise
 }
 
 /** The secret Telegram signs its webhook calls with. */
+/**
+ * What the bot says to somebody who opened it by name rather than through a
+ * link that names them: there is nobody to link the chat to, so say where
+ * such a link is found.
+ */
+export const BARE_START_REPLY =
+  'To connect this chat to your account, open the Telegram link or scan the QR code on your invoice or in the customer portal.'
+
 export async function getOrgTelegramWebhookSecret(organizationId: string): Promise<string | null> {
   const settings = await channelSettings(organizationId, 'telegram')
   return settings.get(ORG_TELEGRAM_KEYS.TELEGRAM_WEBHOOK_SECRET) || null