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

Point out a new link only when somebody just switched it on

The hint manager showed a card whenever its link was eligible and not yet
dismissed. That held for the tire hotel because the module and the hint
shipped together, so "eligible" and "just appeared" were the same moment.
It stops holding the second time: adding a hint to Telegram would have
announced a link to every workshop that has been using it for a year.

So the trigger moves to the flip itself. A registry maps a setting to the
hint its link deserves, every path that writes settings raises the hint
when the value goes off to on, and the card only shows for a raised one.
Adding the next module's hint is a line in HINT_FOR_SETTING.

Telegram gets the first one, in all twelve languages.
Bernt Christian Egeland 2 месяцев назад
Родитель
Сommit
4c6a8f8838

+ 4 - 0
messages/de/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Reifenhotel ist aktiv",
     "body": "Saisonreifen Ihrer Kunden einlagern. Erst Regale anlegen, dann einlagern."
+  },
+  "telegram": {
+    "title": "Telegram ist aktiv",
+    "body": "Kundengespräche landen hier. Antworten Sie, ohne die App zu verlassen."
   }
 }

+ 4 - 0
messages/en/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Tire hotel is on",
     "body": "Store customers’ seasonal tires. Set up shelves, then check a set in."
+  },
+  "telegram": {
+    "title": "Telegram is on",
+    "body": "Customer conversations land here. Reply without leaving the app."
   }
 }

+ 4 - 0
messages/es/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "El hotel de neumáticos está activo",
     "body": "Guarda neumáticos de temporada. Crea los estantes y recibe un juego."
+  },
+  "telegram": {
+    "title": "Telegram está activo",
+    "body": "Las conversaciones con clientes llegan aquí. Responde sin salir de la app."
   }
 }

+ 4 - 0
messages/fr/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "L’hôtel à pneus est actif",
     "body": "Stockez les pneus saisonniers. Créez vos étagères, puis réceptionnez un jeu."
+  },
+  "telegram": {
+    "title": "Telegram est activé",
+    "body": "Les conversations clients arrivent ici. Répondez sans quitter l'application."
   }
 }

+ 4 - 0
messages/it/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "L’hotel pneumatici è attivo",
     "body": "Conserva gli pneumatici stagionali. Crea gli scaffali, poi accetta un treno."
+  },
+  "telegram": {
+    "title": "Telegram è attivo",
+    "body": "Le conversazioni con i clienti arrivano qui. Rispondi senza uscire dall'app."
   }
 }

+ 4 - 0
messages/lt/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Padangų viešbutis įjungtas",
     "body": "Saugokite sezonines padangas. Sukurkite lentynas ir priimkite komplektą."
+  },
+  "telegram": {
+    "title": "Telegram įjungtas",
+    "body": "Pokalbiai su klientais atsiduria čia. Atsakykite neišeidami iš programos."
   }
 }

+ 4 - 0
messages/nb/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Dekkhotellet er på",
     "body": "Lagre kundenes sesongdekk. Sett opp hyller, så tar du inn et sett."
+  },
+  "telegram": {
+    "title": "Telegram er på",
+    "body": "Kundesamtaler havner her. Svar uten å forlate appen."
   }
 }

+ 4 - 0
messages/nl/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Bandenhotel staat aan",
     "body": "Bewaar seizoensbanden van klanten. Zet stellingen op en neem een set in."
+  },
+  "telegram": {
+    "title": "Telegram staat aan",
+    "body": "Klantgesprekken komen hier binnen. Beantwoord ze zonder de app te verlaten."
   }
 }

+ 4 - 0
messages/pl/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Hotel opon jest włączony",
     "body": "Przechowuj opony sezonowe. Skonfiguruj półki, potem przyjmij komplet."
+  },
+  "telegram": {
+    "title": "Telegram jest włączony",
+    "body": "Rozmowy z klientami trafiają tutaj. Odpowiadaj bez wychodzenia z aplikacji."
   }
 }

+ 4 - 0
messages/pt-BR/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "O hotel de pneus está ativo",
     "body": "Guarde pneus de temporada. Configure prateleiras e receba um jogo."
+  },
+  "telegram": {
+    "title": "O Telegram está ativo",
+    "body": "As conversas com clientes chegam aqui. Responda sem sair do aplicativo."
   }
 }

+ 4 - 0
messages/ru/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Шинный отель включён",
     "body": "Храните сезонные шины. Настройте полки, затем примите комплект."
+  },
+  "telegram": {
+    "title": "Telegram включён",
+    "body": "Переписка с клиентами приходит сюда. Отвечайте, не выходя из приложения."
   }
 }

+ 4 - 0
messages/tr/featureHints.json

@@ -2,5 +2,9 @@
   "tire-hotel": {
     "title": "Lastik oteli açık",
     "body": "Sezonluk lastikleri saklayın. Rafları kurun, sonra takım kabul edin."
+  },
+  "telegram": {
+    "title": "Telegram açık",
+    "body": "Müşteri konuşmaları buraya düşer. Uygulamadan çıkmadan yanıtlayın."
   }
 }

+ 33 - 7
src/__tests__/components/feature-hint.test.tsx

@@ -48,7 +48,7 @@ beforeEach(() => {
 describe('which hint shows', () => {
   it('shows the first one registered', () => {
     render(
-      <FeatureHintProvider initialSeen={[]}>
+      <FeatureHintProvider initialSeen={[]} pending={['first', 'second']}>
         <Probe id="first" />
         <Probe id="second" />
       </FeatureHintProvider>
@@ -59,7 +59,7 @@ describe('which hint shows', () => {
   it('holds the second back until the first is dismissed', () => {
     // A screen with two of these is noise nobody reads.
     render(
-      <FeatureHintProvider initialSeen={[]}>
+      <FeatureHintProvider initialSeen={[]} pending={['first', 'second']}>
         <Probe id="first" />
         <Probe id="second" />
       </FeatureHintProvider>
@@ -74,7 +74,7 @@ describe('which hint shows', () => {
   it('skips a hint that is not eligible rather than stalling behind it', () => {
     // An unswitched module must not sit at the head of the queue forever.
     render(
-      <FeatureHintProvider initialSeen={[]}>
+      <FeatureHintProvider initialSeen={[]} pending={['first', 'second']}>
         <Probe id="first" eligible={false} />
         <Probe id="second" />
       </FeatureHintProvider>
@@ -87,7 +87,7 @@ describe('which hint shows', () => {
 describe('staying dismissed', () => {
   it('records the dismissal against the workshop', () => {
     render(
-      <FeatureHintProvider initialSeen={[]}>
+      <FeatureHintProvider initialSeen={[]} pending={['first']}>
         <Probe id="first" />
       </FeatureHintProvider>
     )
@@ -98,7 +98,7 @@ describe('staying dismissed', () => {
   it('closes straight away rather than waiting on the write', () => {
     // Nobody should watch a spinner to put a note away.
     render(
-      <FeatureHintProvider initialSeen={[]}>
+      <FeatureHintProvider initialSeen={[]} pending={['first']}>
         <Probe id="first" />
       </FeatureHintProvider>
     )
@@ -108,7 +108,7 @@ describe('staying dismissed', () => {
 
   it('never shows one the workshop has already been shown', () => {
     render(
-      <FeatureHintProvider initialSeen={['first']}>
+      <FeatureHintProvider initialSeen={['first']} pending={['first', 'second']}>
         <Probe id="first" />
         <Probe id="second" />
       </FeatureHintProvider>
@@ -120,7 +120,7 @@ describe('staying dismissed', () => {
   it('treats a new version as a different hint', () => {
     // Which is how reworded copy reaches a workshop that dismissed the old one.
     render(
-      <FeatureHintProvider initialSeen={['thing.v1']}>
+      <FeatureHintProvider initialSeen={['thing.v1']} pending={['thing.v2']}>
         <Probe id="thing.v2" />
       </FeatureHintProvider>
     )
@@ -128,6 +128,32 @@ describe('staying dismissed', () => {
   })
 })
 
+describe('only announcing what was just switched on', () => {
+  it('says nothing about a link that was already there', () => {
+    // The failure this exists to stop: adding a hint for a feature a workshop
+    // has been using for a year, and announcing it to them as new. Eligible
+    // is true on every page load; raised is true only at the moment of the
+    // flip.
+    render(
+      <FeatureHintProvider initialSeen={[]} pending={[]}>
+        <Probe id="telegram.v1" />
+      </FeatureHintProvider>
+    )
+    expect(state('telegram.v1')).toBe('closed')
+  })
+
+  it('lets a raised hint through while an unraised one waits its turn', () => {
+    render(
+      <FeatureHintProvider initialSeen={[]} pending={['second']}>
+        <Probe id="first" />
+        <Probe id="second" />
+      </FeatureHintProvider>
+    )
+    expect(state('first')).toBe('closed')
+    expect(state('second')).toBe('open')
+  })
+})
+
 describe('outside a provider', () => {
   it('shows nothing rather than throwing', () => {
     // A hint rendered somewhere the provider does not reach should be

+ 93 - 0
src/__tests__/features/settings/arm-feature-hints.test.ts

@@ -0,0 +1,93 @@
+/**
+ * When a feature hint is raised.
+ *
+ * The rule is narrow on purpose and easy to get wrong in the generous
+ * direction. "The link is visible" is not the trigger, because it is true on
+ * every page load forever after; "somebody just switched it on" is, and that
+ * is a single moment. Getting this wrong means announcing a year-old feature
+ * to the workshop that has been using it all year.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { hintsToArm, HINT_FOR_SETTING } from '@/features/settings/Lib/featureHints'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const TIRE = SETTING_KEYS.TIRE_HOTEL_ENABLED
+const HINT = HINT_FOR_SETTING[TIRE]
+
+describe('raising a hint', () => {
+  it('raises one when a setting is switched on', () => {
+    expect(hintsToArm({ entries: { [TIRE]: 'true' }, current: {}, seen: [] })).toEqual([HINT])
+  })
+
+  it('stays quiet when the setting was already on', () => {
+    // Settings forms submit every field they own on every save, so an
+    // already-on toggle arrives on a save that changed something else
+    // entirely. Treating that as a flip would re-announce on every save.
+    expect(
+      hintsToArm({ entries: { [TIRE]: 'true' }, current: { [TIRE]: 'true' }, seen: [] })
+    ).toEqual([])
+  })
+
+  it('stays quiet when the setting is being switched off', () => {
+    expect(
+      hintsToArm({ entries: { [TIRE]: 'false' }, current: { [TIRE]: 'true' }, seen: [] })
+    ).toEqual([])
+  })
+
+  it('stays quiet for a hint already dismissed', () => {
+    // Turning something off and back on is not a reason to be told again.
+    expect(hintsToArm({ entries: { [TIRE]: 'true' }, current: {}, seen: [HINT] })).toEqual([])
+  })
+
+  it('ignores settings that carry no hint', () => {
+    const entries = { 'workshop.name': 'true', [SETTING_KEYS.TAX_ENABLED]: 'true' }
+    expect(hintsToArm({ entries, current: {}, seen: [] })).toEqual([])
+  })
+
+  it('raises each hint once even if a save repeats it', () => {
+    const entries = Object.fromEntries(Object.keys(HINT_FOR_SETTING).map((key) => [key, 'true']))
+    const raised = hintsToArm({ entries, current: {}, seen: [] })
+    expect(new Set(raised).size).toBe(raised.length)
+  })
+})
+
+describe('the registry', () => {
+  it('covers more than the one hint it started with', () => {
+    expect(Object.keys(HINT_FOR_SETTING).length).toBeGreaterThan(1)
+  })
+
+  it('gives every watched setting a versioned id', () => {
+    // Bumping the version is the only way to re-show reworded copy, so an id
+    // without one can never be revised.
+    for (const id of Object.values(HINT_FOR_SETTING)) {
+      expect(id, `${id} is not versioned`).toMatch(/\.v\d+$/)
+    }
+  })
+
+  it('names hints the sidebar can find copy for', () => {
+    // The sidebar looks up featureHints.<prefix>, taking the prefix from the
+    // part before the first dot. A missing entry throws in next-intl, on the
+    // very screen the hint was meant to help with.
+    const messages = JSON.parse(readFileSync('messages/en/featureHints.json', 'utf-8'))
+    for (const id of Object.values(HINT_FOR_SETTING)) {
+      const prefix = id.split('.')[0]
+      expect(messages[prefix], `no copy for ${prefix}`).toBeTruthy()
+      expect(messages[prefix].title).toBeTruthy()
+      expect(messages[prefix].body).toBeTruthy()
+    }
+  })
+
+  it('has that copy in every language', () => {
+    const locales = ['de', 'es', 'fr', 'it', 'lt', 'nb', 'nl', 'pl', 'pt-BR', 'ru', 'tr']
+    for (const locale of locales) {
+      const messages = JSON.parse(readFileSync(`messages/${locale}/featureHints.json`, 'utf-8'))
+      for (const id of Object.values(HINT_FOR_SETTING)) {
+        const prefix = id.split('.')[0]
+        expect(messages[prefix]?.title, `${locale} has no title for ${prefix}`).toBeTruthy()
+        expect(messages[prefix]?.body, `${locale} has no body for ${prefix}`).toBeTruthy()
+      }
+    }
+  })
+})

+ 9 - 9
src/app/(authenticated)/layout.tsx

@@ -8,7 +8,7 @@ import { NotificationInitializer } from '@/features/notifications/Components/Not
 import { ConfirmProvider } from '@/components/confirm-dialog'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { parseSeenHints } from '@/features/settings/Lib/featureHints'
+import { parseHintIds } from '@/features/settings/Lib/featureHints'
 import { getFeatures, isCloudMode } from '@/lib/features'
 import { WhiteLabelCtaProvider } from '@/components/white-label-cta-context'
 import { DateSettingsProvider } from '@/components/date-settings-context'
@@ -73,16 +73,16 @@ export default async function DashboardLayout({ children }: { children: React.Re
 
   // Read here so the first paint already knows, and a hint that was dismissed
   // months ago never flashes up before the client can suppress it.
-  const seenHintsRow = await db.appSetting.findUnique({
+  const hintRows = await db.appSetting.findMany({
     where: {
-      organizationId_key: {
-        organizationId: data.organizationId,
-        key: SETTING_KEYS.FEATURE_HINTS_SEEN,
-      },
+      organizationId: data.organizationId,
+      key: { in: [SETTING_KEYS.FEATURE_HINTS_SEEN, SETTING_KEYS.FEATURE_HINTS_PENDING] },
     },
-    select: { value: true },
+    select: { key: true, value: true },
   })
-  const seenHints = parseSeenHints(seenHintsRow?.value)
+  const hintValues = new Map(hintRows.map((row) => [row.key, row.value]))
+  const seenHints = parseHintIds(hintValues.get(SETTING_KEYS.FEATURE_HINTS_SEEN))
+  const pendingHints = parseHintIds(hintValues.get(SETTING_KEYS.FEATURE_HINTS_PENDING))
 
   // Determine which subjects the user can access (for sidebar visibility)
   const isOwnerOrAdmin =
@@ -166,7 +166,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                 currencyFormat={data.currencyFormat}
               >
                 <ConfirmProvider>
-                  <FeatureHintProvider initialSeen={seenHints}>
+                  <FeatureHintProvider initialSeen={seenHints} pending={pendingHints}>
                     <AppSidebar
                       companyLogo={data.companyLogo}
                       organizations={data.organizations}

+ 3 - 0
src/components/app-sidebar.tsx

@@ -139,6 +139,8 @@ export function AppSidebar({
       icon: MessageSquare,
       subject: 'customers',
     },
+    // Same story as the tire hotel below: the link only exists once Telegram
+    // is connected, so it is worth pointing at the first time it appears.
     ...(telegramEnabled
       ? [
           {
@@ -146,6 +148,7 @@ export function AppSidebar({
             url: '/telegram',
             icon: Send,
             subject: 'customers',
+            hint: 'telegram.v1',
           },
         ]
       : []),

+ 13 - 1
src/components/feature-hint/feature-hint-provider.tsx

@@ -39,13 +39,22 @@ const Context = createContext<FeatureHintContext>({
  *
  * Registration order decides the queue, which means sidebar order decides it,
  * which is the order somebody reads the screen in anyway.
+ *
+ * A hint is only ever shown because somebody switched the thing on. Being
+ * eligible is not enough on its own: a workshop that has had Telegram running
+ * for a year has an eligible link on every page load and should never be told
+ * about it. The server side raises the hint at the moment of the flip, and
+ * this only decides which of the raised ones is showing.
  */
 export function FeatureHintProvider({
   initialSeen,
+  pending,
   children,
 }: {
   /** Ids already shown, read on the server so nothing flashes on load. */
   initialSeen: string[]
+  /** Ids raised by a setting being switched on, and not yet dismissed. */
+  pending: string[]
   children: React.ReactNode
 }) {
   const [seen, setSeen] = useState<string[]>(initialSeen)
@@ -67,7 +76,10 @@ export function FeatureHintProvider({
     void dismissFeatureHint(id)
   }, [])
 
-  const active = useMemo(() => queue.find((id) => !seen.includes(id)) ?? null, [queue, seen])
+  const active = useMemo(
+    () => queue.find((id) => pending.includes(id) && !seen.includes(id)) ?? null,
+    [queue, pending, seen]
+  )
 
   const value = useMemo(
     () => ({ active, register, unregister, dismiss }),

+ 20 - 5
src/features/settings/Actions/featureHintActions.ts

@@ -3,7 +3,7 @@
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { SETTING_KEYS } from '../Schema/settingsSchema'
-import { parseSeenHints } from '../Lib/featureHints'
+import { parseHintIds } from '../Lib/featureHints'
 
 /**
  * Marks a feature hint as shown, for the whole workshop.
@@ -20,14 +20,29 @@ export async function dismissFeatureHint(id: string) {
     const trimmed = id.trim().slice(0, 100)
     if (!trimmed) return { seen: [] as string[] }
 
-    const existing = await db.appSetting.findUnique({
+    const rows = await db.appSetting.findMany({
       where: {
-        organizationId_key: { organizationId, key: SETTING_KEYS.FEATURE_HINTS_SEEN },
+        organizationId,
+        key: { in: [SETTING_KEYS.FEATURE_HINTS_SEEN, SETTING_KEYS.FEATURE_HINTS_PENDING] },
       },
-      select: { value: true },
+      select: { key: true, value: true },
     })
+    const stored = new Map(rows.map((row) => [row.key, row.value]))
+
+    // Taken off the pending list as well as added to the seen one. Leaving it
+    // pending would cost nothing today, but a later hint bumped to .v2 would
+    // find a stale .v1 sitting in front of it in the queue forever.
+    const pending = parseHintIds(stored.get(SETTING_KEYS.FEATURE_HINTS_PENDING))
+    if (pending.includes(trimmed)) {
+      await db.appSetting.update({
+        where: {
+          organizationId_key: { organizationId, key: SETTING_KEYS.FEATURE_HINTS_PENDING },
+        },
+        data: { value: JSON.stringify(pending.filter((id) => id !== trimmed)) },
+      })
+    }
 
-    const seen = parseSeenHints(existing?.value)
+    const seen = parseHintIds(stored.get(SETTING_KEYS.FEATURE_HINTS_SEEN))
     if (seen.includes(trimmed)) return { seen }
 
     // Capped so a long-lived workshop cannot grow this without bound. The

+ 3 - 0
src/features/settings/Actions/settingsActions.ts

@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
 import type { SettingKey } from "../Schema/settingsSchema";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { demoGuardSettingKey } from "@/lib/demo";
+import { armFeatureHints } from "../Lib/armFeatureHints";
 
 export async function getSetting(key: SettingKey) {
   return withAuth(async ({ userId, organizationId }) => {
@@ -35,6 +36,7 @@ export async function getSettings(keys?: SettingKey[]) {
 export async function setSetting(key: SettingKey, value: string) {
   return withAuth(async ({ userId, organizationId }) => {
     demoGuardSettingKey(key);
+    await armFeatureHints(db, organizationId, userId, { [key]: value });
     const setting = await db.appSetting.upsert({
       where: { organizationId_key: { organizationId, key } },
       update: { value },
@@ -48,6 +50,7 @@ export async function setSetting(key: SettingKey, value: string) {
 export async function setSettings(entries: Record<string, string>) {
   return withAuth(async ({ userId, organizationId }) => {
     for (const key of Object.keys(entries)) demoGuardSettingKey(key);
+    await armFeatureHints(db, organizationId, userId, entries);
     await db.$transaction(
       Object.entries(entries).map(([key, value]) =>
         db.appSetting.upsert({

+ 66 - 0
src/features/settings/Lib/armFeatureHints.ts

@@ -0,0 +1,66 @@
+import type { Prisma } from '@/generated/prisma/client'
+import { db as prisma } from '@/lib/db'
+import { SETTING_KEYS } from '../Schema/settingsSchema'
+import { HINT_FOR_SETTING, hintsToArm, parseHintIds } from './featureHints'
+
+type Db = Prisma.TransactionClient | typeof prisma
+
+/**
+ * Raises the hints for any setting this write is switching on.
+ *
+ * Call it before the write, while the old values are still readable. Every
+ * path that saves settings goes through here, including the ones that write
+ * their own transaction, so a new toggle only has to add a line to
+ * HINT_FOR_SETTING to get its link pointed out.
+ *
+ * Deliberately does its own small write rather than joining the caller's
+ * transaction. If the settings write then fails, the workshop is left with a
+ * hint armed for a link that did not appear, and an armed hint with nothing
+ * rendering it is inert: the card only exists where the link does.
+ */
+export async function armFeatureHints(
+  db: Db,
+  organizationId: string,
+  userId: string,
+  entries: Record<string, string>
+): Promise<void> {
+  // Cheap exit for the overwhelming majority of saves, which touch no
+  // hint-bearing setting at all.
+  const watched = Object.keys(entries).filter((key) => key in HINT_FOR_SETTING)
+  if (watched.length === 0) return
+
+  const rows = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: {
+        in: [...watched, SETTING_KEYS.FEATURE_HINTS_SEEN, SETTING_KEYS.FEATURE_HINTS_PENDING],
+      },
+    },
+    select: { key: true, value: true },
+  })
+  const stored = new Map(rows.map((row) => [row.key, row.value]))
+
+  const armed = hintsToArm({
+    entries,
+    current: Object.fromEntries(watched.map((key) => [key, stored.get(key)])),
+    seen: parseHintIds(stored.get(SETTING_KEYS.FEATURE_HINTS_SEEN)),
+  })
+  if (armed.length === 0) return
+
+  const pending = parseHintIds(stored.get(SETTING_KEYS.FEATURE_HINTS_PENDING))
+  const next = [...pending, ...armed.filter((id) => !pending.includes(id))]
+  if (next.length === pending.length) return
+
+  await db.appSetting.upsert({
+    where: {
+      organizationId_key: { organizationId, key: SETTING_KEYS.FEATURE_HINTS_PENDING },
+    },
+    create: {
+      userId,
+      organizationId,
+      key: SETTING_KEYS.FEATURE_HINTS_PENDING,
+      value: JSON.stringify(next),
+    },
+    update: { value: JSON.stringify(next) },
+  })
+}

+ 60 - 3
src/features/settings/Lib/featureHints.ts

@@ -1,10 +1,31 @@
+import { SETTING_KEYS } from '../Schema/settingsSchema'
+import { ORG_TELEGRAM_KEYS } from '@/features/telegram/Schema/telegramSettingsSchema'
+
 /**
- * Reads the feature hint ids a workshop has already been shown.
+ * Which hint to raise when a setting is switched on.
+ *
+ * Keyed by the setting, because that is the moment worth announcing. A link
+ * that has always been in the sidebar needs no note; one that appeared while
+ * somebody was looking at a different screen does.
+ *
+ * The id carries a version. Rewording a hint means bumping it, which shows
+ * the new wording to workshops that dismissed the old one.
+ */
+export const HINT_FOR_SETTING: Record<string, string> = {
+  [SETTING_KEYS.TIRE_HOTEL_ENABLED]: 'tire-hotel.v1',
+  [ORG_TELEGRAM_KEYS.TELEGRAM_ENABLED]: 'telegram.v1',
+}
+
+/** The value a setting holds when it is on. Everything else counts as off. */
+const ON = 'true'
+
+/**
+ * Reads a stored list of hint ids.
  *
  * Lives outside the action file because a 'use server' module may only export
- * async functions, and both the server layout and the action need this one.
+ * async functions, and the server layout needs this one too.
  */
-export function parseSeenHints(value: string | null | undefined): string[] {
+export function parseHintIds(value: string | null | undefined): string[] {
   if (!value) return []
   try {
     const parsed = JSON.parse(value)
@@ -15,3 +36,39 @@ export function parseSeenHints(value: string | null | undefined): string[] {
     return []
   }
 }
+
+/**
+ * The hints a settings write should raise.
+ *
+ * Only an off-to-on flip counts. Saving a page that happens to contain an
+ * already-on toggle raises nothing, which matters because most settings
+ * forms submit every field they own on every save. Turning something off and
+ * on again raises nothing either, once the hint has been dismissed: whoever
+ * did that knows where the link is.
+ */
+export function hintsToArm({
+  entries,
+  current,
+  seen,
+}: {
+  /** The values about to be written. */
+  entries: Record<string, string>
+  /** What those keys hold right now. */
+  current: Record<string, string | undefined>
+  /** Hints this workshop has already been shown. */
+  seen: string[]
+}): string[] {
+  const armed: string[] = []
+
+  for (const [key, value] of Object.entries(entries)) {
+    const hint = HINT_FOR_SETTING[key]
+    if (!hint) continue
+    if (value !== ON) continue
+    if (current[key] === ON) continue
+    if (seen.includes(hint)) continue
+    if (armed.includes(hint)) continue
+    armed.push(hint)
+  }
+
+  return armed
+}

+ 5 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -124,6 +124,11 @@ export const SETTING_KEYS = {
   /// something appeared in this workshop's sidebar, and once somebody here
   /// has seen it, the workshop has been told.
   FEATURE_HINTS_SEEN: 'featureHints.seen',
+  /// Hints raised by a setting being switched on and not yet dismissed.
+  /// Separate from the seen list because being eligible for a hint and
+  /// having been shown one are different things: a workshop that has had
+  /// Telegram on for a year is eligible forever and should never be told.
+  FEATURE_HINTS_PENDING: 'featureHints.pending',
 
   /// Tire hotel is off until a workshop opts in. Everything about the module
   /// — sidebar entry, routes, cron sweeps — keys off this one flag, so a shop

+ 5 - 0
src/features/telegram/Actions/telegramSettingsActions.ts

@@ -10,6 +10,7 @@ import {
   ORG_TELEGRAM_KEYS,
 } from "../Schema/telegramSettingsSchema";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { armFeatureHints } from "@/features/settings/Lib/armFeatureHints";
 import {
   getTelegramBotInfo,
   setTelegramWebhook,
@@ -66,6 +67,10 @@ export async function setTelegramSettings(settings: { botToken: string }) {
         [ORG_TELEGRAM_KEYS.TELEGRAM_WEBHOOK_SECRET]: webhookSecret,
       };
 
+      // Connecting a bot switches Telegram on, which is the same event as
+      // ticking the toggle and deserves the same note about the new link.
+      await armFeatureHints(db, organizationId, userId, entries);
+
       await db.$transaction(
         Object.entries(entries).map(([key, value]) =>
           db.appSetting.upsert({