diff --git a/src/components/shared/success-modal.tsx b/src/components/shared/success-modal.tsx index c30ef8b..2a07db2 100644 --- a/src/components/shared/success-modal.tsx +++ b/src/components/shared/success-modal.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { CheckCircle2, X } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { AlertCircle, CheckCircle2, X } from "lucide-react"; interface SuccessModalProps { isOpen: boolean; @@ -9,35 +9,57 @@ interface SuccessModalProps { title: string; message: string; duration?: number; + /** + * `error` change l'icône, l'accent et **désactive la fermeture automatique** : + * un message d'échec doit rester à l'écran le temps d'être lu, et il ne doit + * pas s'afficher sous une coche verte de succès. + */ + variant?: "success" | "error"; + /** Action secondaire optionnelle (ex. « Recharger la page »). */ + action?: { label: string; onClick: () => void }; } +/** Durée de l'animation de sortie, alignée sur les classes `animate-out`. */ +const EXIT_MS = 200; + export function SuccessModal({ isOpen, onClose, title, message, - duration = 3000 + duration = 3000, + variant = "success", + action, }: SuccessModalProps) { - const [isVisible, setIsVisible] = useState(false); + // Seul l'état de SORTIE est porté par un state : l'entrée est jouée par CSS + // au montage (`animate-in`), ce qui évite un setState synchrone dans l'effet. + const [closing, setClosing] = useState(false); + const isError = variant === "error"; + + const handleClose = useCallback(() => { + setClosing(true); + setTimeout(() => { + setClosing(false); + onClose(); + }, EXIT_MS); + }, [onClose]); useEffect(() => { - if (isOpen) { - setIsVisible(true); - const timer = setTimeout(() => handleClose(), duration); - return () => clearTimeout(timer); - } - }, [isOpen, duration]); + // Une erreur ne disparaît pas toute seule : l'utilisateur la ferme. + if (!isOpen || isError) return; + const timer = setTimeout(() => handleClose(), duration); + return () => clearTimeout(timer); + }, [isOpen, duration, isError, handleClose]); - const handleClose = () => { - setIsVisible(false); - setTimeout(onClose, 250); - }; + if (!isOpen) return null; - if (!isOpen && !isVisible) return null; + const state = closing ? "closed" : "open"; + const Icon = isError ? AlertCircle : CheckCircle2; return (
{/* Backdrop */}
{/* Accent bar */} -
+
{/* Close button */} + )}
diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 07df5a8..dbf5b7d 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -7,6 +7,7 @@ import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } fro import { useMemo, useState, useTransition } from "react"; import { saveSnapshot } from "@/features/snapshots/api/save-snapshot"; import { SuccessModal } from "@/components/shared/success-modal"; +import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; import { DropdownMenu, DropdownMenuContent, @@ -37,7 +38,13 @@ export function FloatingSummaryBar() { const [isPending, startTransition] = useTransition(); const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); - const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({ + const [modal, setModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + variant?: "success" | "error"; + action?: { label: string; onClick: () => void }; + }>({ isOpen: false, title: "", message: "" @@ -118,12 +125,26 @@ export function FloatingSummaryBar() { } catch (err) { console.error(err); if (!labelOverride) { - const msg = err instanceof Error ? err.message : String(err); - setModal({ - isOpen: true, - title: "Erreur Snapshot", - message: `Impossible de créer le snapshot : ${msg}` - }); + // Onglet resté ouvert pendant un déploiement : les identifiants de + // Server Action ont changé côté serveur. Rien à réparer côté + // métier, il faut recharger le bundle client. + if (isStaleServerActionError(err)) { + setModal({ + isOpen: true, + title: "Page à recharger", + message: STALE_ACTION_MESSAGE, + variant: "error", + action: { label: "Recharger la page", onClick: () => window.location.reload() }, + }); + } else { + const msg = err instanceof Error ? err.message : String(err); + setModal({ + isOpen: true, + title: "Erreur Snapshot", + message: `Impossible de créer le snapshot : ${msg}`, + variant: "error", + }); + } } } finally { setIsSavingSnapshot(false); @@ -327,6 +348,8 @@ export function FloatingSummaryBar() { onClose={() => setModal(prev => ({ ...prev, isOpen: false }))} title={modal.title} message={modal.message} + variant={modal.variant} + action={modal.action} />
); diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index 8281ee3..5f072bd 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -15,6 +15,7 @@ import { Loader2, } from "lucide-react"; import { SuccessModal } from "@/components/shared/success-modal"; +import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; import { ConfirmModal } from "@/components/shared/confirm-modal"; const formatDate = (date: Date) => { @@ -36,12 +37,34 @@ export function SnapshotList({ type }: SnapshotListProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isDeleting, setIsDeleting] = useState(null); - const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({ + const [modal, setModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + variant?: "success" | "error"; + action?: { label: string; onClick: () => void }; + }>({ isOpen: false, title: "", message: "" }); + /** + * Onglet resté ouvert pendant un déploiement : les identifiants de Server + * Action ont changé côté serveur. On propose le rechargement au lieu + * d'afficher une erreur technique incompréhensible. + */ + const modalErreur = (err: unknown, titre: string, message: string) => + isStaleServerActionError(err) + ? { + isOpen: true, + title: "Page à recharger", + message: STALE_ACTION_MESSAGE, + variant: "error" as const, + action: { label: "Recharger la page", onClick: () => window.location.reload() }, + } + : { isOpen: true, title: titre, message, variant: "error" as const }; + const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; title: string; @@ -59,7 +82,7 @@ export function SnapshotList({ type }: SnapshotListProps) { const data = await getSnapshots(type); setSnapshots(data); } catch (err) { - setError("Impossible de charger les données."); + setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données."); } finally { setLoading(false); } @@ -83,10 +106,10 @@ export function SnapshotList({ type }: SnapshotListProps) { setSnapshots(prev => prev.filter(s => s.id !== id)); setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." }); } else { - setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." }); + setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément.", variant: "error" }); } } catch (err) { - setModal({ isOpen: true, title: "Erreur Technique", message: "Une erreur est survenue lors de la suppression." }); + setModal(modalErreur(err, "Erreur Technique", "Une erreur est survenue lors de la suppression.")); } finally { setIsDeleting(null); } @@ -215,6 +238,8 @@ export function SnapshotList({ type }: SnapshotListProps) { onClose={() => setModal(prev => ({ ...prev, isOpen: false }))} title={modal.title} message={modal.message} + variant={modal.variant} + action={modal.action} />