Browse Source

Show a one-time update banner after new deployments (#222)

* Show a one-time update banner when a new version is deployed

When the running APP_VERSION differs from the version stored on the
user record, a dismissible banner appears above the app linking to the
release notes. Dismissing (or opening the notes) stores the version
per user, so the notice shows exactly once per account per release on
any device. New accounts are seeded silently. Adds lastSeenVersion to
User (idempotent migration), banner strings in 12 languages, and a
RELEASE_NOTES_URL override honored by both the banner and the About
page changelog link. Hidden entirely when APP_VERSION is unset.

* Style the update banner like the demo instance banner

Thin amber full-width strip above the app frame with centered text and
an inline what's-new link, matching the demo branch's DemoBanner, plus
a dismiss control on the right.
Bernt Christian Egeland 1 month ago
parent
commit
c158dc1dbd

+ 5 - 0
messages/de/common.json

@@ -39,5 +39,10 @@
     "title": "Seite nicht gefunden",
     "description": "Sieht aus, als wäre dieses Teil noch in der Werkstatt. Die Seite, die Sie suchen, existiert nicht oder wurde verschoben.",
     "backToDashboard": "Zurück zum Dashboard"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice wurde auf {version} aktualisiert",
+    "whatsNew": "Neuigkeiten ansehen",
+    "dismiss": "Schließen"
   }
 }

+ 5 - 0
messages/en/common.json

@@ -39,5 +39,10 @@
     "title": "Page Not Found",
     "description": "Looks like this part is still in the shop. The page you're looking for doesn't exist or has been moved.",
     "backToDashboard": "Back to Dashboard"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice has been updated to {version}",
+    "whatsNew": "See what is new",
+    "dismiss": "Dismiss"
   }
 }

+ 5 - 0
messages/es/common.json

@@ -39,5 +39,10 @@
     "title": "Página no encontrada",
     "description": "Parece que esta pieza aún está en el taller. La página que buscas no existe o ha sido movida.",
     "backToDashboard": "Volver al panel"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice se ha actualizado a {version}",
+    "whatsNew": "Ver novedades",
+    "dismiss": "Cerrar"
   }
 }

+ 5 - 0
messages/fr/common.json

@@ -39,5 +39,10 @@
     "title": "Page introuvable",
     "description": "On dirait que cette pièce est encore à l'atelier. La page que vous recherchez n'existe pas ou a été déplacée.",
     "backToDashboard": "Retour au tableau de bord"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice a été mis à jour vers {version}",
+    "whatsNew": "Voir les nouveautés",
+    "dismiss": "Fermer"
   }
 }

+ 5 - 0
messages/it/common.json

@@ -39,5 +39,10 @@
     "title": "Pagina non trovata",
     "description": "Sembra che questo pezzo sia ancora in officina. La pagina che stai cercando non esiste o è stata spostata.",
     "backToDashboard": "Torna alla dashboard"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice è stato aggiornato alla versione {version}",
+    "whatsNew": "Scopri le novità",
+    "dismiss": "Chiudi"
   }
 }

+ 5 - 0
messages/lt/common.json

@@ -39,5 +39,10 @@
     "title": "Puslapis nerastas",
     "description": "Atrodo, ši dalis vis dar servise. Puslapis, kurio ieškote, neegzistuoja arba buvo perkeltas.",
     "backToDashboard": "Grįžti į valdymo skydelį"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice atnaujinta į {version}",
+    "whatsNew": "Žiūrėti naujienas",
+    "dismiss": "Uždaryti"
   }
 }

+ 5 - 0
messages/nb/common.json

@@ -39,5 +39,10 @@
     "title": "Siden ble ikke funnet",
     "description": "Det ser ut som denne delen fortsatt er på verkstedet. Siden du leter etter finnes ikke eller har blitt flyttet.",
     "backToDashboard": "Tilbake til dashbordet"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice er oppdatert til {version}",
+    "whatsNew": "Se hva som er nytt",
+    "dismiss": "Lukk"
   }
 }

+ 5 - 0
messages/nl/common.json

@@ -39,5 +39,10 @@
     "title": "Pagina niet gevonden",
     "description": "Het lijkt erop dat dit onderdeel nog in de werkplaats is. De pagina die je zoekt bestaat niet of is verplaatst.",
     "backToDashboard": "Terug naar dashboard"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice is bijgewerkt naar {version}",
+    "whatsNew": "Bekijk wat er nieuw is",
+    "dismiss": "Sluiten"
   }
 }

+ 5 - 0
messages/pl/common.json

@@ -39,5 +39,10 @@
     "title": "Nie znaleziono strony",
     "description": "Wygląda na to, że ta część jest jeszcze w warsztacie. Strona, której szukasz, nie istnieje lub została przeniesiona.",
     "backToDashboard": "Wróć do panelu"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice zaktualizowano do wersji {version}",
+    "whatsNew": "Zobacz nowości",
+    "dismiss": "Zamknij"
   }
 }

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

@@ -39,5 +39,10 @@
     "title": "Página não encontrada",
     "description": "Parece que essa peça ainda está na oficina. A página que você procura não existe ou foi movida.",
     "backToDashboard": "Voltar ao painel"
+  },
+  "updateBanner": {
+    "updated": "O Torqvoice foi atualizado para {version}",
+    "whatsNew": "Ver novidades",
+    "dismiss": "Fechar"
   }
 }

+ 5 - 0
messages/ru/common.json

@@ -39,5 +39,10 @@
     "title": "Страница не найдена",
     "description": "Похоже, эта деталь ещё в ремонте. Страница, которую вы ищете, не существует или была перемещена.",
     "backToDashboard": "Вернуться на главную"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice обновлён до версии {version}",
+    "whatsNew": "Что нового",
+    "dismiss": "Закрыть"
   }
 }

+ 5 - 0
messages/tr/common.json

@@ -39,5 +39,10 @@
     "title": "Sayfa Bulunamadı",
     "description": "Bu parça hâlâ atölyede gibi görünüyor. Aradığınız sayfa mevcut değil veya taşınmış olabilir.",
     "backToDashboard": "Panele Dön"
+  },
+  "updateBanner": {
+    "updated": "Torqvoice {version} sürümüne güncellendi",
+    "whatsNew": "Yenilikleri gör",
+    "dismiss": "Kapat"
   }
 }

+ 4 - 0
prisma/migrations/20260811100000_user_last_seen_version/migration.sql

@@ -0,0 +1,4 @@
+-- Add per-user last seen app version for the update banner.
+-- IF NOT EXISTS keeps this idempotent for environments where the column
+-- was applied manually before the migration was recorded.
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "lastSeenVersion" TEXT;

+ 3 - 0
prisma/schema.prisma

@@ -21,6 +21,9 @@ model User {
   termsAcceptedAt  DateTime?
   lastLogin        DateTime?
   lastSeen         DateTime?
+  /// App version this user last saw/dismissed the update banner for.
+  /// Null = never seeded (seeded silently on first load, no banner).
+  lastSeenVersion  String?
 
   sessions               Session[]
   accounts               Account[]

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

@@ -8,6 +8,7 @@ import { getLayoutData } from "@/lib/get-layout-data";
 import { getFeatures, isCloudMode } from "@/lib/features";
 import { WhiteLabelCtaProvider } from "@/components/white-label-cta-context";
 import { DateSettingsProvider } from "@/components/date-settings-context";
+import { UpdateBanner } from "@/components/update-banner";
 import { CurrencySettingsProvider } from "@/components/currency-settings-context";
 import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
@@ -109,6 +110,13 @@ export default async function DashboardLayout({
     <ServiceTypeProvider serviceType={data.serviceType}>
     <LicenseExpiryProvider daysUntilExpiry={daysUntilExpiry} dismissed={licenseExpiryDismissed}>
     <WhiteLabelCtaProvider show={showWhiteLabelCta}>
+    <UpdateBanner
+      currentVersion={process.env.APP_VERSION || "development"}
+      lastSeenVersion={data.lastSeenVersion}
+      releaseNotesUrl={
+        process.env.RELEASE_NOTES_URL || "https://github.com/Torqvoice/torqvoice/releases"
+      }
+    />
     <SidebarProvider
       style={
         {

+ 3 - 1
src/app/(authenticated)/settings/about/page.tsx

@@ -7,11 +7,13 @@ import { ExternalLink, Info } from 'lucide-react'
 export default async function AboutSettingsPage() {
   const t = await getTranslations('settings')
   const version = process.env.APP_VERSION || 'development'
+  const releaseNotesUrl =
+    process.env.RELEASE_NOTES_URL || 'https://github.com/Torqvoice/torqvoice/releases'
 
   const links = [
     { label: t('about.website'), href: 'https://torqvoice.com/' },
     { label: t('about.documentation'), href: 'https://torqvoice.com/docs' },
-    { label: t('about.changelog'), href: 'https://github.com/Torqvoice/torqvoice/releases' },
+    { label: t('about.changelog'), href: releaseNotesUrl },
   ]
 
   return (

+ 70 - 0
src/components/update-banner.tsx

@@ -0,0 +1,70 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { X } from "lucide-react";
+import { markVersionSeen } from "@/features/users/Actions/versionActions";
+
+/**
+ * One-time "the app was updated" notice, shown when the running APP_VERSION
+ * differs from the version stored on the user record. Dismissing (or opening
+ * the release notes) stores the current version server-side, so the banner
+ * appears exactly once per account per release, on any device.
+ */
+export function UpdateBanner({
+  currentVersion,
+  lastSeenVersion,
+  releaseNotesUrl,
+}: {
+  currentVersion: string;
+  lastSeenVersion: string | null;
+  releaseNotesUrl: string;
+}) {
+  const t = useTranslations("common.updateBanner");
+  const [dismissed, setDismissed] = useState(false);
+
+  const neverSeeded = lastSeenVersion === null;
+  const show =
+    !dismissed &&
+    !neverSeeded &&
+    currentVersion !== "development" &&
+    lastSeenVersion !== currentVersion;
+
+  // First load ever for this account: seed silently so a brand-new user is
+  // not greeted with "what's new" for a version they never used.
+  useEffect(() => {
+    if (neverSeeded && currentVersion !== "development") {
+      markVersionSeen(currentVersion);
+    }
+  }, [neverSeeded, currentVersion]);
+
+  if (!show) return null;
+
+  const acknowledge = () => {
+    setDismissed(true);
+    markVersionSeen(currentVersion);
+  };
+
+  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>
+      <button
+        type="button"
+        onClick={acknowledge}
+        className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-amber-950/70 transition-colors hover:text-amber-950"
+      >
+        <X className="h-3.5 w-3.5" />
+        <span className="sr-only">{t("dismiss")}</span>
+      </button>
+    </div>
+  );
+}

+ 24 - 0
src/features/users/Actions/versionActions.ts

@@ -0,0 +1,24 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { getCachedSession } from "@/lib/cached-session";
+
+/**
+ * Records that the current user has seen (or dismissed) the update banner for
+ * the given app version. Stored per user, so the banner shows exactly once per
+ * account per release across all devices. Deliberately session-scoped rather
+ * than org-scoped: the version is a property of the deployment, not the org.
+ */
+export async function markVersionSeen(version: string) {
+  const session = await getCachedSession();
+  if (!session?.user?.id) return { success: false };
+
+  const clean = version.slice(0, 64);
+  if (!clean) return { success: false };
+
+  await db.user.update({
+    where: { id: session.user.id },
+    data: { lastSeenVersion: clean },
+  });
+  return { success: true };
+}

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

@@ -12,6 +12,7 @@ type AuthResult =
       role: string;
       isSuperAdmin: boolean;
       emailVerified: boolean;
+      lastSeenVersion: string | null;
       companyLogo: string | undefined;
       dateFormat: string | undefined;
       timeFormat: string | undefined;
@@ -31,7 +32,7 @@ export async function getLayoutData(): Promise<AuthResult> {
     getCachedMembership(session.user.id),
     db.user.findUnique({
       where: { id: session.user.id },
-      select: { isSuperAdmin: true, emailVerified: true },
+      select: { isSuperAdmin: true, emailVerified: true, lastSeenVersion: true },
     }),
   ]);
 
@@ -81,6 +82,7 @@ export async function getLayoutData(): Promise<AuthResult> {
     role: isSuperAdmin ? "super_admin" : (membership?.role ?? "member"),
     isSuperAdmin,
     emailVerified: user?.emailVerified ?? false,
+    lastSeenVersion: user?.lastSeenVersion ?? 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,