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

Tell workshops about the designer, once, in the sidebar (#278)

Nothing in the product announced the invoice designer, and the existing
feature hints could only be raised by a setting being switched on, which
shipping a feature never does. Announcements now join that same queue from a
registry, so only one card ever shows and the dismissal is still recorded
against the workshop rather than the browser.

Three rules decide whether an account is told: it needs the rights the
feature sits behind, its plan has to include the feature, and the workshop
has to predate the feature shipping, since an organization that signed up
afterwards has never known the product without it. Opening the designer
counts as having been told, so one person settles it for their colleagues.

The card is anchored to Settings, wears the surface of a selected sidebar
link so it does not sit white on a white page, and waits for a button rather
than closing on a stray click, which would spend the workshop's one notice
on everybody's behalf. It points at the templates page instead of the
designer itself, so the route is learned rather than skipped.

Also gates the designer route on settings rights. It edits the sheet every
customer receives and was reachable by any member who knew the URL.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
fd1330c211

+ 5 - 0
messages/de/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram ist aktiv",
     "body": "Kundengespräche landen hier. Antworten Sie, ohne die App zu verlassen."
+  },
+  "invoice-designer": {
+    "title": "Rechnungen wurden überarbeitet",
+    "body": "Legen Sie Layout, Farben und Schriften für Rechnungen und Angebote im neuen Designer fest, unter Vorlagen.",
+    "cta": "Vorlagen öffnen"
   }
 }

+ 5 - 0
messages/en/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram is on",
     "body": "Customer conversations land here. Reply without leaving the app."
+  },
+  "invoice-designer": {
+    "title": "Invoices have been overhauled",
+    "body": "Set the layout, colours and fonts for your invoices and quotes in the new designer, under Templates.",
+    "cta": "Open Templates"
   }
 }

+ 5 - 0
messages/es/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram está activo",
     "body": "Las conversaciones con clientes llegan aquí. Responde sin salir de la app."
+  },
+  "invoice-designer": {
+    "title": "Las facturas se han renovado",
+    "body": "Define el diseño, los colores y las tipografías de tus facturas y presupuestos en el nuevo diseñador, en Plantillas.",
+    "cta": "Abrir Plantillas"
   }
 }

+ 5 - 0
messages/fr/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram est activé",
     "body": "Les conversations clients arrivent ici. Répondez sans quitter l'application."
+  },
+  "invoice-designer": {
+    "title": "Les factures ont été repensées",
+    "body": "Définissez la mise en page, les couleurs et les polices de vos factures et devis dans le nouvel éditeur, sous Modèles.",
+    "cta": "Ouvrir Modèles"
   }
 }

+ 5 - 0
messages/it/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram è attivo",
     "body": "Le conversazioni con i clienti arrivano qui. Rispondi senza uscire dall'app."
+  },
+  "invoice-designer": {
+    "title": "Le fatture sono state rinnovate",
+    "body": "Imposta layout, colori e caratteri di fatture e preventivi nel nuovo editor, in Modelli.",
+    "cta": "Apri Modelli"
   }
 }

+ 5 - 0
messages/lt/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram įjungtas",
     "body": "Pokalbiai su klientais atsiduria čia. Atsakykite neišeidami iš programos."
+  },
+  "invoice-designer": {
+    "title": "Sąskaitos buvo atnaujintos",
+    "body": "Nustatykite sąskaitų ir pasiūlymų išdėstymą, spalvas ir šriftus naujame redaktoriuje, skiltyje Šablonai.",
+    "cta": "Atidaryti šablonus"
   }
 }

+ 5 - 0
messages/nb/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram er på",
     "body": "Kundesamtaler havner her. Svar uten å forlate appen."
+  },
+  "invoice-designer": {
+    "title": "Fakturaene er bygget om",
+    "body": "Bestem oppsett, farger og skrifter på fakturaer og tilbud i den nye designeren, under Maler.",
+    "cta": "Åpne Maler"
   }
 }

+ 5 - 0
messages/nl/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram staat aan",
     "body": "Klantgesprekken komen hier binnen. Beantwoord ze zonder de app te verlaten."
+  },
+  "invoice-designer": {
+    "title": "Facturen zijn vernieuwd",
+    "body": "Bepaal de indeling, kleuren en lettertypen van je facturen en offertes in de nieuwe ontwerper, onder Sjablonen.",
+    "cta": "Sjablonen openen"
   }
 }

+ 5 - 0
messages/pl/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram jest włączony",
     "body": "Rozmowy z klientami trafiają tutaj. Odpowiadaj bez wychodzenia z aplikacji."
+  },
+  "invoice-designer": {
+    "title": "Faktury zostały przebudowane",
+    "body": "Ustaw układ, kolory i czcionki faktur oraz ofert w nowym kreatorze, w sekcji Szablony.",
+    "cta": "Otwórz Szablony"
   }
 }

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

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "O Telegram está ativo",
     "body": "As conversas com clientes chegam aqui. Responda sem sair do aplicativo."
+  },
+  "invoice-designer": {
+    "title": "As faturas foram reformuladas",
+    "body": "Defina o layout, as cores e as fontes das suas faturas e orçamentos no novo editor, em Modelos.",
+    "cta": "Abrir Modelos"
   }
 }

+ 5 - 0
messages/ru/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram включён",
     "body": "Переписка с клиентами приходит сюда. Отвечайте, не выходя из приложения."
+  },
+  "invoice-designer": {
+    "title": "Счета полностью переработаны",
+    "body": "Задайте расположение, цвета и шрифты счетов и предложений в новом редакторе, в разделе «Шаблоны».",
+    "cta": "Открыть шаблоны"
   }
 }

+ 5 - 0
messages/tr/featureHints.json

@@ -6,5 +6,10 @@
   "telegram": {
     "title": "Telegram açık",
     "body": "Müşteri konuşmaları buraya düşer. Uygulamadan çıkmadan yanıtlayın."
+  },
+  "invoice-designer": {
+    "title": "Faturalar yenilendi",
+    "body": "Faturalarınızın ve tekliflerinizin düzenini, renklerini ve yazı tiplerini Şablonlar altındaki yeni tasarımcıda belirleyin.",
+    "cta": "Şablonları aç"
   }
 }

+ 136 - 0
src/__tests__/components/feature-hint-card.test.tsx

@@ -0,0 +1,136 @@
+/**
+ * The card itself, rather than the queue behind it.
+ *
+ * The rule worth pinning is what counts as having been told. A hint about a
+ * toggle somebody just flipped may close on any click, but an announcement is
+ * dismissed once for the whole workshop, so a stray click at the other end of
+ * the screen must not spend that on everybody's behalf.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+
+const dismissFeatureHint = vi.fn().mockResolvedValue({ success: true, data: { seen: [] } })
+vi.mock('@/features/settings/Actions/featureHintActions', () => ({
+  dismissFeatureHint: (id: string) => dismissFeatureHint(id),
+}))
+
+// Radix positions the card with floating-ui, which measures its anchor. jsdom
+// has no ResizeObserver, and without one the content never mounts. Nothing
+// here needs measuring: these tests are about what the card does, not where
+// it lands.
+// biome-ignore lint/suspicious/noEmptyBlockStatements: a stub that measures nothing
+const noop = () => {}
+class StubObserver {
+  observe = noop
+  unobserve = noop
+  disconnect = noop
+}
+vi.stubGlobal('ResizeObserver', StubObserver)
+vi.stubGlobal('DOMRect', class {})
+
+const { FeatureHintProvider } = await import('@/components/feature-hint/feature-hint-provider')
+const { FeatureHint } = await import('@/components/feature-hint/feature-hint')
+
+async function renderCard(props: { variant?: 'hint' | 'announcement' } = {}) {
+  render(
+    <FeatureHintProvider initialSeen={[]} pending={['designer.v1']}>
+      <FeatureHint
+        id="designer.v1"
+        eligible
+        title="Invoices have been overhauled"
+        body="Open the new designer."
+        href="/invoice-designer"
+        cta="Open the designer"
+        {...props}
+      >
+        <button type="button">Settings</button>
+      </FeatureHint>
+    </FeatureHintProvider>
+  )
+  // Radix arms its outside-click listener in a timeout, so a click dispatched
+  // straight after render lands before anything is listening. Without this
+  // wait the outside-click tests pass whatever the component does.
+  await act(async () => {
+    await new Promise((resolve) => setTimeout(resolve, 0))
+  })
+}
+
+/** A click that lands anywhere but the card. */
+function clickOutside() {
+  act(() => {
+    document.body.dispatchEvent(
+      new PointerEvent('pointerdown', { bubbles: true, cancelable: true })
+    )
+    document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
+  })
+}
+
+const showing = () => screen.queryByText('Invoices have been overhauled') !== null
+
+beforeEach(() => {
+  dismissFeatureHint.mockClear()
+})
+
+describe('an announcement waiting to be acknowledged', () => {
+  it('shows its two ways out', async () => {
+    await renderCard({ variant: 'announcement' })
+    expect(showing()).toBe(true)
+    expect(screen.getByText('Open the designer')).toBeInTheDocument()
+    expect(screen.getByText('Got it')).toBeInTheDocument()
+  })
+
+  it('survives a click that lands somewhere else', async () => {
+    // The reported bug: reading the card, clicking anywhere, and never being
+    // able to get it back, for the whole workshop.
+    await renderCard({ variant: 'announcement' })
+    clickOutside()
+    expect(showing()).toBe(true)
+    expect(dismissFeatureHint).not.toHaveBeenCalled()
+  })
+
+  it('closes when the acknowledgement is actually pressed', async () => {
+    await renderCard({ variant: 'announcement' })
+    act(() => {
+      screen.getByText('Got it').click()
+    })
+    expect(showing()).toBe(false)
+    expect(dismissFeatureHint).toHaveBeenCalledWith('designer.v1')
+  })
+})
+
+describe('how loud the card is', () => {
+  /** The popover surface, which is what carries the card's colours. */
+  const surface = () =>
+    screen.getByText('Invoices have been overhauled').closest('[data-slot="popover-content"]')
+
+  it('gives an announcement the surface a selected sidebar link wears', async () => {
+    // Left on the default popover surface it is white on a white page, which
+    // is how an announcement goes unread.
+    await renderCard({ variant: 'announcement' })
+    expect(surface()?.className).toContain('bg-sidebar-accent')
+  })
+
+  it('keeps that surface opaque, so the page cannot read through the card', async () => {
+    // An alpha modifier here would tint nicely and leave the card's own text
+    // sitting over whatever it happens to be floating above.
+    await renderCard({ variant: 'announcement' })
+    expect(surface()?.className).not.toMatch(/bg-sidebar-accent\//)
+  })
+
+  it('leaves an ordinary hint on the usual popover surface', async () => {
+    await renderCard()
+    expect(surface()?.className).not.toContain('bg-sidebar-accent')
+  })
+})
+
+describe('a hint about something just switched on', () => {
+  it('still closes on a click elsewhere', async () => {
+    // Unchanged from before announcements existed: somebody who flipped the
+    // toggle already knows, so any click is fair evidence they are done.
+    await renderCard()
+    clickOutside()
+    expect(showing()).toBe(false)
+    expect(dismissFeatureHint).toHaveBeenCalledWith('designer.v1')
+  })
+})

+ 144 - 3
src/__tests__/features/settings/arm-feature-hints.test.ts

@@ -10,9 +10,17 @@
 
 import { describe, it, expect } from 'vitest'
 import { readFileSync } from 'node:fs'
-import { hintsToArm, HINT_FOR_SETTING } from '@/features/settings/Lib/featureHints'
+import {
+  announcementsToShow,
+  hintsToArm,
+  ANNOUNCEMENTS,
+  HINT_FOR_SETTING,
+  INVOICE_DESIGNER_ANNOUNCEMENT,
+} from '@/features/settings/Lib/featureHints'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
+const LOCALES = ['de', 'es', 'fr', 'it', 'lt', 'nb', 'nl', 'pl', 'pt-BR', 'ru', 'tr']
+
 const TIRE = SETTING_KEYS.TIRE_HOTEL_ENABLED
 const HINT = HINT_FOR_SETTING[TIRE]
 
@@ -80,8 +88,7 @@ describe('the registry', () => {
   })
 
   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) {
+    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]
@@ -91,3 +98,137 @@ describe('the registry', () => {
     }
   })
 })
+
+/**
+ * Announcing something the product itself gained.
+ *
+ * The failure worth guarding is the same one in a different disguise: telling
+ * somebody about a feature that is not news to them. For a setting flip that
+ * meant re-announcing on every save; here it means greeting a workshop that
+ * signed up last week with the history of the product, or offering a
+ * technician a door their role keeps locked.
+ */
+const DESIGNER = ANNOUNCEMENTS[0].id
+const SHIPPED = ANNOUNCEMENTS[0].shippedAt
+
+describe('showing an announcement', () => {
+  it('shows one to a workshop that predates the feature', () => {
+    expect(announcementsToShow({ organizationCreatedAt: '2026-01-04', seen: [] })).toContain(
+      DESIGNER
+    )
+  })
+
+  it('stays quiet for a workshop that signed up after it shipped', () => {
+    // They have never known the product without it, so it is not news, and a
+    // backlog of announcements is a poor way to greet a new customer.
+    expect(announcementsToShow({ organizationCreatedAt: '2026-12-01', seen: [] })).toEqual([])
+  })
+
+  it('stays quiet once the workshop has been told', () => {
+    expect(announcementsToShow({ organizationCreatedAt: '2026-01-04', seen: [DESIGNER] })).toEqual(
+      []
+    )
+  })
+
+  it('stays quiet for a role that cannot reach the feature', () => {
+    // Offering a door that stays locked is worse than saying nothing.
+    expect(
+      announcementsToShow({
+        organizationCreatedAt: '2026-01-04',
+        visibleSubjects: ['vehicles', 'work_orders'],
+        seen: [],
+      })
+    ).toEqual([])
+  })
+
+  it('shows one to a role that can', () => {
+    expect(
+      announcementsToShow({
+        organizationCreatedAt: '2026-01-04',
+        visibleSubjects: ['vehicles', 'settings'],
+        seen: [],
+      })
+    ).toContain(DESIGNER)
+  })
+
+  it('stays quiet when the plan does not include the feature', () => {
+    // The card cannot be waved away without acknowledging it, so pointing it
+    // at an upsell page is a poor way to sell anything.
+    expect(
+      announcementsToShow({
+        organizationCreatedAt: '2026-01-04',
+        features: { customTemplates: false },
+        seen: [],
+      })
+    ).toEqual([])
+  })
+
+  it('shows one when the plan does include it', () => {
+    expect(
+      announcementsToShow({
+        organizationCreatedAt: '2026-01-04',
+        features: { customTemplates: true },
+        seen: [],
+      })
+    ).toContain(DESIGNER)
+  })
+
+  it('treats unrestricted access and an unknown signup date as eligible', () => {
+    // Owners and admins arrive with no subject list, and an org row without a
+    // readable date must not silence an announcement for everybody.
+    expect(announcementsToShow({ seen: [] })).toContain(DESIGNER)
+    expect(announcementsToShow({ organizationCreatedAt: 'not a date', seen: [] })).toContain(
+      DESIGNER
+    )
+  })
+})
+
+describe('the announcement registry', () => {
+  it('versions every id, so reworded copy can be shown again', () => {
+    for (const announcement of ANNOUNCEMENTS) {
+      expect(announcement.id, `${announcement.id} is not versioned`).toMatch(/\.v\d+$/)
+    }
+  })
+
+  it('ships every announcement with a date the age gate can read', () => {
+    for (const announcement of ANNOUNCEMENTS) {
+      expect(
+        Number.isNaN(new Date(announcement.shippedAt).getTime()),
+        `${announcement.id} has an unreadable shippedAt`
+      ).toBe(false)
+    }
+  })
+
+  it('carries a title, a body and a link label in every language', () => {
+    // The card renders all three. A missing one throws in next-intl, on the
+    // screen the announcement was meant to help with.
+    for (const locale of ['en', ...LOCALES]) {
+      const messages = JSON.parse(readFileSync(`messages/${locale}/featureHints.json`, 'utf-8'))
+      for (const announcement of ANNOUNCEMENTS) {
+        const prefix = announcement.id.split('.')[0]
+        for (const field of ['title', 'body', 'cta']) {
+          expect(messages[prefix]?.[field], `${locale} has no ${field} for ${prefix}`).toBeTruthy()
+        }
+      }
+    }
+  })
+
+  it('points every announcement at a real destination', () => {
+    for (const announcement of ANNOUNCEMENTS) {
+      expect(announcement.href.startsWith('/'), `${announcement.id} needs an app path`).toBe(true)
+    }
+  })
+
+  it('sends people to the page that owns the designer, not straight into it', () => {
+    // Landing in a full-screen tool teaches nobody where it lives, and the
+    // question comes back next week.
+    const designer = ANNOUNCEMENTS.find((item) => item.id === INVOICE_DESIGNER_ANNOUNCEMENT)
+    expect(designer?.href).toBe('/settings/templates')
+  })
+
+  it('keeps the designer announcement dated no later than today', () => {
+    // A shippedAt in the future gates the announcement off for every existing
+    // workshop, which is the silent way for this to never appear at all.
+    expect(new Date(SHIPPED).getTime()).toBeLessThanOrEqual(Date.now())
+  })
+})

+ 17 - 2
src/app/(authenticated)/layout.tsx

@@ -9,7 +9,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 { parseHintIds } from '@/features/settings/Lib/featureHints'
+import { announcementsToShow, 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'
@@ -112,6 +112,17 @@ export default async function DashboardLayout({ children }: { children: React.Re
     return <NoAccess organizationName={org?.name ?? ''} />
   }
 
+  // Product announcements join the same queue as the hints a setting flip
+  // raises, so only ever one card shows. They are worked out per request
+  // rather than stored, because who may be told depends on the account
+  // reading the page, not on anything written when the feature shipped.
+  const announcements = announcementsToShow({
+    organizationCreatedAt: data.organizationCreatedAt,
+    visibleSubjects: isOwnerOrAdmin ? undefined : visibleSubjects,
+    features,
+    seen: seenHints,
+  })
+
   // Check license expiry (only for admin/owner with white-label)
   let daysUntilExpiry: number | null = null
   let licenseExpiryDismissed = false
@@ -178,7 +189,10 @@ export default async function DashboardLayout({ children }: { children: React.Re
                 currencyFormat={data.currencyFormat}
               >
                 <ConfirmProvider>
-                  <FeatureHintProvider initialSeen={seenHints} pending={pendingHints}>
+                  <FeatureHintProvider
+                    initialSeen={seenHints}
+                    pending={[...pendingHints, ...announcements]}
+                  >
                     <AppSidebar
                       companyLogo={data.companyLogo}
                       organizations={data.organizations}
@@ -187,6 +201,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                       features={features}
                       tireHotelEnabled={tireHotelEnabled}
                       visibleSubjects={visibleSubjects}
+                      announcement={announcements[0] ?? null}
                       isAdminOrOwner={isOwnerOrAdmin}
                     />
                     <SidebarInset>

+ 50 - 31
src/app/(designer)/invoice-designer/page.tsx

@@ -10,6 +10,8 @@ import {
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFieldActions'
 import { InvoiceDesigner } from '@/features/invoice-designer/Components/InvoiceDesigner'
+import { DismissOnArrival } from '@/components/feature-hint'
+import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 
 export default async function InvoiceDesignerPage({
@@ -26,7 +28,7 @@ export default async function InvoiceDesignerPage({
   // gate the settings page used applies here.
   if (!features.customTemplates) redirect('/settings/templates')
 
-  const [settingsResult, invoiceLayout, quoteLayout, organization, customFieldsResult] =
+  const [settingsResult, invoiceLayout, quoteLayout, organization, customFieldsResult, seenRow] =
     await Promise.all([
       getSettings(),
       getInvoiceLayoutConfig(),
@@ -36,8 +38,22 @@ export default async function InvoiceDesignerPage({
         select: { name: true },
       }),
       features.customFields ? getFieldDefinitions() : Promise.resolve({ success: true, data: [] }),
+      db.appSetting.findUnique({
+        where: {
+          organizationId_key: {
+            organizationId: data.organizationId,
+            key: SETTING_KEYS.FEATURE_HINTS_SEEN,
+          },
+        },
+        select: { value: true },
+      }),
     ])
 
+  // Somebody is looking at the designer, so the workshop knows it exists. Only
+  // written when the card is still outstanding, to keep a settled announcement
+  // from costing a write on every visit.
+  const announcementLive = !parseHintIds(seenRow?.value).includes(INVOICE_DESIGNER_ANNOUNCEMENT)
+
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
 
   let savedDesigns: unknown = []
@@ -69,35 +85,38 @@ export default async function InvoiceDesignerPage({
   })
 
   return (
-    <InvoiceDesigner
-      initialDocumentType={doc === 'quote' ? 'quote' : 'invoice'}
-      initialView={view === 'designer' ? 'designer' : 'gallery'}
-      initialPresetId={preset}
-      initialActiveDesigns={{
-        invoice: settings['invoice.activeDesign'] || '',
-        quote: settings['quote.activeDesign'] || '',
-      }}
-      invoiceLayout={invoiceLayout.success ? invoiceLayout.data : undefined}
-      quoteLayout={quoteLayout.success ? quoteLayout.data : undefined}
-      invoiceTemplate={templateFor('invoice')}
-      quoteTemplate={templateFor('quote')}
-      initialSavedDesigns={savedDesigns as SavedDesign[]}
-      customFields={
-        customFieldsResult.success && customFieldsResult.data
-          ? customFieldsResult.data
-              .filter((f) => f.entityType === 'service_record')
-              .map((f) => ({ id: f.id, label: f.label, name: f.name, isActive: f.isActive }))
-          : []
-      }
-      workshop={{
-        name: organization?.name ?? '',
-        address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] ?? '',
-        phone: settings[SETTING_KEYS.WORKSHOP_PHONE] ?? '',
-        email: settings[SETTING_KEYS.WORKSHOP_EMAIL] ?? '',
-        slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] ?? '',
-        orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] ?? '',
-        logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] ?? '',
-      }}
-    />
+    <>
+      {announcementLive && <DismissOnArrival id={INVOICE_DESIGNER_ANNOUNCEMENT} />}
+      <InvoiceDesigner
+        initialDocumentType={doc === 'quote' ? 'quote' : 'invoice'}
+        initialView={view === 'designer' ? 'designer' : 'gallery'}
+        initialPresetId={preset}
+        initialActiveDesigns={{
+          invoice: settings['invoice.activeDesign'] || '',
+          quote: settings['quote.activeDesign'] || '',
+        }}
+        invoiceLayout={invoiceLayout.success ? invoiceLayout.data : undefined}
+        quoteLayout={quoteLayout.success ? quoteLayout.data : undefined}
+        invoiceTemplate={templateFor('invoice')}
+        quoteTemplate={templateFor('quote')}
+        initialSavedDesigns={savedDesigns as SavedDesign[]}
+        customFields={
+          customFieldsResult.success && customFieldsResult.data
+            ? customFieldsResult.data
+                .filter((f) => f.entityType === 'service_record')
+                .map((f) => ({ id: f.id, label: f.label, name: f.name, isActive: f.isActive }))
+            : []
+        }
+        workshop={{
+          name: organization?.name ?? '',
+          address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] ?? '',
+          phone: settings[SETTING_KEYS.WORKSHOP_PHONE] ?? '',
+          email: settings[SETTING_KEYS.WORKSHOP_EMAIL] ?? '',
+          slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] ?? '',
+          orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] ?? '',
+          logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] ?? '',
+        }}
+      />
+    </>
   )
 }

+ 23 - 1
src/app/(designer)/layout.tsx

@@ -1,5 +1,7 @@
 import { redirect } from 'next/navigation'
 import { getLayoutData } from '@/lib/get-layout-data'
+import { getCachedMembership } from '@/lib/cached-session'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { ServiceTypeProvider } from '@/components/service-type-context'
 import { ConfirmProvider } from '@/components/confirm-dialog'
 // The faces the PDFs embed, so the canvas measures what the paper prints.
@@ -8,7 +10,12 @@ import '@/features/invoice-designer/Render/documentFonts.css'
 /**
  * The designer runs full-bleed: no sidebar, no banners, nothing but the tool.
  * It sits outside the dashboard layout for that reason alone, so it repeats the
- * dashboard's two guards rather than inheriting them.
+ * dashboard's guards rather than inheriting them.
+ *
+ * Settings rights, because what this edits is the sheet every customer of the
+ * workshop receives. It is reached from the templates page, which has always
+ * been behind that permission, and a full-screen tool on its own URL must not
+ * be the way around it.
  */
 export default async function DesignerLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -16,6 +23,21 @@ export default async function DesignerLayout({ children }: { children: React.Rea
   if (data.status === 'unauthenticated') redirect('/auth/sign-in')
   if (data.status === 'no-organization') redirect('/onboarding')
 
+  const isOwnerOrAdmin =
+    data.role === 'owner' || data.role === 'admin' || data.role === 'super_admin'
+  if (!isOwnerOrAdmin) {
+    const membership = await getCachedMembership(data.userId)
+    // A member with no custom role keeps full access, as everywhere else.
+    if (membership?.roleId) {
+      const permissions = membership?.customRole?.permissions ?? []
+      const canEdit = hasPermission(permissions, {
+        action: PermissionAction.UPDATE,
+        subject: PermissionSubject.SETTINGS,
+      })
+      if (!canEdit) redirect('/')
+    }
+  }
+
   return (
     <ServiceTypeProvider serviceType={data.serviceType ?? 'automotive'}>
       <ConfirmProvider>

+ 54 - 8
src/components/app-sidebar.tsx

@@ -82,6 +82,7 @@ import {
 import { SidebarInstallButton } from '@/components/pwa-install-prompt'
 import { FullscreenToggle } from '@/components/fullscreen-toggle'
 import { FeatureHint } from '@/components/feature-hint'
+import { ANNOUNCEMENTS } from '@/features/settings/Lib/featureHints'
 import { cn } from '@/lib/utils'
 
 type OrgInfo = { id: string; name: string; role: string }
@@ -95,6 +96,7 @@ export function AppSidebar({
   tireHotelEnabled = false,
   isAdminOrOwner = false,
   visibleSubjects,
+  announcement = null,
   ...props
 }: React.ComponentProps<typeof Sidebar> & {
   companyLogo?: string
@@ -105,6 +107,8 @@ export function AppSidebar({
   tireHotelEnabled?: boolean
   isAdminOrOwner?: boolean
   visibleSubjects?: string[]
+  /** The one product announcement to show, worked out on the server. */
+  announcement?: string | null
 }) {
   const pathname = usePathname()
   const router = useRouter()
@@ -224,6 +228,50 @@ export function AppSidebar({
     if (isMobile) setOpenMobile(false)
   }
 
+  /**
+   * The Settings link, optionally carrying whichever product announcement the
+   * server decided this account should see. Written as one row either way so
+   * the announcement cannot drift from the link it points at.
+   */
+  const settingsRow = () => {
+    const row = (highlighted: boolean) => (
+      <SidebarMenuItem>
+        <SidebarMenuButton
+          asChild
+          isActive={pathname.startsWith('/settings')}
+          className={cn(highlighted && 'ring-2 ring-primary ring-offset-1 ring-offset-sidebar')}
+        >
+          <Link href="/settings" className="font-medium" onClick={closeMobileSidebar}>
+            <Settings className="size-4" />
+            {t('sidebar.settings')}
+          </Link>
+        </SidebarMenuButton>
+      </SidebarMenuItem>
+    )
+
+    const entry = ANNOUNCEMENTS.find((item) => item.id === announcement)
+    if (!entry) return row(false)
+
+    const prefix = entry.id.split('.')[0]
+    return (
+      <FeatureHint
+        id={entry.id}
+        eligible
+        title={tHint(`${prefix}.title`)}
+        body={tHint(`${prefix}.body`)}
+        cta={tHint(`${prefix}.cta`)}
+        href={entry.href}
+        // Painted in the accent colour and waiting for a button: the workshop
+        // is told once, between all of them, so a stray click elsewhere on the
+        // screen must not spend that on everybody's behalf.
+        variant="announcement"
+        side={isMobile ? 'bottom' : 'right'}
+      >
+        {(open) => row(open)}
+      </FeatureHint>
+    )
+  }
+
   const renderNavGroup = (
     items: {
       titleKey: string
@@ -410,14 +458,12 @@ export function AppSidebar({
         {canAccess('settings') && (
           <SidebarGroup>
             <SidebarMenu className="gap-2">
-              <SidebarMenuItem>
-                <SidebarMenuButton asChild isActive={pathname.startsWith('/settings')}>
-                  <Link href="/settings" className="font-medium" onClick={closeMobileSidebar}>
-                    <Settings className="size-4" />
-                    {t('sidebar.settings')}
-                  </Link>
-                </SidebarMenuButton>
-              </SidebarMenuItem>
+              {/* Product announcements hang off Settings: everything they
+                  point at so far lives behind it, and it is the one link on
+                  the screen that is never scrolled away or filtered out by a
+                  role. Which one is worth showing was decided on the server,
+                  so this only says where the card goes. */}
+              {settingsRow()}
             </SidebarMenu>
           </SidebarGroup>
         )}

+ 27 - 0
src/components/feature-hint/dismiss-on-arrival.tsx

@@ -0,0 +1,27 @@
+'use client'
+
+import { useEffect } from 'react'
+import { dismissFeatureHint } from '@/features/settings/Actions/featureHintActions'
+
+/**
+ * Marks an announcement as seen because somebody arrived at the thing it
+ * announces.
+ *
+ * Having found the feature is the same as having been told about it, and the
+ * workshop is told once between all of them: whoever opens the designer
+ * settles it for everybody, so a colleague does not get a card pointing at
+ * something their shop has already been using. It also covers the routes the
+ * card itself does not own, like reaching the designer through the templates
+ * page.
+ *
+ * Rendered only when the announcement is actually still live, so a page that
+ * everybody visits is not writing a settled row on every load. Fire and
+ * forget: nobody waits on this, and a failed write costs one extra sighting.
+ */
+export function DismissOnArrival({ id }: { id: string }) {
+  useEffect(() => {
+    void dismissFeatureHint(id)
+  }, [id])
+
+  return null
+}

+ 89 - 13
src/components/feature-hint/feature-hint.tsx

@@ -1,9 +1,11 @@
 'use client'
 
 import { useEffect } from 'react'
+import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/components/ui/popover'
 import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
 import { useFeatureHint } from './feature-hint-provider'
 
 /**
@@ -20,7 +22,8 @@ import { useFeatureHint } from './feature-hint-provider'
  *
  * The anchor is an anchor, not a trigger: the link underneath stays clickable,
  * and clicking it counts as having found the thing, so the hint closes.
- * Anything else outside closes it too.
+ * Anything else outside closes it too, unless the caller asks for an
+ * acknowledgement.
  *
  * Non-modal on purpose. It never traps focus and never steals it, because
  * stealing focus on page load throws a keyboard user out of whatever they were
@@ -32,6 +35,9 @@ export function FeatureHint({
   title,
   body,
   side = 'right',
+  href,
+  cta,
+  variant = 'hint',
   children,
 }: {
   /**
@@ -44,6 +50,33 @@ export function FeatureHint({
   title: string
   body: string
   side?: 'top' | 'right' | 'bottom' | 'left'
+  /**
+   * Where the feature lives. A hint about a toggle somebody just flipped needs
+   * no link, because the anchor underneath already is the way there. An
+   * announcement about something further in does: the anchored link is only
+   * the neighbourhood, and leaving somebody to hunt for the rest of the route
+   * is how an announcement ends up ignored.
+   */
+  href?: string
+  /** The wording on that link. Required whenever `href` is given. */
+  cta?: string
+  /**
+   * How much of an event this is, which decides both how loud the card looks
+   * and what it takes to close it.
+   *
+   * A `hint` follows something the workshop just did, so it is a quiet note in
+   * the usual popover colours and a click anywhere is fair evidence they are
+   * done reading.
+   *
+   * An `announcement` is the opposite on both counts. Nobody asked for it, so
+   * it is painted in the accent colour to be worth the interruption rather
+   * than sitting white on a white page. And it is the one time the workshop is
+   * told, dismissed for everybody at once, so it waits for a button: spending
+   * that on a stray click at the far side of the screen is how a colleague
+   * ends up never hearing about the feature at all. Escape still closes it,
+   * being a deliberate keypress rather than a mis-aimed click.
+   */
+  variant?: 'hint' | 'announcement'
   /**
    * What the card points at. Stays fully interactive. Given the open state so
    * it can highlight itself while the card is up.
@@ -52,6 +85,7 @@ export function FeatureHint({
 }) {
   const t = useTranslations('common')
   const { open, dismiss } = useFeatureHint(id, eligible)
+  const loud = variant === 'announcement'
 
   // Escape closes it wherever focus happens to be. Focus is never moved into
   // the card, and Radix only sees the key when it is.
@@ -77,17 +111,39 @@ export function FeatureHint({
         // Focus stays where the person put it.
         onOpenAutoFocus={(event) => event.preventDefault()}
         onCloseAutoFocus={(event) => event.preventDefault()}
-        // Clicking the thing it points at counts as finding it.
-        onPointerDownOutside={() => dismiss()}
+        // Clicking the thing it points at counts as finding it, unless this
+        // one is waiting to be acknowledged. `open` is controlled, so leaving
+        // the click alone simply leaves the card up.
+        onPointerDownOutside={() => {
+          if (!loud) dismiss()
+        }}
         onEscapeKeyDown={() => dismiss()}
         aria-labelledby={`${id}-hint-title`}
         aria-describedby={`${id}-hint-body`}
-        className="w-auto max-w-xs border-primary/30 p-0 shadow-lg"
+        className={cn(
+          'w-auto max-w-xs p-0',
+          loud
+            ? // The surface a selected sidebar link wears, so the card reads as
+              // part of the nav it is pointing into. A solid token rather than
+              // primary at half alpha, which would let the page read through
+              // the card's own text.
+              'border-primary/40 bg-sidebar-accent text-sidebar-accent-foreground shadow-xl'
+            : 'border-primary/30 shadow-lg'
+        )}
       >
-        <PopoverArrow className="fill-popover stroke-primary/30" width={12} height={6} />
+        <PopoverArrow
+          className={
+            loud ? 'fill-sidebar-accent stroke-primary/40' : 'fill-popover stroke-primary/30'
+          }
+          width={12}
+          height={6}
+        />
         {/* Announced without moving focus, so a screen reader user hears it
             without being pulled out of what they were doing. */}
-        <div aria-live="polite" className="flex items-center gap-3 py-2 pr-2 pl-3">
+        <div
+          aria-live="polite"
+          className={href ? 'flex flex-col gap-2 p-3' : 'flex items-center gap-3 py-2 pr-2 pl-3'}
+        >
           <div className="min-w-0">
             <p id={`${id}-hint-title`} className="text-sm font-semibold">
               {title}
@@ -96,13 +152,33 @@ export function FeatureHint({
               {body}
             </p>
           </div>
-          <Button
-            size="sm"
-            className="h-7 shrink-0 self-center px-2.5 text-xs"
-            onClick={() => dismiss()}
-          >
-            {t('buttons.gotIt')}
-          </Button>
+          {href && cta ? (
+            // Going there settles it: somebody who followed the link has been
+            // told, so the workshop is not asked to acknowledge it as well.
+            <div className="flex items-center justify-end gap-1.5">
+              <Button
+                size="sm"
+                variant="ghost"
+                className="h-7 px-2.5 text-xs"
+                onClick={() => dismiss()}
+              >
+                {t('buttons.gotIt')}
+              </Button>
+              <Button asChild size="sm" className="h-7 px-2.5 text-xs">
+                <Link href={href} onClick={() => dismiss()}>
+                  {cta}
+                </Link>
+              </Button>
+            </div>
+          ) : (
+            <Button
+              size="sm"
+              className="h-7 shrink-0 self-center px-2.5 text-xs"
+              onClick={() => dismiss()}
+            >
+              {t('buttons.gotIt')}
+            </Button>
+          )}
         </div>
       </PopoverContent>
     </Popover>

+ 1 - 0
src/components/feature-hint/index.ts

@@ -1,2 +1,3 @@
 export { FeatureHintProvider, useFeatureHint } from './feature-hint-provider'
 export { FeatureHint } from './feature-hint'
+export { DismissOnArrival } from './dismiss-on-arrival'

+ 103 - 0
src/features/settings/Lib/featureHints.ts

@@ -1,5 +1,7 @@
 import { SETTING_KEYS } from '../Schema/settingsSchema'
 import { ORG_TELEGRAM_KEYS } from '@/features/telegram/Schema/telegramSettingsSchema'
+import { PermissionSubject } from '@/lib/permissions'
+import type { PlanFeatures } from '@/lib/features'
 
 /**
  * Which hint to raise when a setting is switched on.
@@ -16,6 +18,107 @@ export const HINT_FOR_SETTING: Record<string, string> = {
   [ORG_TELEGRAM_KEYS.TELEGRAM_ENABLED]: 'telegram.v1',
 }
 
+/**
+ * The plan flags that are simply on or off.
+ *
+ * Narrower than every plan key on purpose: a limit like `maxOrganizations` is
+ * a number, and gating on it would read as satisfied for any non-zero value
+ * while meaning nothing.
+ */
+type PlanFlag = {
+  [K in keyof PlanFeatures]: PlanFeatures[K] extends boolean ? K : never
+}[keyof PlanFeatures]
+
+/**
+ * Something new in the product itself, rather than in this workshop's setup.
+ *
+ * A setting flip is a moment; shipping a feature is not, so these carry the
+ * date they landed and are raised by that instead. Everything after the
+ * raising is shared with the setting-flip hints: one card at a time, anchored
+ * to where the thing lives, dismissed once for the whole workshop.
+ */
+export interface Announcement {
+  /** Versioned like any hint, so reworded copy can be shown again. */
+  id: string
+  /** Where it points, and the sidebar link the card is anchored to. */
+  href: string
+  /**
+   * The rights the feature needs. A technician who cannot reach the screen is
+   * never told it exists, because the note would only be an offer of a door
+   * that stays locked.
+   */
+  subject: PermissionSubject
+  /**
+   * When the feature shipped. A workshop that signed up afterwards has never
+   * known the product without it, so it is not news to them, and greeting a
+   * new customer with a backlog of announcements is how this feature turns
+   * into noise nobody reads.
+   */
+  shippedAt: string
+  /**
+   * The plan feature the announcement needs, when it needs one. A workshop
+   * whose plan does not include it would be sent to an upsell page by a card
+   * it cannot dismiss without acknowledging, which is a poor way to sell
+   * anything and the same locked door the permission gate exists to avoid.
+   */
+  feature?: PlanFlag
+}
+
+/** Named, because the designer page marks this one read on arrival. */
+export const INVOICE_DESIGNER_ANNOUNCEMENT = 'invoice-designer.v1'
+
+export const ANNOUNCEMENTS: Announcement[] = [
+  {
+    id: INVOICE_DESIGNER_ANNOUNCEMENT,
+    // The templates page rather than the designer itself. Somebody sent
+    // straight into a full-screen tool learns nothing about where it lives,
+    // and has to ask the same question again next week; landing on the page
+    // that owns it means the route is learned once.
+    href: '/settings/templates',
+    subject: PermissionSubject.SETTINGS,
+    shippedAt: '2026-08-31',
+    feature: 'customTemplates',
+  },
+]
+
+/**
+ * The announcements a given account should be shown, newest feature last.
+ *
+ * Pure, because every reason to stay quiet here is a rule worth a test: told
+ * already, joined after it shipped, or not allowed in.
+ */
+export function announcementsToShow({
+  announcements = ANNOUNCEMENTS,
+  organizationCreatedAt,
+  visibleSubjects,
+  features,
+  seen,
+}: {
+  announcements?: Announcement[]
+  /** When this workshop signed up. Unknown counts as old enough to be told. */
+  organizationCreatedAt?: Date | string | null
+  /** Subjects this account can read; undefined means unrestricted. */
+  visibleSubjects?: string[]
+  /** What this workshop's plan includes; undefined means do not check. */
+  features?: Partial<PlanFeatures>
+  seen: string[]
+}): string[] {
+  const joined = organizationCreatedAt ? new Date(organizationCreatedAt) : null
+
+  return announcements
+    .filter((announcement) => {
+      if (seen.includes(announcement.id)) return false
+      if (visibleSubjects && !visibleSubjects.includes(announcement.subject)) return false
+      if (announcement.feature && features && !features[announcement.feature]) return false
+      // An unparseable date must not silence an announcement for everybody.
+      if (joined && !Number.isNaN(joined.getTime())) {
+        if (joined.getTime() >= new Date(announcement.shippedAt).getTime()) return false
+      }
+      return true
+    })
+    .map((announcement) => announcement.id)
+}
+
 /** The value a setting holds when it is on. Everything else counts as off. */
 const ON = 'true'
 

+ 6 - 1
src/lib/get-layout-data.ts

@@ -9,6 +9,8 @@ type AuthResult =
       status: 'ok'
       userId: string
       organizationId: string
+      /** When the active workshop signed up, for age-gated announcements. */
+      organizationCreatedAt: Date | null
       role: string
       isSuperAdmin: boolean
       emailVerified: boolean
@@ -68,7 +70,7 @@ export async function getLayoutData(): Promise<AuthResult> {
       where: { userId: session.user.id },
       select: {
         role: true,
-        organization: { select: { id: true, name: true } },
+        organization: { select: { id: true, name: true, createdAt: true } },
       },
     }),
   ])
@@ -79,6 +81,9 @@ export async function getLayoutData(): Promise<AuthResult> {
     status: 'ok',
     userId: session.user.id,
     organizationId: membership?.organizationId ?? '',
+    organizationCreatedAt:
+      memberships.find((m) => m.organization.id === membership?.organizationId)?.organization
+        .createdAt ?? null,
     role: isSuperAdmin ? 'super_admin' : (membership?.role ?? 'member'),
     isSuperAdmin,
     emailVerified: user?.emailVerified ?? false,