Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
137 changes: 80 additions & 57 deletions apps/web/src/components/checkout/checkout-form.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,46 @@
"use client";

/**
* PWA-S6 (#454) — `<CheckoutForm>` : the body of `/checkout` (US 44, PRD
* §10 PWA Client + decisions-log Q3/Q8).
* PWA-S6 / S7 (#454 / #458) — `<CheckoutForm>` : 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 `<input>` 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 `<input>` controls pre-filled via `decideCheckoutPrefill`. The
* refs let the lazy-loaded `<StripePaymentLazy>` 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
* `<PushEnrollmentModal>` (S6a-c) — no Stripe code is loaded.
* - gate ACTIVE → render `<StripePaymentLazy>` (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 `<StripePaymentSection>` (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";
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,
Expand Down Expand Up @@ -116,6 +120,21 @@ export function CheckoutForm({
}
}, [gate.kind, modalRequested]);

// Refs to the three contact `<input>` so `<StripePaymentLazy>` can
// snapshot the live values at click-Payer time (without forcing the
// parent to lift state out of the uncontrolled inputs).
const firstNameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const phoneRef = useRef<HTMLInputElement>(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.
Expand All @@ -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
// `<StripePaymentLazy>` branch automatically (US 27-38).
setModalRequested(true);
};

return (
Expand All @@ -168,6 +175,7 @@ export function CheckoutForm({
<label className="flex flex-col gap-1">
<span className="text-xs text-zinc-600">Prénom</span>
<input
ref={firstNameRef}
type="text"
name="firstName"
autoComplete="given-name"
Expand All @@ -179,6 +187,7 @@ export function CheckoutForm({
<label className="flex flex-col gap-1">
<span className="text-xs text-zinc-600">Email</span>
<input
ref={emailRef}
type="email"
name="email"
autoComplete="email"
Expand All @@ -191,6 +200,7 @@ export function CheckoutForm({
<label className="flex flex-col gap-1">
<span className="text-xs text-zinc-600">Téléphone</span>
<input
ref={phoneRef}
type="tel"
name="phone"
autoComplete="tel"
Expand All @@ -212,11 +222,30 @@ export function CheckoutForm({
fidélité <strong className="text-zinc-700">KitchenBoost</strong>.
</p>

<PayButton
gate={gate}
totalCentimes={totalsRow.totalCentimes}
onPayClick={onPayClick}
/>
{gate.kind === "active" ? (
// S7 (#458) — gate open → mount the lazy Stripe payment surface.
// The acceptance criterion « Bundle Stripe lazy-loaded sur /checkout »
// is satisfied because `<StripePaymentLazy>` is the ONLY entry
// chain that imports `@stripe/*`, hidden behind `next/dynamic`
// (`ssr: false`) — a customer who never reaches gate=active
// (e.g. exits at /panier) never downloads the Stripe SDK.
<StripePaymentLazy
tenantId={tenantId}
fiche={customer}
customerAddress={customer?.address}
customerLat={customer?.lat}
customerLng={customer?.lng}
getContactValues={getContactValues}
/>
) : (
// S6 — gate disabled : surface the « Payer » CTA that opens the
// enrollment modal. NO Stripe code is loaded in this branch (the
// import lives inside `<StripePaymentLazy>`).
<PayButton
totalCentimes={totalsRow.totalCentimes}
onPayClick={onGateDisabledClick}
/>
)}

<PushEnrollmentModal
open={modalOpen}
Expand All @@ -228,33 +257,27 @@ export function CheckoutForm({
}

/**
* The actual "Payer X €" CTA. Extracted so the gate narrowing happens once
* per render, in one place, and so a future test can mount the button in
* isolation if needed (no jsdom in apps/web V1 → not done here, but the
* boundary is clean).
* The « Payer X € » CTA rendered in the GATE-DISABLED branch (S6a). A
* click opens the `<PushEnrollmentModal>`; once the user enrolls a push
* channel, the parent's Convex sub re-runs `decidePaymentGate`, the
* branch switches to `<StripePaymentLazy>` (S7), and the CTA inside
* `<StripePaymentSection>` takes over.
*
* S6a (#455) — the button is ALWAYS clickable now: a gate-disabled click opens
* the `<PushEnrollmentModal>` (per the parent's `onPayClick`), a gate-active
* click triggers the Stripe payment (S7). The visual styling still
* differentiates the two states so the user sees the gate, but `disabled` is
* deliberately dropped to keep the modal opening on click.
* Extracted so the JSX stays readable; `data-gate="disabled"` is kept as
* a stable selector for the S6a/b/c E2E plan.
*/
function PayButton({
gate,
totalCentimes,
onPayClick,
}: {
gate: ReturnType<typeof decidePaymentGate>;
totalCentimes: number;
onPayClick: () => void;
}): React.JSX.Element {
const isActive = gate.kind === "active";
return (
<button
type="button"
onClick={onPayClick}
aria-disabled={!isActive}
data-gate={isActive ? "active" : "disabled"}
data-gate="disabled"
className="rounded-lg bg-emerald-700 px-4 py-3 text-base font-semibold text-white hover:bg-emerald-800"
>
Payer {formatEur(totalCentimes)}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/checkout/stripe-payment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* PWA-S7 (#458) — `stripe-payment` UI module API.
*
* The PARENT `<CheckoutForm>` imports `<StripePaymentLazy>` ONLY — the
* `next/dynamic` boundary ensures the Stripe SDK ships in a SEPARATE
* chunk fetched only when `/checkout` mounts (acceptance criterion #458
* lazy load).
*/
export { type CheckoutContactValues } from "./stripe-payment-section";
export { StripePaymentLazy } from "./stripe-payment-lazy";
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

/**
* PWA-S7 (#458) — `<LatchingConfirmModal>` — the blocking confirm modal
* shown when the FRESH `recaptureQuoteAtPayment` verdict returns a higher
* fee than the cached panier fee (US 47, decisions-log Q7 « si fee monte →
* modal `<LatchingConfirmModal>` bloquant »).
*
* Stays open until the user explicitly chooses « Oui » (proceed with the
* higher fee) or « Non » (cancel — back to the cart). Non-skippable Esc /
* click-outside (same Radix primitive contract as
* `<PushEnrollmentModal>`): a stray Esc keystroke should not silently
* accept a price hike.
*
* Pure presentation — the latching DECISION is owned by
* `decideLatchingOutcome` in `lib/stripe-payment/`; this component just
* renders the `confirm-surge` outcome's two values + the two callbacks.
*/
import { Dialog as DialogPrimitive } from "radix-ui";

function formatEur(centimes: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(centimes / 100);
}

export type LatchingConfirmModalProps = {
open: boolean;
/** Cached panier fee (centimes) the user agreed to. */
fromCentimes: number;
/** Fresh fee (centimes) Stripe will charge if confirmed. */
toCentimes: number;
/** User clicked « Oui » — proceed with the higher fee. */
onConfirm: () => void;
/** User clicked « Non » — cancel; the form returns to its idle state. */
onCancel: () => void;
};

export function LatchingConfirmModal({
open,
fromCentimes,
toCentimes,
onConfirm,
onCancel,
}: LatchingConfirmModalProps): React.JSX.Element {
return (
<DialogPrimitive.Root open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60" />
<DialogPrimitive.Content
// Non-skippable: Esc + click-outside both prevented (US 47:
// never silently accept a price hike).
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed left-1/2 top-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-zinc-200 bg-white p-6 shadow-xl"
>
<DialogPrimitive.Title className="text-lg font-semibold text-black">
Le tarif livraison a changé
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-zinc-700">
Le tarif livraison est passé de{" "}
<strong className="text-zinc-900">{formatEur(fromCentimes)}</strong>{" "}
à <strong className="text-zinc-900">{formatEur(toCentimes)}</strong>
. Confirmes-tu ?
</DialogPrimitive.Description>
<div className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={onConfirm}
className="rounded-lg bg-emerald-700 px-4 py-3 text-base font-semibold text-white hover:bg-emerald-800"
>
Oui, payer {formatEur(toCentimes)} de livraison
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-zinc-300 bg-white px-4 py-3 text-base font-medium text-zinc-800 hover:bg-zinc-50"
>
Non, annuler
</button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
65 changes: 65 additions & 0 deletions apps/web/src/components/checkout/stripe-payment/new-card-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

/**
* PWA-S7 (#458) — `<NewCardPanel>` — the new-card branch (US 46): Stripe
* Payment Element on the resto's CONNECTED account.
*
* Renders inside the `<StripeElementsProvider>` (deferred-intent mode), so
* `<PaymentElement>` auto-shows the available wallet buttons (Apple Pay,
* Google Pay) + the card form (acceptance criterion #458 « Apple Pay
* button visible iOS »). The actual confirm flow is owned by the parent
* `<StripePaymentSection>` — this panel just renders the Element.
*
* ── « Sauvegarder ma carte » — DELIBERATELY NOT RENDERED V1 ─────────────
* The PRD US 46 mentions a « Sauvegarder ma carte » checkbox; the issue
* description lists it in « What to build ». It is NOT rendered in this
* slice because the cross-resto save mechanism (which is the whole VALUE
* of the saved-card UX, ADR-aligned, payment CONTEXT « Stripe Customer
* cross-tenant ») requires a PLATFORM-level Stripe Elements instance to
* collect a PaymentMethod attachable to the platform Customer — and the
* backend `saveCard` action (2.5-D, `packages/backend/convex/lib/stripe/
* savedCard.ts`) EXPECTS exactly that platform PM.
*
* S7 (this slice) charges on the CONNECTED account (direct charge,
* `Stripe-Account: acct_resto`) — the PaymentMethod created here belongs
* to the connected account and CANNOT be re-cloned to other restos. So
* surfacing a checkbox here would either:
* (a) silently save same-resto-only — breaking the « mes prochaines
* commandes » promise across restos;
* (b) save nothing — silently lie to the user.
*
* Both options violate the « no shortcut fixes » project rule. The
* checkbox lands in a follow-up slice that:
* - adds `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` usage WITHOUT
* `stripeAccount` for a second platform-context Elements instance;
* - branches the new-card flow into « one-shot » vs « collect + save »
* paths (the save path mounts platform Elements, calls
* `stripe.createPaymentMethod({ elements })`, persists via the
* already-merged `saveCard` mutation, then proceeds with
* `payWithSavedCard`);
* - SoT-side, the consumption path is already complete here through
* `<SavedCardPanel>` — a customer who acquired a saved card by any
* means (e.g. a future « save » slice OR a manual ops seed) sees it
* pre-selected as the tile.
*
* Tracking: docs/contexts/payment/CONTEXT.md + parent issue #458
* follow-up note in the PR body.
*/
import { PaymentElement } from "@stripe/react-stripe-js";

export function NewCardPanel(): React.JSX.Element {
return (
<div className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-4">
<PaymentElement
options={{
layout: { type: "tabs", defaultCollapsed: false },
// Apple Pay / Google Pay are auto-included via
// `automatic_payment_methods` (backend `createPaymentIntent`),
// mirrored client-side here by the default `paymentMethodOrder`
// (the Payment Element promotes the available wallet at the top
// when the device supports it).
}}
/>
</div>
);
}
Loading
Loading