mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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 | | --- | --- | |  |  | |  |  | |  |  | ### Purchase Result States | Before | After | | --- | --- | |  |  | |  |  | ## 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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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 <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1cbbd5a4e2
commit
b0cfeb5fb9
@ -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<EditingItem | null>(null);
|
||||
const [isAddingItem, setIsAddingItem] = useState(false);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>('');
|
||||
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,
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{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 (
|
||||
<div key={itemId} className="group flex items-center text-sm leading-6">
|
||||
<span className="font-semibold tabular-nums text-foreground">{prettyPrintWithMagnitudes(item.quantity)}×</span>
|
||||
@ -1242,14 +1276,8 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config,
|
||||
No items available for this customer type.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 text-center">
|
||||
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.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setEditingItem(null);
|
||||
setIsAddingItem(false);
|
||||
}}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : editingItem && (
|
||||
<div className="grid gap-4 py-4">
|
||||
@ -1355,19 +1383,34 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config,
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={editingItem ? () => handleSaveItem(editingItem) : undefined}>
|
||||
{isAddingItem ? "Add Item" : "Apply"}
|
||||
<Button
|
||||
onClick={isAddingItem && availableItems.length === 0
|
||||
? openCreateItemDialog
|
||||
: editingItem ? () => handleSaveItem(editingItem) : undefined}
|
||||
>
|
||||
{isAddingItem && availableItems.length === 0 ? "Create Item" : isAddingItem ? "Add Item" : "Apply"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const createItemDialog = (
|
||||
<ItemDialog
|
||||
open={showCreateItemDialog}
|
||||
onOpenChange={setShowCreateItemDialog}
|
||||
onSave={handleCreateAndAddItem}
|
||||
existingItemIds={Object.keys(config.payments.items)}
|
||||
forceCustomerType={product.customerType}
|
||||
/>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<>
|
||||
{listContent}
|
||||
{itemDialog}
|
||||
{createItemDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1377,6 +1420,7 @@ function ProductItemsSection({ productId, product, items, onItemsChange, config,
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Included Items</h3>
|
||||
{listContent}
|
||||
{itemDialog}
|
||||
{createItemDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<yup.InferType<typeof inlineProductSchema>, "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<ProductData | null>(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<boolean>(() => {
|
||||
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 (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-white px-6 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<DesignCard glassmorphic contentClassName="flex flex-col items-center gap-4 p-8">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircleIcon className="size-6 text-destructive" weight="fill" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Typography type="h2" className="mb-2 text-xl font-semibold text-foreground">
|
||||
Invalid Purchase Code
|
||||
</Typography>
|
||||
<Typography type="p" variant="secondary" className="text-sm">
|
||||
The purchase code is invalid or has expired. Please check your link and try again.
|
||||
</Typography>
|
||||
</div>
|
||||
</DesignCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="relative min-h-screen bg-white dark:bg-zinc-950">
|
||||
<div className="relative flex min-h-screen w-full flex-col lg:flex-row">
|
||||
{/* Left Panel: Product & Pricing Selection */}
|
||||
<div className="flex flex-1 flex-col border-b border-border/40 bg-white dark:bg-zinc-950 lg:w-1/2 lg:border-b-0 lg:border-r">
|
||||
<div className="mx-auto w-full max-w-md px-6 pb-12 pt-16 lg:pt-20">
|
||||
{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>
|
||||
<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 className="space-y-5">
|
||||
<Skeleton className="size-12 rounded-full" />
|
||||
<Skeleton className="mt-4 h-10 w-2/3" />
|
||||
<Skeleton className="mt-2 h-5 w-full" />
|
||||
<Skeleton className="mt-8 h-20 w-full rounded-xl" />
|
||||
<Skeleton className="mt-4 h-24 w-full rounded-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-8">
|
||||
{/* Product Logo */}
|
||||
{data?.project_logo_url && (
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<Image
|
||||
src={data.project_logo_url}
|
||||
alt="Project logo"
|
||||
className="h-10 w-10 object-contain"
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-12 rounded-full border border-border/40 bg-white p-1 object-contain shadow-sm dark:bg-zinc-950"
|
||||
width={48}
|
||||
height={48}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography type="h2" className="text-xl font-semibold">
|
||||
{data?.product?.display_name || "Choose Your Plan"}
|
||||
</Typography>
|
||||
</div>
|
||||
{/* Product Name */}
|
||||
<Typography type="h1" className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{data?.product?.display_name || "Choose Your Plan"}
|
||||
</Typography>
|
||||
|
||||
{/* Prominent Selected Price Display */}
|
||||
{selectedPriceData && (
|
||||
<div className="py-2">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-5xl font-bold tabular-nums tracking-tight text-foreground">
|
||||
${selectedPriceData.USD ?? "0.00"}
|
||||
</span>
|
||||
{selectedPriceData.interval && (
|
||||
<span className="text-lg font-medium text-muted-foreground">
|
||||
/{shortenedInterval(selectedPriceData.interval)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflict / Already Purchased Alerts */}
|
||||
{(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>
|
||||
<DesignAlert
|
||||
variant="error"
|
||||
title="Already Purchased"
|
||||
description="You already have this product and cannot purchase it again."
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
<DesignAlert
|
||||
variant="warning"
|
||||
title="Plan Change Detected"
|
||||
description={
|
||||
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."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pricing Options */}
|
||||
{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">
|
||||
<Typography type="label" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Select a Pricing Option
|
||||
</Typography>
|
||||
<div className="grid gap-3">
|
||||
{typedEntries(data.product.prices).map(([priceId, priceData], index) => (
|
||||
<Card
|
||||
<div className="grid gap-2.5">
|
||||
{typedEntries(data.product.prices).map(([priceId, priceData]) => (
|
||||
<PurchasePriceOption
|
||||
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>
|
||||
priceId={priceId}
|
||||
priceData={priceData}
|
||||
selected={selectedPriceId === priceId}
|
||||
onSelect={setSelectedPriceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stackable Quantity Selector */}
|
||||
{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)))}
|
||||
>
|
||||
<MinusIcon 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))}
|
||||
>
|
||||
<PlusIcon 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 className="rounded-xl border border-border/40 bg-foreground/[0.01] p-4 sm:p-5">
|
||||
<PurchaseQuantitySelector
|
||||
quantityInput={quantityInput}
|
||||
quantityNumber={quantityNumber}
|
||||
onQuantityChange={setQuantityInput}
|
||||
isTooLarge={isTooLarge}
|
||||
selectedPriceId={selectedPriceId}
|
||||
priceData={data.product.prices[selectedPriceId]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<div className="w-full max-w-lg">
|
||||
{data.test_mode ? (
|
||||
<TestModeBypassForm
|
||||
onBypass={handleBypass}
|
||||
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true}
|
||||
/>
|
||||
) : data.stripe_account_id == null ? (
|
||||
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
|
||||
<Typography type="h3" variant="destructive">Payments not enabled</Typography>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project does not have payments enabled yet. Please contact the app developer to finish setting up payments.
|
||||
</p>
|
||||
{/* Right Panel: Checkout Form / Payment Details */}
|
||||
<div className="flex flex-1 flex-col justify-center bg-zinc-200 dark:bg-black lg:w-1/2">
|
||||
<div className="mx-auto w-full max-w-md px-6 py-12">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-64 w-full rounded-2xl" />
|
||||
</div>
|
||||
) : (
|
||||
<StripeElementsProvider
|
||||
stripeAccountId={data.stripe_account_id}
|
||||
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 ?? false}
|
||||
isFree={isFreeSelected}
|
||||
/>
|
||||
</StripeElementsProvider>
|
||||
)}
|
||||
) : data ? (
|
||||
<div className="space-y-4">
|
||||
{data.test_mode ? (
|
||||
<TestModeBypassForm
|
||||
onBypass={handleBypass}
|
||||
disabled={checkoutDisabled}
|
||||
/>
|
||||
) : data.stripe_account_id == null ? (
|
||||
<PaymentsNotEnabledCard />
|
||||
) : (
|
||||
<StripeElementsProvider
|
||||
stripeAccountId={data.stripe_account_id}
|
||||
amount={elementsAmountCents}
|
||||
mode={elementsMode}
|
||||
>
|
||||
<CheckoutForm
|
||||
fullCode={code}
|
||||
stripeAccountId={data.stripe_account_id}
|
||||
setupSubscription={setupSubscription}
|
||||
returnUrl={returnUrl ?? undefined}
|
||||
disabled={checkoutDisabled}
|
||||
chargesEnabled={data.charges_enabled ?? false}
|
||||
isFree={isFreeSelected}
|
||||
/>
|
||||
</StripeElementsProvider>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center h-screen text-center px-4 gap-4">
|
||||
{state.kind === "loading" && (
|
||||
<>
|
||||
<Typography type="h2">Finalizing purchase…</Typography>
|
||||
<Typography type="label">Please wait while we verify your payment.</Typography>
|
||||
</>
|
||||
)}
|
||||
{state.kind === "success" && (
|
||||
<>
|
||||
<Typography type="h2">Purchase successful</Typography>
|
||||
<Typography type="label">{state.message}</Typography>
|
||||
</>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<>
|
||||
<Typography type="h2">Purchase failed</Typography>
|
||||
<Typography type="label">
|
||||
The following error occurred: "{state.message}"
|
||||
</Typography>
|
||||
<Typography type="label">
|
||||
<StyledLink href={`/purchase/${purchaseFullCode}`}>Click here</StyledLink> to try making your purchase again.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-white px-4 py-12 dark:bg-black">
|
||||
<DesignCard glassmorphic className="relative w-full max-w-md" contentClassName="flex flex-col items-center gap-5 p-8 text-center">
|
||||
{state.kind === "loading" && (
|
||||
<>
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-primary/10">
|
||||
<SpinnerGapIcon className="size-7 animate-spin text-primary" />
|
||||
</div>
|
||||
<Typography type="h2" className="text-xl font-semibold tracking-tight">
|
||||
Finalizing purchase…
|
||||
</Typography>
|
||||
<Typography type="label" className="text-sm text-muted-foreground">
|
||||
Please wait while we verify your payment.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === "success" && (
|
||||
<>
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircleIcon className="size-7 text-emerald-600 dark:text-emerald-400" weight="fill" />
|
||||
</div>
|
||||
<Typography type="h2" className="text-xl font-semibold tracking-tight">
|
||||
Purchase successful
|
||||
</Typography>
|
||||
<Typography type="label" className="text-sm text-muted-foreground">
|
||||
{state.message}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === "error" && (
|
||||
<>
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircleIcon className="size-7 text-destructive" weight="fill" />
|
||||
</div>
|
||||
<Typography type="h2" className="text-xl font-semibold tracking-tight">
|
||||
Purchase failed
|
||||
</Typography>
|
||||
<Typography type="label" className="text-sm text-muted-foreground">
|
||||
The following error occurred: "{state.message}"
|
||||
</Typography>
|
||||
<Typography type="label" className="text-sm text-muted-foreground">
|
||||
<StyledLink href={`/purchase/${purchaseFullCode}`}>Click here</StyledLink> to try making your purchase again.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</DesignCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ export type DesignSelectorDropdownProps = {
|
||||
className?: string,
|
||||
triggerClassName?: string,
|
||||
contentClassName?: string,
|
||||
triggerId?: string,
|
||||
};
|
||||
|
||||
const triggerSizeClasses = new Map<DesignSelectorSize, string>([
|
||||
@ -53,13 +54,14 @@ export function DesignSelectorDropdown({
|
||||
className,
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
triggerId,
|
||||
}: DesignSelectorDropdownProps) {
|
||||
const triggerSizeClass = getMapValueOrThrow(triggerSizeClasses, size, "triggerSizeClasses");
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger className={cn(triggerSizeClass, triggerClassName)}>
|
||||
<SelectTrigger id={triggerId} className={cn(triggerSizeClass, triggerClassName)}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={contentClassName}>
|
||||
|
||||
@ -249,11 +249,15 @@ export function SelectField<F extends FieldValues>(props: {
|
||||
<FormItem>
|
||||
<FieldLabel required={props.required}>{props.label}</FieldLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={props.disabled}>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectTrigger className="max-w-lg">
|
||||
<SelectValue placeholder={props.placeholder} />
|
||||
<SelectValue placeholder={props.placeholder ?? "Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
<SelectGroup>
|
||||
{props.options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { Button, Typography } from "@/components/ui";
|
||||
"use client";
|
||||
|
||||
import { DesignButton } from "@/components/design-components/button";
|
||||
import { DesignCard } from "@/components/design-components/card";
|
||||
import { Typography } from "@/components/ui";
|
||||
import {
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { StripeError, StripePaymentElementOptions } from "@stripe/stripe-js";
|
||||
import { FlaskIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
const paymentElementOptions = {
|
||||
@ -27,6 +32,26 @@ type Props = {
|
||||
isFree: boolean,
|
||||
};
|
||||
|
||||
export function PaymentsNotEnabledCard() {
|
||||
return (
|
||||
<DesignCard glassmorphic contentClassName="space-y-4 p-5 sm:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
|
||||
<WarningCircleIcon className="size-4" weight="fill" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography type="h3" className="text-base font-semibold text-destructive">
|
||||
Payments not enabled
|
||||
</Typography>
|
||||
<Typography type="p" variant="secondary" className="text-sm">
|
||||
This project does not have payments enabled yet. Please contact the app developer to finish setting up payments.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</DesignCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestModeBypassForm({
|
||||
onBypass,
|
||||
disabled,
|
||||
@ -35,20 +60,27 @@ export function TestModeBypassForm({
|
||||
disabled?: boolean,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
|
||||
<div className="space-y-1">
|
||||
<Typography type="h3">Test mode active</Typography>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project is in test mode. Use the bypass button to simulate a purchase.
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center space-y-6 py-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-orange-500/10 text-orange-500 shadow-[0_0_20px_rgba(249,115,22,0.05)]">
|
||||
<FlaskIcon className="size-5" weight="fill" />
|
||||
</div>
|
||||
<Button
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Typography type="h3" className="text-lg font-semibold text-foreground">
|
||||
Test mode active
|
||||
</Typography>
|
||||
<Typography type="p" variant="secondary" className="text-sm leading-relaxed text-muted-foreground">
|
||||
This project is in test mode. Use the bypass button to simulate a purchase.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<DesignButton
|
||||
disabled={disabled}
|
||||
onClick={onBypass}
|
||||
className="mt-2"
|
||||
className="h-11 w-full max-w-xs rounded-xl text-sm font-semibold"
|
||||
>
|
||||
Complete test purchase
|
||||
</Button>
|
||||
</DesignButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -113,30 +145,24 @@ export function CheckoutForm({
|
||||
};
|
||||
|
||||
if (!chargesEnabled) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
|
||||
<div className="space-y-1">
|
||||
<Typography type="h3" variant="destructive">Payments not enabled</Typography>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project does not have payments enabled yet. Please contact the app developer to finish setting up payments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PaymentsNotEnabledCard />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-md w-full p-6 rounded-md bg-background">
|
||||
<DesignCard glassmorphic contentClassName="space-y-5 p-5 sm:p-6">
|
||||
<PaymentElement options={paymentElementOptions} />
|
||||
<Button
|
||||
<DesignButton
|
||||
disabled={!stripe || !elements || disabled || !chargesEnabled}
|
||||
onClick={handleSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</DesignButton>
|
||||
{message && (
|
||||
<div className="text-destructive">{message}</div>
|
||||
<Typography type="p" variant="destructive" className="text-sm">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</DesignCard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
|
||||
import { StyledLink } from "@/components/link";
|
||||
import {
|
||||
DesignAlert,
|
||||
DesignButton,
|
||||
DesignDialog,
|
||||
DesignDialogClose,
|
||||
DesignSelectorDropdown,
|
||||
} from "@/components/design-components";
|
||||
import { InlineCode, Label, toast } from "@/components/ui";
|
||||
import { LinkIcon, ShoppingCartIcon } from "@phosphor-icons/react";
|
||||
import { ServerUser, Team } from "@hexclave/next";
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { Result } from "@hexclave/shared/dist/utils/results";
|
||||
import { ActionDialog, InlineCode, Typography, toast } from "@/components/ui";
|
||||
import { useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { FormDialog } from "../form-dialog";
|
||||
import { SelectField } from "../form-fields";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
open: boolean,
|
||||
@ -26,58 +36,136 @@ export function CreateCheckoutDialog(props: Props) {
|
||||
const project = hexclaveAdminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
|
||||
const [productId, setProductId] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const customer = props.user ?? props.team;
|
||||
const products = config.payments.products;
|
||||
const shownProducts = Object.keys(products).filter(id => products[id].customerType === (props.user ? "user" : "team"));
|
||||
const customerType = props.user ? "user" : "team";
|
||||
const createProductHref = urlString`/projects/${hexclaveAdminApp.projectId}/payments/products/new?customerType=${customerType}`;
|
||||
|
||||
const createCheckoutUrl = async (data: { productId: string }) => {
|
||||
const result = await Result.fromPromise(customer.createCheckoutUrl({ productId: data.productId }));
|
||||
if (result.status === "ok") {
|
||||
setCheckoutUrl(result.data);
|
||||
const productOptions = useMemo(() => {
|
||||
return Object.entries(products)
|
||||
.filter(([, product]) => product.customerType === customerType)
|
||||
.map(([id, product]) => ({
|
||||
value: id,
|
||||
label: product.displayName ? `${product.displayName} (${id})` : id,
|
||||
}));
|
||||
}, [products, customerType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
setProductId("");
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
const createCheckoutUrl = async () => {
|
||||
if (!productId) {
|
||||
toast({ title: "Please select a product", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (result.error instanceof KnownErrors.ProductDoesNotExist) {
|
||||
toast({ title: "Product with given productId does not exist", variant: "destructive" });
|
||||
} else if (result.error instanceof KnownErrors.ProductCustomerTypeDoesNotMatch) {
|
||||
toast({ title: "Customer type does not match expected type for this product", variant: "destructive" });
|
||||
} else if (result.error instanceof KnownErrors.CustomerDoesNotExist) {
|
||||
toast({ title: "Customer with given customerId does not exist", variant: "destructive" });
|
||||
} else if (result.error instanceof KnownErrors.ProductAlreadyGranted) {
|
||||
toast({ title: "This customer already owns the selected product", variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: "An unknown error occurred", variant: "destructive" });
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await Result.fromPromise(customer.createCheckoutUrl({ productId }));
|
||||
if (result.status === "ok") {
|
||||
setCheckoutUrl(result.data);
|
||||
props.onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
if (result.error instanceof KnownErrors.ProductDoesNotExist) {
|
||||
toast({ title: "Product with given productId does not exist", variant: "destructive" });
|
||||
} else if (result.error instanceof KnownErrors.ProductCustomerTypeDoesNotMatch) {
|
||||
toast({ title: "Customer type does not match expected type for this product", variant: "destructive" });
|
||||
} else if (result.error instanceof KnownErrors.CustomerDoesNotExist) {
|
||||
toast({ title: "Customer with given customerId does not exist", variant: "destructive" });
|
||||
} else if (result.error instanceof KnownErrors.ProductAlreadyGranted) {
|
||||
toast({ title: "This customer already owns the selected product", variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: "An unknown error occurred", variant: "destructive" });
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
return "prevent-close";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormDialog
|
||||
<DesignDialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
size="md"
|
||||
icon={ShoppingCartIcon}
|
||||
title="Create Checkout URL"
|
||||
formSchema={yup.object({
|
||||
productId: yup.string().defined().label("Product ID"),
|
||||
})}
|
||||
cancelButton
|
||||
okButton={{ label: "Create" }}
|
||||
onSubmit={values => createCheckoutUrl(values)}
|
||||
render={form => <SelectField
|
||||
control={form.control}
|
||||
name="productId"
|
||||
label="Product"
|
||||
options={shownProducts.map(id => ({ value: id, label: id }))}
|
||||
/>}
|
||||
/>
|
||||
<ActionDialog
|
||||
open={checkoutUrl !== null}
|
||||
onOpenChange={() => setCheckoutUrl(null)}
|
||||
title="Checkout URL"
|
||||
okButton
|
||||
footer={(
|
||||
<>
|
||||
<DesignDialogClose asChild>
|
||||
<DesignButton variant="secondary" size="sm" type="button" disabled={isCreating}>
|
||||
Cancel
|
||||
</DesignButton>
|
||||
</DesignDialogClose>
|
||||
<DesignButton
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={isCreating || productOptions.length === 0}
|
||||
loading={isCreating}
|
||||
onClick={() => runAsynchronouslyWithAlert(createCheckoutUrl())}
|
||||
>
|
||||
Create
|
||||
</DesignButton>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Typography className="mb-2">This is a temporary URL. It will expire in 24 hours.</Typography>
|
||||
<InlineCode className="whitespace-nowrap overflow-x-auto block">{checkoutUrl}</InlineCode>
|
||||
</ActionDialog>
|
||||
<div className="grid gap-4">
|
||||
{productOptions.length === 0 ? (
|
||||
<DesignAlert
|
||||
variant="warning"
|
||||
title="No products available"
|
||||
description={(
|
||||
<>
|
||||
No {customerType} products are configured for this project.{" "}
|
||||
<StyledLink href={createProductHref}>Create one here</StyledLink>.
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="checkout-product" className="text-sm font-medium">
|
||||
Product
|
||||
</Label>
|
||||
<DesignSelectorDropdown
|
||||
value={productId}
|
||||
onValueChange={setProductId}
|
||||
options={productOptions}
|
||||
placeholder="Select a product"
|
||||
size="md"
|
||||
triggerId="checkout-product"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DesignDialog>
|
||||
|
||||
<DesignDialog
|
||||
open={checkoutUrl !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCheckoutUrl(null);
|
||||
}
|
||||
}}
|
||||
size="md"
|
||||
icon={LinkIcon}
|
||||
title="Checkout URL"
|
||||
description="This is a temporary URL. It will expire in 24 hours."
|
||||
footer={(
|
||||
<DesignDialogClose asChild>
|
||||
<DesignButton variant="secondary" size="sm" type="button">
|
||||
Close
|
||||
</DesignButton>
|
||||
</DesignDialogClose>
|
||||
)}
|
||||
>
|
||||
<InlineCode className="block overflow-x-auto whitespace-nowrap">{checkoutUrl}</InlineCode>
|
||||
</DesignDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPriceLabel, shortenedInterval } from "./purchase-utils";
|
||||
|
||||
type PriceData = {
|
||||
USD?: string,
|
||||
interval?: [number, string],
|
||||
};
|
||||
|
||||
type Props = {
|
||||
priceId: string,
|
||||
priceData: PriceData,
|
||||
selected: boolean,
|
||||
onSelect: (priceId: string) => void,
|
||||
};
|
||||
|
||||
export function PurchasePriceOption({ priceId, priceData, selected, onSelect }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(priceId)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"group relative w-full rounded-2xl border py-5 px-6 text-left transition-all duration-150 hover:transition-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
selected
|
||||
? "border-blue-500 bg-blue-500/[0.03] ring-1 ring-blue-500/20 shadow-[0_0_20px_rgba(59,130,246,0.06)]"
|
||||
: "border-border/30 bg-foreground/[0.015] hover:border-blue-500/30 hover:bg-foreground/[0.03] hover:shadow-[0_0_15px_rgba(255,255,255,0.01)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
{/* Custom Radio Indicator */}
|
||||
{selected ? (
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.3)]">
|
||||
<div className="size-2 rounded-full bg-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="size-5 shrink-0 rounded-full border border-border/60 dark:border-white/20 group-hover:border-blue-500/50 transition-colors duration-150" />
|
||||
)}
|
||||
<span className="text-base font-semibold text-foreground truncate">
|
||||
{getPriceLabel(priceData.interval)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<span className="text-base font-bold text-foreground">${priceData.USD ?? "0.00"}</span>
|
||||
{priceData.interval && (
|
||||
<span className="text-xs text-muted-foreground/80 ml-1">
|
||||
/{shortenedInterval(priceData.interval)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { DesignInput } from "@/components/design-components/input";
|
||||
import { DesignButton } from "@/components/design-components/button";
|
||||
import { Typography } from "@/components/ui";
|
||||
import { MinusIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { shortenedInterval } from "./purchase-utils";
|
||||
|
||||
type PriceData = {
|
||||
USD?: string,
|
||||
interval?: [number, string],
|
||||
};
|
||||
|
||||
type Props = {
|
||||
quantityInput: string,
|
||||
quantityNumber: number,
|
||||
onQuantityChange: (value: string) => void,
|
||||
isTooLarge: boolean,
|
||||
selectedPriceId: string,
|
||||
priceData: PriceData,
|
||||
};
|
||||
|
||||
export function PurchaseQuantitySelector({
|
||||
quantityInput,
|
||||
quantityNumber,
|
||||
onQuantityChange,
|
||||
isTooLarge,
|
||||
selectedPriceId,
|
||||
priceData,
|
||||
}: Props) {
|
||||
const unitPriceUsd = Number(priceData.USD ?? "0");
|
||||
const totalAmount = selectedPriceId && Number.isFinite(unitPriceUsd)
|
||||
? (unitPriceUsd * Math.max(0, quantityNumber)).toFixed(2)
|
||||
: "0.00";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Typography type="label" className="text-sm font-semibold text-foreground">
|
||||
Quantity
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<DesignButton
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 border-border/40 bg-foreground/[0.01] hover:bg-foreground/[0.03]"
|
||||
disabled={quantityNumber <= 1}
|
||||
aria-label="Decrease quantity"
|
||||
onClick={() => onQuantityChange(String(Math.max(1, quantityNumber - 1)))}
|
||||
>
|
||||
<MinusIcon className="size-3.5 text-foreground" />
|
||||
</DesignButton>
|
||||
<DesignInput
|
||||
className="h-8 w-20 text-center text-sm font-semibold tabular-nums border-border/40 bg-foreground/[0.01] text-foreground focus-visible:ring-blue-500/20"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
type="text"
|
||||
value={quantityInput}
|
||||
aria-label="Quantity"
|
||||
onChange={(event) => {
|
||||
const digitsOnly = event.target.value.replace(/[^0-9]/g, "");
|
||||
onQuantityChange(digitsOnly);
|
||||
}}
|
||||
/>
|
||||
<DesignButton
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 border-border/40 bg-foreground/[0.01] hover:bg-foreground/[0.03]"
|
||||
aria-label="Increase quantity"
|
||||
onClick={() => onQuantityChange(String(quantityNumber + 1))}
|
||||
>
|
||||
<PlusIcon className="size-3.5 text-foreground" />
|
||||
</DesignButton>
|
||||
</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="border-t border-border/40 pt-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<Typography type="label" className="text-sm font-semibold text-foreground">
|
||||
Total Amount
|
||||
</Typography>
|
||||
<div className="text-right">
|
||||
<Typography type="h2" className="text-xl font-bold tabular-nums text-foreground">
|
||||
${totalAmount}
|
||||
</Typography>
|
||||
{selectedPriceId && priceData.interval && (
|
||||
<Typography type="p" variant="secondary" className="text-xs text-muted-foreground mt-0.5">
|
||||
per {shortenedInterval(priceData.interval)}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/dashboard/src/components/payments/purchase-utils.ts
Normal file
48
apps/dashboard/src/components/payments/purchase-utils.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export function shortenedInterval(interval: [number, string]): string {
|
||||
if (interval[0] === 1) {
|
||||
return interval[1];
|
||||
}
|
||||
return `${interval[0]} ${interval[1]}s`;
|
||||
}
|
||||
|
||||
export function 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`;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFreePrice(usd: string | undefined): boolean {
|
||||
if (usd == null || usd.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const usdAmount = Number(usd);
|
||||
return Number.isFinite(usdAmount) && usdAmount === 0;
|
||||
}
|
||||
@ -81,17 +81,18 @@ SelectScrollDownButton.displayName =
|
||||
const SelectContent = forwardRefIfNeeded<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
>(({ className, children, position = "popper", style, ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"stack-scope relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] dark:border-white/[0.08] bg-white/95 dark:bg-background/95 backdrop-blur-xl text-popover-foreground shadow-lg ring-1 ring-black/[0.08] dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"stack-scope relative z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-black/[0.08] dark:border-white/[0.08] bg-white/95 dark:bg-background/95 backdrop-blur-xl text-popover-foreground shadow-lg ring-1 ring-black/[0.08] dark:ring-white/[0.08] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
style={{ ...style, maxHeight: "var(--radix-select-content-available-height)" }}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
@ -99,7 +100,7 @@ const SelectContent = forwardRefIfNeeded<
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user