diff --git a/apps/web/package.json b/apps/web/package.json index afe5e8c2..f38e4c2f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,8 @@ "@googlemaps/js-api-loader": "^2.1.0", "@hookform/resolvers": "^5.2.2", "@packages/backend": "workspace:*", + "@stripe/react-stripe-js": "^6.6.0", + "@stripe/stripe-js": "^9.8.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/checkout/checkout-form.tsx b/apps/web/src/components/checkout/checkout-form.tsx index a67afda3..0f982e46 100644 --- a/apps/web/src/components/checkout/checkout-form.tsx +++ b/apps/web/src/components/checkout/checkout-form.tsx @@ -1,35 +1,35 @@ "use client"; /** - * PWA-S6 (#454) — `` : the body of `/checkout` (US 44, PRD - * §10 PWA Client + decisions-log Q3/Q8). + * PWA-S6 / S7 (#454 / #458) — `` : the body of `/checkout` + * (US 44-49, PRD §10 PWA Client + decisions-log Q3/Q6/Q7/Q8). * - * Owns the React IO around the pure decisions of `lib/checkout-gate`: + * Owns the React IO around the pure decisions of `lib/checkout-gate` + * (S6) and `lib/stripe-payment` (S7): * - `usePreloadedQuery(preloadedCustomer)` → reactive Convex sub on * `customers.pushEnrollment` so a Wallet install / Web Push subscribe - * happening in another tab / from the address-first soft prompt 5 min - * earlier unlocks the "Payer X €" button without ANY reload (acceptance - * criterion #454 « install Wallet pendant client sur page → bouton se - * débloque sans reload »). The RSC parent (`app/checkout/page.tsx`) - * seeds this sub via `preloadQuery` so the gate is evaluated on the SSR - * pass too (no FOUC, no "active → disabled" flash on mount). + * happening in another tab unlocks the "Payer X €" CTA without ANY + * reload (S6 #454). * - `useCart()` → reads the localStorage-backed cart (S5). Empty cart - * on /checkout has no business case → redirect to /panier where the - * « Ton panier est vide » empty state lives (decided by - * `decideCheckoutRedirect`). - * - Form `` controls pre-filled via `decideCheckoutPrefill`. - * - "Payer X €" CTA is the pure decision `decidePaymentGate` reading the - * live `pushEnrollment` snapshot. S6 stub : click logs to console and - * surfaces a "Coming next: modal push enrollment (S6a)" placeholder — - * the actual modal lives in #455 / #456 / #457 and the Stripe payment - * in #458 / S7. + * on /checkout redirects back to /panier. + * - Form `` controls pre-filled via `decideCheckoutPrefill`. The + * refs let the lazy-loaded `` snapshot the live + * values at click-Payer (so a user editing post-render is reflected). + * - Two payment surfaces depending on the gate state: + * - gate DISABLED → render the « Payer » CTA that opens + * `` (S6a-c) — no Stripe code is loaded. + * - gate ACTIVE → render `` (next/dynamic) which + * mounts Stripe Elements + the saved-card / new-card branch (S7). * * Consent at click-Payer (ADR 0007) : the CGV/loyalty wording lives ABOVE * the button, ≥ 12 px (acceptance criterion #454 « Wording CGV visible et * lisible (≥ 12px) »). The button text IS the consent action — no - * checkbox, no separate « J'accepte » step. + * checkbox, no separate « J'accepte » step. The actual `recordConsentAtCheckout` + * mutation is fired by `` (S7) at click-Payer, so + * each successful payment re-stamps the then-active CGV hash (re-consent + * par achat, ADR 0005). */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { usePreloadedQuery, type Preloaded } from "convex/react"; import { api } from "@packages/backend/convex/_generated/api"; @@ -37,6 +37,10 @@ import type { Id } from "@packages/backend/convex/_generated/dataModel"; import { useCart } from "@/components/cart/cart-context"; import { useDeliveryMode } from "@/components/delivery-mode/delivery-mode-context"; import { PushEnrollmentModal } from "@/components/checkout/push-enrollment-modal"; +import { + StripePaymentLazy, + type CheckoutContactValues, +} from "@/components/checkout/stripe-payment"; import { decideCartTotals } from "@/lib/delivery-mode"; import { decideCheckoutPrefill, @@ -116,6 +120,21 @@ export function CheckoutForm({ } }, [gate.kind, modalRequested]); + // Refs to the three contact `` so `` can + // snapshot the live values at click-Payer time (without forcing the + // parent to lift state out of the uncontrolled inputs). + const firstNameRef = useRef(null); + const emailRef = useRef(null); + const phoneRef = useRef(null); + const getContactValues = useCallback( + (): CheckoutContactValues => ({ + firstName: firstNameRef.current?.value ?? "", + email: emailRef.current?.value ?? "", + phone: phoneRef.current?.value ?? "", + }), + [], + ); + if (redirect.kind === "redirect") { // Render nothing while the navigation is en route — avoids a flash of // the empty checkout form before the redirect lands. @@ -131,24 +150,12 @@ export function CheckoutForm({ const modalOpen = modalRequested && gate.kind !== "active"; - const onPayClick = (): void => { - if (gate.kind !== "active") { - // Gate closed → open the enrollment modal (S6a). The CTA `disabled` - // attribute keeps a keyboard user from reaching here on a `disabled` - // gate, but we double-check defensively so a future ref-driven click - // still routes correctly. - setModalRequested(true); - return; - } - // S6 stub — the actual Stripe payment lands in S7 (#458). Logging here - // makes it easy to confirm in the E2E plan that the gate enabled the - // click (vs the button being disabled — onClick wouldn't fire then). - console.log("[PWA-S6] Payer clicked", { - tenantId, - subtotalCentimes: totals.subtotalCentimes, - totalCentimes: totalsRow.totalCentimes, - enrolledChannels: gate.enrolledChannels, - }); + const onGateDisabledClick = (): void => { + // Gate closed → open the enrollment modal (S6a). When the modal lands + // and the user enrolls, the Convex sub re-runs `decidePaymentGate` + // → `gate.kind` flips to "active" → the form switches to the + // `` branch automatically (US 27-38). + setModalRequested(true); }; return ( @@ -168,6 +175,7 @@ export function CheckoutForm({