From 6951ebc97b0ca19578487c9b5b6ac90d09d5af72 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 3 Feb 2026 14:55:53 +0000 Subject: [PATCH] chore: sandbox & effective period logic --- .../checkout/CheckoutBackground.tsx | 17 ++++++-- .../components/checkout/PlanGroupSection.tsx | 40 ++++++++++--------- .../src/components/checkout/SandboxBanner.tsx | 40 +++++++++++++++++++ apps/checkout/src/index.css | 3 ++ apps/checkout/src/pages/CheckoutPage.tsx | 6 +-- .../src/utils/buildHeaderDescription.ts | 8 ++-- apps/checkout/src/utils/formatUtils.ts | 12 +++++- .../lineItems/lineItemToPreviewLineItem.ts | 1 + .../checkouts/handlers/handleGetCheckout.ts | 1 + .../handlers/handlePreviewCheckout.ts | 1 + .../billing/common/billingPreviewResponse.ts | 6 +++ shared/internal/checkout/checkoutResponses.ts | 1 + 12 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 apps/checkout/src/components/checkout/SandboxBanner.tsx diff --git a/apps/checkout/src/components/checkout/CheckoutBackground.tsx b/apps/checkout/src/components/checkout/CheckoutBackground.tsx index 1c9a0eeb7..4a97989f9 100644 --- a/apps/checkout/src/components/checkout/CheckoutBackground.tsx +++ b/apps/checkout/src/components/checkout/CheckoutBackground.tsx @@ -1,13 +1,19 @@ import type { ReactNode } from "react"; import { motion } from "motion/react"; import { BackgroundBeams } from "@/components/bg/background-beams"; +import { SandboxBanner } from "@/components/checkout/SandboxBanner"; import { SLOW_TRANSITION, SPRING_TRANSITION } from "@/lib/animations"; +interface CheckoutBackgroundProps { + children: ReactNode; + isSandbox?: boolean; +} + /** * Full-screen background wrapper with subtle diagonal gradients from primary color. * Includes entrance animation for the content container. */ -export function CheckoutBackground({ children }: { children: ReactNode }) { +export function CheckoutBackground({ children, isSandbox }: CheckoutBackgroundProps) { return (
{/* Top-right diagonal gradient */} @@ -31,7 +37,7 @@ export function CheckoutBackground({ children }: { children: ReactNode }) { {/* Frosted glass content container */} - {children} + {/* Sandbox banner - outside padding, inside scrolling container */} + {isSandbox && } + {/* Padded content wrapper */} +
+ {children} +
); diff --git a/apps/checkout/src/components/checkout/PlanGroupSection.tsx b/apps/checkout/src/components/checkout/PlanGroupSection.tsx index dc4831abc..deed604f2 100644 --- a/apps/checkout/src/components/checkout/PlanGroupSection.tsx +++ b/apps/checkout/src/components/checkout/PlanGroupSection.tsx @@ -4,7 +4,7 @@ import { motion } from "motion/react"; import { Separator } from "@/components/ui/separator"; import { FAST_TRANSITION } from "@/lib/animations"; import { cn } from "@/lib/utils"; -import { formatAmount } from "@/utils/formatUtils"; +import { formatAmount, formatPeriodRange } from "@/utils/formatUtils"; type PlanChangeType = "incoming" | "outgoing"; @@ -23,7 +23,6 @@ export function PlanGroupSection({ currency, type, }: PlanGroupSectionProps) { - const Icon = type === "outgoing" ? Minus : Plus; const groupTotal = items.reduce((sum, item) => sum + item.amount, 0); // Sort items so base price appears first @@ -33,27 +32,32 @@ export function PlanGroupSection({ return 0; }); + // Extract group-level effective period from the first item with one + // (typically all items in a group share the same billing period) + const groupEffectivePeriod = items.find( + (item) => item.effective_period, + )?.effective_period; + return (
{/* Plan header */} -
-
- - - {planName} +
+
+
+ + {planName} + +
+ + {formatAmount(groupTotal, currency)}
- - {formatAmount(groupTotal, currency)} - + {/* Group-level proration period */} + {groupEffectivePeriod && ( +

+ {formatPeriodRange(groupEffectivePeriod.start, groupEffectivePeriod.end)} +

+ )}
{/* Line items for this plan */} diff --git a/apps/checkout/src/components/checkout/SandboxBanner.tsx b/apps/checkout/src/components/checkout/SandboxBanner.tsx new file mode 100644 index 000000000..c835733fe --- /dev/null +++ b/apps/checkout/src/components/checkout/SandboxBanner.tsx @@ -0,0 +1,40 @@ +import { ArrowRightIcon, FlaskIcon } from "@phosphor-icons/react"; +import { motion } from "motion/react"; + +export function SandboxBanner() { + return ( +
+ {/* Top-right diagonal gradient */} +
+ ); +} diff --git a/apps/checkout/src/index.css b/apps/checkout/src/index.css index 33bb3dec0..f112fe3ae 100644 --- a/apps/checkout/src/index.css +++ b/apps/checkout/src/index.css @@ -38,6 +38,7 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: #504af6; + --sandbox: #0f9bff; } .dark { @@ -72,10 +73,12 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.552 0.016 285.938); + --sandbox: #0f9bff; } @theme inline { --font-sans: "Inter", "Inter Fallback", sans-serif; + --color-sandbox: var(--sandbox); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); diff --git a/apps/checkout/src/pages/CheckoutPage.tsx b/apps/checkout/src/pages/CheckoutPage.tsx index 21cf5af8e..0259882ca 100644 --- a/apps/checkout/src/pages/CheckoutPage.tsx +++ b/apps/checkout/src/pages/CheckoutPage.tsx @@ -177,7 +177,7 @@ export function CheckoutPage() { } // Main checkout view - same structure for loading and loaded states - const { preview, incoming, outgoing, org } = checkoutData ?? {}; + const { env, preview, incoming, outgoing, org } = checkoutData ?? {}; const currency = preview?.currency ?? "usd"; const total = preview?.total ?? 0; const isUpdating = previewMutation.isPending; @@ -191,7 +191,7 @@ export function CheckoutPage() { const trialEndDate = incoming?.[0]?.period_end; return ( - + - {hasActiveTrial && preview.next_cycle.starts_at + {preview.next_cycle.starts_at ? `Amount due on ${format(preview.next_cycle.starts_at, "do MMMM yyyy")}` : "Total due next cycle"} diff --git a/apps/checkout/src/utils/buildHeaderDescription.ts b/apps/checkout/src/utils/buildHeaderDescription.ts index 6928d7a8e..33f8a557d 100644 --- a/apps/checkout/src/utils/buildHeaderDescription.ts +++ b/apps/checkout/src/utils/buildHeaderDescription.ts @@ -109,11 +109,11 @@ export function buildHeaderDescription({ let sentence = `${action}. You'll receive a ${creditAmount} credit for unused time on your previous plan.`; if (hasActiveTrial && next_cycle) { - const nextDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy"); const nextAmount = formatAmount(next_cycle.total, currency); sentence += ` Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`; } else if (next_cycle) { - const nextDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy"); const nextAmount = formatAmount(next_cycle.total, currency); sentence += ` Your next charge of ${nextAmount} is on ${nextDate}.`; } @@ -123,14 +123,14 @@ export function buildHeaderDescription({ // Handle free trial (no immediate payment, trial starts) if (hasActiveTrial && next_cycle) { - const nextDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy"); const nextAmount = formatAmount(next_cycle.total, currency); return `${action}. Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`; } // Handle scheduled changes (no immediate charges) if (isScheduledChange) { - const effectiveDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const effectiveDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy"); return `${action} with ${formatAmount(total, currency)} due today. Changes take effect ${effectiveDate}.`; } diff --git a/apps/checkout/src/utils/formatUtils.ts b/apps/checkout/src/utils/formatUtils.ts index 0f7e13723..72fc41f0f 100644 --- a/apps/checkout/src/utils/formatUtils.ts +++ b/apps/checkout/src/utils/formatUtils.ts @@ -8,5 +8,15 @@ export function formatAmount(amount: number, currency: string): string { } export function formatDate(timestamp: number): string { - return format(new Date(timestamp * 1000), "d MMM yyyy"); + return format(new Date(timestamp * 1000), "do MMMM yyyy"); +} + +/** Formats a millisecond timestamp to a readable date (e.g., "3rd February") */ +export function formatPeriodDate(timestampMs: number): string { + return format(new Date(timestampMs), "do MMMM"); +} + +/** Formats a period range from millisecond timestamps (e.g., "Feb 18 – Mar 4") */ +export function formatPeriodRange(startMs: number, endMs: number): string { + return `${formatPeriodDate(startMs)} – ${formatPeriodDate(endMs)}`; } diff --git a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts index 565cdd7cf..1f858ab50 100644 --- a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts @@ -18,5 +18,6 @@ export const lineItemToPreviewLineItem = (line: LineItem): PreviewLineItem => { paid_quantity: line.paid_quantity ?? 1, plan_id: line.context.product.id, deferred_for_trial: line.deferredForTrial, + effective_period: line.context.effectivePeriod, }; }; diff --git a/server/src/internal/checkouts/handlers/handleGetCheckout.ts b/server/src/internal/checkouts/handlers/handleGetCheckout.ts index 0db111f36..0000febdf 100644 --- a/server/src/internal/checkouts/handlers/handleGetCheckout.ts +++ b/server/src/internal/checkouts/handlers/handleGetCheckout.ts @@ -65,6 +65,7 @@ export const handleGetCheckout = createRoute({ }); const response: GetCheckoutResponse = { + env: checkout.env, preview, org: { name: ctx.org.name, diff --git a/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts b/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts index 04bb30232..5ea63adec 100644 --- a/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts +++ b/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts @@ -84,6 +84,7 @@ export const handlePreviewCheckout = createRoute({ }); const response: GetCheckoutResponse = { + env: checkout.env, preview, org: { name: ctx.org.name, diff --git a/shared/api/billing/common/billingPreviewResponse.ts b/shared/api/billing/common/billingPreviewResponse.ts index 050af0fb9..368d1ff85 100644 --- a/shared/api/billing/common/billingPreviewResponse.ts +++ b/shared/api/billing/common/billingPreviewResponse.ts @@ -9,6 +9,12 @@ export const PreviewLineItemSchema = z.object({ paid_quantity: z.number(), plan_id: z.string(), deferred_for_trial: z.boolean().optional(), + effective_period: z + .object({ + start: z.number(), + end: z.number(), + }) + .optional(), }); export type PreviewLineItem = z.infer; diff --git a/shared/internal/checkout/checkoutResponses.ts b/shared/internal/checkout/checkoutResponses.ts index ae05d4db3..c7e7164aa 100644 --- a/shared/internal/checkout/checkoutResponses.ts +++ b/shared/internal/checkout/checkoutResponses.ts @@ -57,6 +57,7 @@ export const CheckoutChangeSchema = z.object({ * GET /checkouts/:checkout_id response */ export const GetCheckoutResponseSchema = z.object({ + env: z.string(), preview: BillingPreviewResponseSchema, org: CheckoutOrgSchema, customer: CheckoutCustomerSchema,