Browse Source

Suggest an integration from the catalog, with a read-more link to the docs (#343)

Bernt Christian Egeland 3 weeks ago
parent
commit
e881e2ce17

+ 6 - 1
messages/de/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Dienste, die Sie hinzufügen können. Jeder wird einmal eingerichtet und läuft im Hintergrund.",
     "allConnected": "Alle verfügbaren Dienste sind verbunden.",
     "searchPlaceholder": "Integrationen durchsuchen",
-    "noMatches": "Nichts passt zu „{query}“."
+    "noMatches": "Nichts passt zu „{query}“.",
+    "readMore": "Mehr erfahren",
+    "suggestTitle": "Fehlt ein Dienst, den Sie nutzen?",
+    "suggestDescription": "Sagen Sie uns, welche Integration Ihrer Werkstatt helfen würde, und wir prüfen sie.",
+    "suggest": "Integration vorschlagen",
+    "suggestSubject": "Integrationsvorschlag"
   },
   "categories": {
     "calendar": "Kalender",

+ 6 - 1
messages/en/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Services you can add. Each one is set up once and runs in the background.",
     "allConnected": "Every available service is connected.",
     "searchPlaceholder": "Search integrations",
-    "noMatches": "Nothing matches \"{query}\"."
+    "noMatches": "Nothing matches \"{query}\".",
+    "readMore": "Read more",
+    "suggestTitle": "Missing a service you use?",
+    "suggestDescription": "Tell us which integration would help your workshop and we will look into it.",
+    "suggest": "Suggest an integration",
+    "suggestSubject": "Integration suggestion"
   },
   "categories": {
     "calendar": "Calendar",

+ 6 - 1
messages/es/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Servicios que puede añadir. Cada uno se configura una vez y funciona en segundo plano.",
     "allConnected": "Todos los servicios disponibles están conectados.",
     "searchPlaceholder": "Buscar integraciones",
-    "noMatches": "Nada coincide con «{query}»."
+    "noMatches": "Nada coincide con «{query}».",
+    "readMore": "Leer más",
+    "suggestTitle": "¿Falta un servicio que usas?",
+    "suggestDescription": "Cuéntanos qué integración ayudaría a tu taller y la estudiaremos.",
+    "suggest": "Sugerir una integración",
+    "suggestSubject": "Sugerencia de integración"
   },
   "categories": {
     "calendar": "Calendario",

+ 6 - 1
messages/fr/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Services que vous pouvez ajouter. Chacun se configure une fois et fonctionne en arrière-plan.",
     "allConnected": "Tous les services disponibles sont connectés.",
     "searchPlaceholder": "Rechercher une intégration",
-    "noMatches": "Rien ne correspond à « {query} »."
+    "noMatches": "Rien ne correspond à « {query} ».",
+    "readMore": "En savoir plus",
+    "suggestTitle": "Il manque un service que vous utilisez ?",
+    "suggestDescription": "Dites-nous quelle intégration aiderait votre atelier et nous l'étudierons.",
+    "suggest": "Suggérer une intégration",
+    "suggestSubject": "Suggestion d'intégration"
   },
   "categories": {
     "calendar": "Calendrier",

+ 6 - 1
messages/it/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Servizi che puoi aggiungere. Ognuno si configura una volta e lavora in background.",
     "allConnected": "Tutti i servizi disponibili sono collegati.",
     "searchPlaceholder": "Cerca integrazioni",
-    "noMatches": "Nessun risultato per «{query}»."
+    "noMatches": "Nessun risultato per «{query}».",
+    "readMore": "Scopri di più",
+    "suggestTitle": "Manca un servizio che usi?",
+    "suggestDescription": "Dicci quale integrazione aiuterebbe la tua officina e la valuteremo.",
+    "suggest": "Suggerisci un'integrazione",
+    "suggestSubject": "Suggerimento di integrazione"
   },
   "categories": {
     "calendar": "Calendario",

+ 6 - 1
messages/lt/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Paslaugos, kurias galite pridėti. Kiekviena nustatoma vieną kartą ir veikia fone.",
     "allConnected": "Visos galimos paslaugos prijungtos.",
     "searchPlaceholder": "Ieškoti integracijų",
-    "noMatches": "Nieko nerasta pagal „{query}“."
+    "noMatches": "Nieko nerasta pagal „{query}“.",
+    "readMore": "Skaityti daugiau",
+    "suggestTitle": "Trūksta paslaugos, kurią naudojate?",
+    "suggestDescription": "Pasakykite, kokia integracija padėtų jūsų dirbtuvėms, ir mes ją apsvarstysime.",
+    "suggest": "Pasiūlyti integraciją",
+    "suggestSubject": "Integracijos pasiūlymas"
   },
   "categories": {
     "calendar": "Kalendorius",

+ 6 - 1
messages/nb/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Tjenester du kan legge til. Hver settes opp én gang og kjører i bakgrunnen.",
     "allConnected": "Alle tilgjengelige tjenester er tilkoblet.",
     "searchPlaceholder": "Søk i integrasjoner",
-    "noMatches": "Ingenting passer med «{query}»."
+    "noMatches": "Ingenting passer med «{query}».",
+    "readMore": "Les mer",
+    "suggestTitle": "Mangler du en tjeneste du bruker?",
+    "suggestDescription": "Fortell oss hvilken integrasjon som ville hjulpet verkstedet ditt, så ser vi på det.",
+    "suggest": "Foreslå en integrasjon",
+    "suggestSubject": "Forslag til integrasjon"
   },
   "categories": {
     "calendar": "Kalender",

+ 6 - 1
messages/nl/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Diensten die u kunt toevoegen. Elke dienst stelt u één keer in en draait daarna op de achtergrond.",
     "allConnected": "Alle beschikbare diensten zijn verbonden.",
     "searchPlaceholder": "Integraties zoeken",
-    "noMatches": "Niets komt overeen met \"{query}\"."
+    "noMatches": "Niets komt overeen met \"{query}\".",
+    "readMore": "Meer lezen",
+    "suggestTitle": "Mist u een dienst die u gebruikt?",
+    "suggestDescription": "Vertel ons welke integratie uw werkplaats zou helpen en we bekijken het.",
+    "suggest": "Een integratie voorstellen",
+    "suggestSubject": "Integratievoorstel"
   },
   "categories": {
     "calendar": "Agenda",

+ 6 - 1
messages/pl/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Usługi, które możesz dodać. Każdą konfiguruje się raz, a potem działa w tle.",
     "allConnected": "Wszystkie dostępne usługi są połączone.",
     "searchPlaceholder": "Szukaj integracji",
-    "noMatches": "Nic nie pasuje do „{query}”."
+    "noMatches": "Nic nie pasuje do „{query}”.",
+    "readMore": "Czytaj więcej",
+    "suggestTitle": "Brakuje usługi, z której korzystasz?",
+    "suggestDescription": "Powiedz nam, która integracja pomogłaby Twojemu warsztatowi, a przyjrzymy się jej.",
+    "suggest": "Zaproponuj integrację",
+    "suggestSubject": "Propozycja integracji"
   },
   "categories": {
     "calendar": "Kalendarz",

+ 6 - 1
messages/pt-BR/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Serviços que você pode adicionar. Cada um é configurado uma vez e roda em segundo plano.",
     "allConnected": "Todos os serviços disponíveis estão conectados.",
     "searchPlaceholder": "Buscar integrações",
-    "noMatches": "Nada corresponde a \"{query}\"."
+    "noMatches": "Nada corresponde a \"{query}\".",
+    "readMore": "Saiba mais",
+    "suggestTitle": "Falta um serviço que você usa?",
+    "suggestDescription": "Conte para nós qual integração ajudaria sua oficina e vamos analisar.",
+    "suggest": "Sugerir uma integração",
+    "suggestSubject": "Sugestão de integração"
   },
   "categories": {
     "calendar": "Calendário",

+ 6 - 1
messages/ru/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Сервисы, которые можно добавить. Каждый настраивается один раз и работает в фоне.",
     "allConnected": "Все доступные сервисы подключены.",
     "searchPlaceholder": "Поиск интеграций",
-    "noMatches": "Ничего не найдено по запросу «{query}»."
+    "noMatches": "Ничего не найдено по запросу «{query}».",
+    "readMore": "Подробнее",
+    "suggestTitle": "Не хватает сервиса, которым вы пользуетесь?",
+    "suggestDescription": "Расскажите, какая интеграция помогла бы вашему автосервису, и мы её рассмотрим.",
+    "suggest": "Предложить интеграцию",
+    "suggestSubject": "Предложение интеграции"
   },
   "categories": {
     "calendar": "Календарь",

+ 6 - 1
messages/tr/integrations.json

@@ -14,7 +14,12 @@
     "availableDescription": "Ekleyebileceğiniz hizmetler. Her biri bir kez kurulur ve arka planda çalışır.",
     "allConnected": "Kullanılabilir tüm hizmetler bağlı.",
     "searchPlaceholder": "Entegrasyonlarda ara",
-    "noMatches": "\"{query}\" ile eşleşen bir şey yok."
+    "noMatches": "\"{query}\" ile eşleşen bir şey yok.",
+    "readMore": "Daha fazla bilgi",
+    "suggestTitle": "Kullandığınız bir hizmet eksik mi?",
+    "suggestDescription": "Atölyenize hangi entegrasyonun yardımcı olacağını söyleyin, inceleyelim.",
+    "suggest": "Entegrasyon öner",
+    "suggestSubject": "Entegrasyon önerisi"
   },
   "categories": {
     "calendar": "Takvim",

+ 31 - 3
src/__tests__/components/integrations-catalog.test.tsx

@@ -9,12 +9,15 @@
  * that list empty out while you type reads as though something came undone.
  */
 
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { fireEvent, render, screen } from '@testing-library/react'
 import { IntegrationsCatalog } from '@/app/(authenticated)/settings/integrations/integrations-catalog'
 import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
+import { openSupport } from '@/features/support/Lib/supportVisibility'
 import { getManifest } from '@/integrations/registry'
 
+vi.mock('@/features/support/Lib/supportVisibility', () => ({ openSupport: vi.fn() }))
+
 function entry(id: string, overrides: Partial<CatalogEntry> = {}): CatalogEntry {
   const manifest = getManifest(id)
   if (!manifest) throw new Error(`no manifest for ${id}`)
@@ -36,8 +39,8 @@ const ENTRIES: CatalogEntry[] = [
   entry('zoom', { status: 'active', externalAccountName: 'workshop@example.com' }),
 ]
 
-function show() {
-  render(<IntegrationsCatalog entries={ENTRIES} />)
+function show(canSuggest = false) {
+  render(<IntegrationsCatalog entries={ENTRIES} canSuggest={canSuggest} />)
   return screen.getByRole('searchbox')
 }
 
@@ -133,3 +136,28 @@ describe('integration search', () => {
     expect(screen.getByText('Nothing matches "google".')).toBeInTheDocument()
   })
 })
+
+/**
+ * Asking for an integration goes through the support widget, so the button
+ * is only offered where that widget exists: a self-hosted workshop has nobody
+ * to send the suggestion to, and a dead button is worse than none.
+ */
+describe('suggesting an integration', () => {
+  it('is not offered where there is no support desk', () => {
+    show(false)
+    expect(screen.queryByRole('button', { name: 'Suggest an integration' })).toBeNull()
+  })
+
+  it('opens the support widget with the subject already filled in', () => {
+    show(true)
+    fireEvent.click(screen.getByRole('button', { name: 'Suggest an integration' }))
+    expect(openSupport).toHaveBeenCalledWith({ subject: 'Integration suggestion' })
+  })
+
+  it('always points at the documentation', () => {
+    show(false)
+    const link = screen.getByRole('link', { name: /Read more/ })
+    expect(link).toHaveAttribute('href', 'https://torqvoice.com/docs/integrations')
+    expect(link).toHaveAttribute('target', '_blank')
+  })
+})

+ 43 - 2
src/app/(authenticated)/settings/integrations/integrations-catalog.tsx

@@ -4,13 +4,16 @@ import { useCallback, useMemo, useState } from 'react'
 import Image from 'next/image'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
-import { AlertTriangle, ArrowRight, Check, Plug, Search, Settings2 } from 'lucide-react'
+import { AlertTriangle, ArrowRight, Check, Lightbulb, Plug, Search, Settings2 } from 'lucide-react'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
 import type { IntegrationCategory } from '@/features/integrations/Lib/types'
+import { openSupport } from '@/features/support/Lib/supportVisibility'
+
+const DOCS_URL = 'https://torqvoice.com/docs/integrations'
 
 const CATEGORY_ORDER: IntegrationCategory[] = [
   'ai',
@@ -42,7 +45,14 @@ function normalize(value: string): string {
  * what this workshop already runs stays whole while you look for something
  * to add to it.
  */
-export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
+export function IntegrationsCatalog({
+  entries,
+  canSuggest,
+}: {
+  entries: CatalogEntry[]
+  /** Whether the support widget is there to receive a suggestion. */
+  canSuggest: boolean
+}) {
   const t = useTranslations('integrations')
   const [category, setCategory] = useState<IntegrationCategory | 'all'>('all')
   const [query, setQuery] = useState('')
@@ -169,6 +179,16 @@ export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
         icon={Plug}
         title={t('catalog.availableTitle')}
         description={t('catalog.availableDescription')}
+        action={
+          <a
+            href={DOCS_URL}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
+          >
+            {t('catalog.readMore')} →
+          </a>
+        }
       >
         <div className="relative mb-4">
           <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -259,6 +279,27 @@ export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
             })}
           </div>
         )}
+
+        {canSuggest && (
+          <div className="mt-4 flex flex-col gap-3 rounded-lg border border-dashed px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
+            <div className="flex items-start gap-3">
+              <Lightbulb className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+              <div>
+                <p className="text-sm font-medium">{t('catalog.suggestTitle')}</p>
+                <p className="text-xs text-muted-foreground">{t('catalog.suggestDescription')}</p>
+              </div>
+            </div>
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              className="shrink-0"
+              onClick={() => openSupport({ subject: t('catalog.suggestSubject') })}
+            >
+              {t('catalog.suggest')}
+            </Button>
+          </div>
+        )}
       </AppCard>
     </div>
   )

+ 6 - 2
src/app/(authenticated)/settings/integrations/page.tsx

@@ -1,6 +1,7 @@
 import { redirect } from 'next/navigation'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures, isCloudMode } from '@/lib/features'
+import { isSupportEnabled } from '@/lib/support'
 import { anyConnectorAllowed } from '@/features/integrations/Lib/plan'
 import { getIntegrationCatalog } from '@/features/integrations/Actions/integrationActions'
 import { FeatureLockedMessage } from '../feature-locked-message'
@@ -22,10 +23,13 @@ export default async function IntegrationsPage() {
     )
   }
 
-  const result = await getIntegrationCatalog()
+  // Suggestions go through the support widget, so the button exists exactly
+  // where the widget does: cloud mode with support switched on. A self-hosted
+  // install has nobody on the other end to suggest anything to.
+  const [result, canSuggest] = await Promise.all([getIntegrationCatalog(), isSupportEnabled()])
   const catalog =
     result.success && result.data
       ? result.data
       : { entries: [], enabled: true, isCloud: isCloudMode() }
-  return <IntegrationsCatalog entries={catalog.entries} />
+  return <IntegrationsCatalog entries={catalog.entries} canSuggest={canSuggest} />
 }

+ 18 - 7
src/features/support/Components/SupportBubble.tsx

@@ -27,6 +27,7 @@ import {
 import {
   SUPPORT_OPEN_EVENT,
   SUPPORT_VISIBILITY_EVENT,
+  type SupportOpenDetail,
   isSupportBubbleHidden,
   setSupportBubbleHidden,
 } from '@/features/support/Lib/supportVisibility'
@@ -88,13 +89,6 @@ export function SupportBubble() {
     }
   }, [])
 
-  // The settings page opens the widget for someone who cannot find it.
-  useEffect(() => {
-    const open = () => setIsOpen(true)
-    window.addEventListener(SUPPORT_OPEN_EVENT, open)
-    return () => window.removeEventListener(SUPPORT_OPEN_EVENT, open)
-  }, [])
-
   /**
    * The panel follows the button. It is right-aligned to the button and opens
    * upward when the button sits low, which is where it usually will after being
@@ -125,6 +119,23 @@ export function SupportBubble() {
     setStatus('idle')
   }, [])
 
+  // The settings page opens the widget for someone who cannot find it, and
+  // other pages open it with a subject already filled in. A subject only
+  // lands on an empty form: someone halfway through describing a problem
+  // does not lose their heading because they clicked another button.
+  useEffect(() => {
+    const open = (event: Event) => {
+      const subject = (event as CustomEvent<SupportOpenDetail>).detail?.subject
+      if (subject) {
+        if (status === 'sent') reset()
+        setSubject((current) => current || subject)
+      }
+      setIsOpen(true)
+    }
+    window.addEventListener(SUPPORT_OPEN_EVENT, open)
+    return () => window.removeEventListener(SUPPORT_OPEN_EVENT, open)
+  }, [status, reset])
+
   const handleClose = useCallback(() => {
     setIsOpen(false)
     if (status !== 'sent') setError(null)

+ 5 - 8
src/features/support/Components/SupportSettingsControls.tsx

@@ -7,9 +7,9 @@ import { Label } from '@/components/ui/label'
 import { Switch } from '@/components/ui/switch'
 import { Separator } from '@/components/ui/separator'
 import {
-  SUPPORT_OPEN_EVENT,
   SUPPORT_VISIBILITY_EVENT,
   isSupportBubbleHidden,
+  openSupport,
   setSupportBubbleHidden,
 } from '@/features/support/Lib/supportVisibility'
 
@@ -67,13 +67,10 @@ export function SupportSettingsControls() {
           variant="outline"
           size="sm"
           onClick={() => {
-            // Opening while hidden would dispatch into nothing, so make it
-            // visible first and let the widget mount before asking it to open.
-            if (isSupportBubbleHidden()) {
-              setSupportBubbleHidden(false)
-              setVisible(true)
-            }
-            requestAnimationFrame(() => window.dispatchEvent(new Event(SUPPORT_OPEN_EVENT)))
+            // The toggle above mirrors the stored flag, which openSupport
+            // clears when the button was hidden.
+            setVisible(true)
+            openSupport()
           }}
         >
           {t('support.contactAction')}

+ 22 - 0
src/features/support/Lib/supportVisibility.ts

@@ -18,6 +18,28 @@ export const SUPPORT_VISIBILITY_EVENT = 'torqvoice:support:visibility'
 /** Asks the mounted widget to open, for the "contact support" button in settings. */
 export const SUPPORT_OPEN_EVENT = 'torqvoice:support:open'
 
+/** What a caller can hand the widget when opening it. */
+export interface SupportOpenDetail {
+  /** Prefills the subject of an empty form; never overwrites what someone typed. */
+  subject?: string
+}
+
+/**
+ * Opens the widget from anywhere in the app.
+ *
+ * Un-hides it first, because a dispatch into an unmounted widget is simply
+ * lost, and waits a frame so the widget has mounted before it is asked to
+ * open. Callers that hand over a subject, like the integration suggestion
+ * button, get a form that already says what it is about.
+ */
+export function openSupport(detail: SupportOpenDetail = {}): void {
+  if (typeof window === 'undefined') return
+  if (isSupportBubbleHidden()) setSupportBubbleHidden(false)
+  window.requestAnimationFrame(() => {
+    window.dispatchEvent(new CustomEvent<SupportOpenDetail>(SUPPORT_OPEN_EVENT, { detail }))
+  })
+}
+
 export function isSupportBubbleHidden(): boolean {
   if (typeof window === 'undefined') return false
   try {