feat(checkin): modale in-app pour confirmer le paiement au login
Permet à l'user de répondre aux check-ins directement dans l'app, sans passer par les liens email. Au mount du layout `_app`, on liste les factures en `awaiting_user_confirmation` et on les présente une par une dans une modale séquentielle : - "Oui, payée" → mark paid + bonus rubis + cancel relances - "Non, en attente" → schedule relances + status → in_relance - "Plus tard" → skip session-only 3 endpoints auth-protected sous /api/v1/checkin/inapp/ (déclarés AVANT le groupe public à token sinon /:token/pending mange /inapp/pending). La modale fait toujours confiance au serveur : queue = pending refetch, display = queue[0], pas de cursor manuel — sinon on saute des factures quand le serveur retire la réponse précédente. Wording rassurant : "Aucune relance ne part sans votre validation". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
89c9a732d6
commit
92a9fac62b
@ -1,5 +1,6 @@
|
||||
import CheckinTask from '#models/checkin_task'
|
||||
import Invoice from '#models/invoice'
|
||||
import InvoiceTransformer from '#transformers/invoice_transformer'
|
||||
import { hashCheckinToken } from '#services/checkin_token'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
||||
@ -7,6 +8,20 @@ import * as clock from '#services/clock'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import env from '#start/env'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
|
||||
/** Garde org-id sur l'auth — partagé avec invoices_controller, gardé inline ici. */
|
||||
function requireOrgId(auth: HttpContext['auth']): string {
|
||||
const user = auth.getUserOrFail()
|
||||
if (!user.organizationId) {
|
||||
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
||||
}
|
||||
return user.organizationId
|
||||
}
|
||||
|
||||
function serializeInvoice(invoice: Invoice) {
|
||||
return new InvoiceTransformer(invoice).toObject()
|
||||
}
|
||||
|
||||
const CHECKIN_TTL_HOURS = 24
|
||||
|
||||
@ -140,4 +155,150 @@ export default class CheckinController {
|
||||
|
||||
return response.redirect(spaRedirectUrl('pending', invoice))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/checkin/inapp/pending — auth requise.
|
||||
*
|
||||
* Retourne les factures en `awaiting_user_confirmation` pour l'org de
|
||||
* l'user courant. La modale de check-in in-app les affiche au login pour
|
||||
* que l'user réponde "payée" / "toujours impayée" sans passer par mail.
|
||||
*
|
||||
* Tri : échéance croissante (les plus anciennes d'abord).
|
||||
*/
|
||||
async inappPending({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const invoices = await Invoice.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('status', 'awaiting_user_confirmation')
|
||||
.preload('client')
|
||||
.preload('plan')
|
||||
.orderBy('due_date', 'asc')
|
||||
|
||||
return response.json({ data: invoices.map(serializeInvoice) })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/checkin/inapp/:invoiceId/paid — auth requise.
|
||||
*
|
||||
* Réponse "oui, payée" en in-app. Effets identiques au flow mail :
|
||||
* - mark facture paid + bonus rubis + cancel relances futures
|
||||
* - mark CheckinTask answered/paid si elle existe (idempotent sinon)
|
||||
* - record activity event
|
||||
*/
|
||||
async inappRespondPaid({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const invoice = await Invoice.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', params.invoiceId)
|
||||
.preload('client')
|
||||
.preload('plan')
|
||||
.first()
|
||||
|
||||
if (!invoice) {
|
||||
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const nowOrg = await clock.now(invoice.organizationId)
|
||||
|
||||
// Mark CheckinTask answered (si elle existe — peut être null si l'user
|
||||
// déclenche le check-in in-app avant que l'email scheduler ait tourné).
|
||||
const task = await CheckinTask.query({ client: trx })
|
||||
.where('invoice_id', invoice.id)
|
||||
.whereIn('status', ['scheduled', 'sent'])
|
||||
.first()
|
||||
if (task) {
|
||||
task.useTransaction(trx)
|
||||
task.status = 'answered'
|
||||
task.answer = 'paid'
|
||||
task.answeredAt = nowOrg
|
||||
await task.save()
|
||||
}
|
||||
|
||||
if (invoice.status !== 'paid') {
|
||||
invoice.useTransaction(trx)
|
||||
invoice.status = 'paid'
|
||||
invoice.paidAt = nowOrg
|
||||
invoice.rubisEarned = invoice.rubisEarned + 1
|
||||
await invoice.save()
|
||||
|
||||
await trx
|
||||
.from('organizations')
|
||||
.where('id', invoice.organizationId)
|
||||
.increment('rubis_count', 1)
|
||||
|
||||
await recordActivity({
|
||||
organizationId: invoice.organizationId,
|
||||
kind: 'invoice_paid',
|
||||
label: `Facture <b>${invoice.numero}</b> marquée encaissée via confirmation`,
|
||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||
trx,
|
||||
})
|
||||
|
||||
await cancelFutureRelances(invoice.id, trx)
|
||||
}
|
||||
})
|
||||
|
||||
return response.json({ data: serializeInvoice(invoice) })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/checkin/inapp/:invoiceId/pending — auth requise.
|
||||
*
|
||||
* Réponse "non, toujours impayée" en in-app. Effets :
|
||||
* - mark CheckinTask answered/still_pending si elle existe
|
||||
* - schedule les relances client (BullMQ + RelanceTask)
|
||||
* - bascule invoice.status → in_relance (l'user voit immédiatement
|
||||
* la facture sortir du état d'attente, sans devoir attendre le
|
||||
* premier envoi)
|
||||
* - record activity event
|
||||
*/
|
||||
async inappRespondPending({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const invoice = await Invoice.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', params.invoiceId)
|
||||
.preload('client')
|
||||
.preload('plan', (q) => q.preload('steps'))
|
||||
.first()
|
||||
|
||||
if (!invoice) {
|
||||
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const nowOrg = await clock.now(invoice.organizationId)
|
||||
|
||||
const task = await CheckinTask.query({ client: trx })
|
||||
.where('invoice_id', invoice.id)
|
||||
.whereIn('status', ['scheduled', 'sent'])
|
||||
.first()
|
||||
if (task) {
|
||||
task.useTransaction(trx)
|
||||
task.status = 'answered'
|
||||
task.answer = 'still_pending'
|
||||
task.answeredAt = nowOrg
|
||||
await task.save()
|
||||
}
|
||||
|
||||
if (invoice.planId) {
|
||||
invoice.useTransaction(trx)
|
||||
await scheduleRelancesForInvoice(invoice, trx)
|
||||
}
|
||||
|
||||
invoice.useTransaction(trx)
|
||||
invoice.status = 'in_relance'
|
||||
await invoice.save()
|
||||
|
||||
await recordActivity({
|
||||
organizationId: invoice.organizationId,
|
||||
kind: 'relance_sent',
|
||||
label: `Relances activées pour la facture <b>${invoice.numero}</b>`,
|
||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||
trx,
|
||||
})
|
||||
})
|
||||
|
||||
return response.json({ data: serializeInvoice(invoice) })
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,33 @@ router
|
||||
.prefix('auth')
|
||||
.as('auth')
|
||||
|
||||
/**
|
||||
* Check-in in-app — auth requise. La modale au login liste les factures
|
||||
* en attente de confirmation et permet à l'user d'y répondre directement
|
||||
* sans passer par l'email.
|
||||
*
|
||||
* IMPORTANT : ce groupe doit être déclaré AVANT le groupe public à token,
|
||||
* sinon `/checkin/:token/pending` mange `/checkin/inapp/pending` (token
|
||||
* littéral = "inapp") et redirige vers le SPA en 302.
|
||||
*/
|
||||
router
|
||||
.group(() => {
|
||||
router
|
||||
.get('pending', [controllers.Checkin, 'inappPending'])
|
||||
.as('pending')
|
||||
router
|
||||
.post(':invoiceId/paid', [controllers.Checkin, 'inappRespondPaid'])
|
||||
.as('paid')
|
||||
.where('invoiceId', router.matchers.uuid())
|
||||
router
|
||||
.post(':invoiceId/pending', [controllers.Checkin, 'inappRespondPending'])
|
||||
.as('pending.post')
|
||||
.where('invoiceId', router.matchers.uuid())
|
||||
})
|
||||
.prefix('checkin/inapp')
|
||||
.as('checkin.inapp')
|
||||
.use(middleware.auth())
|
||||
|
||||
/**
|
||||
* Check-in — public (pas d'auth Bearer). Token signé en URL,
|
||||
* lookup hash en DB. Redirige vers le SPA avec ?checkin=... pour
|
||||
|
||||
233
apps/web/src/components/checkin/InAppCheckinModal.tsx
Normal file
233
apps/web/src/components/checkin/InAppCheckinModal.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, AlertCircle, ArrowRight, FileText, Calendar } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
usePendingCheckins,
|
||||
useCheckinPaid,
|
||||
useCheckinStillPending,
|
||||
type PendingCheckinInvoice,
|
||||
} from "@/lib/checkin";
|
||||
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
|
||||
* `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
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
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<Set<string>>(new Set());
|
||||
|
||||
// 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<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return sessionStorage.getItem(SESSION_DISMISS_KEY) === "1";
|
||||
});
|
||||
|
||||
// Queue = pending serveur, moins les skippés locaux. Ordre serveur
|
||||
// (échéance asc) préservé.
|
||||
const queue = useMemo<PendingCheckinInvoice[]>(
|
||||
() => pending.filter((p) => !skipped.has(p.id)),
|
||||
[pending, skipped],
|
||||
);
|
||||
const current = queue[0];
|
||||
const totalSeen = pending.length; // utilisé pour le compteur "X / Y"
|
||||
const positionLeft = queue.length;
|
||||
|
||||
const shouldOpen = !isLoading && !dismissed && queue.length > 0;
|
||||
|
||||
const handleClose = () => {
|
||||
sessionStorage.setItem(SESSION_DISMISS_KEY, "1");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const onPaid = () => {
|
||||
if (!current) return;
|
||||
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."),
|
||||
});
|
||||
};
|
||||
|
||||
const onStillPending = () => {
|
||||
if (!current) return;
|
||||
stillPendingMutation.mutate(current.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Relances activées pour ${current.numero}.`);
|
||||
},
|
||||
onError: () =>
|
||||
toast.error("Impossible de programmer les relances. Réessayez."),
|
||||
});
|
||||
};
|
||||
|
||||
const onSkip = () => {
|
||||
if (!current) return;
|
||||
setSkipped((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(current.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
open={shouldOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent maxWidth={520}>
|
||||
<DialogHeader>
|
||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
||||
Confirmation · {cursorLabel}
|
||||
</p>
|
||||
<DialogTitle className="mt-1">
|
||||
Avez-vous été <em className="text-rubis not-italic">payé</em> sur cette facture ?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1.5">
|
||||
Aucune relance ne part sans votre validation. Si la facture est
|
||||
réglée, on évite l'email inutile et on encaisse +1 rubis.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<InvoiceCard invoice={current} />
|
||||
|
||||
<div className="mt-5 flex flex-col gap-2.5">
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
loading={paidMutation.isPending}
|
||||
disabled={isPending}
|
||||
onClick={onPaid}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Check size={15} aria-hidden="true" />
|
||||
Oui — la facture est payée
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
loading={stillPendingMutation.isPending}
|
||||
disabled={isPending}
|
||||
onClick={onStillPending}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<AlertCircle size={15} aria-hidden="true" />
|
||||
Non — toujours en attente, lance les relances
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
"text-[12.5px] text-ink-3 hover:text-rubis underline-offset-4 hover:underline cursor-pointer",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
Plus tard — passer à la suivante
|
||||
</button>
|
||||
{remaining > 0 && (
|
||||
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1">
|
||||
<ArrowRight size={11} aria-hidden="true" />
|
||||
{remaining} autre{remaining > 1 ? "s" : ""} après
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Petite fiche récap de la facture concernée. */
|
||||
function InvoiceCard({ invoice }: { invoice: PendingCheckinInvoice }) {
|
||||
const isLate = isOverdue(invoice.dueDate);
|
||||
const dueLabel = formatDueDelta(invoice.dueDate);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-line bg-white px-4 py-3.5">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" />
|
||||
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
|
||||
{invoice.numero}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink">
|
||||
{formatEuros(invoice.amountTtcCents)}
|
||||
</p>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums">
|
||||
<Calendar size={11} aria-hidden="true" />
|
||||
<span>échue le {formatDate(invoice.dueDate)}</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 text-[11.5px] font-medium tabular-nums",
|
||||
isLate ? "text-rubis-deep" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{dueLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{invoice.planName && (
|
||||
<p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3">
|
||||
Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/lib/checkin.ts
Normal file
62
apps/web/src/lib/checkin.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import type { InvoiceStatus } from "@rubis/shared";
|
||||
|
||||
/**
|
||||
* Forme minimale renvoyée par GET /api/v1/checkin/inapp/pending — basée sur
|
||||
* InvoiceTransformer côté API. On ne garde que ce dont la modale a besoin.
|
||||
*/
|
||||
export type PendingCheckinInvoice = {
|
||||
id: string;
|
||||
numero: string;
|
||||
amountTtcCents: number;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
status: InvoiceStatus;
|
||||
clientName: string;
|
||||
planName: string | null;
|
||||
};
|
||||
|
||||
/** Liste des factures en attente de check-in pour l'org courante. */
|
||||
export function usePendingCheckins() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.checkin.pending(),
|
||||
queryFn: () =>
|
||||
api.get<PendingCheckinInvoice[]>("/api/v1/checkin/inapp/pending"),
|
||||
// Pas de polling — la liste change uniquement quand l'user répond ou
|
||||
// qu'une nouvelle invoice arrive en awaiting_user_confirmation. On
|
||||
// refetch sur mount + sur invalidate.
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mutation : "oui, payée" — délègue à l'endpoint inappRespondPaid. */
|
||||
export function useCheckinPaid() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (invoiceId: string) =>
|
||||
api.post(`/api/v1/checkin/inapp/${invoiceId}/paid`),
|
||||
onSuccess: () => {
|
||||
// Tout l'écosystème dépend du statut : invalidate large (factures,
|
||||
// dashboard KPIs, timeseries, pipeline, counts).
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.checkin.pending() });
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.invoices.all() });
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mutation : "non, toujours impayée" — programme les relances. */
|
||||
export function useCheckinStillPending() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (invoiceId: string) =>
|
||||
api.post(`/api/v1/checkin/inapp/${invoiceId}/pending`),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.checkin.pending() });
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.invoices.all() });
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -25,4 +25,8 @@ export const queryKeys = {
|
||||
kpis: () => ["dashboard", "kpis"] as const,
|
||||
activity: () => ["dashboard", "activity"] as const,
|
||||
},
|
||||
checkin: {
|
||||
all: () => ["checkin"] as const,
|
||||
pending: () => ["checkin", "pending"] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -2,6 +2,7 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
import { authStore } from "@/lib/auth";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { InAppCheckinModal } from "@/components/checkin/InAppCheckinModal";
|
||||
|
||||
/**
|
||||
* `_app` — layout pathless pour l'app authentifiée.
|
||||
@ -29,6 +30,7 @@ function AppRouteComponent() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
<InAppCheckinModal />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user