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";