diff --git a/apps/web/src/components/checkin/InAppCheckinModal.tsx b/apps/web/src/components/checkin/InAppCheckinModal.tsx index abc5bdb..139d6a2 100644 --- a/apps/web/src/components/checkin/InAppCheckinModal.tsx +++ b/apps/web/src/components/checkin/InAppCheckinModal.tsx @@ -1,5 +1,13 @@ -import { useMemo, useState } from "react"; -import { Check, AlertCircle, ArrowRight, FileText, Calendar } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { + Check, + AlertCircle, + ArrowRight, + FileText, + Calendar, + X, +} from "lucide-react"; import { toast } from "sonner"; import { @@ -11,64 +19,71 @@ import { import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format"; import { cn } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/Dialog"; import { Button } from "@/components/ui/Button"; -const SESSION_DISMISS_KEY = "rubis.checkin.dismissed"; - /** - * Modale qui se déclenche au login si l'org a des factures en + * Modale qui se déclenche quand l'org a des factures en * `awaiting_user_confirmation`. Pour chacune, l'user répond directement : * - "Oui, payée" → mark paid + cancel relances * - "Non, impayée" → schedule relances + status → in_relance - * - "Plus tard" → on passe à la suivante, on ne touche pas la facture + * - "Plus tard" → skip session-only, on passe à la suivante + * + * UX : + * - Mobile : bottom-sheet qui slide up depuis le bas, full-width avec + * safe-area-inset-bottom, coins arrondis en haut, contenu + * scrollable. Plus de pouce-friendly. + * - Desktop : modale centrée classique, max 540px. * * Stratégie de queue : on s'appuie sur le serveur comme source de vérité. * Après chaque réponse, l'invoice quitte le statut `awaiting_user_confirmation` * et disparaît du refetch. On affiche **toujours queue[0]**, donc la * suivante remonte naturellement à la position 0 — pas de cursor à gérer. * - * Pour "Plus tard" (skip), on garde un set local d'IDs ignorés cette - * session, qu'on filtre côté client (le serveur les retournera toujours - * tant qu'elles sont awaiting_user_confirmation). + * Stratégie de re-pop : + * - X (close) → ferme pour MAINTENANT seulement + * - Refocus / visible → reset le dismiss → si pending non vide après + * refetch automatique TanStack Query, la modale + * re-pop pour rappeler à l'user de répondre. + * - "Plus tard" persist en mémoire le temps de la session — on ne + * re-spame pas la même facture toutes les 5 minutes. */ export function InAppCheckinModal() { const { data: pending = [], isLoading } = usePendingCheckins(); const paidMutation = useCheckinPaid(); const stillPendingMutation = useCheckinStillPending(); - // IDs ignorés cette session (skip "Plus tard"). Persiste en mémoire - // pendant la vie du composant, perdu au refresh — ce qui est le but : - // l'user retombe dessus au prochain login. const [skipped, setSkipped] = useState>(new Set()); + // dismissed = "fermé maintenant" (X). Pas persistant — au refocus de + // la fenêtre, on reset à false pour redonner sa chance à la modale. + const [dismissed, setDismissed] = useState(false); - // sessionStorage flag — true = l'user a explicitement fermé (X), on - // ne ré-ouvre pas tant qu'il ne reload pas l'onglet. - const [dismissed, setDismissed] = useState(() => { - if (typeof window === "undefined") return false; - return sessionStorage.getItem(SESSION_DISMISS_KEY) === "1"; - }); + // Reset le dismiss au refocus de l'onglet ou au retour de visibilité. + // TanStack Query refetch déjà sur focus (par défaut) → si pending + // change pendant qu'on était sur un autre onglet, la modale reflète. + useEffect(() => { + const reset = () => setDismissed(false); + const onVisibility = () => { + if (document.visibilityState === "visible") reset(); + }; + window.addEventListener("focus", reset); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", reset); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); - // Queue = pending serveur, moins les skippés locaux. Ordre serveur - // (échéance asc) préservé. const queue = useMemo( () => pending.filter((p) => !skipped.has(p.id)), [pending, skipped], ); const current = queue[0]; - const totalSeen = pending.length; // utilisé pour le compteur "X / Y" + const totalSeen = pending.length; const positionLeft = queue.length; const shouldOpen = !isLoading && !dismissed && queue.length > 0; const handleClose = () => { - sessionStorage.setItem(SESSION_DISMISS_KEY, "1"); setDismissed(true); }; @@ -77,8 +92,6 @@ export function InAppCheckinModal() { paidMutation.mutate(current.id, { onSuccess: () => { toast.success(`${current.numero} marquée encaissée. + 1 rubis.`); - // Le refetch va retirer cette invoice de pending — current devient - // automatiquement la suivante (queue[0]). }, onError: () => toast.error("Impossible de marquer la facture. Réessayez."), @@ -109,120 +122,181 @@ export function InAppCheckinModal() { const isPending = paidMutation.isPending || stillPendingMutation.isPending; - // Position courante = totalSeen - positionLeft + 1, pour avoir "1/3, 2/3…" - // même si la queue rétrécit après chaque réponse. const cursorLabel = `${totalSeen - positionLeft + 1} / ${totalSeen}`; const remaining = positionLeft - 1; return ( - { if (!open) handleClose(); }} > - - -

- Confirmation · {cursorLabel} -

- - Avez-vous été payé sur cette facture ? - - - Aucune relance ne part sans votre validation. Si la facture est - réglée, on évite l'email inutile et on encaisse +1 rubis. - -
- - - -
- - -
- -
- - {remaining > 0 && ( -

-

+ + - -
+ /> + + {/* Drag handle visuel — exclusivement mobile, signal "tu peux fermer". */} +