Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=""

Expand Down
7 changes: 4 additions & 3 deletions app/api/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions app/checkout/components/CheckoutBackButton.tsx
Original file line number Diff line number Diff line change
@@ -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) {

Check warning on line 31 in app/checkout/components/CheckoutBackButton.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaBxooC3RV0_tXUK1zhg&open=AaBxooC3RV0_tXUK1zhg&pullRequest=1508
const router = useRouter();

const handleBack = () => {
if (window.history.length > 1) {
router.back();
} else {
router.replace(sourceHref);
}
};

return (
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className={cn(
"gap-1.5 text-muted-foreground hover:text-foreground",
className,
)}
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
);
}
123 changes: 123 additions & 0 deletions app/checkout/lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 18 additions & 7 deletions app/checkout/plans/class/[planId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -458,12 +466,12 @@ export default function ClassCheckoutPage({
</div>
<p className="font-semibold text-lg mb-2">Unable to load checkout</p>
<p className="text-background/70 text-sm">{error}</p>
<button
onClick={() => window.history.back()}
className="mt-5 inline-flex items-center rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Go back
</button>
<div className="mt-5 flex justify-center">
<CheckoutBackButton
sourceHref={classBackHref}
className="text-background/80 hover:bg-background/10 hover:text-background"
/>
</div>
</div>
</div>
);
Expand All @@ -487,6 +495,9 @@ export default function ClassCheckoutPage({
return (
<>
<div className="flex flex-col gap-6 border-r border-border bg-gradient-to-br from-muted via-background to-muted p-6 sm:p-8">
<div className="flex justify-start">
<CheckoutBackButton sourceHref={classBackHref} />
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<Avatar className="w-12 h-12 border shrink-0">
Expand Down Expand Up @@ -854,7 +865,7 @@ export default function ClassCheckoutPage({
disabled={isMaintenanceBlocked}
/>
) : null}
{process.env.NODE_ENV === "development" && (
{isMockPayEnabled() && (
<Button
variant="secondary"
onClick={() => handleCheckout(gateway.gateway, true)}
Expand Down
27 changes: 19 additions & 8 deletions app/checkout/plans/consultation/[planId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
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,
Expand All @@ -50,6 +51,7 @@ import {
fetchCheckoutWithBusyRetry,
reportPaymentsError,
} from "@/app/checkout/plans/utils";
import { isMockPayEnabled } from "@/app/checkout/plans/mockPay";

// price arrives as number: extended client + JSON serialization (#780)
type ConsultationPlanWithConsultant = Omit<ConsultationPlan, "price"> & {
Expand Down Expand Up @@ -150,6 +152,12 @@ export default function ConsultationCheckoutPage({
blockReason: maintenanceBlockReason,
} = useMaintenanceGuard();

// Smart-back source: the expert profile behind this plan. Falls back to the
// explore directory when the plan failed to load (error state).
const consultantBackHref = eventData?.data?.consultantProfile?.id
? `/explore/experts/${eventData.data.consultantProfile.id}`
: "/explore/experts";

// Validate search params once with Zod — single source of truth for all checkout flows
const validatedSearchParams = useMemo((): ConsultationSearchParams | null => {
const result =
Expand Down Expand Up @@ -588,12 +596,12 @@ export default function ConsultationCheckoutPage({
</div>
<p className="font-semibold text-lg mb-2">Unable to load checkout</p>
<p className="text-background/70 text-sm">{error}</p>
<button
onClick={() => window.history.back()}
className="mt-5 inline-flex items-center rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Go back
</button>
<div className="mt-5 flex justify-center">
<CheckoutBackButton
sourceHref={consultantBackHref}
className="text-background/80 hover:bg-background/10 hover:text-background"
/>
</div>
</div>
</div>
);
Expand All @@ -605,6 +613,9 @@ export default function ConsultationCheckoutPage({
return (
<>
<div className="flex flex-col gap-6 border-r border-border bg-gradient-to-br from-muted via-background to-muted p-6 sm:p-8">
<div className="flex justify-start">
<CheckoutBackButton sourceHref={consultantBackHref} />
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<Avatar className="w-12 h-12 border shrink-0">
Expand Down Expand Up @@ -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() && (
<Button
variant="secondary"
onClick={() => handleCheckout(gateway.gateway, true)}
Expand Down
17 changes: 17 additions & 0 deletions app/checkout/plans/mockPay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Mock-payment gating for dev + Netlify previews only — NEVER production.
*
* Thin, bundle-safe client alias over the canonical server-side check in
* `@/lib/payments/operations/mock` (`shouldEnableMockPayments`). The pages and
* the checkout route use this name for one consistent mood:
*
* - Server (route handlers): `CONTEXT` (deploy-preview / branch-deploy) is
* readable server-side, so preview builds gate directly on it.
* - Client (Mock Pay button render): `CONTEXT` is NOT inlined into client
* bundles (only `NEXT_PUBLIC_*` vars are). `netlify.toml` therefore exports
* `NEXT_PUBLIC_MOCK_PAYMENTS_ENABLED=true` under `[context.deploy-preview]`
* and `[context.branch-deploy]`, which Next inlines at preview build time.
*
* `NODE_ENV === "development"` is the local shortcut and always wins.
*/
export { shouldEnableMockPayments as isMockPayEnabled } from "@/lib/payments/operations/mock";
Loading
Loading