Bernt Christian Egeland 22 часов назад
Родитель
Сommit
eb161d43ef
45 измененных файлов с 104 добавлено и 72 удалено
  1. 1 1
      messages/de/integrations.json
  2. 1 1
      messages/de/reminders.json
  3. 1 1
      messages/en/integrations.json
  4. 1 1
      messages/en/reminders.json
  5. 1 1
      messages/es/integrations.json
  6. 1 1
      messages/es/reminders.json
  7. 1 1
      messages/fr/integrations.json
  8. 1 1
      messages/fr/reminders.json
  9. 1 1
      messages/it/integrations.json
  10. 1 1
      messages/it/reminders.json
  11. 1 1
      messages/lt/integrations.json
  12. 1 1
      messages/lt/reminders.json
  13. 1 1
      messages/nb/integrations.json
  14. 1 1
      messages/nb/reminders.json
  15. 1 1
      messages/nl/integrations.json
  16. 1 1
      messages/nl/reminders.json
  17. 1 1
      messages/pl/integrations.json
  18. 1 1
      messages/pl/reminders.json
  19. 1 1
      messages/pt-BR/integrations.json
  20. 1 1
      messages/pt-BR/reminders.json
  21. 1 1
      messages/pt-PT/integrations.json
  22. 1 1
      messages/pt-PT/reminders.json
  23. 1 1
      messages/ru/integrations.json
  24. 1 1
      messages/ru/reminders.json
  25. 1 1
      messages/tr/integrations.json
  26. 1 1
      messages/tr/reminders.json
  27. 19 3
      src/__tests__/lib/demo-hardening.test.ts
  28. 1 1
      src/__tests__/lib/demo-outbound.test.ts
  29. 2 2
      src/app/api/protected/backup/export/route.ts
  30. 2 2
      src/app/api/protected/backup/import-invoice-ninja/route.ts
  31. 2 2
      src/app/api/protected/backup/import-lubelog/route.ts
  32. 2 2
      src/app/api/protected/backup/import/route.ts
  33. 2 3
      src/app/api/protected/fetch-metadata/route.ts
  34. 2 2
      src/app/api/protected/import/analyze/route.ts
  35. 2 2
      src/app/api/protected/subscription/account-link/route.ts
  36. 2 2
      src/app/api/protected/subscription/billing-portal/route.ts
  37. 2 2
      src/app/api/protected/subscription/checkout/route.ts
  38. 2 2
      src/app/api/protected/subscription/upgrade-preview/route.ts
  39. 2 2
      src/app/api/protected/subscription/upgrade/route.ts
  40. 2 3
      src/app/api/public/auth/[...all]/route.ts
  41. 1 1
      src/features/onboarding/Actions/createOnboardingOrg.ts
  42. 2 2
      src/features/team/Actions/acceptInvitation.ts
  43. 1 1
      src/features/webhooks/Lib/deliver.ts
  44. 28 10
      src/lib/demo.ts
  45. 2 2
      src/lib/torqvoice-com.ts

+ 1 - 1
messages/de/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Etwas ist schiefgelaufen",
-    "demo": "In der Demo können keine Integrationen verbunden werden.",
+    "demo": "In der Demo können keine Integrationen verbunden werden. Legen Sie ein kostenloses Konto auf app.torqvoice.com an, um es mit Ihrer eigenen Werkstatt zu nutzen.",
     "forbidden": "Nur ein Administrator kann Integrationen verbinden.",
     "unknown": "Unbekannte Integration.",
     "plan": "Diese Integration ist in Ihrem Tarif nicht enthalten.",

+ 1 - 1
messages/de/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Übersprungen: wiederholt eine frühere Zeile der Datei."
     },
     "fileErrors": {
-      "demo": "Der Import ist in der Demo deaktiviert.",
+      "demo": "Der Import ist in der Demo deaktiviert. Legen Sie ein kostenloses Konto auf app.torqvoice.com an, um es mit Ihrer eigenen Werkstatt zu nutzen.",
       "noFile": "Wählen Sie eine Datei zum Hochladen.",
       "tooLarge": "Die Datei ist größer als 900 KB. Teilen Sie sie auf und importieren Sie sie in Teilen.",
       "unsupported": "Laden Sie eine .csv- oder .xlsx-Datei hoch.",

+ 1 - 1
messages/en/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Something went wrong",
-    "demo": "Integrations cannot be connected on the demo.",
+    "demo": "Integrations cannot be connected on the demo. Create a free account at app.torqvoice.com to use it with your own workshop.",
     "forbidden": "Only an administrator can connect integrations.",
     "unknown": "Unknown integration.",
     "plan": "This integration is not included in your plan.",

+ 1 - 1
messages/en/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Skipped: repeats an earlier row in the file."
     },
     "fileErrors": {
-      "demo": "Importing is turned off on the demo.",
+      "demo": "Importing is turned off on the demo. Create a free account at app.torqvoice.com to use it with your own workshop.",
       "noFile": "Choose a file to upload.",
       "tooLarge": "The file is larger than 900 KB. Split it and import it in parts.",
       "unsupported": "Upload a .csv or .xlsx file.",

+ 1 - 1
messages/es/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Algo salió mal",
-    "demo": "Las integraciones no se pueden conectar en la demo.",
+    "demo": "Las integraciones no se pueden conectar en la demo. Cree una cuenta gratuita en app.torqvoice.com para usarlo con su propio taller.",
     "forbidden": "Solo un administrador puede conectar integraciones.",
     "unknown": "Integración desconocida.",
     "plan": "Esta integración no está incluida en su plan.",

+ 1 - 1
messages/es/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Omitida: repite una fila anterior del archivo."
     },
     "fileErrors": {
-      "demo": "La importación está desactivada en la demo.",
+      "demo": "La importación está desactivada en la demo. Cree una cuenta gratuita en app.torqvoice.com para usarlo con su propio taller.",
       "noFile": "Elija un archivo para subir.",
       "tooLarge": "El archivo supera los 900 KB. Divídalo e impórtelo por partes.",
       "unsupported": "Suba un archivo .csv o .xlsx.",

+ 1 - 1
messages/fr/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Une erreur s'est produite",
-    "demo": "Les intégrations ne peuvent pas être connectées sur la démo.",
+    "demo": "Les intégrations ne peuvent pas être connectées sur la démo. Créez un compte gratuit sur app.torqvoice.com pour l'utiliser avec votre propre atelier.",
     "forbidden": "Seul un administrateur peut connecter des intégrations.",
     "unknown": "Intégration inconnue.",
     "plan": "Cette intégration n'est pas incluse dans votre forfait.",

+ 1 - 1
messages/fr/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Ignorée : répète une ligne précédente du fichier."
     },
     "fileErrors": {
-      "demo": "L'import est désactivé sur la démo.",
+      "demo": "L'import est désactivé sur la démo. Créez un compte gratuit sur app.torqvoice.com pour l'utiliser avec votre propre atelier.",
       "noFile": "Choisissez un fichier à téléverser.",
       "tooLarge": "Le fichier dépasse 900 KB. Divisez-le et importez-le en plusieurs fois.",
       "unsupported": "Téléversez un fichier .csv ou .xlsx.",

+ 1 - 1
messages/it/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Qualcosa è andato storto",
-    "demo": "Le integrazioni non possono essere collegate nella demo.",
+    "demo": "Le integrazioni non possono essere collegate nella demo. Crea un account gratuito su app.torqvoice.com per usarlo con la tua officina.",
     "forbidden": "Solo un amministratore può collegare le integrazioni.",
     "unknown": "Integrazione sconosciuta.",
     "plan": "Questa integrazione non è inclusa nel tuo piano.",

+ 1 - 1
messages/it/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Saltata: ripete una riga precedente del file."
     },
     "fileErrors": {
-      "demo": "L'importazione è disattivata nella demo.",
+      "demo": "L'importazione è disattivata nella demo. Crea un account gratuito su app.torqvoice.com per usarlo con la tua officina.",
       "noFile": "Scegli un file da caricare.",
       "tooLarge": "Il file supera 900 KB. Dividilo e importalo in più parti.",
       "unsupported": "Carica un file .csv o .xlsx.",

+ 1 - 1
messages/lt/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Kažkas nepavyko",
-    "demo": "Demonstracinėje versijoje integracijų prijungti negalima.",
+    "demo": "Demonstracinėje versijoje integracijų prijungti negalima. Susikurkite nemokamą paskyrą app.torqvoice.com ir naudokite tai su savo dirbtuvėmis.",
     "forbidden": "Integracijas prijungti gali tik administratorius.",
     "unknown": "Nežinoma integracija.",
     "plan": "Ši integracija neįtraukta į jūsų planą.",

+ 1 - 1
messages/lt/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Praleista: kartoja ankstesnę failo eilutę."
     },
     "fileErrors": {
-      "demo": "Demonstracinėje versijoje importavimas išjungtas.",
+      "demo": "Demonstracinėje versijoje importavimas išjungtas. Susikurkite nemokamą paskyrą app.torqvoice.com ir naudokite tai su savo dirbtuvėmis.",
       "noFile": "Pasirinkite failą įkėlimui.",
       "tooLarge": "Failas didesnis nei 900 KB. Padalykite jį ir importuokite dalimis.",
       "unsupported": "Įkelkite .csv arba .xlsx failą.",

+ 1 - 1
messages/nb/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Noe gikk galt",
-    "demo": "Integrasjoner kan ikke kobles til i demoen.",
+    "demo": "Integrasjoner kan ikke kobles til i demoen. Opprett en gratis konto på app.torqvoice.com for å bruke det med ditt eget verksted.",
     "forbidden": "Bare en administrator kan koble til integrasjoner.",
     "unknown": "Ukjent integrasjon.",
     "plan": "Denne integrasjonen er ikke inkludert i abonnementet ditt.",

+ 1 - 1
messages/nb/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Hoppet over: gjentar en tidligere rad i filen."
     },
     "fileErrors": {
-      "demo": "Import er slått av i demoen.",
+      "demo": "Import er slått av i demoen. Opprett en gratis konto på app.torqvoice.com for å bruke det med ditt eget verksted.",
       "noFile": "Velg en fil å laste opp.",
       "tooLarge": "Filen er større enn 900 KB. Del den opp og importer den i flere omganger.",
       "unsupported": "Last opp en .csv- eller .xlsx-fil.",

+ 1 - 1
messages/nl/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Er is iets misgegaan",
-    "demo": "Integraties kunnen niet worden gekoppeld in de demo.",
+    "demo": "Integraties kunnen niet worden gekoppeld in de demo. Maak een gratis account aan op app.torqvoice.com om het met uw eigen werkplaats te gebruiken.",
     "forbidden": "Alleen een beheerder kan integraties koppelen.",
     "unknown": "Onbekende integratie.",
     "plan": "Deze integratie is niet inbegrepen in uw abonnement.",

+ 1 - 1
messages/nl/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Overgeslagen: herhaalt een eerdere rij in het bestand."
     },
     "fileErrors": {
-      "demo": "Importeren is uitgeschakeld in de demo.",
+      "demo": "Importeren is uitgeschakeld in de demo. Maak een gratis account aan op app.torqvoice.com om het met uw eigen werkplaats te gebruiken.",
       "noFile": "Kies een bestand om te uploaden.",
       "tooLarge": "Het bestand is groter dan 900 KB. Splits het en importeer het in delen.",
       "unsupported": "Upload een .csv- of .xlsx-bestand.",

+ 1 - 1
messages/pl/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Coś poszło nie tak",
-    "demo": "W wersji demo nie można łączyć integracji.",
+    "demo": "W wersji demo nie można łączyć integracji. Załóż darmowe konto na app.torqvoice.com, aby korzystać z tego we własnym warsztacie.",
     "forbidden": "Tylko administrator może łączyć integracje.",
     "unknown": "Nieznana integracja.",
     "plan": "Ta integracja nie jest objęta Twoim planem.",

+ 1 - 1
messages/pl/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Pominięto: powtarza wcześniejszy wiersz w pliku."
     },
     "fileErrors": {
-      "demo": "Import jest wyłączony w wersji demo.",
+      "demo": "Import jest wyłączony w wersji demo. Załóż darmowe konto na app.torqvoice.com, aby korzystać z tego we własnym warsztacie.",
       "noFile": "Wybierz plik do przesłania.",
       "tooLarge": "Plik jest większy niż 900 KB. Podziel go i zaimportuj w częściach.",
       "unsupported": "Prześlij plik .csv lub .xlsx.",

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

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Algo deu errado",
-    "demo": "Não é possível conectar integrações na demonstração.",
+    "demo": "Não é possível conectar integrações na demonstração. Crie uma conta gratuita em app.torqvoice.com para usar com a sua própria oficina.",
     "forbidden": "Somente um administrador pode conectar integrações.",
     "unknown": "Integração desconhecida.",
     "plan": "Esta integração não está incluída no seu plano.",

+ 1 - 1
messages/pt-BR/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Pulada: repete uma linha anterior do arquivo."
     },
     "fileErrors": {
-      "demo": "A importação está desativada na demonstração.",
+      "demo": "A importação está desativada na demonstração. Crie uma conta gratuita em app.torqvoice.com para usar com a sua própria oficina.",
       "noFile": "Escolha um arquivo para enviar.",
       "tooLarge": "O arquivo é maior que 900 KB. Divida-o e importe em partes.",
       "unsupported": "Envie um arquivo .csv ou .xlsx.",

+ 1 - 1
messages/pt-PT/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Algo correu mal",
-    "demo": "Não é possível ligar integrações na demonstração.",
+    "demo": "Não é possível ligar integrações na demonstração. Crie uma conta gratuita em app.torqvoice.com para usar com a sua própria oficina.",
     "forbidden": "Só um administrador pode ligar integrações.",
     "unknown": "Integração desconhecida.",
     "plan": "Esta integração não está incluída no seu plano.",

+ 1 - 1
messages/pt-PT/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Ignorada: repete uma linha anterior do ficheiro."
     },
     "fileErrors": {
-      "demo": "A importação está desativada na demonstração.",
+      "demo": "A importação está desativada na demonstração. Crie uma conta gratuita em app.torqvoice.com para usar com a sua própria oficina.",
       "noFile": "Escolha um ficheiro para carregar.",
       "tooLarge": "O ficheiro tem mais de 900 KB. Divida-o e importe-o por partes.",
       "unsupported": "Carregue um ficheiro .csv ou .xlsx.",

+ 1 - 1
messages/ru/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Что-то пошло не так",
-    "demo": "В демо-режиме интеграции подключить нельзя.",
+    "demo": "В демо-режиме интеграции подключить нельзя. Создайте бесплатный аккаунт на app.torqvoice.com, чтобы пользоваться этим в своей мастерской.",
     "forbidden": "Подключать интеграции может только администратор.",
     "unknown": "Неизвестная интеграция.",
     "plan": "Эта интеграция не входит в ваш тариф.",

+ 1 - 1
messages/ru/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Пропущено: повторяет одну из предыдущих строк файла."
     },
     "fileErrors": {
-      "demo": "В демоверсии импорт отключён.",
+      "demo": "В демоверсии импорт отключён. Создайте бесплатный аккаунт на app.torqvoice.com, чтобы пользоваться этим в своей мастерской.",
       "noFile": "Выберите файл для загрузки.",
       "tooLarge": "Файл больше 900 KB. Разделите его и импортируйте по частям.",
       "unsupported": "Загрузите файл .csv или .xlsx.",

+ 1 - 1
messages/tr/integrations.json

@@ -185,7 +185,7 @@
   },
   "errors": {
     "generic": "Bir şeyler ters gitti",
-    "demo": "Demoda entegrasyon bağlanamaz.",
+    "demo": "Demoda entegrasyon bağlanamaz. Kendi atölyenizle kullanmak için app.torqvoice.com adresinde ücretsiz bir hesap oluşturun.",
     "forbidden": "Entegrasyonları yalnızca bir yönetici bağlayabilir.",
     "unknown": "Bilinmeyen entegrasyon.",
     "plan": "Bu entegrasyon planınıza dahil değil.",

+ 1 - 1
messages/tr/reminders.json

@@ -68,7 +68,7 @@
       "duplicateInFile": "Atlandı: dosyadaki önceki bir satırı tekrarlıyor."
     },
     "fileErrors": {
-      "demo": "Demoda içe aktarma kapalı.",
+      "demo": "Demoda içe aktarma kapalı. Kendi atölyenizle kullanmak için app.torqvoice.com adresinde ücretsiz bir hesap oluşturun.",
       "noFile": "Yüklenecek bir dosya seçin.",
       "tooLarge": "Dosya 900 KB'tan büyük. Dosyayı bölün ve parçalar halinde içe aktarın.",
       "unsupported": "Bir .csv veya .xlsx dosyası yükleyin.",

+ 19 - 3
src/__tests__/lib/demo-hardening.test.ts

@@ -198,9 +198,9 @@ describe('AI on the demo', () => {
   it('refuses to build a vendor client at all', async () => {
     const { createClient, getAiConfig } = await load<typeof import('@/lib/ai')>('@/lib/ai', true)
     expect(() => createClient({ provider: 'openai', apiKey: 'sk-real', model: 'gpt-4o' })).toThrow(
-      /disabled on the demo/
+      /turned off on the demo/
     )
-    await expect(getAiConfig('org')).rejects.toThrow(/disabled on the demo/)
+    await expect(getAiConfig('org')).rejects.toThrow(/turned off on the demo/)
   })
 
   it('builds one as before off the demo', async () => {
@@ -230,7 +230,7 @@ describe('billing on torqvoice.com from the demo', () => {
         '@/lib/torqvoice-com',
         true
       )
-      await expect(billingRequest('sync', {})).rejects.toThrow(/disabled on the demo/)
+      await expect(billingRequest('sync', {})).rejects.toThrow(/turned off on the demo/)
       expect(fetchSpy).not.toHaveBeenCalled()
     } finally {
       delete process.env.TORQVOICE_SERVICE_SECRET
@@ -238,3 +238,19 @@ describe('billing on torqvoice.com from the demo', () => {
     }
   })
 })
+
+describe('what a demo visitor is told', () => {
+  it('points to a free cloud account, never to installing it themselves', async () => {
+    const { DEMO_DISABLED_MESSAGE, demoOffMessage, DEMO_AI_DISABLED_MESSAGE } = await import(
+      '@/lib/demo'
+    )
+    for (const message of [
+      DEMO_DISABLED_MESSAGE,
+      demoOffMessage('Importing data'),
+      DEMO_AI_DISABLED_MESSAGE,
+    ]) {
+      expect(message).toContain('app.torqvoice.com')
+      expect(message).not.toMatch(/own server|install/i)
+    }
+  })
+})

+ 1 - 1
src/__tests__/lib/demo-outbound.test.ts

@@ -28,7 +28,7 @@ describe('demo mode blocks every outbound transport', () => {
   it('refuses email, SMS, WhatsApp and Telegram', async () => {
     const { assertOutboundAllowed } = await import('@/lib/demo')
     for (const channel of ['email', 'sms', 'whatsapp', 'telegram'] as const) {
-      expect(() => assertOutboundAllowed(channel)).toThrow(/disabled on the demo/)
+      expect(() => assertOutboundAllowed(channel)).toThrow(/turned off on the demo/)
     }
   })
 

+ 2 - 2
src/app/api/protected/backup/export/route.ts

@@ -3,7 +3,7 @@ import { rateLimit } from '@/lib/rate-limit'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import JSZip from 'jszip'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, DEMO_DISABLED_MESSAGE } from '@/lib/demo'
 import { UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { readdir, readFile, stat } from 'fs/promises'
 import path from 'path'
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
   const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
   if (limited) return limited
   if (isDemoMode) {
-    return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+    return NextResponse.json({ error: DEMO_DISABLED_MESSAGE }, { status: 403 })
   }
 
   const ctx = await getAuthContext()

+ 2 - 2
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -3,7 +3,7 @@ import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-gua
 import { rateLimit } from '@/lib/rate-limit'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, demoOffMessage } from '@/lib/demo'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
@@ -211,7 +211,7 @@ export async function POST(request: NextRequest) {
   const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
   if (limited) return limited
   if (isDemoMode) {
-    return NextResponse.json({ error: 'Data import is disabled on the demo.' }, { status: 403 })
+    return NextResponse.json({ error: demoOffMessage('Importing data') }, { status: 403 })
   }
 
   const ctx = await getAuthContext()

+ 2 - 2
src/app/api/protected/backup/import-lubelog/route.ts

@@ -3,7 +3,7 @@ import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-gua
 import { rateLimit } from '@/lib/rate-limit'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, demoOffMessage } from '@/lib/demo'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
@@ -207,7 +207,7 @@ export async function POST(request: NextRequest) {
   const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
   if (limited) return limited
   if (isDemoMode) {
-    return NextResponse.json({ error: 'Data import is disabled on the demo.' }, { status: 403 })
+    return NextResponse.json({ error: demoOffMessage('Importing data') }, { status: 403 })
   }
 
   const ctx = await getAuthContext()

+ 2 - 2
src/app/api/protected/backup/import/route.ts

@@ -4,7 +4,7 @@ import { assertContentLength, assertZipWithinLimits } from '@/lib/backup/zip-gua
 import { rateLimit } from '@/lib/rate-limit'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db, type TxClient } from '@/lib/db'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, demoOffMessage } from '@/lib/demo'
 import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { rewriteFileUrl, rewriteFileUrlsWithin, withFileUrls } from '@/lib/backup/file-urls'
 import { columnsOf } from '@/lib/backup/rows'
@@ -400,7 +400,7 @@ export async function POST(request: NextRequest) {
   const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
   if (limited) return limited
   if (isDemoMode) {
-    return NextResponse.json({ error: 'Backup import is disabled on the demo.' }, { status: 403 })
+    return NextResponse.json({ error: demoOffMessage('Restoring a backup') }, { status: 403 })
   }
 
   const ctx = await getAuthContext()

+ 2 - 3
src/app/api/protected/fetch-metadata/route.ts

@@ -1,7 +1,7 @@
 import { NextRequest, NextResponse } from 'next/server'
 import { auth } from '@/lib/auth'
 import { headers } from 'next/headers'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, demoOffMessage } from '@/lib/demo'
 import { checkWebhookUrl } from '@/features/webhooks/Lib/ssrf'
 
 // Cap the redirect chain we will follow (mirrors browser/undici defaults) so a
@@ -34,8 +34,7 @@ export async function POST(request: NextRequest) {
   if (isDemoMode) {
     return NextResponse.json(
       {
-        error:
-          'Looking up a part from a URL is disabled on the demo. Install Torqvoice on your own server to use it.',
+        error: demoOffMessage('Looking up a part from a link'),
       },
       { status: 403 }
     )

+ 2 - 2
src/app/api/protected/import/analyze/route.ts

@@ -1,7 +1,7 @@
 import { NextRequest, NextResponse } from 'next/server'
 import { canImportAnything } from '@/features/import/Lib/importAccess.server'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, demoOffMessage } from '@/lib/demo'
 import { db } from '@/lib/db'
 import { getAiConfig } from '@/lib/ai'
 import { getFeatures } from '@/lib/features'
@@ -31,7 +31,7 @@ const SAMPLE_ROWS = 5
  */
 export async function POST(request: NextRequest) {
   if (isDemoMode) {
-    return NextResponse.json({ error: 'Data import is disabled on the demo.' }, { status: 403 })
+    return NextResponse.json({ error: demoOffMessage('Importing data') }, { status: 403 })
   }
   const ctx = await getAuthContext()
   if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

+ 2 - 2
src/app/api/protected/subscription/account-link/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { isDemoMode } from '@/lib/demo'
+import { DEMO_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import {
   accountLinkUrl,
   createAccountLinkToken,
@@ -15,7 +15,7 @@ import {
 export async function POST() {
   try {
     if (isDemoMode) {
-      return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+      return NextResponse.json({ error: DEMO_DISABLED_MESSAGE }, { status: 403 })
     }
     const ctx = await getAuthContext()
     if (!ctx) {

+ 2 - 2
src/app/api/protected/subscription/billing-portal/route.ts

@@ -1,14 +1,14 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { isDemoMode } from '@/lib/demo'
+import { DEMO_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import { billingErrorResponse, billingRequest } from '@/lib/torqvoice-com'
 
 /** The Stripe billing portal, opened through torqvoice.com, returning here. */
 export async function POST() {
   try {
     if (isDemoMode) {
-      return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+      return NextResponse.json({ error: DEMO_DISABLED_MESSAGE }, { status: 403 })
     }
 
     // The active organisation from the session, and only its owners and

+ 2 - 2
src/app/api/protected/subscription/checkout/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, DEMO_DISABLED_MESSAGE } from '@/lib/demo'
 import {
   checkoutUrl,
   createHandoffToken,
@@ -16,7 +16,7 @@ import {
 export async function POST(request: Request) {
   try {
     if (isDemoMode) {
-      return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+      return NextResponse.json({ error: DEMO_DISABLED_MESSAGE }, { status: 403 })
     }
 
     // The active organisation from the session, and only its owners and

+ 2 - 2
src/app/api/protected/subscription/upgrade-preview/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
-import { isDemoMode } from '@/lib/demo'
+import { isDemoMode, DEMO_DISABLED_MESSAGE } from '@/lib/demo'
 import { billingErrorResponse, billingRequest } from '@/lib/torqvoice-com'
 
 type Preview = { amountDue: number; currency: string; prorationDate: number }
@@ -11,7 +11,7 @@ export async function POST() {
   try {
     // As its siblings: the demo has no subscription of anyone's to price.
     if (isDemoMode) {
-      return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+      return NextResponse.json({ error: DEMO_DISABLED_MESSAGE }, { status: 403 })
     }
 
     // The active organisation from the session, and only its owners and

+ 2 - 2
src/app/api/protected/subscription/upgrade/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
-import { isDemoMode } from '@/lib/demo'
+import { DEMO_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import { billingErrorResponse, billingRequest } from '@/lib/torqvoice-com'
 
 /**
@@ -12,7 +12,7 @@ import { billingErrorResponse, billingRequest } from '@/lib/torqvoice-com'
 export async function POST(request: Request) {
   try {
     if (isDemoMode) {
-      return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+      return NextResponse.json({ error: DEMO_DISABLED_MESSAGE }, { status: 403 })
     }
 
     // The active organisation from the session, and only its owners and

+ 2 - 3
src/app/api/public/auth/[...all]/route.ts

@@ -1,6 +1,6 @@
 import { NextResponse } from 'next/server'
 import { auth } from '@/lib/auth'
-import { isDemoMode } from '@/lib/demo'
+import { DEMO_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import { isDemoBlockedAuthPath } from '@/lib/demo-auth-paths'
 import { limitAuthRequest } from '@/lib/auth-rate-limit'
 import { toNextJsHandler } from 'better-auth/next-js'
@@ -25,8 +25,7 @@ const authAuditPrefixes = [
  */
 function refuseOnDemo(pathname: string): NextResponse | null {
   if (!isDemoMode || !isDemoBlockedAuthPath(pathname)) return null
-  const error =
-    'This action is disabled on the demo. Install Torqvoice on your own server to use it.'
+  const error = DEMO_DISABLED_MESSAGE
   // `message` as well, which is what the better-auth client shows.
   return NextResponse.json({ error, message: error }, { status: 403 })
 }

+ 1 - 1
src/features/onboarding/Actions/createOnboardingOrg.ts

@@ -36,7 +36,7 @@ export async function createOnboardingOrg(
       return {
         success: false,
         error:
-          'Creating a workshop is disabled on the demo. Sign in with the demo account, or install Torqvoice on your own server.',
+          'Creating a workshop is turned off on the demo. Sign in with the demo account, or create a free account at app.torqvoice.com.',
       }
     }
 

+ 2 - 2
src/features/team/Actions/acceptInvitation.ts

@@ -4,11 +4,11 @@ import { cookies } from 'next/headers'
 import { db } from '@/lib/db'
 import { getCachedSession } from '@/lib/cached-session'
 import { acceptInvitationSchema } from '../Schema/teamSchema'
-import { isDemoMode } from '@/lib/demo'
+import { DEMO_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 
 export async function acceptInvitation(input: unknown) {
   if (isDemoMode) {
-    return { success: false, error: 'This action is disabled on the demo.' }
+    return { success: false, error: DEMO_DISABLED_MESSAGE }
   }
   try {
     const data = acceptInvitationSchema.parse(input)

+ 1 - 1
src/features/webhooks/Lib/deliver.ts

@@ -74,7 +74,7 @@ export async function deliverOnce(deliveryId: string): Promise<void> {
   if (isDemoMode) {
     await db.webhookDelivery.update({
       where: { id: deliveryId },
-      data: { status: 'failed', errorMessage: 'webhook delivery is disabled on the demo' },
+      data: { status: 'failed', errorMessage: 'webhook delivery is turned off on the demo' },
     })
     return
   }

+ 28 - 10
src/lib/demo.ts

@@ -16,6 +16,22 @@ export const isDemoMode = process.env.DEMO_MODE === 'true'
 export const DEMO_USER_EMAIL = process.env.DEMO_USER_EMAIL || 'demo@torqvoice.com'
 export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || 'demo'
 
+/**
+ * Where a demo visitor is sent when something is off here: the hosted app,
+ * free to start, is where the thing they just tried works with their own
+ * workshop. Every refusal on the demo ends with it, so it reads the same.
+ */
+export const DEMO_SIGN_UP =
+  'Create a free account at app.torqvoice.com to use it with your own workshop.'
+
+/** The refusal for anything the demo turns off without a more specific word for it. */
+export const DEMO_DISABLED_MESSAGE = `This is turned off on the demo. ${DEMO_SIGN_UP}`
+
+/** A refusal naming what is off, ending with the same way on. */
+export function demoOffMessage(what: string): string {
+  return `${what} is turned off on the demo. ${DEMO_SIGN_UP}`
+}
+
 /**
  * Throws inside a server action when demo mode is active. `withAuth`
  * catches the error and surfaces it as `{ success: false, error }` to
@@ -23,9 +39,7 @@ export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || 'demo'
  */
 export function demoGuard(): void {
   if (isDemoMode) {
-    throw new Error(
-      'This action is disabled on the demo. Install Torqvoice on your own server to use it.'
-    )
+    throw new Error(DEMO_DISABLED_MESSAGE)
   }
 }
 
@@ -39,10 +53,17 @@ export function demoGuard(): void {
  * to go through. Seed data carries customer-looking addresses, so a demo reset
  * is enough to queue mail at real inboxes without it.
  */
+const CHANNEL_NAMES = {
+  email: 'email',
+  sms: 'SMS',
+  whatsapp: 'WhatsApp messages',
+  telegram: 'Telegram messages',
+} as const
+
 export function assertOutboundAllowed(channel: 'email' | 'sms' | 'whatsapp' | 'telegram'): void {
   if (isDemoMode) {
     throw new Error(
-      `Outbound ${channel} is disabled on the demo. Install Torqvoice on your own server to send for real.`
+      `Sending ${CHANNEL_NAMES[channel]} is turned off on the demo, so no real customer is contacted. ${DEMO_SIGN_UP}`
     )
   }
 }
@@ -56,9 +77,7 @@ export function assertOutboundAllowed(channel: 'email' | 'sms' | 'whatsapp' | 't
  */
 export function assertConnectorAllowed(): void {
   if (isDemoMode) {
-    throw new Error(
-      'Integrations are disabled on the demo. Install Torqvoice on your own server to connect one.'
-    )
+    throw new Error(demoOffMessage('Connecting integrations'))
   }
 }
 
@@ -105,13 +124,12 @@ export function isDemoBlockedSettingKey(key: string): boolean {
  */
 export function demoGuardSettingKey(key: string): void {
   if (isDemoMode && isDemoBlockedSettingKey(key)) {
-    throw new Error("This setting can't be changed on the demo.")
+    throw new Error(`Saving credentials is turned off on the demo. ${DEMO_SIGN_UP}`)
   }
 }
 
 /** What a refused AI or dictation request is told on the demo. */
-export const DEMO_AI_DISABLED_MESSAGE =
-  'AI is disabled on the demo. Install Torqvoice on your own server to connect a provider.'
+export const DEMO_AI_DISABLED_MESSAGE = demoOffMessage('AI')
 
 /**
  * Hard stop for the AI and speech vendors. Connecting one is refused on the

+ 2 - 2
src/lib/torqvoice-com.ts

@@ -1,6 +1,6 @@
 import { createHmac, randomBytes } from 'node:crypto'
 import { NextResponse } from 'next/server'
-import { isDemoMode } from './demo'
+import { isDemoMode, demoOffMessage } from './demo'
 import { isCloudMode } from './features'
 
 /**
@@ -191,7 +191,7 @@ export async function billingRequest<T>(
   // this deployment's should be spent asking about one.
   // No upstream status: the site was never asked, so nothing reads this as the
   // site refusing the app's secret.
-  if (isDemoMode) throw new TorqvoiceComError('Billing is disabled on the demo.', 403, 0)
+  if (isDemoMode) throw new TorqvoiceComError(demoOffMessage('Billing'), 403, 0)
 
   const secret = serviceSecret()
   if (!secret) throw new TorqvoiceComError('Billing is not configured', 500)