diff --git a/.env.sample b/.env.sample index 13c1f52b1..e65d82e0c 100644 --- a/.env.sample +++ b/.env.sample @@ -154,6 +154,12 @@ DEV_BYPASS_AUTH="false" # Defaults to disabled — production must NEVER set this to "true". NEXT_PUBLIC_ENABLE_DEV_TOOLS="false" +# Shows the "Mock Pay" button on checkout plan pages. Netlify deploy previews +# and branch deploys set this automatically via netlify.toml; leave it "false" +# for production. The server route additionally honours a deploy-preview +# CONTEXT server-side (see app/checkout/plans/mockPay.ts). +NEXT_PUBLIC_MOCK_PAYMENTS_ENABLED="false" + # Logo.dev API - company logo stickers for work experience (https://logo.dev) NEXT_PUBLIC_LOGO_DEV_TOKEN="" diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 6d2a41476..f7f7ee0a3 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -24,6 +24,7 @@ import { checkoutLimiter, applyRateLimit } from "@/lib/rate-limit"; import { ZodError } from "zod"; import { Prisma } from "@prisma/client"; import { replayByIdempotencyKey } from "@/lib/payments/operations/checkout-replay"; +import { isMockPayEnabled } from "@/app/checkout/plans/mockPay"; import { routeGateway } from "@/lib/payments/gateway-router"; import { resolveCheckoutTaxContext } from "@/lib/payments/tax/checkout-context"; @@ -47,9 +48,9 @@ export async function POST(req: NextRequest) { // Validate request body const body = await req.json(); const validatedData = checkoutSchema.parse(body); - // Only allow mock payments in development — prevent client-side bypass in production - const isMockPayment = - body.isMockPayment === true && process.env.NODE_ENV === "development"; + // Only allow mock payments in dev or on Netlify preview builds — prevent + // client-side bypass in production. + const isMockPayment = body.isMockPayment === true && isMockPayEnabled(); // #828 — fast-path replay: a double-click / network retry / second tab // with the same key gets the original attempt's response, never a second diff --git a/app/checkout/components/CheckoutBackButton.tsx b/app/checkout/components/CheckoutBackButton.tsx new file mode 100644 index 000000000..29f1efaed --- /dev/null +++ b/app/checkout/components/CheckoutBackButton.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/tailwind"; +import { useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +type CheckoutBackButtonProps = { + /** + * Where to go when there is nothing to go back to (deep link, first-time + * sign-in + onboarding redirect left no in-tab history). The page replaces + * to this — pushing would let a later "Back" land back on checkout. + */ + sourceHref: string; + className?: string; +}; + +/** + * Smart back button shared across checkout. Tries the browser/Next history + * first; falls back to `sourceHref` when the tab has no in-app history + * (`history.length === 1`, e.g. a fresh deep link or a user who was just + * routed through sign-in + onboarding via `router.replace`). + * + * The onboarding-wizard edge is self-healing downstream: an onboarded user who + * somehow lands back on `/form/onboarding` is bounced to `/dashboard` by + * `requireNotOnboarded`, so we never loop them back into the wizard. + */ +export function CheckoutBackButton({ + sourceHref, + className, +}: CheckoutBackButtonProps) { + const router = useRouter(); + + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.replace(sourceHref); + } + }; + + return ( + + ); +} diff --git a/app/checkout/lifecycle.md b/app/checkout/lifecycle.md new file mode 100644 index 000000000..71972c55f --- /dev/null +++ b/app/checkout/lifecycle.md @@ -0,0 +1,123 @@ +# Checkout UI — Back button + Mock Pay on previews + +Change bundle for the checkout surfaces: + +- A shared smart Back button on every checkout page (plan pages, trial) that + survives the "empty history" edge (deep link, or first-time sign-in -> + onboarding redirect where `router.replace` leaves nothing to go back to). +- A shared mock-payment gate so the Mock Pay button works on local dev AND on + Netlify preview builds (`deploy-preview` / `branch-deploy`) — never on + production — both for the server route and the client button. + +This file is the design record; the lifecycle matrix at the bottom is +inventory + intended design (no new check-in/check-out/lookup UI shipped). + +## Back button + +`app/checkout/components/CheckoutBackButton.tsx` — client component: + +``` +on click: + window.history.length > 1 ? router.back() : router.replace(sourceHref) +``` + +- `sourceHref` is per-family: + - consultation -> `/explore/experts/{consultantProfile.id}` (expert profile) + - subscription -> `/explore/programs/plans/subscriptions/{planId}` + - webinar -> `/explore/programs/plans/webinars/{planId}` + - class -> `/explore/programs/plans/classes/{planId}` + - trial -> `/dashboard` (linked from paid-trial widgets / appointment sheet) +- If history is non-empty we go back; if not we REPLACE to the source so a + later "Back" never lands back on checkout. +- The onboarding-wizard edge self-heals downstream: an onboarded user who ends + up back on `/form/onboarding` is bounced to `/dashboard` by + `requireNotOnboarded` (auth-guard.ts) — we never re-loop them into the + wizard, and `requireOnboarded` (checkout layout) handles the forward path. +- Error-state "Go back" now uses the same component (was `window.history.back()`). + +## Mock pay gating + +One source of truth: `shouldEnableMockPayments()` in +`lib/payments/operations/mock.ts`, re-exported as `isMockPayEnabled()` by +`app/checkout/plans/mockPay.ts`: + +``` +NODE_ENV === "development" -> true (local) +CONTEXT === "deploy-preview" | "branch-deploy" -> true (server-side only) +ENABLE_MOCK_PAYMENTS === "true" -> true (ops escape hatch) +NEXT_PUBLIC_MOCK_PAYMENTS_ENABLED === "true" -> true (inlined client-side) +``` + +- The server route (`app/api/checkout/route.ts`) gates on `isMockPayEnabled()` + so a preview build accepts `isMockPayment`. Production (`CONTEXT=production`) + never does. +- The client button gates on the same util. `CONTEXT` is not inlined into + client bundles, so `netlify.toml` exports + `NEXT_PUBLIC_MOCK_PAYMENTS_ENABLED=true` under `[context.deploy-preview]` + and `[context.branch-deploy]`. Preview checkouts are still auth + rate-limited. +- The same check powers `createPaymentIntent`'s mock branch + (`lib/payments/index.ts` → `shouldEnableMockPayments()`). Without it, a + preview would accept `isMockPayment: true` at the route and then fall through + to a REAL gateway call — the mocked path must stay in lockstep with the + route gate. +- Scope on previews is Mock Pay ONLY. Simulating Razorpay/Stripe webhooks on a + preview (to exercise post-capture flows) is a follow-up, described below. + +## What we deliberately did NOT change + +- The 4 plan checkout pages stay client components (interactive discount / + referral / org-payer / tax + gateway modal UI). No server actions on this + money path — `POST /api/checkout` route stays. +- Success / failure pages unchanged (success already redirects to dashboard; + failure has its own back + support CTA). +- No server-component data-fetching refactor here (see follow-ups). + +## Follow-ups (inventory, not shipped) + +1. **Data fetching**: the plan pages read plan/slot/credits via `useEffect` + + `fetch` client-side. Next 16 guidance is server-first: static-ish reads + (plan, reviews, slot) are candidate server fetches passed to client leaves; + personalized reads (credits, tax context, discount validation) and all + mutations stay on the API. Convert carefully, separately, with mock-dev + verification — this is a money path. +2. **Preview webhook simulation**: Razorpay/Stripe capture webhooks are not + simulated on previews, so a Mock Pay on the preview never exercises + webhook-driven post-capture state. Ships only after previews are password / + ACL protected (see next item), otherwise it is an open paid-for-booking + minting surface. +3. **Preview access control**: no preview password/ACL today. Add that before + exposing anything more than the (auth + rate-limited, developer-gated) Mock + Pay button. + +## Lifecycle matrix (status -> dominant action today) + +Design intent for check-in / check-out / lookup: presence is auto-captured by +Stream webhooks into `MeetingAttendance` (`firstJoinedAt`, `lastLeftAt`, +`joinCount`) which feeds no-show detection. No explicit check-in/out UI is +needed — a learner "checking in" IS joining the call, which is recorded. The +dominant surfacing is the dashboard's action item + appointments list, not a +dedicated check-in/out/lookup page. + +`AppointmentStatus` (Booking -> Appointment): + +| Status | Consultee sees / does | Consultant sees / does | +| -------------------------- | ---------------------------------------------- | --------------------------- | +| PENDING | "Awaiting consultant" ; no pay, no join | Allocate slot (#bookings) | +| APPROVED / APPROVED_PENDING_PAYMENT | Pay CTA while unpaid (checkout / pending-payments widget); slot held | Approved; holds slot | +| SCHEDULED | Join CTA in join window (10 min pre-start) | Join CTA (15 min pre-start) | +| COMPLETED | Review/feedback; recording | Summary / earnings | +| REJECTED / CANCELLED / EXPIRED | Refund / re-book affordances, no Join | Slot released back to grid | + +`TrialSessionStatus`: + +| Status | Consultee sees / does | Notes | +| ---------------- | ------------------------------------------- | ------------------------------ | +| PENDING | Waiting on consultant | | +| AWAITING_PAYMENT | Trial checkout (this PR's trial page) | pay-link; `paymentDueAt` window | +| SCHEDULED | Join CTA in join window | trial slot confirmed | +| COMPLETED | Convert CTA (subscribe) or finish | | +| CONVERTED | Active subscription | | +| CANCELLED / REJECTED | Re-request, no Join | expired unpaid lapses here | + +`MeetingAttendance` is written by `lib/stream/session-handlers.ts` from Stream +webhooks; no user-facing check-in/out/lookup page or API is added in this PR. \ No newline at end of file diff --git a/app/checkout/plans/class/[planId]/page.tsx b/app/checkout/plans/class/[planId]/page.tsx index a20a8330f..1850d6cd9 100644 --- a/app/checkout/plans/class/[planId]/page.tsx +++ b/app/checkout/plans/class/[planId]/page.tsx @@ -21,6 +21,7 @@ import { CompanyLogo } from "@/components/ui/company-logo"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import RazorpayCheckout from "../../../components/RazorpayCheckout"; import StripeCheckout from "../../../components/StripeCheckout"; +import { isMockPayEnabled } from "@/app/checkout/plans/mockPay"; import { createHandleApiError, createHandleCheckoutSuccess, @@ -35,6 +36,7 @@ import { useCurrency } from "@/hooks/useCurrency"; import type { AppliedDiscount } from "@/types/checkout"; import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; import { FxEstimateNote } from "@/app/checkout/components/FxEstimateNote"; +import { CheckoutBackButton } from "@/app/checkout/components/CheckoutBackButton"; import { BillingStateSelect, useBillingState, @@ -132,6 +134,12 @@ export default function ClassCheckoutPage({ blockReason: maintenanceBlockReason, } = useMaintenanceGuard(); + // Smart-back source: the class plan's public detail page. Falls back to the + // explore directory when the plan id is unavailable (error state). + const classBackHref = resolvedParams.planId + ? `/explore/programs/plans/classes/${resolvedParams.planId}` + : "/explore"; + // Validate search params once with Zod — single source of truth for all checkout flows const validatedSearchParams = useMemo((): SearchParams | null => { const result = searchParamsSchema.safeParse(resolvedSearchParams); @@ -458,12 +466,12 @@ export default function ClassCheckoutPage({

Unable to load checkout

{error}

- +
+ +
); @@ -487,6 +495,9 @@ export default function ClassCheckoutPage({ return ( <>
+
+ +
@@ -854,7 +865,7 @@ export default function ClassCheckoutPage({ disabled={isMaintenanceBlocked} /> ) : null} - {process.env.NODE_ENV === "development" && ( + {isMockPayEnabled() && (

Unable to load checkout

{error}

- +
+ +
); @@ -605,6 +613,9 @@ export default function ConsultationCheckoutPage({ return ( <>
+
+ +
@@ -1000,8 +1011,8 @@ export default function ConsultationCheckoutPage({ } /> ) : null} - {/* Mock Payment Button - development only */} - {process.env.NODE_ENV === "development" && ( + {/* Mock Payment Button - dev + Netlify previews only */} + {isMockPayEnabled() && (

Unable to load checkout

{error}

- +
+ +
); @@ -532,6 +540,9 @@ export default function SubscriptionCheckoutPage({ return ( <>
+
+ +
@@ -938,7 +949,7 @@ export default function SubscriptionCheckoutPage({ disabled={isMaintenanceBlocked} /> ) : null} - {process.env.NODE_ENV === "development" && ( + {isMockPayEnabled() && (

Unable to load checkout

{error}

- +
+ +
); @@ -607,6 +615,9 @@ export default function WebinarCheckoutPage({ return ( <>
+
+ +
@@ -965,7 +976,7 @@ export default function WebinarCheckoutPage({ disabled={isMaintenanceBlocked || isSoldOut} /> ) : null} - {process.env.NODE_ENV === "development" && ( + {isMockPayEnabled() && (