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

Point workshops at the new email designer (#376)

Bernt Christian Egeland 2 недель назад
Родитель
Сommit
efc607f597

+ 5 - 0
messages/de/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Ihre E-Mails haben ein neues Aussehen",
+    "body": "Kunden-E-Mails gehen jetzt in einem klareren Design raus. Farben, Texte und Aufbau ändern Sie im neuen Designer unter E-Mail-Vorlagen.",
+    "cta": "E-Mail-Vorlagen öffnen"
   }
 }

+ 2 - 1
messages/de/settings.json

@@ -140,7 +140,8 @@
         "description": "Was Ihre E-Mails zu Rechnung, Angebot und Bericht sagen"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Neu"
   },
   "readOnly": {
     "banner": "Sie haben nur Lesezugriff auf die Einstellungen. Kontaktieren Sie einen Administrator, um Anderungen vorzunehmen."

+ 5 - 0
messages/en/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Your emails have a new look",
+    "body": "Customer emails now go out on a cleaner design. Change colours, wording and layout in the new designer, under Email templates.",
+    "cta": "Open Email templates"
   }
 }

+ 2 - 1
messages/en/settings.json

@@ -140,7 +140,8 @@
         "description": "What your invoice, quote and report emails say"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "New"
   },
   "readOnly": {
     "banner": "You have view-only access to settings. Contact an admin to make changes."

+ 5 - 0
messages/es/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Tus correos tienen un nuevo aspecto",
+    "body": "Los correos a clientes salen ahora con un diseño más limpio. Cambia colores, textos y disposición en el nuevo diseñador, en Plantillas de correo.",
+    "cta": "Abrir Plantillas de correo"
   }
 }

+ 2 - 1
messages/es/settings.json

@@ -140,7 +140,8 @@
         "description": "Lo que dicen tus correos de factura, presupuesto e informe"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Nuevo"
   },
   "readOnly": {
     "banner": "Tiene acceso de solo lectura a la configuración. Contacte a un administrador para realizar cambios."

+ 5 - 0
messages/fr/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Vos e-mails ont un nouveau look",
+    "body": "Les e-mails clients partent désormais avec un design plus épuré. Modifiez couleurs, textes et mise en page dans le nouvel éditeur, sous Modèles d'e-mail.",
+    "cta": "Ouvrir les Modèles d'e-mail"
   }
 }

+ 2 - 1
messages/fr/settings.json

@@ -140,7 +140,8 @@
         "description": "Ce que disent vos e-mails de facture, devis et rapport"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Nouveau"
   },
   "readOnly": {
     "banner": "Vous avez un accès en lecture seule aux paramètres. Contactez un administrateur pour effectuer des modifications."

+ 5 - 0
messages/it/featureHints.json

@@ -11,5 +11,10 @@
     "title": "Le fatture sono state rinnovate",
     "body": "Imposta layout, colori e caratteri di fatture e preventivi nel nuovo editor, in Modelli.",
     "cta": "Apri Modelli"
+  },
+  "email-designer": {
+    "title": "Le tue email hanno un nuovo aspetto",
+    "body": "Le email ai clienti ora partono con un design più pulito. Cambia colori, testi e impaginazione nel nuovo editor, sotto Modelli email.",
+    "cta": "Apri Modelli email"
   }
 }

+ 2 - 1
messages/it/settings.json

@@ -140,7 +140,8 @@
         "description": "Cosa dicono le email di fattura, preventivo e rapporto"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Nuovo"
   },
   "readOnly": {
     "banner": "Hai accesso in sola lettura alle impostazioni. Contatta un amministratore per apportare modifiche."

+ 5 - 0
messages/lt/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Jūsų laiškai atrodo naujai",
+    "body": "Laiškai klientams dabar siunčiami su švaresniu dizainu. Spalvas, tekstą ir išdėstymą keiskite naujame redaktoriuje, skiltyje El. laiškų šablonai.",
+    "cta": "Atidaryti El. laiškų šablonus"
   }
 }

+ 2 - 1
messages/lt/settings.json

@@ -140,7 +140,8 @@
         "description": "Ką rašo sąskaitų, pasiūlymų ir ataskaitų laiškai"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Nauja"
   },
   "readOnly": {
     "banner": "Turite tik peržiūros prieigą prie nustatymų. Susisiekite su administratoriumi, kad atliktumėte pakeitimus."

+ 5 - 0
messages/nb/featureHints.json

@@ -11,5 +11,10 @@
     "title": "Fakturaene er bygget om",
     "body": "Bestem oppsett, farger og skrifter på fakturaer og tilbud i den nye designeren, under Maler.",
     "cta": "Åpne Maler"
+  },
+  "email-designer": {
+    "title": "E-postene dine har fått nytt utseende",
+    "body": "Kunde-e-poster går nå ut med et renere design. Endre farger, tekst og oppsett i den nye designeren, under E-postmaler.",
+    "cta": "Åpne E-postmaler"
   }
 }

+ 2 - 1
messages/nb/settings.json

@@ -140,7 +140,8 @@
         "description": "Hva e-postene for faktura, tilbud og rapport sier"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Ny"
   },
   "readOnly": {
     "banner": "Du har kun lesetilgang til innstillingene. Kontakt en administrator for a gjore endringer."

+ 5 - 0
messages/nl/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Je e-mails hebben een nieuwe look",
+    "body": "Klantmails gaan nu uit in een strakker ontwerp. Pas kleuren, tekst en indeling aan in de nieuwe ontwerper, onder E-mailsjablonen.",
+    "cta": "E-mailsjablonen openen"
   }
 }

+ 2 - 1
messages/nl/settings.json

@@ -140,7 +140,8 @@
         "description": "Wat je e-mails voor factuur, offerte en rapport zeggen"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Nieuw"
   },
   "readOnly": {
     "banner": "U heeft alleen-lezen toegang tot de instellingen. Neem contact op met een beheerder om wijzigingen aan te brengen."

+ 5 - 0
messages/pl/featureHints.json

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Twoje e-maile mają nowy wygląd",
+    "body": "E-maile do klientów wychodzą teraz w czystszym projekcie. Kolory, treść i układ zmienisz w nowym edytorze, w sekcji Szablony e-mail.",
+    "cta": "Otwórz Szablony e-mail"
   }
 }

+ 2 - 1
messages/pl/settings.json

@@ -140,7 +140,8 @@
         "description": "Co mówią e-maile z fakturą, ofertą i raportem"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Nowość"
   },
   "readOnly": {
     "banner": "Masz dostęp tylko do odczytu ustawień. Skontaktuj się z administratorem, aby wprowadzić zmiany."

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

@@ -11,5 +11,10 @@
     "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"
+  },
+  "email-designer": {
+    "title": "Seus e-mails têm um novo visual",
+    "body": "Os e-mails para clientes agora saem com um design mais limpo. Altere cores, textos e layout no novo editor, em Modelos de e-mail.",
+    "cta": "Abrir Modelos de e-mail"
   }
 }

+ 2 - 1
messages/pt-BR/settings.json

@@ -140,7 +140,8 @@
         "description": "O que dizem seus e-mails de fatura, orçamento e relatório"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Novo"
   },
   "readOnly": {
     "banner": "Você tem acesso somente leitura as configurações. Contate um administrador para fazer alterações."

+ 5 - 0
messages/ru/featureHints.json

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

+ 2 - 1
messages/ru/settings.json

@@ -140,7 +140,8 @@
         "description": "Что говорят письма о счёте, предложении и отчёте"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Новое"
   },
   "readOnly": {
     "banner": "У вас есть доступ только для просмотра настроек. Обратитесь к администратору для внесения изменений."

+ 5 - 0
messages/tr/featureHints.json

@@ -11,5 +11,10 @@
     "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ç"
+  },
+  "email-designer": {
+    "title": "E-postalarınız yeni bir görünüme kavuştu",
+    "body": "Müşteri e-postaları artık daha sade bir tasarımla gönderiliyor. Renkleri, metni ve düzeni E-posta şablonları altındaki yeni tasarımcıda değiştirin.",
+    "cta": "E-posta şablonlarını aç"
   }
 }

+ 2 - 1
messages/tr/settings.json

@@ -140,7 +140,8 @@
         "description": "Fatura, teklif ve rapor e-postalarınızın metni"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Yeni"
   },
   "readOnly": {
     "banner": "Ayarlara yalnızca görüntüleme erişiminiz var. Değişiklik yapmak için bir yöneticiyle iletişime geçin."

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

@@ -14,8 +14,12 @@ import {
   announcementsToShow,
   hintsToArm,
   ANNOUNCEMENTS,
+  EMAIL_DESIGNER_ANNOUNCEMENT,
   HINT_FOR_SETTING,
   INVOICE_DESIGNER_ANNOUNCEMENT,
+  newSettingsEntries,
+  NEW_FOR_DAYS,
+  SETTINGS_SHIPPED_AT,
 } from '@/features/settings/Lib/featureHints'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
@@ -125,9 +129,13 @@ describe('showing an announcement', () => {
   })
 
   it('stays quiet once the workshop has been told', () => {
-    expect(announcementsToShow({ organizationCreatedAt: '2026-01-04', seen: [DESIGNER] })).toEqual(
-      []
-    )
+    // Told about all of them: a later announcement is still news after the
+    // first has been dismissed, so one seen id does not silence the registry.
+    const seen = ANNOUNCEMENTS.map((item) => item.id)
+    expect(announcementsToShow({ organizationCreatedAt: '2026-01-04', seen })).toEqual([])
+    expect(
+      announcementsToShow({ organizationCreatedAt: '2026-01-04', seen: [DESIGNER] })
+    ).not.toContain(DESIGNER)
   })
 
   it('stays quiet for a role that cannot reach the feature', () => {
@@ -224,6 +232,8 @@ describe('the announcement registry', () => {
     // question comes back next week.
     const designer = ANNOUNCEMENTS.find((item) => item.id === INVOICE_DESIGNER_ANNOUNCEMENT)
     expect(designer?.href).toBe('/settings/templates')
+    const email = ANNOUNCEMENTS.find((item) => item.id === EMAIL_DESIGNER_ANNOUNCEMENT)
+    expect(email?.href).toBe('/settings/email-templates')
   })
 
   it('keeps the designer announcement dated no later than today', () => {
@@ -232,3 +242,72 @@ describe('the announcement registry', () => {
     expect(new Date(SHIPPED).getTime()).toBeLessThanOrEqual(Date.now())
   })
 })
+
+/**
+ * The "New" pill on a settings entry.
+ *
+ * Nothing is stored, so the only things that can go wrong are the two dates.
+ * A pill that never ages out is furniture, and one shown to a workshop that
+ * signed up after the feature is the same non-news the announcements avoid.
+ */
+describe('the New pill on a settings entry', () => {
+  const shippedAt = { '/settings/example': '2026-09-08' }
+  const day = 24 * 60 * 60 * 1000
+  const shipped = new Date('2026-09-08').getTime()
+
+  it('shows for a workshop that predates the feature, while it is fresh', () => {
+    const now = new Date(shipped + 3 * day)
+    expect(newSettingsEntries({ shippedAt, organizationCreatedAt: '2026-01-01', now })).toEqual([
+      '/settings/example',
+    ])
+  })
+
+  it('ages out after the window', () => {
+    const now = new Date(shipped + NEW_FOR_DAYS * day)
+    expect(newSettingsEntries({ shippedAt, organizationCreatedAt: '2026-01-01', now })).toEqual([])
+  })
+
+  it('stays off before the feature has shipped', () => {
+    const now = new Date(shipped - day)
+    expect(newSettingsEntries({ shippedAt, organizationCreatedAt: '2026-01-01', now })).toEqual([])
+  })
+
+  it('stays off for a workshop that signed up after it shipped', () => {
+    const now = new Date(shipped + 3 * day)
+    expect(newSettingsEntries({ shippedAt, organizationCreatedAt: '2026-09-09', now })).toEqual([])
+  })
+
+  it('treats an unknown signup date as old enough to be told', () => {
+    const now = new Date(shipped + 3 * day)
+    expect(newSettingsEntries({ shippedAt, organizationCreatedAt: null, now })).toEqual([
+      '/settings/example',
+    ])
+  })
+
+  it('never pins a pill on an entry with an unreadable date', () => {
+    const now = new Date(shipped + 3 * day)
+    expect(
+      newSettingsEntries({
+        shippedAt: { '/settings/example': 'soon' },
+        organizationCreatedAt: null,
+        now,
+      })
+    ).toEqual([])
+  })
+
+  it('ships every registered entry with a date the rule can read, no later than today', () => {
+    for (const [href, date] of Object.entries(SETTINGS_SHIPPED_AT)) {
+      expect(href.startsWith('/settings/'), `${href} is not a settings path`).toBe(true)
+      const time = new Date(date).getTime()
+      expect(Number.isNaN(time), `${href} has an unreadable shippedAt`).toBe(false)
+      expect(time, `${href} is dated in the future`).toBeLessThanOrEqual(Date.now())
+    }
+  })
+
+  it('reads the pill label in every language', () => {
+    for (const locale of ['en', ...LOCALES]) {
+      const messages = JSON.parse(readFileSync(`messages/${locale}/settings.json`, 'utf-8'))
+      expect(messages.nav?.new, `${locale} has no nav.new`).toBeTruthy()
+    }
+  })
+})

+ 4 - 0
src/app/(authenticated)/settings/layout.tsx

@@ -8,6 +8,7 @@ import { redirect } from 'next/navigation'
 import { getCachedMembership } from '@/lib/cached-session'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getTranslations } from 'next-intl/server'
+import { newSettingsEntries } from '@/features/settings/Lib/featureHints'
 
 export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -41,6 +42,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
   const features = await getFeatures(data.organizationId)
   const supportEnabled = await isSupportEnabled()
   const t = await getTranslations('settings')
+  const newItems = newSettingsEntries({ organizationCreatedAt: data.organizationCreatedAt })
 
   return (
     <SettingsPermissionProvider canEdit={canEditSettings}>
@@ -57,6 +59,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
                 features={features}
                 isCloud={isCloudMode()}
                 supportEnabled={supportEnabled}
+                newItems={newItems}
               />
             </aside>
             <div className="min-w-0 flex-1 overflow-y-auto pb-8">
@@ -65,6 +68,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
                   features={features}
                   isCloud={isCloudMode()}
                   supportEnabled={supportEnabled}
+                  newItems={newItems}
                   mobile
                 />
               </div>

+ 18 - 1
src/app/(authenticated)/settings/settings-nav.tsx

@@ -128,17 +128,30 @@ export function SettingsNav({
   features,
   isCloud,
   supportEnabled,
+  newItems = [],
   mobile,
 }: {
   features?: PlanFeatures
   isCloud?: boolean
   supportEnabled?: boolean
+  /**
+   * Paths that wear a "New" pill, decided on the server. Recently shipped and
+   * older than this workshop, so the pill is news to whoever reads it.
+   */
+  newItems?: string[]
   mobile?: boolean
 }) {
   const pathname = usePathname()
   const router = useRouter()
   const t = useTranslations('settings')
 
+  const newPill = (item: SettingsNavItem) =>
+    newItems.includes(item.href) ? (
+      <span className="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-primary">
+        {t('nav.new')}
+      </span>
+    ) : null
+
   const filterItem = (item: SettingsNavItem) => {
     if (item.cloudOnly && !isCloud) return false
     if (item.selfHostedOnly && isCloud) return false
@@ -161,7 +174,10 @@ export function SettingsNav({
             if (visibleItems.length === 0) return null
             return visibleItems.map((item) => (
               <SelectItem key={item.href} value={item.href}>
-                {t(`nav.items.${item.key}.title`)}
+                <span className="flex items-center gap-2">
+                  {t(`nav.items.${item.key}.title`)}
+                  {newPill(item)}
+                </span>
               </SelectItem>
             ))
           })}
@@ -208,6 +224,7 @@ export function SettingsNav({
                             {t('nav.pro')}
                           </span>
                         )}
+                        {newPill(item)}
                       </div>
                       <p className="hidden truncate text-xs text-muted-foreground lg:block">
                         {t(`nav.items.${item.key}.description`)}

+ 18 - 1
src/app/(designer)/email-designer/page.tsx

@@ -14,6 +14,8 @@ import { sampleSummaryRows, sampleValuesFor } from '@/features/email/Lib/emailCo
 import { isEmailKind } from '@/features/email/Lib/emailKinds'
 import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
 import { presetTemplate } from '@/features/email/Lib/emailPresets'
+import { DismissOnArrival } from '@/components/feature-hint'
+import { EMAIL_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 
 const SETTINGS_PAGE = '/settings/email-templates'
 
@@ -39,7 +41,7 @@ export default async function EmailDesignerPage({
   const { kind, template: templateId } = await searchParams
   if (!kind || !isEmailKind(kind)) redirect(SETTINGS_PAGE)
 
-  const [settingsResult, organization, user, templatesResult, activeResult, messages] =
+  const [settingsResult, organization, user, templatesResult, activeResult, messages, seenRow] =
     await Promise.all([
       getSettings([
         SETTING_KEYS.WORKSHOP_PHONE,
@@ -54,8 +56,22 @@ export default async function EmailDesignerPage({
       listEmailTemplates(),
       getActiveEmailTemplates(),
       loadEmailMessages(await getLocale()),
+      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 while the card is still outstanding, to keep a settled announcement
+  // from costing a write on every visit.
+  const announcementLive = !parseHintIds(seenRow?.value).includes(EMAIL_DESIGNER_ANNOUNCEMENT)
+
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
   const workshop = {
     name: organization?.name,
@@ -78,6 +94,7 @@ export default async function EmailDesignerPage({
   // is dressed in the app's theme, so it brings its own ground.
   return (
     <div className="h-screen bg-background text-foreground">
+      {announcementLive && <DismissOnArrival id={EMAIL_DESIGNER_ANNOUNCEMENT} />}
       <EmailDesigner
         kind={kind}
         preset={presetTemplate(kind, messages)}

+ 66 - 2
src/features/settings/Lib/featureHints.ts

@@ -64,8 +64,9 @@ export interface Announcement {
   feature?: PlanFlag
 }
 
-/** Named, because the designer page marks this one read on arrival. */
+/** Named, because the designer pages mark these read on arrival. */
 export const INVOICE_DESIGNER_ANNOUNCEMENT = 'invoice-designer.v1'
+export const EMAIL_DESIGNER_ANNOUNCEMENT = 'email-designer.v1'
 
 export const ANNOUNCEMENTS: Announcement[] = [
   {
@@ -76,11 +77,74 @@ export const ANNOUNCEMENTS: Announcement[] = [
     // that owns it means the route is learned once.
     href: '/settings/templates',
     subject: PermissionSubject.SETTINGS,
-    shippedAt: '2026-08-31',
+    // Release dates, not merge dates: the day customers could first see it.
+    shippedAt: '2026-09-01',
+    feature: 'customTemplates',
+  },
+  {
+    id: EMAIL_DESIGNER_ANNOUNCEMENT,
+    // The gallery page for the same reason: it is the page that owns the
+    // designer, and the one they will come back to.
+    href: '/settings/email-templates',
+    subject: PermissionSubject.SETTINGS,
+    // Ships with the release that carries this file. Bump if it slips.
+    shippedAt: '2026-09-11',
     feature: 'customTemplates',
   },
 ]
 
+/**
+ * Settings entries that wear a "New" pill for a while after shipping.
+ *
+ * Quieter than an announcement, and the one that scales: nothing is stored
+ * and nothing is dismissed, the pill simply ages out. It only reaches people
+ * already in Settings, which is the audience that would act on it. Keyed by
+ * the entry's path so the nav can look its own rows up.
+ */
+export const SETTINGS_SHIPPED_AT: Record<string, string> = {
+  '/settings/templates': '2026-09-01',
+  '/settings/email-templates': '2026-09-11',
+}
+
+/** How long an entry counts as new. A month covers the slowest weekly visitor. */
+export const NEW_FOR_DAYS = 30
+
+/**
+ * Which settings entries should carry the pill right now.
+ *
+ * Same age rule as the announcements: a workshop that signed up after the
+ * feature shipped has never known the product without it, and greeting them
+ * with a nav full of "New" is how the pill stops meaning anything. Worked out
+ * on the server so the client never has to agree with it about what time it
+ * is.
+ */
+export function newSettingsEntries({
+  shippedAt = SETTINGS_SHIPPED_AT,
+  organizationCreatedAt,
+  now = new Date(),
+  days = NEW_FOR_DAYS,
+}: {
+  shippedAt?: Record<string, string>
+  organizationCreatedAt?: Date | string | null
+  now?: Date
+  days?: number
+}): string[] {
+  const joined = organizationCreatedAt ? new Date(organizationCreatedAt).getTime() : Number.NaN
+  const windowMs = days * 24 * 60 * 60 * 1000
+
+  return Object.entries(shippedAt)
+    .filter(([, date]) => {
+      const shipped = new Date(date).getTime()
+      // A date nobody can read must not pin a pill on forever.
+      if (Number.isNaN(shipped)) return false
+      if (now.getTime() < shipped) return false
+      if (now.getTime() >= shipped + windowMs) return false
+      if (!Number.isNaN(joined) && joined >= shipped) return false
+      return true
+    })
+    .map(([href]) => href)
+}
+
 /**
  * The announcements a given account should be shown, newest feature last.
  *