From b0cfeb5fb94238b0bb25c97b19eec07ba1007264 Mon Sep 17 00:00:00 2001 From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:08:33 -0700 Subject: [PATCH] Payments checkout page redesign (#1536) ## Summary This PR redesigns the payments checkout experience around a cleaner split-panel purchase flow, clearer pricing selection, and more focused terminal states for successful and invalid purchase links. The new UI reduces visual noise, makes the selected plan and total amount easier to scan, and keeps test-mode bypass behavior explicit without overwhelming the checkout form. ## Visual Review ### Checkout Flow | Before | After | | --- | --- | | ![Checkout before, dark yearly](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/cc77e112e880357cbab5207250d7adf71b91ef56/checkout-after-dark-yearly.png) | ![Checkout after, dark yearly](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/73d059fec23b3a753e6fbb571738c89b08a49e8e/checkout-before-dark-yearly.png) | | ![Checkout before, dark one-time](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/ddb95309611527fabbd8d333cf7253d7e541b798/checkout-after-dark-onetime.png) | ![Checkout after, dark one-time](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/1628aee6dcf0f0862ee4299dc4ec9531fc4c5059/checkout-before-dark-onetime.png) | | ![Checkout before, light](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/47ed5f6c3592cb6f1fe4f85738f213b7a3c6c1fb/checkout-after-light.png) | ![Checkout after, light](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/3b2956ef85307565734929a2ecff01bf8637615c/checkout-before-light.png) | ### Purchase Result States | Before | After | | --- | --- | | ![Success before](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/a1a7a9123fc119f279a76623756a70459a81f9a7/success-after-dark.png) | ![Success after](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/7cc59b1031574e8b32c3ab7ce06545633525928e/success-before-dark.png) | | ![Invalid code before](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/0bb085481106e64bbd8e5e41cab7f10f9c641039/invalid-code-after-dark.png) | ![Invalid code after](https://gist.githubusercontent.com/Developing-Gamer/322c80442c298e126de04039dfecdad1/raw/442c33fecf1617712417c94bd6b66ff055a6eb65/invalid-code-before-dark.png) | ## What's Changed - Introduces a more focused checkout layout with clearer plan selection, pricing, quantity controls, and total amount display. - Improves test-mode purchase handling with a dedicated bypass action that is visually separate from the pricing controls. - Polishes purchase result screens so success and invalid-code states feel intentional and consistent with the checkout surface. - Improves supporting dashboard flows for checkout URL creation and product included-item handling. ## Notes For Reviewers - The screenshots above are the supplied before/after captures for the main changed surfaces. - The checkout screenshots cover dark mode, light mode, recurring pricing, and one-time pricing. - The result-state screenshots cover both successful test purchases and invalid/expired purchase codes. ## Test Plan - Verified checkout UI in dark and light mode. - Verified yearly and one-time price selection display. - Verified test-mode purchase completion state. - Verified invalid purchase-code state. - Ran dashboard lint locally. ## Summary by CodeRabbit * **New Features** * Redesigned purchase flow with dedicated price options and quantity selector * New payment UI cards and a Payments Not Enabled card * Create checkout URL dialog and inline "Create Item" action for products * **Improvements** * Clearer subscription interval and price labeling; improved free-price detection * Return and checkout pages restyled to design system components * Select dropdown positioning/height and trigger accessibility improved * **Bug Fixes** * Safer item display and validation/error messaging during purchase flows --------- Co-authored-by: Cursor --- .../products/[productId]/page-client.tsx | 66 ++- .../(main)/purchase/[code]/page-client.tsx | 412 +++++++----------- .../(main)/purchase/return/page-client.tsx | 76 ++-- .../components/design-components/select.tsx | 4 +- apps/dashboard/src/components/form-fields.tsx | 10 +- .../src/components/payments/checkout.tsx | 76 ++-- .../payments/create-checkout-dialog.tsx | 174 ++++++-- .../payments/purchase-price-option.tsx | 57 +++ .../payments/purchase-quantity-selector.tsx | 107 +++++ .../src/components/payments/purchase-utils.ts | 48 ++ apps/dashboard/src/components/ui/select.tsx | 7 +- 11 files changed, 676 insertions(+), 361 deletions(-) create mode 100644 apps/dashboard/src/components/payments/purchase-price-option.tsx create mode 100644 apps/dashboard/src/components/payments/purchase-quantity-selector.tsx create mode 100644 apps/dashboard/src/components/payments/purchase-utils.ts diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 29f251219..d817facea 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -3,6 +3,7 @@ import { DesignEditableGrid, type DesignEditableGridItem } from "@/components/design-components"; import { EditableInput } from "@/components/editable-input"; import { Link, StyledLink } from "@/components/link"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { useRouter } from "@/components/router"; import { ActionCell, @@ -1056,9 +1057,12 @@ type EditingItem = { }; function ProductItemsSection({ productId, product, items, onItemsChange, config, inline = false }: ProductItemsSectionProps) { + const adminApp = useAdminApp(); + const updateConfig = useUpdateConfig(); const [editingItem, setEditingItem] = useState(null); const [isAddingItem, setIsAddingItem] = useState(false); const [selectedItemId, setSelectedItemId] = useState(''); + const [showCreateItemDialog, setShowCreateItemDialog] = useState(false); // Get all available items for this customer type const availableItems = useMemo(() => { @@ -1089,6 +1093,34 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config, setSelectedItemId(''); }; + const openCreateItemDialog = () => { + setEditingItem(null); + setIsAddingItem(false); + setSelectedItemId(''); + setShowCreateItemDialog(true); + }; + + const handleCreateAndAddItem = async (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => { + const success = await updateConfig({ + adminApp, + configUpdate: { [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }, + pushable: true, + }); + if (!success) { + return; + } + + onItemsChange({ + ...items, + [item.id]: { + quantity: 1, + repeat: 'never', + expires: 'never', + }, + }); + toast({ title: "Item created", description: "Save product changes to keep it included." }); + }; + const handleDeleteItem = (itemId: string) => { const { [itemId]: _, ...remainingItems } = items; onItemsChange(remainingItems); @@ -1159,8 +1191,10 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config, ) : (
{itemEntries.map(([itemId, item]) => { - const itemConfig = config.payments.items[itemId]; - const displayName = itemConfig.displayName || itemId; + const itemConfig = Object.prototype.hasOwnProperty.call(config.payments.items, itemId) + ? config.payments.items[itemId] + : undefined; + const displayName = itemConfig?.displayName || itemId; return (
{prettyPrintWithMagnitudes(item.quantity)}× @@ -1242,14 +1276,8 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config, No items available for this customer type.

- Create items in the Items list first before adding them to a product. + Create an item now and it will be added to this product automatically.

-
) : editingItem && (
@@ -1355,19 +1383,34 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config, }}> Cancel - ); + const createItemDialog = ( + + ); + if (inline) { return ( <> {listContent} {itemDialog} + {createItemDialog} ); } @@ -1377,6 +1420,7 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config,

Included Items

{listContent} {itemDialog} + {createItemDialog}
); } diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index ea80cd28e..c4301bb91 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -1,17 +1,23 @@ "use client"; -import { CheckoutForm, TestModeBypassForm } from "@/components/payments/checkout"; +import { CheckoutForm, PaymentsNotEnabledCard, TestModeBypassForm } from "@/components/payments/checkout"; +import { PurchasePriceOption } from "@/components/payments/purchase-price-option"; +import { PurchaseQuantitySelector } from "@/components/payments/purchase-quantity-selector"; +import { isFreePrice, shortenedInterval } from "@/components/payments/purchase-utils"; import { StripeElementsProvider } from "@/components/payments/stripe-elements-provider"; -import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Input, Skeleton, Typography } from "@/components/ui"; +import { DesignAlert } from "@/components/design-components/alert"; +import { DesignCard } from "@/components/design-components/card"; +import { Skeleton, Typography } from "@/components/ui"; import { getPublicEnvVar } from "@/lib/env"; -import { MinusIcon, PlusIcon } from "@phosphor-icons/react"; +import { XCircleIcon } from "@phosphor-icons/react"; import { inlineProductSchema } from "@hexclave/shared/dist/schema-fields"; import { throwErr } from "@hexclave/shared/dist/utils/errors"; import { typedEntries } from "@hexclave/shared/dist/utils/objects"; -import Image from 'next/image'; +import Image from "next/image"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; + type ProductData = { product?: Omit, "included_items" | "server_only"> & { stackable: boolean }, stripe_account_id: string | null, @@ -25,6 +31,7 @@ type ProductData = { const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); const baseUrl = new URL("/api/v1", apiUrl).toString(); +const MAX_STRIPE_AMOUNT_CENTS = 999_999 * 100; export default function PageClient({ code }: { code: string }) { const [data, setData] = useState(null); @@ -50,8 +57,6 @@ export default function PageClient({ code }: { code: string }) { return Math.round(Number(data.product.prices[selectedPriceId].USD) * 100); }, [data, selectedPriceId]); - const MAX_STRIPE_AMOUNT_CENTS = 999_999 * 100; - const rawAmountCents = useMemo(() => { return unitCents * Math.max(0, quantityNumber); }, [unitCents, quantityNumber]); @@ -63,7 +68,7 @@ export default function PageClient({ code }: { code: string }) { if (rawAmountCents < 1) return unitCents; if (isTooLarge) return MAX_STRIPE_AMOUNT_CENTS; return rawAmountCents; - }, [unitCents, rawAmountCents, isTooLarge, MAX_STRIPE_AMOUNT_CENTS]); + }, [unitCents, rawAmountCents, isTooLarge]); const elementsMode = useMemo<"subscription" | "payment">(() => { if (!selectedPriceId || !data?.product?.prices) return "subscription"; @@ -71,51 +76,11 @@ export default function PageClient({ code }: { code: string }) { return price.interval ? "subscription" : "payment"; }, [data, selectedPriceId]); - const shortenedInterval = (interval: [number, string]) => { - if (interval[0] === 1) { - return interval[1]; - } - return `${interval[0]} ${interval[1]}s`; - }; - - const getPriceLabel = (interval: [number, string] | undefined): string => { - if (!interval) { - return "One-time"; - } - const [count, unit] = interval; - - if (count === 1) { - if (unit === "day") { - return "Daily"; - } else if (unit === "week") { - return "Weekly"; - } else if (unit === "month") { - return "Monthly"; - } else if (unit === "year") { - return "Yearly"; - } else { - return `Every ${unit}`; - } - } - - if (unit === "day") { - return `Every ${count} days`; - } else if (unit === "week") { - return `Once every ${count} weeks`; - } else if (unit === "month") { - return `Every ${count} months`; - } else if (unit === "year") { - return `Every ${count} years`; - } else { - return `Every ${count} ${unit}s`; - } - }; - const validateCode = useCallback(async () => { const response = await fetch(`${baseUrl}/payments/purchases/validate-code`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ full_code: code, @@ -123,39 +88,42 @@ export default function PageClient({ code }: { code: string }) { }), }); if (!response.ok) { - throw new Error('Failed to validate code'); + throw new Error("Failed to validate code"); } const result = await response.json(); setData(result); if (result?.product?.prices) { - const firstPriceId = Object.keys(result.product.prices)[0]; - setSelectedPriceId(firstPriceId); + const priceIds = Object.keys(result.product.prices); + if (priceIds.length > 0) { + setSelectedPriceId(priceIds[0]); + } } }, [code, returnUrl]); useEffect(() => { setLoading(true); validateCode().catch((err) => { - setError(err instanceof Error ? err.message : 'An error occurred'); + setError(err instanceof Error ? err.message : "An error occurred"); }).finally(() => { setLoading(false); }); }, [validateCode]); - // True iff the price the user is about to purchase is $0. The backend - // intentionally omits client_secret for $0 subs (Stripe activates them - // synchronously, nothing to confirm), so this drives both the - // missing-secret-is-ok check below and the skip-Stripe-Elements branch in const isFreeSelected = useMemo(() => { if (!selectedPriceId || !data?.product?.prices) return false; const usd = data.product.prices[selectedPriceId].USD; - return usd === "0" || usd === "0.00"; + return isFreePrice(usd); + }, [data, selectedPriceId]); + + const selectedPriceData = useMemo(() => { + if (!selectedPriceId || !data?.product?.prices) return null; + return data.product.prices[selectedPriceId]; }, [data, selectedPriceId]); const setupSubscription = async () => { const response = await fetch(`${baseUrl}/payments/purchases/purchase-session`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ full_code: code, price_id: selectedPriceId, quantity: quantityNumber }), }); const result = await response.json(); @@ -175,12 +143,12 @@ export default function PageClient({ code }: { code: string }) { return; } const response = await fetch(`${baseUrl}/internal/payments/test-mode-purchase-session`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ full_code: code, price_id: selectedPriceId, - quantity: quantityNumber + quantity: quantityNumber, }), }); if (!response.ok) { @@ -195,235 +163,181 @@ export default function PageClient({ code }: { code: string }) { window.location.assign(url.toString()); }, [code, selectedPriceId, quantityNumber, isTooLarge, returnUrl]); + const checkoutDisabled = quantityNumber < 1 || isTooLarge || data?.already_bought_non_stackable === true; + const showInvalidPurchaseCode = !loading && error != null; + + if (showInvalidPurchaseCode) { + return ( +
+
+ +
+ +
+
+ + Invalid Purchase Code + + + The purchase code is invalid or has expired. Please check your link and try again. + +
+
+
+
+ ); + } + return ( -
-
-
-
+
+
+ {/* Left Panel: Product & Pricing Selection */} +
+
{loading ? ( -
- - - - -
- ) : error ? ( -
-
- - - -
- Invalid Purchase Code - - The purchase code is invalid or has expired. Please check your link and try again. - +
+ + + + +
) : ( -
+
+ {/* Product Logo */} {data?.project_logo_url && ( -
+
Project logo
)} -
- - {data?.product?.display_name || "Choose Your Plan"} - -
+ {/* Product Name */} + + {data?.product?.display_name || "Choose Your Plan"} + + {/* Prominent Selected Price Display */} + {selectedPriceData && ( +
+
+ + ${selectedPriceData.USD ?? "0.00"} + + {selectedPriceData.interval && ( + + /{shortenedInterval(selectedPriceData.interval)} + + )} +
+
+ )} + + {/* Conflict / Already Purchased Alerts */} {(data?.already_bought_non_stackable || (data?.conflicting_products && data.conflicting_products.length > 0)) && (
{data.already_bought_non_stackable && ( - - Already Purchased - - You already have this product and cannot purchase it again. - - + )} {data.conflicting_products && data.conflicting_products.length > 0 && ( - - Plan Change Detected - - {data.conflicting_products.length === 1 ? ( - <>This purchase will replace your current plan: {data.conflicting_products[0].display_name} - ) : ( - <>This purchase will replace one of your existing plans. - )} - - + )}
)} + {/* Pricing Options */} {data?.product?.prices && typedEntries(data.product.prices).length > 0 && (
- + Select a Pricing Option -
- {typedEntries(data.product.prices).map(([priceId, priceData], index) => ( - + {typedEntries(data.product.prices).map(([priceId, priceData]) => ( + setSelectedPriceId(priceId)} - > - -
-
-
- - {getPriceLabel(priceData.interval)} - - {selectedPriceId === priceId && ( -
- - - -
- )} -
- -
-
- - ${priceData.USD} - - {priceData.interval && ( - - per {shortenedInterval(priceData.interval)} - - )} -
-
-
-
+ priceId={priceId} + priceData={priceData} + selected={selectedPriceId === priceId} + onSelect={setSelectedPriceId} + /> ))}
)} + {/* Stackable Quantity Selector */} {data?.product?.stackable && selectedPriceId && ( -
-
-
- - Quantity - -
- - { - const digitsOnly = e.target.value.replace(/[^0-9]/g, ""); - setQuantityInput(digitsOnly); - }} - /> - -
-
- {(quantityNumber < 1 || isTooLarge) && ( - - {quantityNumber < 1 - ? "Please enter a quantity of at least 1." - : "Amount exceeds the maximum limit of $999,999. Please reduce the quantity."} - - )} -
- -
-
- - Total Amount - -
- - ${selectedPriceId ? (Number(data.product.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)).toFixed(2) : "0.00"} - - {selectedPriceId && data.product.prices[selectedPriceId].interval && ( - - per {shortenedInterval(data.product.prices[selectedPriceId].interval!)} - - )} -
-
-
+
+
)}
)}
-
-
- {data && ( -
- {data.test_mode ? ( - - ) : data.stripe_account_id == null ? ( -
- Payments not enabled -

- This project does not have payments enabled yet. Please contact the app developer to finish setting up payments. -

+ {/* Right Panel: Checkout Form / Payment Details */} +
+
+ {loading ? ( +
+
- ) : ( - - - - )} + ) : data ? ( +
+ {data.test_mode ? ( + + ) : data.stripe_account_id == null ? ( + + ) : ( + + + + )} +
+ ) : null}
- )} +
); diff --git a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx index c21b6ac8d..e945c8f6c 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx @@ -1,10 +1,12 @@ "use client"; import { StyledLink } from "@/components/link"; +import { DesignCard } from "@/components/design-components/card"; +import { Typography } from "@/components/ui"; import { getPublicEnvVar } from "@/lib/env"; import { throwErr } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; -import { Typography } from "@/components/ui"; +import { CheckCircleIcon, SpinnerGapIcon, XCircleIcon } from "@phosphor-icons/react"; import { loadStripe } from "@stripe/stripe-js"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; @@ -104,31 +106,53 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu }, [updateViewState]); return ( -
- {state.kind === "loading" && ( - <> - Finalizing purchase… - Please wait while we verify your payment. - - )} - {state.kind === "success" && ( - <> - Purchase successful - {state.message} - - )} - {state.kind === "error" && ( - <> - Purchase failed - - The following error occurred: "{state.message}" - - - Click here to try making your purchase again. - - - )} +
+ + {state.kind === "loading" && ( + <> +
+ +
+ + Finalizing purchase… + + + Please wait while we verify your payment. + + + )} + + {state.kind === "success" && ( + <> +
+ +
+ + Purchase successful + + + {state.message} + + + )} + + {state.kind === "error" && ( + <> +
+ +
+ + Purchase failed + + + The following error occurred: "{state.message}" + + + Click here to try making your purchase again. + + + )} +
); } - diff --git a/apps/dashboard/src/components/design-components/select.tsx b/apps/dashboard/src/components/design-components/select.tsx index 5153d6732..c596092a1 100644 --- a/apps/dashboard/src/components/design-components/select.tsx +++ b/apps/dashboard/src/components/design-components/select.tsx @@ -27,6 +27,7 @@ export type DesignSelectorDropdownProps = { className?: string, triggerClassName?: string, contentClassName?: string, + triggerId?: string, }; const triggerSizeClasses = new Map([ @@ -53,13 +54,14 @@ export function DesignSelectorDropdown({ className, triggerClassName, contentClassName, + triggerId, }: DesignSelectorDropdownProps) { const triggerSizeClass = getMapValueOrThrow(triggerSizeClasses, size, "triggerSizeClasses"); return (
+