mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 15:08:12 +02:00
fix(ui): message clair quand l'onglet est périmé après un déploiement
« Server Action "…" was not found on the server » n'est pas une panne du snapshot : Next.js identifie chaque Server Action par un hash calculé à la compilation, et un onglet ouvert avant un nouveau build continue d'envoyer les anciens identifiants. La seule issue est de recharger la page. - src/lib/stale-action.ts : détection de cette erreur + message utilisateur. - Grille et Snapshots : au lieu d'afficher l'erreur technique brute, on propose « Recharger la page ». Les brouillons de la Grille sont persistés (localStorage), recharger ne perd rien. - SuccessModal accepte `variant="error"` : icône d'alerte rouge au lieu de la coche verte, et surtout plus de fermeture automatique au bout de 3 s — un message d'échec doit rester lisible. Les erreurs s'affichaient jusqu'ici sous une coche de succès. - SuccessModal accepte une action secondaire optionnelle, et joue son animation d'entrée en CSS au lieu d'un setState synchrone dans un effet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
4 files changed
+149
-33
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { CheckCircle2, X } from "lucide-react";
|
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||||
|
|
||||||
interface SuccessModalProps {
|
interface SuccessModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -9,35 +9,57 @@ interface SuccessModalProps {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
duration?: number;
|
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({
|
export function SuccessModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
duration = 3000
|
duration = 3000,
|
||||||
|
variant = "success",
|
||||||
|
action,
|
||||||
}: SuccessModalProps) {
|
}: 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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
// Une erreur ne disparaît pas toute seule : l'utilisateur la ferme.
|
||||||
setIsVisible(true);
|
if (!isOpen || isError) return;
|
||||||
const timer = setTimeout(() => handleClose(), duration);
|
const timer = setTimeout(() => handleClose(), duration);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}, [isOpen, duration, isError, handleClose]);
|
||||||
}, [isOpen, duration]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
if (!isOpen) return null;
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(onClose, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen && !isVisible) return null;
|
const state = closing ? "closed" : "open";
|
||||||
|
const Icon = isError ? AlertCircle : CheckCircle2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
|
data-state={state}
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-sm duration-200 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out"
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
@@ -48,11 +70,19 @@ export function SuccessModal({
|
|||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div
|
<div
|
||||||
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
|
data-state={state}
|
||||||
|
className="relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl duration-200 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95"
|
||||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
{/* Accent bar */}
|
{/* Accent bar */}
|
||||||
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
|
<div
|
||||||
|
className="absolute top-0 left-0 w-full h-0.5"
|
||||||
|
style={{
|
||||||
|
background: isError
|
||||||
|
? "var(--accent-error)"
|
||||||
|
: "linear-gradient(to right, var(--accent), var(--accent-success))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
@@ -64,8 +94,15 @@ export function SuccessModal({
|
|||||||
|
|
||||||
<div className="p-8 flex flex-col items-center text-center">
|
<div className="p-8 flex flex-col items-center text-center">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center mb-5">
|
<div
|
||||||
<CheckCircle2 className="w-8 h-8 text-[var(--accent)]" />
|
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
|
||||||
|
style={
|
||||||
|
isError
|
||||||
|
? { background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)" }
|
||||||
|
: { background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="w-8 h-8" style={{ color: isError ? "var(--accent-error)" : "var(--accent)" }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
|
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
|
||||||
@@ -75,11 +112,16 @@ export function SuccessModal({
|
|||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<button onClick={action.onClick} className="apple-btn-primary mt-7 w-full justify-center">
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="apple-btn-primary mt-7 w-full justify-center"
|
className={`${action ? "apple-btn-secondary mt-2" : "apple-btn-primary mt-7"} w-full justify-center`}
|
||||||
>
|
>
|
||||||
Continuer
|
{action ? "Plus tard" : "Continuer"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } fro
|
|||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
||||||
import { SuccessModal } from "@/components/shared/success-modal";
|
import { SuccessModal } from "@/components/shared/success-modal";
|
||||||
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -37,7 +38,13 @@ export function FloatingSummaryBar() {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
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,
|
isOpen: false,
|
||||||
title: "",
|
title: "",
|
||||||
message: ""
|
message: ""
|
||||||
@@ -118,12 +125,26 @@ export function FloatingSummaryBar() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
if (!labelOverride) {
|
if (!labelOverride) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
// Onglet resté ouvert pendant un déploiement : les identifiants de
|
||||||
setModal({
|
// Server Action ont changé côté serveur. Rien à réparer côté
|
||||||
isOpen: true,
|
// métier, il faut recharger le bundle client.
|
||||||
title: "Erreur Snapshot",
|
if (isStaleServerActionError(err)) {
|
||||||
message: `Impossible de créer le snapshot : ${msg}`
|
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 {
|
} finally {
|
||||||
setIsSavingSnapshot(false);
|
setIsSavingSnapshot(false);
|
||||||
@@ -327,6 +348,8 @@ export function FloatingSummaryBar() {
|
|||||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||||
title={modal.title}
|
title={modal.title}
|
||||||
message={modal.message}
|
message={modal.message}
|
||||||
|
variant={modal.variant}
|
||||||
|
action={modal.action}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SuccessModal } from "@/components/shared/success-modal";
|
import { SuccessModal } from "@/components/shared/success-modal";
|
||||||
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||||
import { ConfirmModal } from "@/components/shared/confirm-modal";
|
import { ConfirmModal } from "@/components/shared/confirm-modal";
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
@@ -36,12 +37,34 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
const [isDeleting, setIsDeleting] = useState<number | null>(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,
|
isOpen: false,
|
||||||
title: "",
|
title: "",
|
||||||
message: ""
|
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<{
|
const [confirmModal, setConfirmModal] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -59,7 +82,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
const data = await getSnapshots(type);
|
const data = await getSnapshots(type);
|
||||||
setSnapshots(data);
|
setSnapshots(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Impossible de charger les données.");
|
setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -83,10 +106,10 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
setSnapshots(prev => prev.filter(s => s.id !== id));
|
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." });
|
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
|
||||||
} else {
|
} 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) {
|
} 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 {
|
} finally {
|
||||||
setIsDeleting(null);
|
setIsDeleting(null);
|
||||||
}
|
}
|
||||||
@@ -215,6 +238,8 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
|||||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||||
title={modal.title}
|
title={modal.title}
|
||||||
message={modal.message}
|
message={modal.message}
|
||||||
|
variant={modal.variant}
|
||||||
|
action={modal.action}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* CollectFlow — Détection des Server Actions périmées après un déploiement.
|
||||||
|
*
|
||||||
|
* Next.js identifie chaque Server Action par un hash calculé **à la compilation**.
|
||||||
|
* Après un nouveau build (déploiement, redémarrage du conteneur), les hashs
|
||||||
|
* changent : un onglet resté ouvert continue d'envoyer les anciens identifiants,
|
||||||
|
* et le serveur répond
|
||||||
|
*
|
||||||
|
* Server Action "4010b9…" was not found on the server.
|
||||||
|
*
|
||||||
|
* Ce n'est pas une panne applicative : c'est le client qui est périmé. La seule
|
||||||
|
* issue est de recharger la page pour récupérer le bundle courant.
|
||||||
|
*
|
||||||
|
* Les brouillons de la Grille sont persistés dans le store zustand
|
||||||
|
* (localStorage) : recharger ne perd pas le travail en cours.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Message affiché à l'utilisateur quand son onglet est périmé. */
|
||||||
|
export const STALE_ACTION_MESSAGE =
|
||||||
|
"L'application a été mise à jour depuis l'ouverture de cet onglet. Rechargez la page pour continuer — vos modifications en cours sont conservées.";
|
||||||
|
|
||||||
|
/** true si l'erreur vient d'une Server Action absente du serveur (bundle périmé). */
|
||||||
|
export function isStaleServerActionError(err: unknown): boolean {
|
||||||
|
const message = err instanceof Error ? err.message : String(err ?? "");
|
||||||
|
return /server action/i.test(message) && /not\s+be?\s*found|was not found|failed-to-find-server-action/i.test(message);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user