updated checkout page (#997)

https://www.loom.com/share/64ad2f97fdd9476ebe5b66202098ec60
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Project logos now display on the purchase page; API surfaces
project_logo_url for the UI.
* Redesigned purchase page with responsive split-panel layout,
selectable pricing grid, quantity controls, and clearer invalid-code
messaging.

* **Tests**
* Added/updated end-to-end tests to cover project logo handling and
validate-code responses.

* **Chores**
  * Updated image-processing dependency to a newer version.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
BilalG1 2025-11-05 16:24:15 -08:00 committed by GitHub
parent b5b311554b
commit 493455434a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 305 additions and 139 deletions

View File

@ -88,7 +88,7 @@
"react-dom": "19.0.0",
"resend": "^6.0.1",
"semver": "^7.6.3",
"sharp": "^0.32.6",
"sharp": "^0.34.4",
"stripe": "^18.3.0",
"svix": "^1.25.0",
"vite": "^6.1.0",

View File

@ -38,6 +38,7 @@ export const POST = createSmartRouteHandler({
product: inlineProductSchema,
stripe_account_id: yupString().defined(),
project_id: yupString().defined(),
project_logo_url: yupString().nullable().defined(),
already_bought_non_stackable: yupBoolean().defined(),
conflicting_products: yupArray(yupObject({
product_id: yupString().defined(),
@ -96,6 +97,7 @@ export const POST = createSmartRouteHandler({
product: productToInlineProduct(product),
stripe_account_id: verificationCode.data.stripeAccountId,
project_id: tenancy.project.id,
project_logo_url: tenancy.project.logo_url ?? null,
already_bought_non_stackable: alreadyBoughtNonStackable,
conflicting_products: conflictingCatalogProducts,
test_mode: tenancy.config.payments.testMode === true,

View File

@ -10,12 +10,14 @@ import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Input,
import { Minus, Plus } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import Image from 'next/image';
import * as yup from "yup";
type ProductData = {
product?: Omit<yup.InferType<typeof inlineProductSchema>, "included_items" | "server_only"> & { stackable: boolean },
stripe_account_id: string,
project_id: string,
project_logo_url: string | null,
already_bought_non_stackable?: boolean,
conflicting_products?: { product_id: string, display_name: string }[],
test_mode: boolean,
@ -77,6 +79,39 @@ export default function PageClient({ code }: { code: string }) {
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',
@ -147,146 +182,219 @@ export default function PageClient({ code }: { code: string }) {
}, [code, selectedPriceId, quantityNumber, isTooLarge, returnUrl]);
return (
<div className="flex flex-row">
<div className="w-1/2 p-6 border-r border-primary/20 h-dvh max-w-md">
{loading ? (
<Skeleton className="w-full h-10" />
) : error ? (
<>
<Typography type="h2" className="mb-2">Invalid URL</Typography>
<Typography type="label" variant="secondary">
The purchase code is invalid or has expired.
</Typography>
</>
) : (
<>
<div className="mb-6">
<Typography type="h2" className="mb-2">{data?.product?.display_name || "Plan"}</Typography>
</div>
<div className="space-y-3">
{data?.already_bought_non_stackable ? (
<Alert variant="destructive">
<AlertTitle>Already purchased</AlertTitle>
<AlertDescription>
You already have this product.
</AlertDescription>
</Alert>
) : data?.conflicting_products && data.conflicting_products.length > 0 ? (
<Alert>
<AlertTitle>Plan change</AlertTitle>
<AlertDescription>
{data.conflicting_products.length === 1 ? (
<>This purchase will change your plan from {data.conflicting_products[0].display_name}.</>
) : (
<>This purchase will change your plan from one of your existing plans.</>
)}
</AlertDescription>
</Alert>
) : null}
{data?.product?.prices && typedEntries(data.product.prices).map(([priceId, priceData]) => (
<Card
key={priceId}
className={`border cursor-pointer transition-colors ${selectedPriceId === priceId ? 'border-blue-500' : 'hover:border-primary/30'}`}
onClick={() => setSelectedPriceId(priceId)}
>
<CardContent className="p-4">
<div className="flex justify-between items-center">
<div>
<Typography type="h4">{priceId}</Typography>
</div>
<div className="text-right">
<Typography type="h3">
${priceData.USD}
{priceData.interval && (
<span className="text-sm text-primary/50">
{" "}/ {shortenedInterval(priceData.interval)}
</span>
)}
</Typography>
</div>
</div>
</CardContent>
</Card>
))}
{data?.product?.stackable && selectedPriceId && (
<div className="pt-4 space-y-2">
<div className="flex items-center justify-between">
<Typography type="label">Quantity</Typography>
<div className="flex items-center gap-2">
<Button
type="button"
size="icon"
variant="outline"
disabled={quantityNumber <= 1}
onClick={() => setQuantityInput(String(Math.max(1, quantityNumber - 1)))}
>
<Minus className="w-4 h-4" />
</Button>
<Input
className="text-center w-20"
inputMode="numeric"
pattern="[0-9]*"
type="text"
value={quantityInput}
onChange={e => {
const digitsOnly = e.target.value.replace(/[^0-9]/g, "");
setQuantityInput(digitsOnly);
}}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => setQuantityInput(String(quantityNumber + 1))}
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
<div className="text-right">
<Typography type="footnote" variant="destructive">
{quantityNumber < 1 ?
"Enter a quantity of at least 1." :
isTooLarge ?
"Amount exceeds maximum of $999,999" :
" "
}
</Typography>
</div>
<div className="pt-4 flex items-baseline justify-between">
<Typography type="label">Total</Typography>
<Typography type="h4">
${selectedPriceId ? (Number(data.product.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)) : 0}
{selectedPriceId && data.product.prices[selectedPriceId].interval && (
<span className="text-sm text-primary/50">
{" "}/ {shortenedInterval(data.product.prices[selectedPriceId].interval!)}
</span>
)}
</Typography>
</div>
<div className="min-h-screen flex flex-col md:flex-row">
<div className="w-full md:w-1/2 flex flex-col bg-background border-b md:border-b-0 md:border-r border-primary/10">
<div className="flex-1 overflow-y-auto">
<div className="max-w-xl mx-auto px-4 py-6 md:py-8">
{loading ? (
<div>
<Skeleton className="h-10 w-10 rounded-lg" />
<Skeleton className="w-3/4 h-7 mt-5" />
<Skeleton className="w-full h-16 mt-5" />
<Skeleton className="w-full h-16 mt-5" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<svg className="w-6 h-6 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
</div>
</>
)}
<Typography type="h2" className="mb-2 text-xl">Invalid Purchase Code</Typography>
<Typography type="p" variant="secondary" className="max-w-md text-sm">
The purchase code is invalid or has expired. Please check your link and try again.
</Typography>
</div>
) : (
<div className="space-y-5">
{data?.project_logo_url && (
<div className="flex items-center">
<Image
src={data.project_logo_url}
alt="Project logo"
className="h-10 w-10 object-contain"
width={40}
height={40}
unoptimized
/>
</div>
)}
<div className="space-y-1">
<Typography type="h2" className="text-xl font-semibold">
{data?.product?.display_name || "Choose Your Plan"}
</Typography>
</div>
{(data?.already_bought_non_stackable || (data?.conflicting_products && data.conflicting_products.length > 0)) && (
<div className="space-y-2">
{data.already_bought_non_stackable && (
<Alert variant="destructive">
<AlertTitle className="text-sm">Already Purchased</AlertTitle>
<AlertDescription className="text-sm">
You already have this product and cannot purchase it again.
</AlertDescription>
</Alert>
)}
{data.conflicting_products && data.conflicting_products.length > 0 && (
<Alert>
<AlertTitle className="text-sm">Plan Change Detected</AlertTitle>
<AlertDescription className="text-sm">
{data.conflicting_products.length === 1 ? (
<>This purchase will replace your current plan: <strong>{data.conflicting_products[0].display_name}</strong></>
) : (
<>This purchase will replace one of your existing plans.</>
)}
</AlertDescription>
</Alert>
)}
</div>
)}
{data?.product?.prices && typedEntries(data.product.prices).length > 0 && (
<div className="space-y-3">
<Typography type="label" className="text-xs font-medium uppercase tracking-wide text-primary/60">
Select a Pricing Option
</Typography>
<div className="grid gap-3">
{typedEntries(data.product.prices).map(([priceId, priceData], index) => (
<Card
key={priceId}
className={`cursor-pointer transition-all duration-200 border-0 ${
selectedPriceId === priceId
? 'outline-2 outline-primary outline'
: 'outline outline-2 outline-primary/20 hover:outline-primary/40'
}`}
onClick={() => setSelectedPriceId(priceId)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<Typography type="h4" className="text-sm font-semibold">
{getPriceLabel(priceData.interval)}
</Typography>
{selectedPriceId === priceId && (
<div className="w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
</div>
<div className="text-right">
<Typography type="h3" className="text-lg font-bold">
${priceData.USD}
</Typography>
{priceData.interval && (
<Typography type="p" variant="secondary" className="text-xs mt-0.5">
per {shortenedInterval(priceData.interval)}
</Typography>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{data?.product?.stackable && selectedPriceId && (
<div className="bg-primary/5 rounded-lg p-4 space-y-4 border border-primary/10">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Typography type="label" className="text-sm font-semibold">
Quantity
</Typography>
<div className="flex items-center gap-2">
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
disabled={quantityNumber <= 1}
onClick={() => setQuantityInput(String(Math.max(1, quantityNumber - 1)))}
>
<Minus className="w-3.5 h-3.5" />
</Button>
<Input
className="text-center w-20 h-8 text-sm font-semibold"
inputMode="numeric"
pattern="[0-9]*"
type="text"
value={quantityInput}
onChange={e => {
const digitsOnly = e.target.value.replace(/[^0-9]/g, "");
setQuantityInput(digitsOnly);
}}
/>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
onClick={() => setQuantityInput(String(quantityNumber + 1))}
>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{(quantityNumber < 1 || isTooLarge) && (
<Typography type="footnote" variant="destructive" className="text-xs">
{quantityNumber < 1
? "Please enter a quantity of at least 1."
: "Amount exceeds the maximum limit of $999,999. Please reduce the quantity."}
</Typography>
)}
</div>
<div className="pt-3 border-t border-primary/10">
<div className="flex items-baseline justify-between">
<Typography type="label" className="text-sm font-semibold">
Total Amount
</Typography>
<div className="text-right">
<Typography type="h2" className="text-xl font-bold">
${selectedPriceId ? (Number(data.product.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)).toFixed(2) : "0.00"}
</Typography>
{selectedPriceId && data.product.prices[selectedPriceId].interval && (
<Typography type="p" variant="secondary" className="text-xs mt-0.5">
per {shortenedInterval(data.product.prices[selectedPriceId].interval!)}
</Typography>
)}
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="grow relative flex justify-center items-center bg-primary/5">
<div className="w-full md:w-1/2 flex flex-grow items-center justify-center bg-gradient-to-br from-primary/5 via-primary/3 to-background p-6 md:p-12">
{data && (
<StripeElementsProvider
stripeAccountId={data.stripe_account_id}
amount={elementsAmountCents}
mode={elementsMode}
>
<CheckoutForm
fullCode={code}
<div className="w-full max-w-lg">
<StripeElementsProvider
stripeAccountId={data.stripe_account_id}
setupSubscription={setupSubscription}
returnUrl={returnUrl ?? undefined}
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true}
chargesEnabled={data.charges_enabled}
onTestModeBypass={data.test_mode ? handleBypass : undefined}
/>
</StripeElementsProvider>
amount={elementsAmountCents}
mode={elementsMode}
>
<CheckoutForm
fullCode={code}
stripeAccountId={data.stripe_account_id}
setupSubscription={setupSubscription}
returnUrl={returnUrl ?? undefined}
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true}
chargesEnabled={data.charges_enabled}
onTestModeBypass={data.test_mode ? handleBypass : undefined}
/>
</StripeElementsProvider>
</div>
)}
</div>
</div>

View File

@ -647,3 +647,53 @@ it("gives an error when updating email_theme with an invalid value", async ({ ex
}
`);
});
it("lets user update logo_url to a valid image", async ({ expect }) => {
await Project.createAndSwitch();
// 1x1 png
const logo_url = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVR4AQEEAPv/ALHBwQRaAjRgT7lCAAAAAElFTkSuQmCC`;
const response = await niceBackendFetch("/api/v1/internal/projects/current", {
method: "PATCH",
accessType: "admin",
body: {
logo_url,
}
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"config": {
"allow_localhost": true,
"allow_team_api_keys": false,
"allow_user_api_keys": false,
"client_team_creation_enabled": false,
"client_user_deletion_enabled": false,
"create_team_on_sign_up": false,
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "<stripped UUID>",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "team_admin" }],
"team_member_default_permissions": [{ "id": "team_member" }],
"user_default_permissions": [],
},
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": "http://localhost:<$NEXT_PUBLIC_STACK_PORT_PREFIX>21/stack-storage/project-logos/<stripped UUID>.png",
"owner_team_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -60,6 +60,7 @@ it("should allow valid code and return offer data", async ({ expect }) => {
"stackable": false,
},
"project_id": "<stripped UUID>",
"project_logo_url": null,
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": false,
},
@ -243,6 +244,7 @@ it("should include conflicting_group_offers when switching within the same group
"stackable": false,
},
"project_id": "<stripped UUID>",
"project_logo_url": null,
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},

View File

@ -354,6 +354,7 @@ it("should return inline product metadata when validating purchase code", async
"stackable": false,
},
"project_id": "<stripped UUID>",
"project_logo_url": null,
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},

View File

@ -60,6 +60,7 @@ it("should allow valid code and return product data", async ({ expect }) => {
"stackable": false,
},
"project_id": "<stripped UUID>",
"project_logo_url": null,
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": false,
},
@ -243,6 +244,7 @@ it("should include conflicting_products when switching within the same group", a
"stackable": false,
},
"project_id": "<stripped UUID>",
"project_logo_url": null,
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
@ -338,6 +340,7 @@ it("should reject untrusted return_url and accept trusted return_url", async ({
"stackable": false,
},
"project_id": "<stripped UUID>",
"project_logo_url": null,
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},

View File

@ -250,8 +250,8 @@ importers:
specifier: ^7.6.3
version: 7.6.3
sharp:
specifier: ^0.32.6
version: 0.32.6
specifier: ^0.34.4
version: 0.34.4
stripe:
specifier: ^18.3.0
version: 18.3.0(@types/node@20.17.6)