From 847e7a3fc570ce2991309cbfb324da02c9d3f6fb Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Mon, 11 May 2026 00:06:06 +0200
Subject: [PATCH] feat(web): toast "Nouvelle version" persistant + lien vers le
changelog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Composant `` monté au root de la SPA qui s'affiche quand
l'utilisateur ouvre l'app sur une version plus récente que la dernière
qu'il a vue.
Mécanique :
- Source de vérité = `apps/web/src/version.ts` (constante semver, à bumper
manuellement à chaque release, en même temps que l'ajout du .md
correspondant dans `apps/landing/src/content/changelog/`).
- Comparaison à `localStorage["rubis:last-seen-version"]` au mount.
- 1re visite (clé absente) → on enregistre la version courante en silence,
pas de toast (sinon spam d'onboarding).
- Version identique à la dernière vue → rien.
- Version différente → toast Sonner persistant (`duration: Infinity`)
avec icône Sparkles rubis et action "Voir les nouveautés ↗" qui ouvre
`rubis.pro/changelog#` dans un nouvel onglet. Au moment de
l'affichage on enregistre la version comme vue — donc même si l'user
ferme sans cliquer, plus de toast pour cette version (il a été informé).
- localStorage indisponible (private mode) → fail silent.
Version initiale : `1.10.0` (remerciement automatique au client, dernière
entrée du changelog).
Co-Authored-By: Claude Opus 4.7
---
apps/web/src/components/version-toast.tsx | 70 +++++++++++++++++++++++
apps/web/src/routes/__root.tsx | 3 +
apps/web/src/version.ts | 17 ++++++
3 files changed, 90 insertions(+)
create mode 100644 apps/web/src/components/version-toast.tsx
create mode 100644 apps/web/src/version.ts
diff --git a/apps/web/src/components/version-toast.tsx b/apps/web/src/components/version-toast.tsx
new file mode 100644
index 0000000..e29d311
--- /dev/null
+++ b/apps/web/src/components/version-toast.tsx
@@ -0,0 +1,70 @@
+import { useEffect } from "react";
+import { toast } from "sonner";
+import { Sparkles } from "lucide-react";
+
+import { APP_VERSION, CHANGELOG_URL } from "../version";
+
+const STORAGE_KEY = "rubis:last-seen-version";
+
+/**
+ * Affiche un toast persistant quand l'utilisateur ouvre l'app sur une version
+ * plus récente que la dernière qu'il a vue (lu/comparé via `localStorage`).
+ *
+ * Comportement :
+ * - 1re visite (pas de clé en localStorage) → on enregistre la version
+ * courante en silence, pas de toast (sinon spam d'onboarding).
+ * - Visite suivante, version identique → rien.
+ * - Visite suivante, version différente → toast persistant (duration: Infinity)
+ * avec action "Voir les nouveautés" qui ouvre `rubis.pro/changelog#`
+ * dans un nouvel onglet. Au moment de l'affichage on enregistre la version
+ * comme vue — donc même si l'user ferme le toast sans cliquer, il ne le
+ * reverra pas (il a été informé).
+ *
+ * localStorage indisponible (private mode, restrictions) → fail silent, pas
+ * de toast et pas de crash.
+ */
+export function VersionToast() {
+ useEffect(() => {
+ let seen: string | null = null;
+ try {
+ seen = localStorage.getItem(STORAGE_KEY);
+ } catch {
+ return;
+ }
+
+ if (seen === null) {
+ try {
+ localStorage.setItem(STORAGE_KEY, APP_VERSION);
+ } catch {
+ // Tant pis — on ne montrera pas de toast non plus.
+ }
+ return;
+ }
+
+ if (seen === APP_VERSION) return;
+
+ toast.message(`Nouvelle version v${APP_VERSION}`, {
+ description: "Découvrez ce qui change.",
+ duration: Infinity,
+ icon: ,
+ action: {
+ label: "Voir les nouveautés ↗",
+ onClick: () => {
+ window.open(
+ `${CHANGELOG_URL}#${APP_VERSION}`,
+ "_blank",
+ "noopener,noreferrer",
+ );
+ },
+ },
+ });
+
+ try {
+ localStorage.setItem(STORAGE_KEY, APP_VERSION);
+ } catch {
+ // Pareil — on a quand même informé l'user via le toast, c'est l'essentiel.
+ }
+ }, []);
+
+ return null;
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 904f4c4..d365711 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -3,6 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { lazy, Suspense } from "react";
+import { VersionToast } from "../components/version-toast";
+
/**
* Devtools — chargés uniquement en dev pour ne pas alourdir le bundle prod.
* Cf. /docs/tech/frontend.md §4.
@@ -47,6 +49,7 @@ function RootLayout() {
},
}}
/>
+
{import.meta.env.DEV && (
diff --git a/apps/web/src/version.ts b/apps/web/src/version.ts
new file mode 100644
index 0000000..f2781d2
--- /dev/null
+++ b/apps/web/src/version.ts
@@ -0,0 +1,17 @@
+/**
+ * Version courante de l'app SPA, source de vérité pour :
+ * - le toast "Nouvelle version" () qui compare cette valeur à
+ * `localStorage["rubis:last-seen-version"]` au mount du root
+ * - le release tag Sentry (à brancher si pas déjà fait)
+ *
+ * À bumper manuellement à chaque release, EN MÊME TEMPS qu'on ajoute le .md
+ * correspondant dans `apps/landing/src/content/changelog/.md`. La
+ * même PR contient les deux — sinon le toast pointe sur une ancre absente.
+ *
+ * Convention : semver (major.minor.patch). Le toast affiche `v${APP_VERSION}`
+ * et linke vers `https://rubis.pro/changelog#${APP_VERSION}`.
+ */
+export const APP_VERSION = "1.10.0";
+
+/** URL absolue de la page changelog (utilisée par le toast). */
+export const CHANGELOG_URL = "https://rubis.pro/changelog";