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 (
+