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

Add a What's new panel with highlights written on torqvoice.com (#585)

* Add a What's new panel with highlights written on torqvoice.com

A gift button in the header opens a panel of recent highlights, with a dot when something is unseen and a thumbs up or down with an optional comment under each entry. Entries are fetched from torqvoice.com by the server, cached, and narrowed to what the install's version, mode and the reader's role can open. The update banner opens the panel when there is something in it.

* Tighten the What's new panel after review

Escape stray angle brackets in highlight bodies and use the shared path check for links into the app. Send a bare thumb without the sender's email on the cloud, one feedback request at a time, retry recording that the panel was opened, and show publication dates in the workshop time zone.

* Tidy how the shared rich text cleaner handles stray angle brackets

A < that does not open a tag is written as text. Editor output is unchanged.
Bernt Christian Egeland 13 часов назад
Родитель
Сommit
17e6a9f583
35 измененных файлов с 1896 добавлено и 120 удалено
  1. 25 0
      messages/de/common.json
  2. 25 0
      messages/en/common.json
  3. 25 0
      messages/es/common.json
  4. 25 0
      messages/fr/common.json
  5. 25 0
      messages/it/common.json
  6. 25 0
      messages/lt/common.json
  7. 25 0
      messages/nb/common.json
  8. 25 0
      messages/nl/common.json
  9. 25 0
      messages/pl/common.json
  10. 25 0
      messages/pt-BR/common.json
  11. 25 0
      messages/pt-PT/common.json
  12. 25 0
      messages/ru/common.json
  13. 25 0
      messages/tr/common.json
  14. 2 0
      prisma/migrations/20261114111444_user_whats_new_seen_at/migration.sql
  15. 3 0
      prisma/schema/auth.prisma
  16. 49 0
      src/__tests__/features/whats-new/sanitize-body.test.ts
  17. 97 0
      src/__tests__/features/whats-new/select.test.ts
  18. 116 0
      src/__tests__/features/whats-new/send-feedback.test.ts
  19. 226 0
      src/__tests__/features/whats-new/whats-new-panel.test.tsx
  20. 44 0
      src/__tests__/lib/sanitize-html.test.ts
  21. 138 110
      src/app/(authenticated)/layout.tsx
  22. 2 0
      src/components/page-header.tsx
  23. 26 9
      src/components/update-banner.tsx
  24. 22 0
      src/features/whats-new/Actions/markWhatsNewSeen.ts
  25. 70 0
      src/features/whats-new/Actions/sendWhatsNewFeedback.ts
  26. 37 0
      src/features/whats-new/Components/WhatsNewButton.tsx
  27. 176 0
      src/features/whats-new/Components/WhatsNewFeedback.tsx
  28. 186 0
      src/features/whats-new/Components/WhatsNewPanel.tsx
  29. 101 0
      src/features/whats-new/Components/WhatsNewProvider.tsx
  30. 76 0
      src/features/whats-new/Lib/feed.server.ts
  31. 73 0
      src/features/whats-new/Lib/sanitizeBody.ts
  32. 65 0
      src/features/whats-new/Lib/select.ts
  33. 48 0
      src/features/whats-new/Schema/whatsNewSchema.ts
  34. 7 0
      src/lib/get-layout-data.ts
  35. 7 1
      src/lib/sanitize-html.ts

+ 25 - 0
messages/de/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Neuigkeiten ansehen",
     "dismiss": "Schließen"
   },
+  "whatsNew": {
+    "title": "Neuigkeiten",
+    "openUnread": "Neuigkeiten: Updates, die Sie noch nicht gesehen haben",
+    "description": "Die neuesten Funktionen, die aktuellsten zuerst.",
+    "unread": "Neu seit Ihrem letzten Besuch",
+    "kindNew": "Neu",
+    "kindImproved": "Verbessert",
+    "open": "Öffnen",
+    "guide": "Anleitung lesen",
+    "version": "Version {version}",
+    "allReleaseNotes": "Alle Versionshinweise",
+    "feedback": {
+      "question": "War das hilfreich?",
+      "up": "Ja, das ist hilfreich",
+      "down": "Nein, nicht das, was ich erwartet habe",
+      "askUp": "Was gefällt Ihnen daran? (optional)",
+      "askDown": "Was hat nicht so funktioniert, wie Sie es erwartet haben?",
+      "privacyIdentified": "Wird mit Ihrer E-Mail-Adresse an das Torqvoice-Team gesendet, damit wir antworten können.",
+      "privacyAnonymous": "Wird ohne Ihren Namen und Ihre E-Mail-Adresse an das Torqvoice-Team gesendet.",
+      "skip": "Überspringen",
+      "send": "Senden",
+      "thanks": "Danke, Ihr Kommentar wurde gesendet.",
+      "failed": "Ihr Feedback konnte nicht gesendet werden. Versuchen Sie es gleich noch einmal."
+    }
+  },
   "pagination": {
     "showing": "{start}–{end} von {total}",
     "perPage": "pro Seite",

+ 25 - 0
messages/en/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "See what is new",
     "dismiss": "Dismiss"
   },
+  "whatsNew": {
+    "title": "What's new",
+    "openUnread": "What's new: updates you have not seen",
+    "description": "The latest additions, newest first.",
+    "unread": "New since you last looked",
+    "kindNew": "New",
+    "kindImproved": "Improved",
+    "open": "Open",
+    "guide": "Read the guide",
+    "version": "Version {version}",
+    "allReleaseNotes": "All release notes",
+    "feedback": {
+      "question": "Was this useful?",
+      "up": "Yes, this is useful",
+      "down": "No, not what I expected",
+      "askUp": "What do you like about it? (optional)",
+      "askDown": "What did not work the way you expected?",
+      "privacyIdentified": "Sent to the Torqvoice team with your email address, so we can reply.",
+      "privacyAnonymous": "Sent to the Torqvoice team without your name or email address.",
+      "skip": "Skip",
+      "send": "Send",
+      "thanks": "Thank you, your comment was sent.",
+      "failed": "Your feedback could not be sent. Try again in a moment."
+    }
+  },
   "pagination": {
     "showing": "Showing {start}-{end} of {total}",
     "perPage": "per page",

+ 25 - 0
messages/es/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Ver novedades",
     "dismiss": "Cerrar"
   },
+  "whatsNew": {
+    "title": "Novedades",
+    "openUnread": "Novedades: actualizaciones que aún no ha visto",
+    "description": "Las últimas incorporaciones, las más recientes primero.",
+    "unread": "Nuevo desde su última visita",
+    "kindNew": "Nuevo",
+    "kindImproved": "Mejorado",
+    "open": "Abrir",
+    "guide": "Leer la guía",
+    "version": "Versión {version}",
+    "allReleaseNotes": "Todas las notas de versión",
+    "feedback": {
+      "question": "¿Le ha resultado útil?",
+      "up": "Sí, es útil",
+      "down": "No, no es lo que esperaba",
+      "askUp": "¿Qué le gusta de esto? (opcional)",
+      "askDown": "¿Qué no ha funcionado como esperaba?",
+      "privacyIdentified": "Se envía al equipo de Torqvoice con su correo electrónico para que podamos responderle.",
+      "privacyAnonymous": "Se envía al equipo de Torqvoice sin su nombre ni su correo electrónico.",
+      "skip": "Omitir",
+      "send": "Enviar",
+      "thanks": "Gracias, su comentario se ha enviado.",
+      "failed": "No se ha podido enviar su opinión. Inténtelo de nuevo en un momento."
+    }
+  },
   "pagination": {
     "showing": "Mostrando {start}–{end} de {total}",
     "perPage": "por página",

+ 25 - 0
messages/fr/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Voir les nouveautés",
     "dismiss": "Fermer"
   },
+  "whatsNew": {
+    "title": "Nouveautés",
+    "openUnread": "Nouveautés : mises à jour que vous n'avez pas encore vues",
+    "description": "Les derniers ajouts, du plus récent au plus ancien.",
+    "unread": "Nouveau depuis votre dernière visite",
+    "kindNew": "Nouveau",
+    "kindImproved": "Amélioré",
+    "open": "Ouvrir",
+    "guide": "Lire le guide",
+    "version": "Version {version}",
+    "allReleaseNotes": "Toutes les notes de version",
+    "feedback": {
+      "question": "Est-ce utile ?",
+      "up": "Oui, c'est utile",
+      "down": "Non, ce n'est pas ce que j'attendais",
+      "askUp": "Qu'est-ce qui vous plaît ? (facultatif)",
+      "askDown": "Qu'est-ce qui n'a pas fonctionné comme vous l'attendiez ?",
+      "privacyIdentified": "Envoyé à l'équipe Torqvoice avec votre adresse e-mail, pour que nous puissions vous répondre.",
+      "privacyAnonymous": "Envoyé à l'équipe Torqvoice sans votre nom ni votre adresse e-mail.",
+      "skip": "Passer",
+      "send": "Envoyer",
+      "thanks": "Merci, votre commentaire a été envoyé.",
+      "failed": "Votre avis n'a pas pu être envoyé. Réessayez dans un instant."
+    }
+  },
   "pagination": {
     "showing": "{start}–{end} sur {total}",
     "perPage": "par page",

+ 25 - 0
messages/it/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Scopri le novità",
     "dismiss": "Chiudi"
   },
+  "whatsNew": {
+    "title": "Novità",
+    "openUnread": "Novità: aggiornamenti che non ha ancora visto",
+    "description": "Le ultime aggiunte, dalle più recenti.",
+    "unread": "Nuovo dalla Sua ultima visita",
+    "kindNew": "Nuovo",
+    "kindImproved": "Migliorato",
+    "open": "Apri",
+    "guide": "Leggi la guida",
+    "version": "Versione {version}",
+    "allReleaseNotes": "Tutte le note di rilascio",
+    "feedback": {
+      "question": "Le è stato utile?",
+      "up": "Sì, è utile",
+      "down": "No, non è quello che mi aspettavo",
+      "askUp": "Cosa Le piace? (facoltativo)",
+      "askDown": "Cosa non ha funzionato come si aspettava?",
+      "privacyIdentified": "Inviato al team di Torqvoice con il Suo indirizzo email, così possiamo risponderLe.",
+      "privacyAnonymous": "Inviato al team di Torqvoice senza il Suo nome né il Suo indirizzo email.",
+      "skip": "Salta",
+      "send": "Invia",
+      "thanks": "Grazie, il Suo commento è stato inviato.",
+      "failed": "Non è stato possibile inviare il Suo feedback. Riprovi tra un momento."
+    }
+  },
   "pagination": {
     "showing": "{start}–{end} di {total}",
     "perPage": "per pagina",

+ 25 - 0
messages/lt/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Žiūrėti naujienas",
     "dismiss": "Uždaryti"
   },
+  "whatsNew": {
+    "title": "Naujienos",
+    "openUnread": "Naujienos: atnaujinimai, kurių dar nematėte",
+    "description": "Naujausi papildymai, nuo naujausių.",
+    "unread": "Nauja nuo paskutinio apsilankymo",
+    "kindNew": "Nauja",
+    "kindImproved": "Patobulinta",
+    "open": "Atidaryti",
+    "guide": "Skaityti vadovą",
+    "version": "Versija {version}",
+    "allReleaseNotes": "Visos versijų pastabos",
+    "feedback": {
+      "question": "Ar tai naudinga?",
+      "up": "Taip, tai naudinga",
+      "down": "Ne, ne to tikėjausi",
+      "askUp": "Kas jums patinka? (neprivaloma)",
+      "askDown": "Kas veikė ne taip, kaip tikėjotės?",
+      "privacyIdentified": "Siunčiama Torqvoice komandai su jūsų el. pašto adresu, kad galėtume atsakyti.",
+      "privacyAnonymous": "Siunčiama Torqvoice komandai be jūsų vardo ir el. pašto adreso.",
+      "skip": "Praleisti",
+      "send": "Siųsti",
+      "thanks": "Ačiū, jūsų komentaras išsiųstas.",
+      "failed": "Nepavyko išsiųsti jūsų atsiliepimo. Po akimirkos bandykite dar kartą."
+    }
+  },
   "pagination": {
     "showing": "Rodoma {start}–{end} iš {total}",
     "perPage": "puslapyje",

+ 25 - 0
messages/nb/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Se hva som er nytt",
     "dismiss": "Lukk"
   },
+  "whatsNew": {
+    "title": "Hva er nytt",
+    "openUnread": "Hva er nytt: oppdateringer du ikke har sett",
+    "description": "De siste nyhetene, nyeste først.",
+    "unread": "Nytt siden sist",
+    "kindNew": "Nytt",
+    "kindImproved": "Forbedret",
+    "open": "Åpne",
+    "guide": "Les veiledningen",
+    "version": "Versjon {version}",
+    "allReleaseNotes": "Alle versjonsmerknader",
+    "feedback": {
+      "question": "Var dette nyttig?",
+      "up": "Ja, dette er nyttig",
+      "down": "Nei, ikke det jeg forventet",
+      "askUp": "Hva liker du med det? (valgfritt)",
+      "askDown": "Hva fungerte ikke slik du forventet?",
+      "privacyIdentified": "Sendes til Torqvoice-teamet med e-postadressen din, slik at vi kan svare.",
+      "privacyAnonymous": "Sendes til Torqvoice-teamet uten navnet eller e-postadressen din.",
+      "skip": "Hopp over",
+      "send": "Send",
+      "thanks": "Takk, kommentaren din er sendt.",
+      "failed": "Tilbakemeldingen din kunne ikke sendes. Prøv igjen om litt."
+    }
+  },
   "pagination": {
     "showing": "Viser {start}–{end} av {total}",
     "perPage": "per side",

+ 25 - 0
messages/nl/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Bekijk wat er nieuw is",
     "dismiss": "Sluiten"
   },
+  "whatsNew": {
+    "title": "Wat is er nieuw",
+    "openUnread": "Wat is er nieuw: updates die u nog niet hebt gezien",
+    "description": "De nieuwste toevoegingen, nieuwste eerst.",
+    "unread": "Nieuw sinds uw laatste bezoek",
+    "kindNew": "Nieuw",
+    "kindImproved": "Verbeterd",
+    "open": "Openen",
+    "guide": "Lees de handleiding",
+    "version": "Versie {version}",
+    "allReleaseNotes": "Alle release notes",
+    "feedback": {
+      "question": "Was dit nuttig?",
+      "up": "Ja, dit is nuttig",
+      "down": "Nee, niet wat ik verwachtte",
+      "askUp": "Wat vindt u er goed aan? (optioneel)",
+      "askDown": "Wat werkte niet zoals u verwachtte?",
+      "privacyIdentified": "Wordt met uw e-mailadres naar het Torqvoice-team gestuurd, zodat we kunnen reageren.",
+      "privacyAnonymous": "Wordt zonder uw naam of e-mailadres naar het Torqvoice-team gestuurd.",
+      "skip": "Overslaan",
+      "send": "Versturen",
+      "thanks": "Bedankt, uw opmerking is verstuurd.",
+      "failed": "Uw feedback kon niet worden verstuurd. Probeer het zo opnieuw."
+    }
+  },
   "pagination": {
     "showing": "{start}–{end} van {total} weergegeven",
     "perPage": "per pagina",

+ 25 - 0
messages/pl/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Zobacz nowości",
     "dismiss": "Zamknij"
   },
+  "whatsNew": {
+    "title": "Nowości",
+    "openUnread": "Nowości: aktualizacje, których jeszcze nie znasz",
+    "description": "Ostatnie zmiany, od najnowszych.",
+    "unread": "Nowe od Twojej ostatniej wizyty",
+    "kindNew": "Nowość",
+    "kindImproved": "Ulepszone",
+    "open": "Otwórz",
+    "guide": "Przeczytaj przewodnik",
+    "version": "Wersja {version}",
+    "allReleaseNotes": "Wszystkie informacje o wydaniach",
+    "feedback": {
+      "question": "Czy to było przydatne?",
+      "up": "Tak, to jest przydatne",
+      "down": "Nie, to nie to, czego oczekiwałem",
+      "askUp": "Co Ci się w tym podoba? (opcjonalnie)",
+      "askDown": "Co nie zadziałało zgodnie z oczekiwaniami?",
+      "privacyIdentified": "Wysyłane do zespołu Torqvoice z Twoim adresem e-mail, abyśmy mogli odpowiedzieć.",
+      "privacyAnonymous": "Wysyłane do zespołu Torqvoice bez Twojego imienia i adresu e-mail.",
+      "skip": "Pomiń",
+      "send": "Wyślij",
+      "thanks": "Dziękujemy, Twój komentarz został wysłany.",
+      "failed": "Nie udało się wysłać opinii. Spróbuj ponownie za chwilę."
+    }
+  },
   "pagination": {
     "showing": "Wyświetlane {start}–{end} z {total}",
     "perPage": "na stronę",

+ 25 - 0
messages/pt-BR/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Ver novidades",
     "dismiss": "Fechar"
   },
+  "whatsNew": {
+    "title": "Novidades",
+    "openUnread": "Novidades: atualizações que você ainda não viu",
+    "description": "As últimas adições, das mais recentes para as mais antigas.",
+    "unread": "Novo desde sua última visita",
+    "kindNew": "Novo",
+    "kindImproved": "Melhorado",
+    "open": "Abrir",
+    "guide": "Ler o guia",
+    "version": "Versão {version}",
+    "allReleaseNotes": "Todas as notas de versão",
+    "feedback": {
+      "question": "Isso foi útil?",
+      "up": "Sim, é útil",
+      "down": "Não, não era o que eu esperava",
+      "askUp": "Do que você gostou? (opcional)",
+      "askDown": "O que não funcionou como você esperava?",
+      "privacyIdentified": "Enviado à equipe da Torqvoice com seu e-mail, para que possamos responder.",
+      "privacyAnonymous": "Enviado à equipe da Torqvoice sem seu nome nem e-mail.",
+      "skip": "Pular",
+      "send": "Enviar",
+      "thanks": "Obrigado, seu comentário foi enviado.",
+      "failed": "Não foi possível enviar seu feedback. Tente novamente em instantes."
+    }
+  },
   "pagination": {
     "showing": "Mostrando {start}–{end} de {total}",
     "perPage": "por página",

+ 25 - 0
messages/pt-PT/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Ver novidades",
     "dismiss": "Fechar"
   },
+  "whatsNew": {
+    "title": "Novidades",
+    "openUnread": "Novidades: atualizações que ainda não viu",
+    "description": "As últimas adições, das mais recentes para as mais antigas.",
+    "unread": "Novo desde a sua última visita",
+    "kindNew": "Novo",
+    "kindImproved": "Melhorado",
+    "open": "Abrir",
+    "guide": "Ler o guia",
+    "version": "Versão {version}",
+    "allReleaseNotes": "Todas as notas de versão",
+    "feedback": {
+      "question": "Foi útil?",
+      "up": "Sim, é útil",
+      "down": "Não, não era o que esperava",
+      "askUp": "Do que gostou? (opcional)",
+      "askDown": "O que não funcionou como esperava?",
+      "privacyIdentified": "Enviado à equipa da Torqvoice com o seu endereço de e-mail, para podermos responder.",
+      "privacyAnonymous": "Enviado à equipa da Torqvoice sem o seu nome nem endereço de e-mail.",
+      "skip": "Saltar",
+      "send": "Enviar",
+      "thanks": "Obrigado, o seu comentário foi enviado.",
+      "failed": "Não foi possível enviar a sua opinião. Tente novamente dentro de momentos."
+    }
+  },
   "pagination": {
     "showing": "A mostrar {start}–{end} de {total}",
     "perPage": "por página",

+ 25 - 0
messages/ru/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Что нового",
     "dismiss": "Закрыть"
   },
+  "whatsNew": {
+    "title": "Что нового",
+    "openUnread": "Что нового: обновления, которые вы ещё не видели",
+    "description": "Последние новинки, сначала самые свежие.",
+    "unread": "Новое с вашего последнего визита",
+    "kindNew": "Новое",
+    "kindImproved": "Улучшено",
+    "open": "Открыть",
+    "guide": "Читать руководство",
+    "version": "Версия {version}",
+    "allReleaseNotes": "Все примечания к выпускам",
+    "feedback": {
+      "question": "Это было полезно?",
+      "up": "Да, это полезно",
+      "down": "Нет, не то, что я ожидал",
+      "askUp": "Что вам понравилось? (необязательно)",
+      "askDown": "Что работало не так, как вы ожидали?",
+      "privacyIdentified": "Отправляется команде Torqvoice с вашим адресом эл. почты, чтобы мы могли ответить.",
+      "privacyAnonymous": "Отправляется команде Torqvoice без вашего имени и адреса эл. почты.",
+      "skip": "Пропустить",
+      "send": "Отправить",
+      "thanks": "Спасибо, ваш комментарий отправлен.",
+      "failed": "Не удалось отправить отзыв. Попробуйте ещё раз чуть позже."
+    }
+  },
   "pagination": {
     "showing": "Показано {start}–{end} из {total}",
     "perPage": "на странице",

+ 25 - 0
messages/tr/common.json

@@ -54,6 +54,31 @@
     "whatsNew": "Yenilikleri gör",
     "dismiss": "Kapat"
   },
+  "whatsNew": {
+    "title": "Yenilikler",
+    "openUnread": "Yenilikler: henüz görmediğiniz güncellemeler",
+    "description": "En son eklenenler, en yenisi önce.",
+    "unread": "Son ziyaretinizden bu yana yeni",
+    "kindNew": "Yeni",
+    "kindImproved": "İyileştirildi",
+    "open": "Aç",
+    "guide": "Kılavuzu oku",
+    "version": "Sürüm {version}",
+    "allReleaseNotes": "Tüm sürüm notları",
+    "feedback": {
+      "question": "Bu faydalı oldu mu?",
+      "up": "Evet, faydalı",
+      "down": "Hayır, beklediğim gibi değil",
+      "askUp": "Nesini beğendiniz? (isteğe bağlı)",
+      "askDown": "Ne beklediğiniz gibi çalışmadı?",
+      "privacyIdentified": "Size yanıt verebilmemiz için e-posta adresinizle birlikte Torqvoice ekibine gönderilir.",
+      "privacyAnonymous": "Adınız ve e-posta adresiniz olmadan Torqvoice ekibine gönderilir.",
+      "skip": "Atla",
+      "send": "Gönder",
+      "thanks": "Teşekkürler, yorumunuz gönderildi.",
+      "failed": "Geri bildiriminiz gönderilemedi. Birazdan tekrar deneyin."
+    }
+  },
   "pagination": {
     "showing": "{total} kayıttan {start}–{end} gösteriliyor",
     "perPage": "sayfa başına",

+ 2 - 0
prisma/migrations/20261114111444_user_whats_new_seen_at/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "whatsNewSeenAt" TIMESTAMP(3);

+ 3 - 0
prisma/schema/auth.prisma

@@ -29,6 +29,9 @@ model User {
   /// second device does not restart them and a closed tab cannot stop them.
   updateBannerVersion String?
   updateBannerShownAt DateTime?
+  /// When this user last opened the "What's new" panel. Entries published
+  /// after it (and after the account was created) carry the unread dot.
+  whatsNewSeenAt      DateTime?
   /// Per-user dashboard grid layout (card positions/sizes/hidden set).
   /// Null = default layout. Shape validated in dashboardLayoutActions.
   dashboardLayout     Json?

+ 49 - 0
src/__tests__/features/whats-new/sanitize-body.test.ts

@@ -0,0 +1,49 @@
+/**
+ * The body of a highlight is HTML written on torqvoice.com and set as HTML
+ * inside a signed-in workshop. Only what the editor can produce survives.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { sanitizeBody } from '@/features/whats-new/Lib/sanitizeBody'
+
+describe('highlight body', () => {
+  it('keeps the formatting the editor produces', () => {
+    const html =
+      '<p>Put a <strong>call</strong> or a <em>delivery</em> on the calendar.</p><ul><li>One</li></ul>'
+    expect(sanitizeBody(html)).toBe(html)
+  })
+
+  it('drops scripts with their content, and every attribute', () => {
+    expect(
+      sanitizeBody('<p onclick="x()" style="color:red">Hi</p><script>alert(1)</script><img src=x>')
+    ).toBe('<p>Hi</p>')
+  })
+
+  it('keeps a web link, opened in a new tab', () => {
+    expect(sanitizeBody('<a href="https://torqvoice.com/docs" class="x">Docs</a>')).toBe(
+      '<a href="https://torqvoice.com/docs" target="_blank" rel="noopener noreferrer">Docs</a>'
+    )
+  })
+
+  it('keeps a link into the app as it is', () => {
+    expect(sanitizeBody('<a href="/calendar">Calendar</a>')).toBe(
+      '<a href="/calendar">Calendar</a>'
+    )
+  })
+
+  it('cannot be tricked into rebuilding a tag out of the pieces of a removed one', () => {
+    const out = sanitizeBody('<<x>img src=x onerror=alert(1)><p>Hi</p><scr<script>ipt>alert(2)')
+    expect(out).not.toMatch(/<(?!\/?(p|a|br|strong|b|em|i|u|s|h3|h4|ul|ol|li|blockquote|code)>)/)
+    expect(out).toContain('&lt;img src=x onerror=alert(1)>')
+    expect(out).toContain('<p>Hi</p>')
+  })
+
+  it('does not take a backslash path for a link into the app', () => {
+    expect(sanitizeBody('<a href="/\\evil.example">x</a>')).toBe('<a>x</a>')
+  })
+
+  it('removes the address from a link that is neither', () => {
+    expect(sanitizeBody('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>')
+    expect(sanitizeBody('<a href="//evil.example">x</a>')).toBe('<a>x</a>')
+  })
+})

+ 97 - 0
src/__tests__/features/whats-new/select.test.ts

@@ -0,0 +1,97 @@
+/**
+ * Who is told about what.
+ *
+ * The entries are written once on torqvoice.com for every install there is.
+ * What keeps that from being noise is that each reader only gets the ones
+ * they can act on: the feature is in their version, on their kind of install,
+ * behind a screen their role opens, and newer than their own account.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { selectEntries, unreadIds } from '@/features/whats-new/Lib/select'
+import type { WhatsNewEntry } from '@/features/whats-new/Schema/whatsNewSchema'
+
+function entry(overrides: Partial<WhatsNewEntry> = {}): WhatsNewEntry {
+  return {
+    id: 'e1',
+    kind: 'new',
+    title: 'Appointments on the calendar',
+    body: '<p>Body</p>',
+    imageUrl: null,
+    ctaLabel: null,
+    ctaHref: null,
+    docsHref: null,
+    minVersion: null,
+    audience: 'all',
+    subject: null,
+    publishedAt: '2026-11-13T09:00:00.000Z',
+    ...overrides,
+  }
+}
+
+const everyone = { appVersion: 'v2.14.0', cloud: false, visibleSubjects: ['vehicles', 'settings'] }
+const ids = (entries: WhatsNewEntry[]) => entries.map((e) => e.id)
+
+describe('which highlights an install shows', () => {
+  it('hides a feature from an install that does not have it yet', () => {
+    const entries = [
+      entry({ id: 'old', minVersion: '2.13.0' }),
+      entry({ id: 'same', minVersion: '2.14.0' }),
+      entry({ id: 'next-patch', minVersion: '2.14.1' }),
+      entry({ id: 'next-minor', minVersion: '2.15.0' }),
+    ]
+    expect(ids(selectEntries(entries, everyone))).toEqual(['old', 'same'])
+  })
+
+  it('shows everything on a build that is not a release', () => {
+    const entries = [entry({ minVersion: '9.0.0' })]
+    expect(selectEntries(entries, { ...everyone, appVersion: 'development' })).toHaveLength(1)
+  })
+
+  it('keeps cloud news on the cloud and self-hosted news off it', () => {
+    const entries = [
+      entry({ id: 'all' }),
+      entry({ id: 'cloud', audience: 'cloud' }),
+      entry({ id: 'self', audience: 'self-hosted' }),
+    ]
+    expect(ids(selectEntries(entries, everyone))).toEqual(['all', 'self'])
+    expect(ids(selectEntries(entries, { ...everyone, cloud: true }))).toEqual(['all', 'cloud'])
+  })
+
+  it('does not announce a screen the reader cannot open', () => {
+    const entries = [
+      entry({ id: 'vehicles', subject: 'vehicles' }),
+      entry({ id: 'billing', subject: 'billing' }),
+      // The admin writes subjects with a hyphen; the app's own use an underscore.
+      entry({ id: 'work-orders', subject: 'work-orders' }),
+      entry({ id: 'unknown', subject: 'something-newer' }),
+    ]
+    expect(ids(selectEntries(entries, everyone))).toEqual(['vehicles', 'unknown'])
+    expect(
+      ids(selectEntries(entries, { ...everyone, visibleSubjects: ['work_orders', 'billing'] }))
+    ).toEqual(['billing', 'work-orders', 'unknown'])
+  })
+})
+
+describe('which highlights are news to this person', () => {
+  const entries = [
+    entry({ id: 'nov-13', publishedAt: '2026-11-13T09:00:00.000Z' }),
+    entry({ id: 'nov-09', publishedAt: '2026-11-09T09:00:00.000Z' }),
+    entry({ id: 'oct-01', publishedAt: '2026-10-01T09:00:00.000Z' }),
+  ]
+
+  it('is everything since the panel was last opened', () => {
+    expect(
+      unreadIds(entries, {
+        seenAt: new Date('2026-11-10T00:00:00Z'),
+        accountCreatedAt: new Date('2025-01-01T00:00:00Z'),
+      })
+    ).toEqual(['nov-13'])
+  })
+
+  it('does not greet a new account with what shipped before it existed', () => {
+    expect(
+      unreadIds(entries, { seenAt: null, accountCreatedAt: new Date('2026-11-01T00:00:00Z') })
+    ).toEqual(['nov-13', 'nov-09'])
+  })
+})

+ 116 - 0
src/__tests__/features/whats-new/send-feedback.test.ts

@@ -0,0 +1,116 @@
+/**
+ * What a thumbs up or down sends to torqvoice.com.
+ *
+ * The panel promises a self-hosted workshop that nothing naming them goes
+ * along, and tells a cloud workshop that their email does. This holds the
+ * action to both sentences.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const ctx = { userId: 'user-1', organizationId: 'org-1' }
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (action: (c: typeof ctx) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await action(ctx) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn(async () => ({ email: 'ingrid@fjordside.example' })) },
+    organization: { findUnique: vi.fn(async () => ({ name: 'Fjordside Auto' })) },
+  },
+}))
+vi.mock('next-intl/server', () => ({ getLocale: async () => 'nb' }))
+vi.mock('@/lib/demo', () => ({ demoGuard: () => undefined }))
+
+let cloud = false
+vi.mock('@/lib/features', () => ({ isCloudMode: () => cloud }))
+
+const { sendWhatsNewFeedback } = await import('@/features/whats-new/Actions/sendWhatsNewFeedback')
+
+const fetchMock = vi.fn(
+  async (_url: string, _init: RequestInit) => new Response(null, { status: 204 })
+)
+
+function sentBody() {
+  return JSON.parse(String(fetchMock.mock.calls.at(-1)?.[1].body))
+}
+
+beforeEach(() => {
+  cloud = false
+  fetchMock.mockClear()
+  vi.stubGlobal('fetch', fetchMock)
+  vi.stubEnv('BETTER_AUTH_SECRET', 'a-secret-for-the-test-only')
+  vi.stubEnv('TORQVOICE_COM_URL', 'https://site.test')
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.unstubAllEnvs()
+})
+
+describe('sending feedback on a highlight', () => {
+  it('sends a self-hosted vote without anything that names the sender', async () => {
+    const result = await sendWhatsNewFeedback({
+      entryId: 'appointments',
+      vote: 'down',
+      comment: 'The reminder never arrived',
+    })
+
+    expect(result.success).toBe(true)
+    expect(fetchMock.mock.calls[0][0]).toBe('https://site.test/api/app/whats-new/feedback')
+    const body = sentBody()
+    expect(body).toMatchObject({
+      entryId: 'appointments',
+      vote: 'down',
+      comment: 'The reminder never arrived',
+      mode: 'self-hosted',
+      locale: 'nb',
+    })
+    expect(body.email).toBeUndefined()
+    expect(body.workshop).toBeUndefined()
+    expect(JSON.stringify(body)).not.toContain('user-1')
+  })
+
+  it('sends the email and workshop from the cloud, so the comment can be answered', async () => {
+    cloud = true
+    await sendWhatsNewFeedback({ entryId: 'appointments', vote: 'up', comment: 'Works well' })
+    expect(sentBody()).toMatchObject({
+      mode: 'cloud',
+      email: 'ingrid@fjordside.example',
+      workshop: 'Fjordside Auto',
+    })
+  })
+
+  it('names nobody for a thumb on its own, even from the cloud', async () => {
+    cloud = true
+    await sendWhatsNewFeedback({ entryId: 'appointments', vote: 'up' })
+    const body = sentBody()
+    expect(body.email).toBeUndefined()
+    expect(body.workshop).toBeUndefined()
+  })
+
+  it('gives one person the same key every time, so a second vote replaces the first', async () => {
+    await sendWhatsNewFeedback({ entryId: 'appointments', vote: 'up' })
+    const first = sentBody().voterKey
+    await sendWhatsNewFeedback({ entryId: 'appointments', vote: 'down' })
+    expect(sentBody().voterKey).toBe(first)
+    expect(first).toMatch(/^[0-9a-f]{64}$/)
+  })
+
+  it('reports a failure when torqvoice.com does not take it', async () => {
+    fetchMock.mockResolvedValueOnce(new Response(null, { status: 429 }))
+    const result = await sendWhatsNewFeedback({ entryId: 'appointments', vote: 'up' })
+    expect(result.success).toBe(false)
+  })
+
+  it('refuses a vote that is neither up nor down', async () => {
+    const result = await sendWhatsNewFeedback({ entryId: 'appointments', vote: 'sideways' })
+    expect(result.success).toBe(false)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+})

+ 226 - 0
src/__tests__/features/whats-new/whats-new-panel.test.tsx

@@ -0,0 +1,226 @@
+/**
+ * The "What's new" button and the panel behind it.
+ *
+ * What a person at the counter notices: a button with a dot when something
+ * shipped since they last looked, a panel of highlights when they press it,
+ * the dot gone afterwards, and a thumb under each highlight that is sent the
+ * moment it is pressed, with a box to say what went wrong.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { WhatsNewEntry } from '@/features/whats-new/Schema/whatsNewSchema'
+
+const markWhatsNewSeen = vi.fn(async () => ({ success: true }))
+vi.mock('@/features/whats-new/Actions/markWhatsNewSeen', () => ({
+  markWhatsNewSeen: () => markWhatsNewSeen(),
+}))
+
+const sendWhatsNewFeedback = vi.fn(async (_input: unknown) => ({ success: true }))
+vi.mock('@/features/whats-new/Actions/sendWhatsNewFeedback', () => ({
+  sendWhatsNewFeedback: (input: unknown) => sendWhatsNewFeedback(input),
+}))
+
+const toastError = vi.fn()
+vi.mock('sonner', () => ({ toast: { error: (message: string) => toastError(message) } }))
+
+const { WhatsNewProvider } = await import('@/features/whats-new/Components/WhatsNewProvider')
+const { WhatsNewButton } = await import('@/features/whats-new/Components/WhatsNewButton')
+
+const ENTRIES: WhatsNewEntry[] = [
+  {
+    id: 'appointments',
+    kind: 'new',
+    title: 'Appointments on the calendar',
+    body: '<p>Put a phone call on the calendar.</p>',
+    imageUrl: null,
+    ctaLabel: 'Open the calendar',
+    ctaHref: '/calendar',
+    docsHref: '/docs/features/calendar',
+    minVersion: null,
+    audience: 'all',
+    subject: null,
+    publishedAt: '2026-11-13T09:00:00.000Z',
+  },
+  {
+    id: 'helpers',
+    kind: 'improved',
+    title: 'Several technicians on one job',
+    body: '<p>A lead and helpers.</p>',
+    imageUrl: null,
+    ctaLabel: null,
+    ctaHref: null,
+    docsHref: null,
+    minVersion: null,
+    audience: 'all',
+    subject: null,
+    publishedAt: '2026-11-09T09:00:00.000Z',
+  },
+]
+
+function show({
+  entries = ENTRIES,
+  unread = ['appointments'],
+  feedback = 'anonymous' as 'identified' | 'anonymous' | null,
+} = {}) {
+  return render(
+    <WhatsNewProvider
+      entries={entries}
+      unreadIds={unread}
+      timeZone="Europe/Oslo"
+      appVersion="v2.14.0"
+      releaseNotesUrl="https://example.test/releases"
+      feedback={feedback}
+    >
+      <WhatsNewButton />
+    </WhatsNewProvider>
+  )
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  window.localStorage.clear()
+})
+
+describe("What's new", () => {
+  it('offers no button when there is nothing to read', () => {
+    show({ entries: [], unread: [] })
+    expect(screen.queryByRole('button')).not.toBeInTheDocument()
+  })
+
+  it('says there is something unseen, then stops saying so once the panel was opened', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /updates you have not seen/ }))
+
+    const panel = await screen.findByRole('dialog', { name: "What's new" })
+    expect(within(panel).getByText('Appointments on the calendar')).toBeInTheDocument()
+    expect(within(panel).getByText('Several technicians on one job')).toBeInTheDocument()
+    // Only the entry that is news carries the marker.
+    expect(within(panel).getAllByLabelText('New since you last looked')).toHaveLength(1)
+    expect(markWhatsNewSeen).toHaveBeenCalledTimes(1)
+
+    await user.keyboard('{Escape}')
+    await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+    expect(screen.getByRole('button', { name: "What's new" })).toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: /have not seen/ })).not.toBeInTheDocument()
+  })
+
+  it('links a highlight to the screen it is about and to its guide', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+
+    expect(screen.getByRole('link', { name: /Open the calendar/ })).toHaveAttribute(
+      'href',
+      '/calendar'
+    )
+    expect(screen.getByRole('link', { name: 'Read the guide' })).toHaveAttribute(
+      'href',
+      'https://torqvoice.com/docs/features/calendar'
+    )
+  })
+
+  it('sends a thumbs down at once, then the comment that explains it', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+
+    const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
+    await user.click(within(first).getByRole('button', { name: 'No, not what I expected' }))
+    expect(sendWhatsNewFeedback).toHaveBeenCalledWith({ entryId: 'appointments', vote: 'down' })
+
+    const box = await within(first).findByLabelText('What did not work the way you expected?')
+    expect(
+      within(first).getByText('Sent to the Torqvoice team without your name or email address.')
+    ).toBeInTheDocument()
+    await user.type(box, 'The reminder never arrived')
+    await user.click(within(first).getByRole('button', { name: 'Send' }))
+
+    expect(sendWhatsNewFeedback).toHaveBeenLastCalledWith({
+      entryId: 'appointments',
+      vote: 'down',
+      comment: 'The reminder never arrived',
+    })
+    expect(await within(first).findByText('Thank you, your comment was sent.')).toBeInTheDocument()
+  })
+
+  it('tells a cloud workshop that their email goes with the comment', async () => {
+    const user = userEvent.setup()
+    show({ feedback: 'identified' })
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+    const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
+    await user.click(within(first).getByRole('button', { name: 'Yes, this is useful' }))
+    expect(
+      await within(first).findByText(
+        'Sent to the Torqvoice team with your email address, so we can reply.'
+      )
+    ).toBeInTheDocument()
+  })
+
+  it('takes the thumb back and says so when the vote could not be sent', async () => {
+    sendWhatsNewFeedback.mockResolvedValueOnce({ success: false })
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+    const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
+    const down = within(first).getByRole('button', { name: 'No, not what I expected' })
+    await user.click(down)
+
+    await waitFor(() => expect(toastError).toHaveBeenCalled())
+    expect(down).toHaveAttribute('aria-pressed', 'false')
+    expect(within(first).queryByRole('textbox')).not.toBeInTheDocument()
+  })
+
+  it('has no thumbs where sending is switched off', async () => {
+    const user = userEvent.setup()
+    show({ feedback: null })
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+    expect(screen.queryByText('Was this useful?')).not.toBeInTheDocument()
+  })
+})
+
+describe("What's new, when things go wrong", () => {
+  it('keeps the dot when opening the panel could not be recorded, and tries again next time', async () => {
+    markWhatsNewSeen.mockResolvedValueOnce({ success: false })
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /updates you have not seen/ }))
+    await user.keyboard('{Escape}')
+
+    const again = await screen.findByRole('button', { name: /updates you have not seen/ })
+    await user.click(again)
+    expect(markWhatsNewSeen).toHaveBeenCalledTimes(2)
+  })
+
+  it('does not take a second vote while the first is still on its way', async () => {
+    let finish: (value: { success: boolean }) => void = () => undefined
+    sendWhatsNewFeedback.mockImplementationOnce(
+      () => new Promise<{ success: boolean }>((resolve) => (finish = resolve))
+    )
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+    const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
+    await user.click(within(first).getByRole('button', { name: 'No, not what I expected' }))
+
+    const up = within(first).getByRole('button', { name: 'Yes, this is useful' })
+    expect(up).toBeDisabled()
+    await user.click(up)
+    expect(sendWhatsNewFeedback).toHaveBeenCalledTimes(1)
+
+    finish({ success: true })
+    await waitFor(() => expect(up).toBeEnabled())
+  })
+
+  it('does not follow a button address that would leave the app', async () => {
+    const user = userEvent.setup()
+    show({
+      entries: [{ ...ENTRIES[0], ctaHref: '/\\evil.example' }],
+      unread: [],
+    })
+    await user.click(screen.getByRole('button', { name: "What's new" }))
+    expect(screen.queryByRole('link', { name: /Open the calendar/ })).not.toBeInTheDocument()
+  })
+})

+ 44 - 0
src/__tests__/lib/sanitize-html.test.ts

@@ -0,0 +1,44 @@
+/**
+ * Notes, calendar descriptions and designer text are typed in the rich text
+ * editor, stored as HTML and later set as HTML in front of other staff and
+ * customers. What comes out of the sanitizer must only ever contain the
+ * handful of tags the editor writes, whatever went in.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { sanitizeHtml } from '@/lib/sanitize-html'
+
+/** Any `<` that is not one of the bare tags the sanitizer itself writes. */
+const FOREIGN_TAG = /<(?!\/?(p|br|strong|b|em|i|u|h2|h3|ul|ol|li|blockquote)>)/
+
+describe('sanitizeHtml', () => {
+  it('leaves what the editor writes exactly as it was', () => {
+    const html =
+      '<h2>Brakes</h2><p>Front pads at <strong>3 mm</strong>, <em>replace</em> soon.</p><ul><li>Pads</li><li>Discs</li></ul><blockquote>Customer approved</blockquote><p>Wear &lt; 3 mm &amp; uneven</p>'
+    expect(sanitizeHtml(html)).toBe(html)
+  })
+
+  it('removes other tags and every attribute, keeping the words', () => {
+    expect(
+      sanitizeHtml(
+        '<p class="x" onclick="steal()">Hi <span style="color:red">there</span></p><img src="x">'
+      )
+    ).toBe('<p>Hi there</p>')
+  })
+
+  it.each([
+    '<<x>img src=x onerror=alert(1)>',
+    '<scr<script>ipt>alert(1)</scr</script>ipt>',
+    '<<p>script>alert(1)<</p>/script>',
+    '<im<b>g src=x onerror=alert(1)>',
+    '<<<x>>svg onload=alert(1)>',
+    '<p>ok</p><',
+    '< img src=x onerror=alert(1)>',
+  ])('never lets the pieces of a removed tag form another: %s', (input) => {
+    expect(sanitizeHtml(input)).not.toMatch(FOREIGN_TAG)
+  })
+
+  it('shows a stray angle bracket as text', () => {
+    expect(sanitizeHtml('<p>a < b</p>')).toBe('<p>a &lt; b</p>')
+  })
+})

+ 138 - 110
src/app/(authenticated)/layout.tsx

@@ -1,5 +1,5 @@
 import { redirect } from 'next/navigation'
-import { getTranslations } from 'next-intl/server'
+import { getLocale, getTranslations } from 'next-intl/server'
 import { connectorName } from '@/features/integrations/Lib/display-name'
 import { NoAccess } from './no-access'
 import { AppSidebar } from '@/components/app-sidebar'
@@ -58,6 +58,9 @@ import { TimeClockProvider } from '@/features/time-tracking/Components/TimeClock
 import { RealtimeProvider } from '@/features/realtime/RealtimeProvider'
 import { SaveShortcutListener } from '@/components/save-shortcut-listener'
 import { AnalyticsIdentity } from '@/components/analytics-identity'
+import { WhatsNewProvider } from '@/features/whats-new/Components/WhatsNewProvider'
+import { getWhatsNewFeed } from '@/features/whats-new/Lib/feed.server'
+import { selectEntries, unreadIds } from '@/features/whats-new/Lib/select'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -173,6 +176,25 @@ export default async function DashboardLayout({ children }: { children: React.Re
     return <NoAccess organizationName={org?.name ?? ''} />
   }
 
+  // The highlights written on torqvoice.com, narrowed to what this person can
+  // actually open on this install. A licensed self-hosted install runs under
+  // the workshop's own name, and news signed by Torqvoice has no place there.
+  const appVersion = process.env.APP_VERSION || 'development'
+  const releaseNotesUrl =
+    process.env.RELEASE_NOTES_URL || 'https://github.com/Torqvoice/torqvoice/releases'
+  const whatsNewHidden = !isCloudMode() && features.brandingRemoved
+  const whatsNewEntries = whatsNewHidden
+    ? []
+    : selectEntries(await getWhatsNewFeed(await getLocale()), {
+        appVersion,
+        cloud: isCloudMode(),
+        visibleSubjects,
+      })
+  const whatsNewUnread = unreadIds(whatsNewEntries, {
+    seenAt: data.whatsNewSeenAt,
+    accountCreatedAt: data.userCreatedAt,
+  })
+
   // Live work for the sidebar pills. Each is a count of things still in hand,
   // never a running total, so the numbers stay small enough to mean
   // something. Only the screens this role can see are counted at all.
@@ -309,130 +331,136 @@ export default async function DashboardLayout({ children }: { children: React.Re
             dismissed={licenseExpiryDismissed}
           >
             <WhiteLabelCtaProvider show={showWhiteLabelCta}>
-              {/* Accent line along the very top of the viewport — the card hairline at
+              <WhatsNewProvider
+                entries={whatsNewEntries}
+                unreadIds={whatsNewUnread}
+                timeZone={safeTimeZone(data.timezone)}
+                appVersion={appVersion}
+                releaseNotesUrl={releaseNotesUrl}
+                feedback={isDemoMode ? null : isCloudMode() ? 'identified' : 'anonymous'}
+              >
+                {/* Accent line along the very top of the viewport — the card hairline at
         page scale: primary on the left, gone by the far edge. Marks where the
         app begins against the browser chrome. */}
-              <div
-                aria-hidden
-                className="pointer-events-none fixed inset-x-0 top-0 z-50 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
-              />
-              {/* Watches for a notice posted while this page is already open. Here
+                <div
+                  aria-hidden
+                  className="pointer-events-none fixed inset-x-0 top-0 z-50 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
+                />
+                {/* Watches for a notice posted while this page is already open. Here
               rather than beside the banner because the socket authenticates on
               the session cookie, which the sign-in page does not have. */}
-              <BroadcastLive />
-              {/* The demo banner already occupies the header, and demo image tags
+                <BroadcastLive />
+                {/* The demo banner already occupies the header, and demo image tags
         (demo-abc1234) are not versions a visitor should be notified about. */}
-              {!isDemoMode && (
-                <UpdateBanner
-                  currentVersion={process.env.APP_VERSION || 'development'}
-                  lastSeenVersion={data.lastSeenVersion}
-                  shownVersion={data.updateBannerVersion}
-                  shownAt={data.updateBannerShownAt?.toISOString() ?? null}
-                  releaseNotesUrl={
-                    process.env.RELEASE_NOTES_URL ||
-                    'https://github.com/Torqvoice/torqvoice/releases'
+                {!isDemoMode && (
+                  <UpdateBanner
+                    currentVersion={appVersion}
+                    lastSeenVersion={data.lastSeenVersion}
+                    shownVersion={data.updateBannerVersion}
+                    shownAt={data.updateBannerShownAt?.toISOString() ?? null}
+                    releaseNotesUrl={releaseNotesUrl}
+                  />
+                )}
+                <SidebarProvider
+                  defaultOpen={sidebarOpen}
+                  style={
+                    {
+                      '--sidebar-width': '19rem',
+                    } as React.CSSProperties
                   }
-                />
-              )}
-              <SidebarProvider
-                defaultOpen={sidebarOpen}
-                style={
-                  {
-                    '--sidebar-width': '19rem',
-                  } as React.CSSProperties
-                }
-              >
-                <DateSettingsProvider
-                  dateFormat={data.dateFormat}
-                  timeFormat={data.timeFormat}
-                  timezone={data.timezone}
-                  weekStartDay={data.weekStartDay}
                 >
-                  <CurrencySettingsProvider
-                    currencyCode={data.currencyCode}
-                    currencyFormat={data.currencyFormat}
+                  <DateSettingsProvider
+                    dateFormat={data.dateFormat}
+                    timeFormat={data.timeFormat}
+                    timezone={data.timezone}
+                    weekStartDay={data.weekStartDay}
                   >
-                    <ConfirmProvider>
-                      <PlateLookupProvider
-                        value={{
-                          available: plateLookupAvailable,
-                          canCreate: canCreateVehicles,
-                          registryName: plateLookupRegistry,
-                        }}
-                      >
-                        <FeatureHintProvider
-                          initialSeen={seenHints}
-                          pending={[...pendingHints, ...announcements]}
+                    <CurrencySettingsProvider
+                      currencyCode={data.currencyCode}
+                      currencyFormat={data.currencyFormat}
+                    >
+                      <ConfirmProvider>
+                        <PlateLookupProvider
+                          value={{
+                            available: plateLookupAvailable,
+                            canCreate: canCreateVehicles,
+                            registryName: plateLookupRegistry,
+                          }}
                         >
-                          {/* One socket for the whole app: record changes, who
+                          <FeatureHintProvider
+                            initialSeen={seenHints}
+                            pending={[...pendingHints, ...announcements]}
+                          >
+                            {/* One socket for the whole app: record changes, who
                           else is on a record, and the older workshop-wide
                           channels the remaining pages still read. */}
-                          <RealtimeProvider>
-                            <TimeClockProvider technicianIds={technicianIds}>
-                              <SaveShortcutListener />
-                              <AnalyticsIdentity
-                                userId={data.userId}
-                                organizationId={data.organizationId}
-                              />
-                              <AppSidebar
-                                companyLogo={data.companyLogo}
-                                organizations={data.organizations}
-                                activeOrgId={data.organizationId}
-                                isSuperAdmin={data.isSuperAdmin}
-                                features={features}
-                                tireHotelEnabled={tireHotelEnabled}
-                                aiEnabled={aiEnabled}
-                                faultCodesEnabled={faultCodesEnabled}
-                                visibleSubjects={visibleSubjects}
-                                announcement={announcements[0] ?? null}
-                                isAdminOrOwner={isOwnerOrAdmin}
-                                counts={sidebarCounts}
-                                isTechnician={technicianIds.length > 0}
-                              />
-                              <SidebarInset>
-                                {/* A flex column with a real height, so the `flex-1` every
+                            <RealtimeProvider>
+                              <TimeClockProvider technicianIds={technicianIds}>
+                                <SaveShortcutListener />
+                                <AnalyticsIdentity
+                                  userId={data.userId}
+                                  organizationId={data.organizationId}
+                                />
+                                <AppSidebar
+                                  companyLogo={data.companyLogo}
+                                  organizations={data.organizations}
+                                  activeOrgId={data.organizationId}
+                                  isSuperAdmin={data.isSuperAdmin}
+                                  features={features}
+                                  tireHotelEnabled={tireHotelEnabled}
+                                  aiEnabled={aiEnabled}
+                                  faultCodesEnabled={faultCodesEnabled}
+                                  visibleSubjects={visibleSubjects}
+                                  announcement={announcements[0] ?? null}
+                                  isAdminOrOwner={isOwnerOrAdmin}
+                                  counts={sidebarCounts}
+                                  isTechnician={technicianIds.length > 0}
+                                />
+                                <SidebarInset>
+                                  {/* A flex column with a real height, so the `flex-1` every
                           page already writes on its wrapper actually resolves.
                           Without it a page that wants to fill the window (the
                           work board's week timeline) stopped at its content and
                           left the rest of the screen blank. */}
-                                <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
-                                  {children}
-                                </div>
-                              </SidebarInset>
-                              <SearchCommand
-                                visibleSubjects={visibleSubjects}
-                                features={features}
-                                tireHotelEnabled={tireHotelEnabled}
-                                aiEnabled={aiEnabled}
-                                isAdminOrOwner={isOwnerOrAdmin}
-                                isCloud={cloudLinked}
-                                supportEnabled={supportEnabled}
-                                isMarine={data.serviceType === 'marine'}
-                                connectors={searchConnectors}
-                                faultCodesEnabled={faultCodesEnabled}
-                              />
-                              <PlateLookupCommand />
-                              {isOwnerOrAdmin && <NotificationInitializer />}
-                              <OnlineTracker />
-                              <ConnectionWatcher />
-                              <UpdateWatcher version={buildVersion()} />
-                              <InstallBanner
-                                appName={appBranding.name}
-                                iconUrl={appBranding.iconUrl}
-                              />
-                            </TimeClockProvider>
-                          </RealtimeProvider>
-                        </FeatureHintProvider>
-                      </PlateLookupProvider>
-                    </ConfirmProvider>
-                  </CurrencySettingsProvider>
-                </DateSettingsProvider>
-              </SidebarProvider>
-              <MobileBottomNav
-                isSuperAdmin={data.isSuperAdmin}
-                tireHotelEnabled={tireHotelEnabled}
-              />
-              {supportEnabled && <SupportBubble userId={data.userId} />}
+                                  <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
+                                    {children}
+                                  </div>
+                                </SidebarInset>
+                                <SearchCommand
+                                  visibleSubjects={visibleSubjects}
+                                  features={features}
+                                  tireHotelEnabled={tireHotelEnabled}
+                                  aiEnabled={aiEnabled}
+                                  isAdminOrOwner={isOwnerOrAdmin}
+                                  isCloud={cloudLinked}
+                                  supportEnabled={supportEnabled}
+                                  isMarine={data.serviceType === 'marine'}
+                                  connectors={searchConnectors}
+                                  faultCodesEnabled={faultCodesEnabled}
+                                />
+                                <PlateLookupCommand />
+                                {isOwnerOrAdmin && <NotificationInitializer />}
+                                <OnlineTracker />
+                                <ConnectionWatcher />
+                                <UpdateWatcher version={buildVersion()} />
+                                <InstallBanner
+                                  appName={appBranding.name}
+                                  iconUrl={appBranding.iconUrl}
+                                />
+                              </TimeClockProvider>
+                            </RealtimeProvider>
+                          </FeatureHintProvider>
+                        </PlateLookupProvider>
+                      </ConfirmProvider>
+                    </CurrencySettingsProvider>
+                  </DateSettingsProvider>
+                </SidebarProvider>
+                <MobileBottomNav
+                  isSuperAdmin={data.isSuperAdmin}
+                  tireHotelEnabled={tireHotelEnabled}
+                />
+                {supportEnabled && <SupportBubble userId={data.userId} />}
+              </WhatsNewProvider>
             </WhiteLabelCtaProvider>
           </LicenseExpiryProvider>
         </EngineSizeUnitProvider>

+ 2 - 0
src/components/page-header.tsx

@@ -22,6 +22,7 @@ import { useLicenseExpiry } from '@/components/license-expiry-context'
 import { BANNER_PRIORITY, useBannerSlot } from '@/components/banner-slot'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { DocsLink } from '@/components/docs-link'
+import { WhatsNewButton } from '@/features/whats-new/Components/WhatsNewButton'
 import { RunningClockPill } from '@/features/time-tracking/Components/RunningClockPill'
 import { useServiceType } from '@/components/service-type-context'
 import { SETTINGS_PAGES } from '@/features/settings/Lib/settingsPages'
@@ -345,6 +346,7 @@ export function PageHeader() {
           <RunningClockPill />
           <PlateTrigger />
           <SearchTrigger />
+          <WhatsNewButton />
           <QuickCreateMenu />
           {showWhiteLabelCta && (
             <Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">

+ 26 - 9
src/components/update-banner.tsx

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'
 import { X } from 'lucide-react'
 import { markUpdateBannerShown, markVersionSeen } from '@/features/users/Actions/versionActions'
 import { decideUpdateBanner } from '@/lib/update-banner-rule'
+import { useWhatsNew } from '@/features/whats-new/Components/WhatsNewProvider'
 import { BANNER_PRIORITY, useBannerSlot } from './banner-slot'
 
 /**
@@ -35,6 +36,7 @@ export function UpdateBanner({
 }) {
   const t = useTranslations('common.updateBanner')
   const [dismissed, setDismissed] = useState(false)
+  const whatsNew = useWhatsNew()
 
   const neverSeeded = lastSeenVersion === null
   const decision = neverSeeded
@@ -80,15 +82,30 @@ export function UpdateBanner({
   return (
     <div className="relative bg-amber-500 px-8 py-1.5 text-center text-xs font-medium text-amber-950">
       {t('updated', { version: currentVersion })}{' '}
-      <a
-        href={releaseNotesUrl}
-        target="_blank"
-        rel="noopener noreferrer"
-        onClick={acknowledge}
-        className="underline underline-offset-2 hover:text-amber-900"
-      >
-        {t('whatsNew')} →
-      </a>
+      {/* The highlights, written for workshops, when there are any to open.
+          Otherwise the release notes, which is all there used to be. */}
+      {whatsNew.available ? (
+        <button
+          type="button"
+          onClick={() => {
+            acknowledge()
+            whatsNew.open()
+          }}
+          className="cursor-pointer underline underline-offset-2 hover:text-amber-900"
+        >
+          {t('whatsNew')} →
+        </button>
+      ) : (
+        <a
+          href={releaseNotesUrl}
+          target="_blank"
+          rel="noopener noreferrer"
+          onClick={acknowledge}
+          className="underline underline-offset-2 hover:text-amber-900"
+        >
+          {t('whatsNew')} →
+        </a>
+      )}
       <button
         type="button"
         onClick={acknowledge}

+ 22 - 0
src/features/whats-new/Actions/markWhatsNewSeen.ts

@@ -0,0 +1,22 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+
+/**
+ * Records that this person has opened the "What's new" panel.
+ *
+ * On the user record, like the update banner's version: the news is about the
+ * product, not about one workshop, so somebody in two workshops reads it once,
+ * and opening the panel on the office computer clears the dot on their phone.
+ */
+export async function markWhatsNewSeen() {
+  return withAuth(
+    async ({ userId }) => {
+      await db.user.update({ where: { id: userId }, data: { whatsNewSeenAt: new Date() } })
+      return { seen: true }
+    },
+    // Reading the news writes nothing to the workshop a restore is replacing.
+    { allowDuringRestore: true }
+  )
+}

+ 70 - 0
src/features/whats-new/Actions/sendWhatsNewFeedback.ts

@@ -0,0 +1,70 @@
+'use server'
+
+import { createHmac } from 'node:crypto'
+import { getLocale } from 'next-intl/server'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { isCloudMode } from '@/lib/features'
+import { torqvoiceComUrl } from '@/lib/torqvoice-com'
+import { withAuth } from '@/lib/with-auth'
+import { whatsNewFeedbackSchema } from '../Schema/whatsNewSchema'
+
+/**
+ * A stable, meaningless name for this person on torqvoice.com, so a second
+ * vote on the same entry replaces the first instead of counting twice. Keyed
+ * with this install's own secret: the site can tell two votes are from one
+ * person and nothing else about them.
+ */
+function voterKey(userId: string): string {
+  const secret = process.env.BETTER_AUTH_SECRET || process.env.ENCRYPTION_KEY
+  if (!secret) throw new Error('No signing secret configured: set BETTER_AUTH_SECRET.')
+  return createHmac('sha256', secret).update(`whats-new:${userId}`).digest('hex')
+}
+
+/**
+ * Sends a thumbs up or down on a highlight, with an optional comment, to the
+ * people who wrote it.
+ *
+ * What leaves the building: the vote, the comment, the app version and the
+ * language. A thumb on its own names nobody. A comment written on Torqvoice
+ * Cloud takes the workshop's name and the sender's email along, so a "this
+ * did not work" can be answered; the line under the comment box says so
+ * before Send is pressed. A self-hosted install never sends either.
+ */
+export async function sendWhatsNewFeedback(input: unknown) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      demoGuard()
+      const data = whatsNewFeedbackSchema.parse(input)
+      const cloud = isCloudMode()
+
+      const identity =
+        cloud && data.comment
+          ? await Promise.all([
+              db.user.findUnique({ where: { id: userId }, select: { email: true } }),
+              db.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
+            ])
+          : null
+
+      const response = await fetch(`${torqvoiceComUrl()}/api/app/whats-new/feedback`, {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        signal: AbortSignal.timeout(8000),
+        body: JSON.stringify({
+          entryId: data.entryId,
+          voterKey: voterKey(userId),
+          vote: data.vote,
+          comment: data.comment || undefined,
+          appVersion: (process.env.APP_VERSION || 'development').slice(0, 64),
+          mode: cloud ? 'cloud' : 'self-hosted',
+          locale: await getLocale(),
+          email: identity?.[0]?.email || undefined,
+          workshop: identity?.[1]?.name?.slice(0, 200) || undefined,
+        }),
+      })
+      if (!response.ok) throw new Error(`Feedback was not accepted (${response.status}).`)
+      return { sent: true }
+    },
+    { allowDuringRestore: true }
+  )
+}

+ 37 - 0
src/features/whats-new/Components/WhatsNewButton.tsx

@@ -0,0 +1,37 @@
+'use client'
+
+import { Gift } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useWhatsNew } from './WhatsNewProvider'
+
+/**
+ * The header's way into the "What's new" panel.
+ *
+ * A dot, not a number: the count pills in the sidebar mean work waiting on
+ * somebody, and three unread announcements are not that. Renders nothing when
+ * there is no news to open, so the header never offers an empty panel.
+ */
+export function WhatsNewButton() {
+  const t = useTranslations('common.whatsNew')
+  const { available, hasUnread, open } = useWhatsNew()
+  if (!available) return null
+
+  const label = hasUnread ? t('openUnread') : t('title')
+  return (
+    <button
+      type="button"
+      onClick={open}
+      aria-label={label}
+      title={label}
+      className="relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+    >
+      <Gift className="h-4 w-4" />
+      {hasUnread && (
+        <span
+          aria-hidden
+          className="absolute right-1 top-1 size-2 rounded-full bg-primary ring-2 ring-background"
+        />
+      )}
+    </button>
+  )
+}

+ 176 - 0
src/features/whats-new/Components/WhatsNewFeedback.tsx

@@ -0,0 +1,176 @@
+'use client'
+
+import { useEffect, useId, useState } from 'react'
+import { Check, Loader2, ThumbsDown, ThumbsUp } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { cn } from '@/lib/utils'
+import { sendWhatsNewFeedback } from '../Actions/sendWhatsNewFeedback'
+
+type Vote = 'up' | 'down'
+
+const STORAGE_KEY = 'torqvoice.whatsNew.votes'
+
+/** This browser's memory of what was voted, so the thumb stays lit next time. */
+function readVotes(): Record<string, Vote> {
+  try {
+    const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}')
+    return parsed && typeof parsed === 'object' ? parsed : {}
+  } catch {
+    return {}
+  }
+}
+
+function rememberVote(entryId: string, vote: Vote) {
+  try {
+    window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readVotes(), [entryId]: vote }))
+  } catch {
+    // Private window, or storage switched off. The vote itself was sent.
+  }
+}
+
+/**
+ * "Was this useful?" under a highlight.
+ *
+ * The thumb is the whole answer and is sent the moment it is pressed. The
+ * comment box that opens under it is an invitation, not a second step: most
+ * people will not type anything, and their vote must not depend on it.
+ *
+ * A bare thumb names nobody, anywhere. Only a comment from the cloud carries
+ * the sender's email, and the line under the box says so before Send.
+ */
+export function WhatsNewFeedback({
+  entryId,
+  identified,
+}: {
+  entryId: string
+  /** Whether the sender's email goes along (the cloud) or nothing does. */
+  identified: boolean
+}) {
+  const t = useTranslations('common.whatsNew.feedback')
+  const commentId = useId()
+  const [vote, setVote] = useState<Vote | null>(null)
+  const [writing, setWriting] = useState(false)
+  const [comment, setComment] = useState('')
+  // One request at a time. A slow first vote that fails must not arrive
+  // after a second one succeeded and take the thumb back.
+  const [busy, setBusy] = useState(false)
+  const [commentSent, setCommentSent] = useState(false)
+
+  useEffect(() => {
+    setVote(readVotes()[entryId] ?? null)
+  }, [entryId])
+
+  async function cast(next: Vote) {
+    if (busy) return
+    setBusy(true)
+    const previous = vote
+    setVote(next)
+    setWriting(true)
+    setCommentSent(false)
+    const result = await sendWhatsNewFeedback({ entryId, vote: next })
+    setBusy(false)
+    if (result.success) {
+      rememberVote(entryId, next)
+    } else {
+      setVote(previous)
+      setWriting(false)
+      toast.error(t('failed'))
+    }
+  }
+
+  async function sendComment() {
+    if (busy || !vote || !comment.trim()) return
+    setBusy(true)
+    const result = await sendWhatsNewFeedback({ entryId, vote, comment })
+    setBusy(false)
+    if (!result.success) {
+      toast.error(t('failed'))
+      return
+    }
+    setComment('')
+    setWriting(false)
+    setCommentSent(true)
+  }
+
+  const thumb = (which: Vote) =>
+    cn(
+      'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+      vote === which
+        ? 'bg-primary/15 text-foreground'
+        : 'text-muted-foreground hover:bg-muted hover:text-foreground'
+    )
+
+  return (
+    <div className="flex flex-col gap-2 pt-1">
+      <div className="flex items-center gap-1">
+        <span className="mr-1 text-xs text-muted-foreground">{t('question')}</span>
+        <button
+          type="button"
+          onClick={() => cast('up')}
+          disabled={busy}
+          aria-pressed={vote === 'up'}
+          aria-label={t('up')}
+          title={t('up')}
+          className={thumb('up')}
+        >
+          <ThumbsUp className="h-3.5 w-3.5" />
+        </button>
+        <button
+          type="button"
+          onClick={() => cast('down')}
+          disabled={busy}
+          aria-pressed={vote === 'down'}
+          aria-label={t('down')}
+          title={t('down')}
+          className={thumb('down')}
+        >
+          <ThumbsDown className="h-3.5 w-3.5" />
+        </button>
+        {commentSent && (
+          <span
+            className="ml-1 flex items-center gap-1 text-xs text-muted-foreground"
+            role="status"
+          >
+            <Check className="h-3.5 w-3.5" />
+            {t('thanks')}
+          </span>
+        )}
+      </div>
+      {writing && vote && (
+        <div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
+          <label htmlFor={commentId} className="text-xs font-medium">
+            {vote === 'down' ? t('askDown') : t('askUp')}
+          </label>
+          <Textarea
+            id={commentId}
+            value={comment}
+            onChange={(event) => setComment(event.target.value)}
+            maxLength={2000}
+            rows={3}
+            className="min-h-20 bg-background"
+          />
+          <p className="text-xs leading-snug text-muted-foreground">
+            {identified ? t('privacyIdentified') : t('privacyAnonymous')}
+          </p>
+          <div className="flex items-center justify-end gap-1">
+            <Button type="button" variant="ghost" size="sm" onClick={() => setWriting(false)}>
+              {t('skip')}
+            </Button>
+            <Button
+              type="button"
+              size="sm"
+              onClick={sendComment}
+              disabled={busy || !comment.trim()}
+            >
+              {busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
+              {t('send')}
+            </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 186 - 0
src/features/whats-new/Components/WhatsNewPanel.tsx

@@ -0,0 +1,186 @@
+'use client'
+
+import Link from 'next/link'
+import { ArrowRight } from 'lucide-react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import {
+  Sheet,
+  SheetContent,
+  SheetDescription,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
+import { MARKETING_URL } from '@/lib/marketing-urls'
+import { safeRedirectPath } from '@/lib/safe-redirect'
+import { cn } from '@/lib/utils'
+import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
+import { WhatsNewFeedback } from './WhatsNewFeedback'
+
+/** A path inside this app, and nothing that could leave it. */
+function appPath(href: string | null): string | null {
+  return safeRedirectPath(href, '') || null
+}
+
+function docsUrl(href: string | null): string | null {
+  return href?.startsWith('/docs/') ? `${MARKETING_URL}${href}` : null
+}
+
+const BODY_STYLES = cn(
+  'text-sm leading-relaxed text-foreground/80',
+  '[&_p+p]:mt-2 [&_p]:m-0',
+  '[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5',
+  '[&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-foreground',
+  '[&_h4]:mb-1 [&_h4]:mt-3 [&_h4]:text-sm [&_h4]:font-medium [&_h4]:text-foreground',
+  '[&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_a]:underline-offset-2',
+  '[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3',
+  '[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.85em]'
+)
+
+function Entry({
+  entry,
+  marked,
+  timeZone,
+  feedback,
+  onNavigate,
+}: {
+  entry: WhatsNewEntry
+  marked: boolean
+  timeZone: string
+  feedback: 'identified' | 'anonymous' | null
+  onNavigate: () => void
+}) {
+  const t = useTranslations('common.whatsNew')
+  const format = useFormatter()
+  const cta = appPath(entry.ctaHref)
+  const guide = docsUrl(entry.docsHref)
+
+  return (
+    <article className="flex flex-col gap-2.5 border-b px-6 py-5 last:border-b-0">
+      <div className="flex items-center gap-2">
+        {marked && (
+          <span className="size-1.5 rounded-full bg-primary" role="img" aria-label={t('unread')} />
+        )}
+        <span
+          className={cn(
+            'rounded px-1.5 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
+            entry.kind === 'new'
+              ? 'bg-primary/15 text-foreground'
+              : 'bg-muted text-muted-foreground'
+          )}
+        >
+          {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
+        </span>
+        <time dateTime={entry.publishedAt} className="text-xs text-muted-foreground">
+          {/* The workshop's calendar day, like every other date in the app. */}
+          {format.dateTime(new Date(entry.publishedAt), {
+            day: 'numeric',
+            month: 'short',
+            year: 'numeric',
+            timeZone,
+          })}
+        </time>
+      </div>
+      <h3 className="text-[15px] font-semibold leading-snug">{entry.title}</h3>
+      {entry.imageUrl && (
+        <img
+          src={entry.imageUrl}
+          alt=""
+          loading="lazy"
+          className="w-full rounded-lg border bg-muted object-cover"
+        />
+      )}
+      {/* Sanitized on the server by Lib/sanitizeBody before it reaches here. */}
+      <div className={BODY_STYLES} dangerouslySetInnerHTML={{ __html: entry.body }} />
+      {(cta || guide) && (
+        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-0.5">
+          {cta && (
+            <Button asChild variant="outline" size="sm">
+              <Link href={cta} onClick={onNavigate}>
+                {entry.ctaLabel || t('open')}
+                <ArrowRight className="h-3.5 w-3.5" />
+              </Link>
+            </Button>
+          )}
+          {guide && (
+            <a
+              href={guide}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+            >
+              {t('guide')}
+            </a>
+          )}
+        </div>
+      )}
+      {feedback && <WhatsNewFeedback entryId={entry.id} identified={feedback === 'identified'} />}
+    </article>
+  )
+}
+
+/**
+ * The highlights, in a sheet from the right.
+ *
+ * Opened by the person, never by the app: news is something to go and read
+ * when there is a quiet minute, not something to be handed in the middle of
+ * writing a work order.
+ */
+export function WhatsNewPanel({
+  open,
+  onOpenChange,
+  entries,
+  markedIds,
+  timeZone,
+  appVersion,
+  releaseNotesUrl,
+  feedback,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  entries: WhatsNewEntry[]
+  markedIds: ReadonlySet<string>
+  /** The workshop's time zone, for the day an entry was published. */
+  timeZone: string
+  appVersion: string
+  releaseNotesUrl: string
+  feedback: 'identified' | 'anonymous' | null
+}) {
+  const t = useTranslations('common.whatsNew')
+
+  return (
+    <Sheet open={open} onOpenChange={onOpenChange}>
+      <SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
+        <SheetHeader className="border-b px-6 py-5">
+          <SheetTitle className="text-lg">{t('title')}</SheetTitle>
+          <SheetDescription>{t('description')}</SheetDescription>
+        </SheetHeader>
+        <div className="min-h-0 flex-1 overflow-y-auto">
+          {entries.map((entry) => (
+            <Entry
+              key={entry.id}
+              entry={entry}
+              marked={markedIds.has(entry.id)}
+              timeZone={timeZone}
+              feedback={feedback}
+              onNavigate={() => onOpenChange(false)}
+            />
+          ))}
+        </div>
+        <div className="flex items-center justify-between gap-4 border-t bg-muted/40 px-6 py-3 text-sm">
+          <span className="text-muted-foreground">
+            {appVersion === 'development' ? appVersion : t('version', { version: appVersion })}
+          </span>
+          <a
+            href={releaseNotesUrl}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="rounded font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+          >
+            {t('allReleaseNotes')}
+          </a>
+        </div>
+      </SheetContent>
+    </Sheet>
+  )
+}

+ 101 - 0
src/features/whats-new/Components/WhatsNewProvider.tsx

@@ -0,0 +1,101 @@
+'use client'
+
+import { createContext, useCallback, useContext, useMemo, useState } from 'react'
+import { markWhatsNewSeen } from '../Actions/markWhatsNewSeen'
+import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
+import { WhatsNewPanel } from './WhatsNewPanel'
+
+const Context = createContext<{
+  /** False when there is nothing to show: the feed is empty, hidden or unreachable. */
+  available: boolean
+  hasUnread: boolean
+  open: () => void
+}>({ available: false, hasUnread: false, open: () => undefined })
+
+export function useWhatsNew() {
+  return useContext(Context)
+}
+
+/**
+ * Holds the "What's new" panel for the signed-in app.
+ *
+ * One panel, here, rather than one per button: the header button and the
+ * update banner's link both open it, and they sit in different parts of the
+ * layout. The entries arrive from the server with the page, so opening the
+ * panel never waits on the network.
+ */
+export function WhatsNewProvider({
+  entries,
+  unreadIds,
+  timeZone,
+  appVersion,
+  releaseNotesUrl,
+  feedback,
+  children,
+}: {
+  entries: WhatsNewEntry[]
+  /** Entries published since this person last opened the panel. */
+  unreadIds: string[]
+  timeZone: string
+  appVersion: string
+  releaseNotesUrl: string
+  /**
+   * Whether a vote can be sent, and what goes with it: on the cloud the
+   * sender's email, so it can be answered; on a self-hosted install nothing
+   * that names anybody. `null` where sending is switched off (the demo).
+   */
+  feedback: 'identified' | 'anonymous' | null
+  children: React.ReactNode
+}) {
+  const [isOpen, setIsOpen] = useState(false)
+  // What has been acknowledged in this tab. The server's list catches up on
+  // the next navigation; until then this is what clears the dot at once.
+  const [acknowledged, setAcknowledged] = useState<ReadonlySet<string>>(new Set())
+  // The entries that were news when the panel was last opened. They keep
+  // their marker while it is being read, which is the whole point of one.
+  const [marked, setMarked] = useState<ReadonlySet<string>>(new Set())
+
+  const pending = useMemo(
+    () => unreadIds.filter((id) => !acknowledged.has(id)),
+    [unreadIds, acknowledged]
+  )
+
+  const open = useCallback(() => {
+    setIsOpen(true)
+    if (pending.length === 0) return
+    setMarked(new Set(pending))
+    setAcknowledged((current) => new Set([...current, ...pending]))
+    // Not recorded: let the dot come back, so the next opening tries again
+    // rather than this tab believing something the server never heard.
+    const forget = () =>
+      setAcknowledged((current) => new Set([...current].filter((id) => !pending.includes(id))))
+    markWhatsNewSeen()
+      .then((result) => {
+        if (!result.success) forget()
+      })
+      .catch(forget)
+  }, [pending])
+
+  const value = useMemo(
+    () => ({ available: entries.length > 0, hasUnread: pending.length > 0, open }),
+    [entries.length, pending.length, open]
+  )
+
+  return (
+    <Context.Provider value={value}>
+      {children}
+      {entries.length > 0 && (
+        <WhatsNewPanel
+          open={isOpen}
+          onOpenChange={setIsOpen}
+          entries={entries}
+          markedIds={marked}
+          timeZone={timeZone}
+          appVersion={appVersion}
+          releaseNotesUrl={releaseNotesUrl}
+          feedback={feedback}
+        />
+      )}
+    </Context.Provider>
+  )
+}

+ 76 - 0
src/features/whats-new/Lib/feed.server.ts

@@ -0,0 +1,76 @@
+import 'server-only'
+import { torqvoiceComUrl } from '@/lib/torqvoice-com'
+import { type WhatsNewEntry, whatsNewFeedSchema } from '../Schema/whatsNewSchema'
+import { sanitizeBody } from './sanitizeBody'
+
+/** How long a fetched feed is served before it is fetched again. */
+const FRESH_MS = 10 * 60 * 1000
+/** After a failure, how long before torqvoice.com is asked again. */
+const RETRY_MS = 60 * 1000
+/** The signed-in layout waits on this, so it is not allowed to be slow. */
+const TIMEOUT_MS = 2500
+
+type Cached = { entries: WhatsNewEntry[]; expiresAt: number; refreshing?: Promise<void> }
+
+const globalForFeed = globalThis as unknown as { __whatsNewFeed?: Map<string, Cached> }
+const cache = (globalForFeed.__whatsNewFeed ??= new Map())
+
+async function load(locale: string): Promise<WhatsNewEntry[]> {
+  const url = `${torqvoiceComUrl()}/api/app/whats-new?locale=${encodeURIComponent(locale)}`
+  const response = await fetch(url, {
+    signal: AbortSignal.timeout(TIMEOUT_MS),
+    headers: { accept: 'application/json' },
+    cache: 'no-store',
+  })
+  if (!response.ok) throw new Error(`whats-new feed answered ${response.status}`)
+  const feed = whatsNewFeedSchema.parse(await response.json())
+  return feed.entries.map((entry) => ({ ...entry, body: sanitizeBody(entry.body) }))
+}
+
+function refresh(locale: string, previous: WhatsNewEntry[]): Promise<void> {
+  return load(locale)
+    .then((entries) => {
+      cache.set(locale, { entries, expiresAt: Date.now() + FRESH_MS })
+    })
+    .catch(() => {
+      // torqvoice.com is unreachable, or an install has no way out to the
+      // internet at all. Keep what there was, and do not ask again on every
+      // page load.
+      cache.set(locale, { entries: previous, expiresAt: Date.now() + RETRY_MS })
+    })
+}
+
+/**
+ * The highlights torqvoice.com has published, in the reader's language.
+ *
+ * Asked for by this server, never by the browser, and with nothing about the
+ * workshop in the request: only the language. Kept in memory for ten minutes
+ * per language; once that has passed the old list is still returned at once
+ * and the new one is fetched behind it, so only the very first request after
+ * a start ever waits. A failure is an empty list, and the button that opens
+ * the panel is simply not drawn.
+ */
+export async function getWhatsNewFeed(locale: string): Promise<WhatsNewEntry[]> {
+  const cached = cache.get(locale)
+  if (!cached) {
+    const entry: Cached = { entries: [], expiresAt: 0 }
+    cache.set(locale, entry)
+    entry.refreshing = refresh(locale, [])
+    await entry.refreshing
+    return cache.get(locale)?.entries ?? []
+  }
+  if (cached.refreshing && cached.expiresAt === 0) {
+    // A second request arriving while the first fetch is still out.
+    await cached.refreshing
+    return cache.get(locale)?.entries ?? []
+  }
+  if (Date.now() >= cached.expiresAt && !cached.refreshing) {
+    cached.refreshing = refresh(locale, cached.entries)
+  }
+  return cached.entries
+}
+
+/** For tests. */
+export function clearWhatsNewFeedCache() {
+  cache.clear()
+}

+ 73 - 0
src/features/whats-new/Lib/sanitizeBody.ts

@@ -0,0 +1,73 @@
+import { safeRedirectPath } from '@/lib/safe-redirect'
+
+const ALLOWED_TAGS = new Set([
+  'p',
+  'br',
+  'strong',
+  'b',
+  'em',
+  'i',
+  'u',
+  's',
+  'h3',
+  'h4',
+  'ul',
+  'ol',
+  'li',
+  'blockquote',
+  'code',
+  'a',
+])
+
+/** Elements whose text is not prose and must go with the tag. */
+const DROPPED_WITH_CONTENT = /<(script|style|iframe|object|embed|template)\b[\s\S]*?<\/\1\s*>/gi
+
+function escapeAttribute(value: string): string {
+  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
+}
+
+/** An absolute web link, or a path inside this app. Anything else is dropped. */
+function safeHref(tag: string): string | null {
+  const match = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag)
+  const raw = (match?.[1] ?? match?.[2] ?? '').replace(/&amp;/g, '&').trim()
+  if (!raw) return null
+  // A path inside the app. `/\\host` and `//host` leave it, so they are not one.
+  if (raw.startsWith('/')) return safeRedirectPath(raw, '') || null
+  try {
+    const url = new URL(raw)
+    return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null
+  } catch {
+    return null
+  }
+}
+
+/**
+ * The body of a highlight, reduced to what the admin's editor can produce.
+ *
+ * torqvoice.com sanitizes on save; this runs again here because the text
+ * crossed the network and is about to be set as HTML inside a signed-in
+ * workshop. Every attribute goes except a link's address, and that has to be
+ * a web address or an app path.
+ *
+ * Every `<` in the input is either the start of a tag this rebuilds from
+ * scratch, part of a tag it removes, or a stray one that is escaped. So no
+ * `<` reaches the output except in the tags written here, and removing one
+ * tag can never leave the pieces of another behind (`<<x>img onerror=...>`).
+ */
+export function sanitizeBody(html: string): string {
+  return html
+    .replace(/<!--[\s\S]*?-->/g, '')
+    .replace(DROPPED_WITH_CONTENT, '')
+    .replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>|</g, (match, tag: string | undefined) => {
+      if (!tag) return '&lt;'
+      const name = tag.toLowerCase()
+      if (!ALLOWED_TAGS.has(name)) return ''
+      if (name === 'br') return '<br>'
+      if (match.startsWith('</')) return `</${name}>`
+      if (name !== 'a') return `<${name}>`
+      const href = safeHref(match)
+      if (!href) return '<a>'
+      const external = !href.startsWith('/')
+      return `<a href="${escapeAttribute(href)}"${external ? ' target="_blank" rel="noopener noreferrer"' : ''}>`
+    })
+}

+ 65 - 0
src/features/whats-new/Lib/select.ts

@@ -0,0 +1,65 @@
+import { PermissionSubject } from '@/lib/permissions'
+import { parseVersion } from '@/lib/update-banner-rule'
+import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
+
+const KNOWN_SUBJECTS = new Set<string>(Object.values(PermissionSubject))
+
+/** Whether the running app is at least `minVersion`. */
+function hasVersion(appVersion: string, minVersion: string | null): boolean {
+  if (!minVersion) return true
+  const min = parseVersion(minVersion)
+  // An entry with a version nobody can read is the author's typo, not a
+  // reason to hide the news from everyone.
+  if (!min) return true
+  const app = parseVersion(appVersion)
+  // A development build, or an image tag that is not a release: there is
+  // nothing to compare, and the person running it is the one who wants to
+  // see the entry they are about to publish.
+  if (!app) return true
+  if (app.major !== min.major) return app.major > min.major
+  if (app.minor !== min.minor) return app.minor > min.minor
+  return app.patch >= min.patch
+}
+
+/**
+ * The entries this person should be told about.
+ *
+ * Three rules, all for the same reason: an announcement of something the
+ * reader cannot open is worse than no announcement.
+ * - The install must already have the feature. Entries are published the day
+ *   a release ships, and a self-hosted workshop may be months behind it.
+ * - Cloud-only and self-hosted-only features stay with their own audience.
+ * - A feature behind a permission is announced to the people who hold it.
+ */
+export function selectEntries(
+  entries: WhatsNewEntry[],
+  viewer: { appVersion: string; cloud: boolean; visibleSubjects: readonly string[] }
+): WhatsNewEntry[] {
+  return entries.filter((entry) => {
+    if (!hasVersion(viewer.appVersion, entry.minVersion)) return false
+    if (entry.audience === 'cloud' && !viewer.cloud) return false
+    if (entry.audience === 'self-hosted' && viewer.cloud) return false
+    if (entry.subject) {
+      const subject = entry.subject.toLowerCase().replace(/-/g, '_')
+      // A subject this version has never heard of cannot be checked, so it
+      // does not hide anything.
+      if (KNOWN_SUBJECTS.has(subject) && !viewer.visibleSubjects.includes(subject)) return false
+    }
+    return true
+  })
+}
+
+/**
+ * Which entries are news to this person.
+ *
+ * Counted from the later of the last time they opened the panel and the day
+ * their account was created: somebody who signed up yesterday has never known
+ * the product without last month's features, so those are not news to them.
+ */
+export function unreadIds(
+  entries: WhatsNewEntry[],
+  viewer: { seenAt: Date | null; accountCreatedAt: Date | null }
+): string[] {
+  const since = Math.max(viewer.seenAt?.getTime() ?? 0, viewer.accountCreatedAt?.getTime() ?? 0)
+  return entries.filter((entry) => Date.parse(entry.publishedAt) > since).map((entry) => entry.id)
+}

+ 48 - 0
src/features/whats-new/Schema/whatsNewSchema.ts

@@ -0,0 +1,48 @@
+import { z } from 'zod'
+
+/**
+ * One highlight, as torqvoice.com serves it from `/api/app/whats-new`.
+ *
+ * The entries are written in the admin on torqvoice.com, so a new one reaches
+ * every install without a deploy. Nothing here is trusted beyond its shape:
+ * the body is sanitized again before it is drawn, and links are checked.
+ */
+export const whatsNewEntrySchema = z.object({
+  id: z.string().min(1).max(64),
+  kind: z.enum(['new', 'improved']).catch('new'),
+  title: z.string().min(1).max(200),
+  /** HTML from the admin's editor. Sanitized in Lib/sanitizeBody before use. */
+  body: z.string().max(20_000),
+  imageUrl: z.string().url().nullable().catch(null),
+  ctaLabel: z.string().max(80).nullable().catch(null),
+  /** A path inside this app. */
+  ctaHref: z.string().max(300).nullable().catch(null),
+  /** A docs path on torqvoice.com, without a locale. */
+  docsHref: z.string().max(300).nullable().catch(null),
+  /** The first release that has the feature. Older installs never see the entry. */
+  minVersion: z.string().max(32).nullable().catch(null),
+  audience: z.enum(['all', 'cloud', 'self-hosted']).catch('all'),
+  /** The permission subject the feature sits behind, when it has one. */
+  subject: z.string().max(40).nullable().catch(null),
+  publishedAt: z.string().datetime({ offset: true }),
+})
+
+export const whatsNewFeedSchema = z.object({
+  // One malformed entry is dropped rather than taking the whole feed with it.
+  entries: z.array(z.unknown()).transform((items) =>
+    items.flatMap((item) => {
+      const parsed = whatsNewEntrySchema.safeParse(item)
+      return parsed.success ? [parsed.data] : []
+    })
+  ),
+})
+
+export type WhatsNewEntry = z.infer<typeof whatsNewEntrySchema>
+
+export const whatsNewFeedbackSchema = z.object({
+  entryId: z.string().min(1).max(64),
+  vote: z.enum(['up', 'down']),
+  comment: z.string().trim().max(2000).optional(),
+})
+
+export type WhatsNewFeedbackInput = z.infer<typeof whatsNewFeedbackSchema>

+ 7 - 0
src/lib/get-layout-data.ts

@@ -20,6 +20,9 @@ type AuthResult =
       lastSeenVersion: string | null
       updateBannerVersion: string | null
       updateBannerShownAt: Date | null
+      /** When the "What's new" panel was last opened, and when the account began. */
+      whatsNewSeenAt: Date | null
+      userCreatedAt: Date | null
       companyLogo: string | undefined
       dateFormat: string | undefined
       timeFormat: string | undefined
@@ -49,6 +52,8 @@ export async function getLayoutData(): Promise<AuthResult> {
         lastSeenVersion: true,
         updateBannerVersion: true,
         updateBannerShownAt: true,
+        whatsNewSeenAt: true,
+        createdAt: true,
       },
     }),
   ])
@@ -109,6 +114,8 @@ export async function getLayoutData(): Promise<AuthResult> {
     lastSeenVersion: user?.lastSeenVersion ?? null,
     updateBannerVersion: user?.updateBannerVersion ?? null,
     updateBannerShownAt: user?.updateBannerShownAt ?? null,
+    whatsNewSeenAt: user?.whatsNewSeenAt ?? null,
+    userCreatedAt: user?.createdAt ?? null,
     companyLogo: orgMap.get(SETTING_KEYS.COMPANY_LOGO) || undefined,
     dateFormat: orgMap.get(SETTING_KEYS.DATE_FORMAT) || undefined,
     timeFormat: orgMap.get(SETTING_KEYS.TIME_FORMAT) || undefined,

+ 7 - 1
src/lib/sanitize-html.ts

@@ -18,9 +18,15 @@ const ALLOWED_TAGS = new Set([
  * Strips all HTML tags and attributes except those produced by Tiptap.
  * Allowed tags are kept but all attributes are removed.
  * Any other tag is removed, keeping its inner text content.
+ *
+ * A `<` that does not open a tag is escaped rather than left alone. The
+ * editor never writes one (it stores `&lt;`), so the only input that has
+ * them is input built to leave something behind once a tag is taken out of
+ * the middle of it. With this, every `<` in the result is one written here.
  */
 export function sanitizeHtml(html: string): string {
-  return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tag: string) => {
+  return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>|</g, (match, tag?: string) => {
+    if (!tag) return '&lt;'
     const lower = tag.toLowerCase()
     if (!ALLOWED_TAGS.has(lower)) return ''
     // Self-closing tags