diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 796ef30d45..7e582d841e 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -329,7 +329,10 @@ export async function seed() { }, }); if (!existingGrowthSub) { - const firstPriceId = Object.keys(growthProduct.prices)[0] ?? null; + const firstPriceId = Object.keys(growthProduct.prices)[0]; + if (!firstPriceId) { + throw new Error("Internal seed invariant violated: the Growth product must have at least one price configured before seeding the internal team subscription."); + } const now = new Date(); // Clone to ensure the stored JSON snapshot is independent of the config object // (mirrors the pattern used in seed-dummy-data.ts). diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index f015004b45..b358669467 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -79,11 +79,22 @@ export const POST = createSmartRouteHandler({ } const fromPriceEntries = typedEntries(fromProduct.prices); const fromHasIntervalPrice = fromPriceEntries.some(([, price]) => price.interval); + // A price counts as "free" only if EVERY supported currency is either absent or zero. + // Checking USD alone would misclassify a price that's only set in another supported + // currency (e.g. EUR-only) as free, and would let the customer switch from it without + // an existing subscription row — bypassing intended billing. + const isPriceFree = (price: typeof fromPriceEntries[number][1]) => + SUPPORTED_CURRENCIES.every(c => { + const amount = (price as Record)[c.code]; + return amount == null || Number(amount) === 0; + }); + const fromIsFreePlan = fromPriceEntries.length === 0 + || fromPriceEntries.every(([, p]) => isPriceFree(p)); // A product with non-interval prices is a one-time purchase and can't be switched. // A product with no prices at all (e.g. auto-migrated from the legacy `include-by-default` // sentinel, or an intentionally free product) is treated as a free plan the customer may - // upgrade away from. - if (fromPriceEntries.length > 0 && !fromHasIntervalPrice) { + // upgrade away from. Free non-recurring plans are also exempted so $0 plans can be upgraded. + if (fromPriceEntries.length > 0 && !fromHasIntervalPrice && !fromIsFreePlan) { throw new StatusError(400, "This subscription cannot be switched."); } @@ -134,17 +145,6 @@ export const POST = createSmartRouteHandler({ const existingSub = Object.values(subMap).find( s => s.productId === body.from_product_id && isActiveSubscription(s) ) ?? null; - // A price counts as "free" only if EVERY supported currency is either absent or zero. - // Checking USD alone would misclassify a price that's only set in another supported - // currency (e.g. EUR-only) as free, and would let the customer switch from it without - // an existing subscription row — bypassing intended billing. - const isPriceFree = (price: typeof fromPriceEntries[number][1]) => - SUPPORTED_CURRENCIES.every(c => { - const amount = (price as Record)[c.code]; - return amount == null || Number(amount) === 0; - }); - const fromIsFreePlan = fromPriceEntries.length === 0 - || fromPriceEntries.every(([, p]) => isPriceFree(p)); if (!existingSub && !fromIsFreePlan) { throw new StatusError(400, "This subscription cannot be switched."); } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index 11cd4aff78..31303a56a4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -52,7 +52,7 @@ import { type DesignDialogVariant, DesignInput, DesignPillToggle, -} from "@stackframe/dashboard-ui-components"; +} from "@/components/design-components"; import { useMemo, useRef, useState } from "react"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -463,6 +463,9 @@ export default function PageClient() { const [dialogShowFooter, setDialogShowFooter] = useState(true); const [dialogShowIcon, setDialogShowIcon] = useState(true); const [dialogHideTopClose, setDialogHideTopClose] = useState(false); + const [dialogUseCustomHeader, setDialogUseCustomHeader] = useState(false); + const [dialogNoBodyPadding, setDialogNoBodyPadding] = useState(false); + const [dialogAccentClassNames, setDialogAccentClassNames] = useState(false); // Editable Grid const [gridCols, setGridCols] = useState<1 | 2>(2); @@ -1016,6 +1019,15 @@ export default function PageClient() { ) ) : undefined; + const customHeader = dialogUseCustomHeader ? ( +
+
JD
+
+
Custom header block
+
Renders instead of the default title/description region.
+
+
+ ) : undefined; return ( {dialogTriggerLabel}} > {body} @@ -1822,6 +1841,15 @@ export default function PageClient() { setDialogHideTopClose(!v)} on="Show" off="Hide" /> + + + + + + + + + ); } @@ -2309,9 +2337,16 @@ export default function PageClient() { : dialogShape === "wide" ? `\n \n {/* tester form */}` : `\n

Body content lives here.

`; + const customHeaderProp = dialogUseCustomHeader + ? `\n customHeader={\n
\n {/* custom header content; replaces title/description region */}\n
\n }` + : ""; + const noBodyPaddingProp = dialogNoBodyPadding ? `\n noBodyPadding` : ""; + const classNameProps = dialogAccentClassNames + ? `\n className=\"ring-2 ring-indigo-500/40\"\n overlayClassName=\"bg-indigo-950/40\"\n headerClassName=\"bg-indigo-500/5\"\n bodyClassName=\"bg-foreground/[0.02]\"\n footerClassName=\"bg-foreground/[0.02]\"` + : ""; return `${escapeAttr(dialogTriggerLabel)}} >${bodySnippet} `; diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index e957fdaa7e..7cab9a8f8f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -431,6 +431,7 @@ function ProjectsListPage() { {!isRemoteDevelopmentEnvironment && ( - - - - +
+ + { + const value = sanitizeUserSpecifiedId(e.target.value); + setProductLineId(value); + setHasManuallyEditedId(true); + setErrors(prev => ({ ...prev, id: undefined })); + }} + placeholder="e.g., pricing-tiers" + size="md" + className={`font-mono text-sm ${errors.id ? "border-destructive focus-visible:ring-destructive/30" : ""}`} + /> + {errors.id && ( + + {errors.id} + + )} +
+ +
); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx index 0e68d4eb72..16d4d5fafc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx @@ -1,16 +1,27 @@ "use client"; +import { + DesignButton, + DesignDialog, + DesignDialogClose, + DesignInput, + DesignSelectorDropdown, +} from "@/components/design-components"; +import { Checkbox, Label, SimpleTooltip, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; +import { PackageIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; -import { Button, Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Typography } from "@/components/ui"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -type Interval = [number, 'day' | 'week' | 'month' | 'year'] | 'never'; type ExpiresOption = 'never' | 'when-purchase-expires' | 'when-repeated'; +// Sentinel value used for the "+ Create new item" row. Includes a `__` prefix so +// it cannot collide with a real item id (item ids cannot start with `__` and +// cannot contain spaces, so this string is unreachable as a user-supplied id). +const CREATE_NEW_ITEM_SENTINEL = '__stack_create_new_item__'; + type Product = CompleteConfig['payments']['products'][string]; type IncludedItem = Product['includedItems'][string]; -type Price = (Product['prices'] & object)[string]; type IncludedItemDialogProps = { open: boolean, @@ -153,109 +164,124 @@ export function IncludedItemDialog({ const selectedItem = existingItems.find(item => item.id === selectedItemId); - return ( - - - - {editingItem ? "Edit Included Item" : "Add Included Item"} - - Configure which items are included with this product and how they behave. - - + const itemSelectOptions = useMemo(() => [ + ...existingItems.map(item => ({ + value: item.id, + label: `${item.displayName || item.id} (${item.customerType.toUpperCase()} · ${item.id})`, + })), + ...(onCreateNewItem ? [{ value: CREATE_NEW_ITEM_SENTINEL, label: '+ Create new item' }] : []), + ], [existingItems, onCreateNewItem]); -
- {/* Item Selection */} -
- - - {errors.itemId && ( - - {errors.itemId} - - )} -
+ const repeatUnitOptions = useMemo(() => [ + { value: 'day', label: 'day(s)' }, + { value: 'week', label: 'week(s)' }, + { value: 'month', label: 'month(s)' }, + { value: 'year', label: 'year(s)' }, + ], []); - {/* Quantity */} -
- - { - setQuantity(e.target.value); - if (errors.quantity) { + const expiresSelectOptions = useMemo(() => EXPIRES_OPTIONS + .filter(option => !option.requiresRepeat || hasRepeat) + .map(option => ({ value: option.value, label: option.label })), [hasRepeat]); + + const expiresDescription = EXPIRES_OPTIONS.find(o => o.value === expires)?.description; + + return ( + { + if (!nextOpen) handleClose(); + }} + size="lg" + icon={PackageIcon} + title={editingItem ? "Edit Included Item" : "Add Included Item"} + description="Configure which items are included with this product and how they behave." + footer={( + <> + + Cancel + + + {editingItem ? "Save Changes" : "Add Item"} + + + )} + > +
+ {/* Item Selection */} +
+ + { + if (value === CREATE_NEW_ITEM_SENTINEL) { + onCreateNewItem?.(); + } else { + setSelectedItemId(value); + if (errors.itemId) { setErrors(prev => { const newErrors = { ...prev }; - delete newErrors.quantity; + delete newErrors.itemId; return newErrors; }); } - }} - className={errors.quantity ? "border-destructive" : ""} - /> - {errors.quantity && ( - - {errors.quantity} - - )} -
+ } + }} + options={itemSelectOptions} + disabled={!!editingItem} + placeholder="Choose an item..." + size="md" + triggerClassName={cn(errors.itemId && "border-destructive")} + /> + {errors.itemId && ( + + {errors.itemId} + + )} +
+ + {/* Quantity */} +
+ + { + setQuantity(e.target.value); + if (errors.quantity) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.quantity; + return newErrors; + }); + } + }} + size="md" + className={errors.quantity ? "border-destructive focus-visible:ring-destructive/30" : ""} + /> + {errors.quantity && ( + + {errors.quantity} + + )} +
- {/* Repeat */} -
-
- { + {/* Repeat */} +
+
+ { setHasRepeat(checked as boolean); // Reset expires if turning off repeat and it was set to 'when-repeated' if (!checked && expires === 'when-repeated') { @@ -268,28 +294,28 @@ export function IncludedItemDialog({ }); } } - }} - /> -
+ + {hasRepeat && ( +
+ -
- - {hasRepeat && ( -
- -
- { +
+ { setRepeatCount(e.target.value); if (errors.repeatCount) { setErrors(prev => { @@ -298,40 +324,37 @@ export function IncludedItemDialog({ return newErrors; }); } - }} - className={cn("w-24", errors.repeatCount ? "border-destructive" : "")} - /> - -
- {errors.repeatCount && ( - - {errors.repeatCount} - - )} + }} + size="md" + className={cn("w-24 shrink-0", errors.repeatCount ? "border-destructive focus-visible:ring-destructive/30" : "")} + /> + setRepeatUnit(value as typeof repeatUnit)} + options={repeatUnitOptions} + size="md" + className="min-w-0 flex-1" + />
- )} -
+ {errors.repeatCount && ( + + {errors.repeatCount} + + )} +
+ )} +
- {/* Expiration */} -
- - - {errors.expires && ( - - {errors.expires} - - )} -
- - {/* Summary */} - {selectedItem && ( -
- - Summary: - - - Grant {quantity}× {selectedItem.displayName || selectedItem.id} - {hasRepeat && ( - - {' '}every {repeatCount} {repeatUnit}{parseInt(repeatCount) > 1 ? 's' : ''} - - )} - {expires !== 'never' && ( - - {' '}(expires {EXPIRES_OPTIONS.find(o => o.value === expires)?.label.toLowerCase()}) - - )} - -
+ }} + options={expiresSelectOptions} + size="md" + triggerClassName={cn(errors.expires && "border-destructive")} + /> + {expiresDescription ? ( + + {expiresDescription} + + ) : null} + {errors.expires && ( + + {errors.expires} + )}
- - - - - -
+ {/* Summary */} + {selectedItem && ( +
+ + Summary + + + Grant {quantity}× {selectedItem.displayName || selectedItem.id} + {hasRepeat && ( + + {' '}every {repeatCount} {repeatUnit}{parseInt(repeatCount) > 1 ? 's' : ''} + + )} + {expires !== 'never' && ( + + {' '}(expires {EXPIRES_OPTIONS.find(o => o.value === expires)?.label.toLowerCase()}) + + )} + +
+ )} + + ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx index 252b4034d8..c497719fb2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx @@ -4,7 +4,12 @@ import { Link } from "@/components/link"; import { ItemDialog } from "@/components/payments/item-dialog"; import { useRouter } from "@/components/router"; import { - Button, + DesignButton, + DesignInput, + DesignSelectorDropdown, +} from "@/components/design-components"; +import { SubpageHeader } from "@/components/design-components/subpage-header"; +import { Card, CardDescription, CardHeader, @@ -14,18 +19,11 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Input, Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, SimpleTooltip, toast, Typography, } from "@/components/ui"; -import { SubpageHeader } from "@/components/design-components/subpage-header"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; import { ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, ClockIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react"; @@ -34,7 +32,7 @@ import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpe import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useSearchParams } from "next/navigation"; -import { useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useAdminApp, useProjectId } from "../../../use-admin-app"; import { CreateProductLineDialog } from "../create-product-line-dialog"; import { IncludedItemDialog } from "../included-item-dialog"; @@ -73,6 +71,13 @@ const CUSTOMER_TYPE_OPTIONS = [ }, ] as const; +const FREE_TRIAL_UNIT_OPTIONS = [ + { value: 'day', label: 'days' }, + { value: 'week', label: 'weeks' }, + { value: 'month', label: 'months' }, + { value: 'year', label: 'years' }, +] as const; + const COLOR_CLASSES = { blue: { hover: 'hover:border-blue-500/40 hover:shadow-[0_0_12px_rgba(59,130,246,0.1)]', @@ -341,6 +346,22 @@ export default function PageClient() { return () => observer.disconnect(); }, [hasSelectedCustomerType]); + const productLineDropdownOptions = useMemo(() => [ + { value: 'no-product-line', label: 'No product line' }, + ...typedEntries(paymentsConfig.productLines) + .filter(([, productLine]) => productLine.customerType === customerType) + .map(([id, productLine]) => ({ + value: id, + label: productLine.displayName || id, + })), + { value: 'create-new', label: '+ Create new' }, + ], [paymentsConfig.productLines, customerType]); + + const freeTrialUnitSelectOptions = useMemo( + () => FREE_TRIAL_UNIT_OPTIONS.map((o) => ({ value: o.value, label: o.label })), + [] + ); + // Computed values const existingProducts = typedEntries(paymentsConfig.products) .map(([id, product]) => ({ @@ -610,11 +631,19 @@ ${Object.entries(prices).map(([id, price]) => { onBack={handleBack} actions={ <> - + {isInlineProduct ? ( - + ) : ( -
+
- + Create Product + -
- +
- + } onClick={() => { const code = generateInlineProductCode(); runAsynchronouslyWithAlert(async () => { @@ -661,12 +696,11 @@ ${Object.entries(prices).map(([id, price]) => { toast({ title: "Code copied to clipboard" }); }); }} - className="flex items-center gap-2" > - - Copy inline product code + Copy code } onClick={() => { const prompt = generateInlineProductPrompt(); runAsynchronouslyWithAlert(async () => { @@ -674,10 +708,8 @@ ${Object.entries(prices).map(([id, price]) => { toast({ title: "Prompt copied to clipboard" }); }); }} - className="flex items-center gap-2" > - - Copy prompt for inline product + Copy prompt @@ -690,13 +722,13 @@ ${Object.entries(prices).map(([id, price]) => {
{/* Left side - Configuration form */}
-
+
{/* Display Name and Product ID - same row */}
{/* Display Name */} -
+
- { @@ -717,15 +749,10 @@ ${Object.entries(prices).map(([id, price]) => { } }} placeholder="e.g., Pro Plan" - className={cn( - "h-8 rounded-lg text-sm", - "bg-foreground/[0.03] border-border/50 dark:border-foreground/[0.1]", - "focus:ring-1 focus:ring-cyan-500/30 focus:border-cyan-500/50", - "transition-all duration-150 hover:transition-none", - errors.displayName && "border-destructive focus:ring-destructive/30" - )} + size="md" + className={cn(errors.displayName && "border-destructive focus-visible:ring-destructive/30")} /> - Visible to customers during checkout + Visible to customers during checkout {errors.displayName && ( {errors.displayName} @@ -734,9 +761,9 @@ ${Object.entries(prices).map(([id, price]) => {
{/* Product ID */} -
+
- { @@ -752,15 +779,13 @@ ${Object.entries(prices).map(([id, price]) => { } }} placeholder="e.g., pro-plan" + size="md" className={cn( - "h-8 rounded-lg font-mono text-sm", - "bg-foreground/[0.03] border-border/50 dark:border-foreground/[0.1]", - "focus:ring-1 focus:ring-cyan-500/30 focus:border-cyan-500/50", - "transition-all duration-150 hover:transition-none", - errors.productId && "border-destructive focus:ring-destructive/30" + "font-mono text-sm", + errors.productId && "border-destructive focus-visible:ring-destructive/30" )} /> - Used to reference this product in code + Used to reference this product in code {errors.productId && ( {errors.productId} @@ -771,7 +796,7 @@ ${Object.entries(prices).map(([id, price]) => { {/* Pricing Section */}
- Pricing +

Pricing

{ @@ -795,15 +820,17 @@ ${Object.entries(prices).map(([id, price]) => { {/* Included Items Section */}
- Included Items +

Included Items

{Object.entries(includedItems).length === 0 ? ( -
-

+

+

No items included yet

- +
) : (
@@ -819,39 +846,44 @@ ${Object.entries(prices).map(([id, price]) => {
-
+
{getItemDisplay(itemId, item, existingItems)}
-
{itemId}
+
{itemId}
-
- - + +
))} - +
)}
{/* Options Section - Two column grid */}
- Options +

Options

{/* Stackable */} @@ -979,7 +1011,7 @@ ${Object.entries(prices).map(([id, price]) => { {freeTrial && (
- { const val = parseInt(e.target.value) || 1; setFreeTrial([val, freeTrial[1]]); }} - className="h-7 w-16 text-sm rounded-md" + size="sm" + className="w-16" /> - + options={freeTrialUnitSelectOptions} + size="sm" + className="w-28 shrink-0" + />
)}
@@ -1010,7 +1036,7 @@ ${Object.entries(prices).map(([id, price]) => { {/* Product Line */} Part of a mutually exclusive group?
- + options={productLineDropdownOptions} + placeholder="No product line" + size="sm" + className="w-full max-w-[200px]" + triggerClassName="w-full max-w-[200px]" + />
{/* Inline Product */} @@ -1059,7 +1073,7 @@ ${Object.entries(prices).map(([id, price]) => { {/* Right side - Preview or Code Snippet (shown when container too small) */} {showPreview && ( -
+
{isInlineProduct ? 'Checkout Code Snippet' : 'Preview'} @@ -1074,9 +1088,10 @@ ${Object.entries(prices).map(([id, price]) => { )}> {generateInlineProductCode()} - +
) : (
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx index 60e972cc3e..1a944589e4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx @@ -13,6 +13,7 @@ import { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; import { prettyPrintWithMagnitudes } from "@stackframe/stack-shared/dist/utils/numbers"; import { typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { useAdminApp, useProjectId } from "../../use-admin-app"; import { ListSection } from "./list-section"; @@ -636,7 +637,7 @@ function ProductsWithoutPricesAlert({ {preview.map(({ id, displayName }) => (
  • {displayName} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx index 9da2f90dfa..8ec26de8b2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx @@ -1,31 +1,24 @@ "use client"; -import { EditableGrid } from "@/components/editable-grid"; import { RepeatingInput } from "@/components/repeating-input"; import { - Button, + DesignButton, + DesignDialog, + DesignDialogClose, + DesignInput, + DesignSelectorDropdown, +} from "@/components/design-components"; +import { cn, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, Label, Popover, PopoverContent, PopoverTrigger, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, } from "@/components/ui"; -import { ClockIcon, HardDriveIcon } from "@phosphor-icons/react"; +import { CaretUpDownIcon, ClockIcon, CurrencyDollarIcon, HardDriveIcon } from "@phosphor-icons/react"; import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { DEFAULT_INTERVAL_UNITS, getPriceCheckoutError, PRICE_INTERVAL_UNITS, type Price } from "./utils"; /** @@ -73,196 +66,229 @@ export function PriceEditDialog({ const [priceFreeTrialPopoverOpen, setPriceFreeTrialPopoverOpen] = useState(false); const [priceFreeTrialCount, setPriceFreeTrialCount] = useState(7); const [priceFreeTrialUnit, setPriceFreeTrialUnit] = useState('day'); + const [isSaving, setIsSaving] = useState(false); + + const freeTrialUnitOptions = useMemo( + () => DEFAULT_INTERVAL_UNITS.map((unit) => ({ + value: unit, + label: `${unit}${priceFreeTrialCount !== 1 ? 's' : ''}`, + })), + [priceFreeTrialCount] + ); const handleClose = () => { onEditingPriceChange(null); onOpenChange(false); }; + const fieldTriggerClasses = cn( + "flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-xl px-3 text-sm", + "border border-black/[0.08] dark:border-white/[0.06]", + "bg-white/80 dark:bg-background/60 backdrop-blur-xl", + "shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06]", + "text-muted-foreground hover:text-foreground", + "transition-all duration-150 hover:transition-none hover:ring-black/[0.12] dark:hover:ring-white/[0.1]", + "focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/30", + ); + const amountError = editingPrice ? validateEditingPriceAmount(editingPrice) : null; return ( - { - if (!isOpen) { - handleClose(); - } else { - onOpenChange(isOpen); - } - }}> - - - {isAdding ? "Add Price" : "Edit Price"} - - Configure the pricing option for this product. - - - {editingPrice && ( -
    - {/* Amount with Billing Frequency */} -
    - - { - if (v === '' || /^\d*(?:\.?\d{0,2})?$/.test(v)) { - onEditingPriceChange({ ...editingPrice, amount: v }); - } - }} - inputType="text" - placeholder="9.99" - prefix="$" - intervalSelection={editingPrice.intervalSelection} - intervalCount={editingPrice.intervalCount} - intervalUnit={editingPrice.priceInterval} - onIntervalChange={(interval) => { - if (interval) { - onEditingPriceChange({ - ...editingPrice, - intervalSelection: interval[0] === 1 ? interval[1] : 'custom', - intervalCount: interval[0], - priceInterval: interval[1], - }); - } else { - onEditingPriceChange({ - ...editingPrice, - intervalSelection: 'one-time', - intervalCount: 1, - priceInterval: undefined, - }); - } - }} - onIntervalSelectionChange={(v) => onEditingPriceChange({ ...editingPrice, intervalSelection: v })} - onIntervalCountChange={(v) => onEditingPriceChange({ ...editingPrice, intervalCount: v })} - onIntervalUnitChange={(v) => onEditingPriceChange({ ...editingPrice, priceInterval: v })} - allowedUnits={PRICE_INTERVAL_UNITS} - /> - {amountError && ( -

    {amountError}

    - )} -
    + { + if (!isOpen) { + handleClose(); + } else { + onOpenChange(isOpen); + } + }} + size="md" + icon={CurrencyDollarIcon} + title={isAdding ? "Add Price" : "Edit Price"} + description="Configure the pricing option for this product." + footer={( + <> + + + Cancel + + + { + if (!editingPrice || isSaving || amountError !== null) return; + setIsSaving(true); + runAsynchronouslyWithAlert(async () => { + try { + await onSave(editingPrice, isAdding); + } finally { + setIsSaving(false); + } + }); + }} + > + {isAdding ? "Add Price" : "Save Changes"} + + + )} + bodyClassName="space-y-5" + > + {editingPrice && ( + <> +
    + + { + if (v === '' || /^\d*(?:\.?\d{0,2})?$/.test(v)) { + onEditingPriceChange({ ...editingPrice, amount: v }); + } + }} + inputType="text" + placeholder="9.99" + prefix="$" + intervalSelection={editingPrice.intervalSelection} + intervalCount={editingPrice.intervalCount} + intervalUnit={editingPrice.priceInterval} + onIntervalChange={(interval) => { + if (interval) { + onEditingPriceChange({ + ...editingPrice, + intervalSelection: interval[0] === 1 ? interval[1] : 'custom', + intervalCount: interval[0], + priceInterval: interval[1], + }); + } else { + onEditingPriceChange({ + ...editingPrice, + intervalSelection: 'one-time', + intervalCount: 1, + priceInterval: undefined, + }); + } + }} + onIntervalSelectionChange={(v) => onEditingPriceChange({ ...editingPrice, intervalSelection: v })} + onIntervalCountChange={(v) => onEditingPriceChange({ ...editingPrice, intervalCount: v })} + onIntervalUnitChange={(v) => onEditingPriceChange({ ...editingPrice, priceInterval: v })} + allowedUnits={PRICE_INTERVAL_UNITS} + className="rounded-xl border-black/[0.08] dark:border-white/[0.08] focus-within:ring-1 focus-within:ring-foreground/[0.1]" + /> + {amountError && ( +

    {amountError}

    + )} +
    - {/* Free Trial & Server Only as EditableGrid */} - , - name: "Free Trial", - tooltip: "Free trial period before billing starts.", - children: ( - { - setPriceFreeTrialPopoverOpen(popoverOpen); - if (popoverOpen) { - // Initialize popover state from editingPrice - setPriceFreeTrialCount(editingPrice.freeTrialCount); - setPriceFreeTrialUnit(editingPrice.freeTrialUnit); - } - }} - > - - + + +
    +
    + setPriceFreeTrialCount(Math.max(1, parseInt(e.target.value) || 1))} + size="sm" + /> + setPriceFreeTrialUnit(v as DayInterval[1])} + options={freeTrialUnitOptions} + size="sm" + className="flex-1" + /> +
    +
    + { + onEditingPriceChange({ + ...editingPrice, + freeTrialEnabled: true, + freeTrialCount: Math.max(1, priceFreeTrialCount), + freeTrialUnit: priceFreeTrialUnit, + }); + setPriceFreeTrialPopoverOpen(false); + }} > - {editingPrice.freeTrialEnabled - ? `${editingPrice.freeTrialCount} ${editingPrice.freeTrialCount === 1 ? editingPrice.freeTrialUnit : editingPrice.freeTrialUnit + 's'}` - : 'None'} - - - -
    -
    - setPriceFreeTrialCount(parseInt(e.target.value) || 1)} - /> - -
    -
    - - {editingPrice.freeTrialEnabled && ( - - )} -
    -
    -
    - - ), - }, - // Server Only - { - type: 'boolean' as const, - icon: , - name: "Server Only", - tooltip: "Server-only prices can only be assigned through server-side API calls.", - value: editingPrice.serverOnly, - onUpdate: (value: boolean) => { - onEditingPriceChange({ ...editingPrice, serverOnly: value }); - return Promise.resolve(); - }, - }, - ]} - /> + Save +
    + {editingPrice.freeTrialEnabled && ( + { + onEditingPriceChange({ ...editingPrice, freeTrialEnabled: false }); + setPriceFreeTrialPopoverOpen(false); + }} + > + Remove + + )} +
    +
    +
    +
    +
    +
    +
    + + Server Only +
    + onEditingPriceChange({ ...editingPrice, serverOnly: v === 'true' })} + options={[ + { value: 'false', label: 'No' }, + { value: 'true', label: 'Yes' }, + ]} + size="md" + /> +
    +
  • - )} - - - - - - + + )} + ); } @@ -319,4 +345,3 @@ export function editingPriceToPrice(editing: EditingPrice): Price { ...(freeTrial && { freeTrial }), }; } - diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx index 7cd6f67872..ea7a55f3cf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx @@ -1,6 +1,7 @@ "use client"; -import { Button, SimpleTooltip, Typography } from "@/components/ui"; +import { DesignButton } from "@/components/design-components"; +import { SimpleTooltip, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; import { GiftIcon, PlusIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react"; import { useState } from "react"; @@ -75,15 +76,16 @@ export function PricingSection({ Click the + button to add your first price - +
    ) : (
    @@ -100,33 +102,38 @@ export function PricingSection({
    - - + +
    ))}
    - +
    )} @@ -170,13 +177,16 @@ export function PricingSection({
    {freePriceId}
    - + +
    ); @@ -193,22 +203,26 @@ export function PricingSection({ No prices configured yet

    - + {onMakeFree && ( - + )}
    @@ -244,42 +258,50 @@ export function PricingSection({
    {priceId}
    - - + +
    ); })}
    - + {onMakeFree && ( - + )}
    diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx index 37554157f4..4db5433744 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx @@ -1,9 +1,12 @@ "use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle, Switch, Typography } from "@/components/ui"; +import { Switch, Typography } from "@/components/ui"; +import { DesignCard } from "@/components/design-components"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; -import { ProhibitIcon } from "@phosphor-icons/react"; +import { LockIcon } from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useRef, useState } from "react"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; import { PaymentMethods } from "./payment-methods"; @@ -16,12 +19,29 @@ export default function PageClient() { const paymentsConfig = project.useConfig().payments; const updateConfig = useUpdateConfig(); - const handleBlockNewPurchasesToggle = async (checked: boolean) => { - await updateConfig({ - adminApp, - configUpdate: { "payments.blockNewPurchases": checked }, - pushable: true, - }); + const [optimisticBlocked, setOptimisticBlocked] = useState(null); + const latestRequestIdRef = useRef(0); + const blocked = optimisticBlocked ?? paymentsConfig.blockNewPurchases; + + const handleBlockChange = (checked: boolean) => { + setOptimisticBlocked(checked); + const nextRequestId = ++latestRequestIdRef.current; + runAsynchronouslyWithAlert((async () => { + try { + await updateConfig({ + adminApp, + configUpdate: { "payments.blockNewPurchases": checked }, + pushable: true, + }); + } finally { + // Only clear the optimistic value if this is the most recent toggle — + // otherwise a slow earlier request can clobber a newer optimistic value + // and momentarily snap the UI back to the stale config value. + if (nextRequestId === latestRequestIdRef.current) { + setOptimisticBlocked(null); + } + } + })()); }; return ( @@ -29,44 +49,39 @@ export default function PageClient() { title="Settings" description="Manage a few global payment behaviors." > -
    +
    - - - Block New Purchases - - Stops new checkouts while keeping existing subscriptions active. - - - -
    -
    -
    - -
    - + + +
    +
    +
    + +
    +
    + Block new purchases + + Stops new checkouts while keeping existing subscriptions active. +
    -
    - - + +
    +
    ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/payment-methods.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/payment-methods.tsx index 9e9072b712..1192fad89e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/payment-methods.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/payment-methods.tsx @@ -1,7 +1,9 @@ "use client"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Switch, Typography } from "@/components/ui"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Switch, Typography } from "@/components/ui"; import { getPaymentMethodIcon } from "@/components/ui/payment-method-icons"; +import { cn } from "@/lib/utils"; +import { DesignBadge, DesignButton, DesignCard } from "@/components/design-components"; import { BankIcon, CircleNotchIcon, CreditCardIcon, CurrencyCircleDollarIcon, GlobeIcon, HandCoinsIcon, LightningIcon, ReceiptIcon, WalletIcon } from "@phosphor-icons/react"; import { getPaymentMethodCategory, PAYMENT_CATEGORIES, PAYMENT_METHOD_DEPENDENCIES, PaymentMethodCategory } from "@stackframe/stack-shared/dist/payments/payment-methods"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; @@ -126,38 +128,32 @@ export function PaymentMethods() { if (loading) { return ( - - - Payment Methods - - Configure which payment methods your customers can use at checkout. - - - -
    - - Loading payment methods... -
    -
    -
    + +
    + + Loading payment methods… +
    +
    ); } if (!config) { return ( - - - Payment Methods - - Configure which payment methods your customers can use at checkout. - - - -
    - Failed to load payment methods. Please try again. -
    -
    -
    + +
    + Failed to load payment methods. Please try again. +
    +
    ); } @@ -185,19 +181,24 @@ export function PaymentMethods() { return (
    -
    +
    {BrandIcon ? ( ) : ( )} -
    - {method.name} -
    + {method.name} + {hasChanged && ( + + )}
    + + Cancel + + + Save Changes + +
    + ) : undefined; + return (
    - - -
    -
    - Payment Methods - - Configure which payment methods your customers can use at checkout. Some methods only appear for customers in specific regions, currencies, or transaction types. - -
    - {hasPendingChanges && ( -
    - - -
    - )} -
    -
    - - {controllableMethods.length === 0 ? ( - - No payment methods are currently available. Complete Stripe onboarding to enable payment methods. - - ) : ( - - {methodsByCategory.map(category => { - const CategoryIcon = category.icon; - const isEmpty = category.methods.length === 0; + + {controllableMethods.length === 0 ? ( + + No payment methods are currently available. Complete Stripe onboarding to enable payment methods. + + ) : ( + + {methodsByCategory.map(category => { + const CategoryIcon = category.icon; + const isEmpty = category.methods.length === 0; - return ( - + - -
    - - {category.name} - - ({category.methods.length}) - -
    -
    - - {isEmpty ? ( - - No methods available in this category. - - ) : ( -
    - {category.methods.map(renderMethodRow)} -
    - )} -
    -
    - ); - })} - - {uncategorizedMethods.length > 0 && ( - -
    - - Other - - ({uncategorizedMethods.length}) + + {category.name} + + ({category.methods.length})
    -
    - {uncategorizedMethods.map(renderMethodRow)} -
    + {isEmpty ? ( + + No methods available in this category. + + ) : ( +
    + {category.methods.map(renderMethodRow)} +
    + )}
    - )} -
    - )} -
    -
    + ); + })} + + {uncategorizedMethods.length > 0 && ( + + +
    + + Other + + ({uncategorizedMethods.length}) + +
    +
    + +
    + {uncategorizedMethods.map(renderMethodRow)} +
    +
    +
    + )} + + )} + {uncontrollableMethods.length > 0 && ( - - - Platform-Managed Methods - - These methods are controlled by the platform and cannot be customized. - - - + +
    {uncontrollableMethods.slice(0, 10).map((method) => (
    -
    - {method.name} -
    + + {method.name} +
    ))} {uncontrollableMethods.length > 10 && ( - - And {uncontrollableMethods.length - 10} more... + + And {uncontrollableMethods.length - 10} more… )} - - +
    +
    )}
    ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/stripe-connection-check.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/stripe-connection-check.tsx index c6938aee7f..8aeeb7e7fb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/stripe-connection-check.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/stripe-connection-check.tsx @@ -1,11 +1,69 @@ "use client"; -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Typography } from "@/components/ui"; +import { Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { ArrowRightIcon, CheckCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; -import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { DesignBadge, DesignButton, DesignCard } from "@/components/design-components"; +import { ArrowRightIcon, CheckCircleIcon, PlugsConnectedIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { useAdminApp } from "../../use-admin-app"; +type StatusVariant = "success" | "warning" | "error"; + +const statusBadgeColor: Record = { + success: "green", + warning: "orange", + error: "red", +}; + +const statusIconClasses: Record = { + success: "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400 ring-1 ring-emerald-500/20", + warning: "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400 ring-1 ring-amber-500/20", + error: "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400 ring-1 ring-red-500/20", +}; + +function StatusRow({ + variant, + icon: Icon, + title, + description, + badges, + action, +}: { + variant: StatusVariant, + icon: React.ElementType, + title: string, + description: string, + badges?: string[], + action?: React.ReactNode, +}) { + return ( +
    +
    +
    + +
    +
    + {title} + + {description} + + {badges && badges.length > 0 && ( +
    + {badges.map((label) => ( + + ))} +
    + )} +
    +
    + {action &&
    {action}
    } +
    + ); +} + export function StripeConnectionCheck() { const adminApp = useAdminApp(); const stripeAccountInfo = adminApp.useStripeAccountInfo(); @@ -16,40 +74,30 @@ export function StripeConnectionCheck() { await wait(2000); }; - // Not connected to Stripe if (!stripeAccountInfo) { return ( - - - Stripe Connection - - Connect your Stripe account to accept payments. - - - -
    -
    -
    - -
    -
    - Not connected - - Set up Stripe to start accepting payments. - -
    -
    - -
    -
    -
    + + } + /> + ); } - // Connected but onboarding incomplete if (!stripeAccountInfo.details_submitted) { const missingCapabilities = [ ...(!stripeAccountInfo.charges_enabled ? ["Charge customers"] : []), @@ -57,95 +105,48 @@ export function StripeConnectionCheck() { ]; return ( - - - Stripe Connection - - Your Stripe account connection status. - - - -
    -
    -
    - -
    -
    - Setup incomplete - - Complete onboarding to unlock full capabilities. - - {missingCapabilities.length > 0 && ( -
    - {missingCapabilities.map((item) => ( - - {item} - - ))} -
    - )} -
    -
    - -
    -
    -
    + + } + /> + ); } - // Fully connected + const enabledCapabilities = [ + ...(stripeAccountInfo.charges_enabled ? ["Charges enabled"] : []), + ...(stripeAccountInfo.payouts_enabled ? ["Payouts enabled"] : []), + ]; + return ( - - - Stripe Connection - - Your Stripe account connection status. - - - -
    -
    -
    - -
    -
    - Connected - - Your Stripe account is fully set up and ready to accept payments. - -
    - {[ - ...(stripeAccountInfo.charges_enabled ? ["Charges enabled"] : []), - ...(stripeAccountInfo.payouts_enabled ? ["Payouts enabled"] : []), - ].map((item) => ( - - {item} - - ))} -
    -
    -
    -
    -
    -
    + + + ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx index c03bf9a5cd..bebe8f4a1d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx @@ -1,8 +1,9 @@ "use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle, Switch, Typography } from "@/components/ui"; +import { Switch, Typography } from "@/components/ui"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; +import { DesignBadge, DesignCard } from "@/components/design-components"; import { FlaskIcon } from "@phosphor-icons/react"; import { useAdminApp } from "../../use-admin-app"; @@ -20,70 +21,53 @@ export function TestModeToggle() { }); }; + const isOn = paymentsConfig.testMode; + + const testModeBadges = [ + "No credit card required", + "Products granted instantly", + "No Stripe transactions", + ]; + return ( - - - Test Mode - - Switch between test and live payment environments. - - - -
    -
    -
    - -
    -
    - - {paymentsConfig.testMode ? "Test mode is active" : "Test mode is disabled"} - - - {paymentsConfig.testMode - ? "All checkouts are bypassed and no real payments are processed." - : "Checkouts will process real payments through Stripe." - } - -
    + +
    +
    +
    + +
    +
    + + {isOn ? "Test mode is active" : "Test mode is disabled"} + + + {isOn + ? "All checkouts are bypassed and no real payments are processed." + : "Checkouts will process real payments through Stripe." + } +
    -
    + +
    - {paymentsConfig.testMode && ( -
    - {[ - "No credit card required", - "Products granted instantly", - "No Stripe transactions", - ].map((item) => ( - - {item} - - ))} -
    - )} - - + {isOn && ( +
    + {testModeBadges.map((label) => ( + + ))} +
    + )} +
    ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index 20b687cc1c..d61e2432af 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -51,7 +51,11 @@ import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, v import { CSS } from '@dnd-kit/utilities'; import { ArrowsDownUpIcon, + CaretDownIcon, + CaretRightIcon, CheckIcon, + CheckCircleIcon, + CircleNotchIcon, ClockIcon, DotsSixVerticalIcon, FlaskIcon, @@ -59,8 +63,10 @@ import { PlusIcon, PulseIcon, ShieldCheckIcon, + SlidersIcon, TrashIcon, UserIcon, + XCircleIcon, XIcon, } from "@phosphor-icons/react"; import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; @@ -489,7 +495,6 @@ function RuleTriggerHistoryDialog({ timespanHours={timespanHours} isLoading={isSparklineLoading} /> - )} headerContent={(
    @@ -728,7 +733,7 @@ function SaveCancelButtons({ state, size = "sm" }: { state: RuleEditorState, siz function ConditionsPanel({ state }: { state: RuleEditorState }) { return ( -
    +
    ); @@ -737,7 +742,7 @@ function ConditionsPanel({ state }: { state: RuleEditorState }) { function NumberedStep({ n, title, children }: { n: number, title: string, children: React.ReactNode }) { return (
    -
    +
    {n}
    @@ -758,7 +763,7 @@ function RuleEditor(props: { const state = useRuleEditorState(props); return ( -
    +
    -
    - - {orderLabel} -
    -
    - {switchControl} -
    -
    -
    - {switchControl} + + {switchControl} +
    +
    {ruleName} +
    {conditionSummary}
    -
    - -
    e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > -
    +
    -
    - -
    void, }) { return ( -
    -
    +
    +
    If no rules match → {value === 'allow' ? 'Allow sign-up' : 'Reject sign-up'}
    - { if (!isDefaultAction(v)) { @@ -966,10 +961,9 @@ function DefaultActionRow({ } onChange(v); }} - className="w-32 shrink-0" options={[ - { value: "allow", label: "Allow" }, - { value: "reject", label: "Reject" }, + { id: "allow", label: "Allow" }, + { id: "reject", label: "Reject" }, ]} />
    @@ -1156,6 +1150,12 @@ function TestRulesCard({ state }: { state: TestRulesState }) {
    ); + const fieldLabel = (text: string) => ( + + {text} + + ); + return (
    @@ -1494,31 +1494,22 @@ function TestRulesDialog({ } function TestRulesPanel({ stackAdminApp }: { stackAdminApp: ReturnType }) { - const state = useTestRulesState(stackAdminApp); + const triggerButton = ( + + + Open tester + + ); return ( - - - -
    -
    - -
    -
    - Test rules - - Try sample sign-ups without touching the live flow - -
    -
    -
    - -
    - -
    -
    -
    -
    + +
    + + Run a simulated sign-up against your current ruleset and see the outcome. + + +
    +
    ); } @@ -1625,7 +1616,9 @@ type PageBodyProps = { isCreatingNew: boolean, newRuleId: string | null, editingRuleId: string | null, + hasOrderChanges: boolean, defaultAction: 'allow' | 'reject', + isSavingOrder: boolean, onAddRule: () => void, onSaveRule: (ruleId: string, rule: SignUpRule) => Promise, onCancelEdit: () => void, @@ -1634,6 +1627,8 @@ type PageBodyProps = { onToggleEnabled: (id: string, enabled: boolean) => void, onDefaultActionChange: (value: 'allow' | 'reject') => void, onDragEnd: (event: DragEndEvent) => void, + onSaveOrder: () => Promise, + onDiscardOrder: () => void, stackAdminApp: ReturnType, }; @@ -1643,55 +1638,30 @@ function PageBody(props: PageBodyProps) { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); - const renderRules = () => { - if (props.signUpRules.length === 0) { - return ( - - ); - } - - return ( -
    -
    - Order - On - Rule / condition - Action - Activity - + const renderRules = () => ( + + r.id)} strategy={verticalListSortingStrategy}> +
    + {props.signUpRules.map((entry, index) => ( + props.onEditRule(entry.id)} + onDelete={() => props.onRequestDelete(entry)} + onToggleEnabled={(enabled) => props.onToggleEnabled(entry.id, enabled)} + onSave={props.onSaveRule} + onCancelEdit={props.onCancelEdit} + /> + ))}
    - - r.id)} strategy={verticalListSortingStrategy}> -
    - {props.signUpRules.map((entry, index) => ( - props.onEditRule(entry.id)} - onDelete={() => props.onRequestDelete(entry)} - onToggleEnabled={(enabled) => props.onToggleEnabled(entry.id, enabled)} - onSave={props.onSaveRule} - onCancelEdit={props.onCancelEdit} - /> - ))} -
    -
    -
    - -
    - ); - }; + + + ); return (
    @@ -1705,20 +1675,44 @@ function PageBody(props: PageBodyProps) { /> )} - {(!props.isCreatingNew || props.signUpRules.length > 0) && ( - renderRules() + {props.hasOrderChanges && ( + + + Save to commit, or discard to revert. + + } + /> )} - {props.signUpRules.length === 0 && props.isCreatingNew && ( -
    - +
    )} -
    + {props.signUpRules.length > 0 && renderRules()} + + {props.signUpRules.length === 0 && !props.isCreatingNew && ( + + )} + + + +
    @@ -1918,22 +1912,13 @@ export default function PageClient() { title="Sign-up Rules" description="Create rules to control who can sign up. Rules are evaluated in order from top to bottom." actions={ -
    - {hasOrderChanges && ( - - )} - - - Add rule - -
    + + + Add rule + } > runAsynchronouslyWithAlert(handleToggleEnabled(id, enabled))} onDefaultActionChange={(v) => runAsynchronouslyWithAlert(handleDefaultActionChange(v))} onDragEnd={handleDragEnd} + onSaveOrder={handleSaveOrderAsync} + onDiscardOrder={handleDiscardOrder} stackAdminApp={stackAdminApp} /> diff --git a/apps/dashboard/src/components/design-components/index.ts b/apps/dashboard/src/components/design-components/index.ts index e92fd172b1..ace84fe7c2 100644 --- a/apps/dashboard/src/components/design-components/index.ts +++ b/apps/dashboard/src/components/design-components/index.ts @@ -1,4 +1,17 @@ export * from "@stackframe/dashboard-ui-components"; +export { + DesignDialog, + DesignDialogClose, + DesignDialogDescription, + DesignDialogRoot, + DesignDialogTitle, + DesignDialogTrigger, +} from "../../../../../packages/dashboard-ui-components/src/components/dialog"; +export type { + DesignDialogProps, + DesignDialogSize, + DesignDialogVariant, +} from "../../../../../packages/dashboard-ui-components/src/components/dialog"; export * from "./analytics-card"; export * from "./editable-grid"; export * from "./list"; diff --git a/apps/dashboard/src/components/payments/item-dialog.tsx b/apps/dashboard/src/components/payments/item-dialog.tsx index 47c4739b43..c35e2b0f72 100644 --- a/apps/dashboard/src/components/payments/item-dialog.tsx +++ b/apps/dashboard/src/components/payments/item-dialog.tsx @@ -1,10 +1,18 @@ "use client"; -import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Typography } from "@/components/ui"; +import { + DesignButton, + DesignDialog, + DesignDialogClose, + DesignInput, + DesignSelectorDropdown, +} from "@/components/design-components"; +import { Label, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; import { PackageIcon } from "@phosphor-icons/react"; import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@stackframe/stack-shared/dist/schema-fields"; -import { useEffect, useState } from "react"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useEffect, useMemo, useState } from "react"; type ItemDialogProps = { open: boolean, @@ -19,6 +27,12 @@ type ItemDialogProps = { forceCustomerType?: 'user' | 'team' | 'custom', }; +const CUSTOMER_TYPE_OPTIONS = [ + { value: 'user', label: 'User' }, + { value: 'team', label: 'Team' }, + { value: 'custom', label: 'Custom' }, +] as const; + export function ItemDialog({ open, onOpenChange, @@ -32,10 +46,14 @@ export function ItemDialog({ const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(forceCustomerType || editingItem?.customerType || 'user'); const [errors, setErrors] = useState>({}); + const customerTypeDropdownOptions = useMemo( + () => CUSTOMER_TYPE_OPTIONS.map((o) => ({ value: o.value, label: o.label })), + [] + ); + const validateAndSave = async () => { const newErrors: Record = {}; - // Validate item ID if (!itemId.trim()) { newErrors.itemId = "Item ID is required"; } else if (!isValidUserSpecifiedId(itemId)) { @@ -44,7 +62,6 @@ export function ItemDialog({ newErrors.itemId = "This item ID already exists"; } - // Validate display name if (!displayName.trim()) { newErrors.displayName = "Display name is required"; } @@ -80,163 +97,112 @@ export function ItemDialog({ }; return ( - - - -
    -
    - -
    -
    - - {editingItem ? "Edit Item" : "Create Item"} - - - Items are features or services that customers receive. - -
    -
    -
    - -
    - {/* Item ID */} -
    - - { - const nextValue = sanitizeUserSpecifiedId(e.target.value); - setItemId(nextValue); - if (errors.itemId) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors.itemId; - return newErrors; - }); - } - }} - placeholder="e.g., api-calls" - disabled={!!editingItem} - className={cn( - "h-10 rounded-xl font-mono text-sm", - "bg-foreground/[0.03] border-border/50 dark:border-foreground/[0.1]", - "focus:ring-1 focus:ring-cyan-500/30 focus:border-cyan-500/50", - "transition-all duration-150 hover:transition-none", - errors.itemId && "border-destructive focus:ring-destructive/30" - )} - /> - {errors.itemId ? ( - - {errors.itemId} - - ) : ( - - Unique identifier used in your code - - )} -
    - - {/* Display Name */} -
    - - { - setDisplayName(e.target.value); - if (errors.displayName) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors.displayName; - return newErrors; - }); - } - }} - placeholder="e.g., API Calls" - className={cn( - "h-10 rounded-xl text-sm", - "bg-foreground/[0.03] border-border/50 dark:border-foreground/[0.1]", - "focus:ring-1 focus:ring-cyan-500/30 focus:border-cyan-500/50", - "transition-all duration-150 hover:transition-none", - errors.displayName && "border-destructive focus:ring-destructive/30" - )} - /> - {errors.displayName ? ( - - {errors.displayName} - - ) : ( - - How this item appears to customers - + { + if (!nextOpen) handleClose(); + }} + size="md" + icon={PackageIcon} + title={editingItem ? "Edit Item" : "Create Item"} + description="Items are features or services that customers receive." + footer={( + <> + + Cancel + + runAsynchronouslyWithAlert(validateAndSave)}> + {editingItem ? "Save Changes" : "Create Item"} + + + )} + > +
    +
    + + { + const nextValue = sanitizeUserSpecifiedId(e.target.value); + setItemId(nextValue); + if (errors.itemId) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.itemId; + return newErrors; + }); + } + }} + placeholder="e.g., api-calls" + disabled={!!editingItem} + size="md" + className={cn( + "font-mono text-sm", + errors.itemId && "border-destructive focus-visible:ring-destructive/30" )} -
    - - {/* Customer Type */} -
    - - + /> + {errors.itemId ? ( + + {errors.itemId} + + ) : ( - Which type of customer can hold this item + Unique identifier used in your code -
    + )}
    - - - - - -
    +
    + + { + setDisplayName(e.target.value); + if (errors.displayName) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.displayName; + return newErrors; + }); + } + }} + placeholder="e.g., API Calls" + size="md" + className={cn(errors.displayName && "border-destructive focus-visible:ring-destructive/30")} + /> + {errors.displayName ? ( + + {errors.displayName} + + ) : ( + + How this item appears to customers + + )} +
    + +
    + + setCustomerType(value as typeof customerType)} + options={customerTypeDropdownOptions} + disabled={!!forceCustomerType} + size="md" + /> + + Which type of customer can hold this item + +
    +
    + ); } diff --git a/apps/dashboard/src/components/repeating-input.tsx b/apps/dashboard/src/components/repeating-input.tsx index d6ced0a445..dd961b730b 100644 --- a/apps/dashboard/src/components/repeating-input.tsx +++ b/apps/dashboard/src/components/repeating-input.tsx @@ -1,15 +1,10 @@ "use client"; +import { DesignInput, DesignSelectorDropdown } from "@/components/design-components"; import { - Input, Popover, PopoverContent, PopoverTrigger, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, } from "@/components/ui"; import { cn } from "@/lib/utils"; import { CaretUpDownIcon } from "@phosphor-icons/react"; @@ -21,7 +16,6 @@ const DEFAULT_INTERVAL_UNITS: DayInterval[1][] = ['day', 'week', 'month', 'year' type IntervalSelection = 'one-time' | 'custom' | DayInterval[1]; export type RepeatingInputProps = { - // Input value value: string, onValueChange: (value: string) => void, inputType?: 'text' | 'number', @@ -29,7 +23,6 @@ export type RepeatingInputProps = { prefix?: string, inputClassName?: string, - // Interval/frequency intervalSelection: IntervalSelection, intervalCount: number, intervalUnit?: DayInterval[1], @@ -38,7 +31,6 @@ export type RepeatingInputProps = { onIntervalCountChange: (count: number) => void, onIntervalUnitChange: (unit: DayInterval[1] | undefined) => void, - // Options allowedUnits?: DayInterval[1][], noneLabel?: string, useDurationLabels?: boolean, @@ -150,75 +142,87 @@ export function RepeatingInput({ const triggerLabel = getIntervalLabel(effectiveSelection, intervalCount, effectiveUnit, useDurationLabels); return ( -
    - {/* Input field */} -
    - {prefix && ( - - {prefix} - +
    + {prefix && ( +
    + {prefix} +
    + )} + onValueChange(e.target.value)} + placeholder={placeholder} + disabled={disabled || readOnly} + className={cn( + "min-w-0 flex-1 bg-transparent px-3 text-sm text-foreground", + "placeholder:text-muted-foreground/50", + "focus:outline-none", + "disabled:cursor-not-allowed", + inputClassName )} - onValueChange(e.target.value)} - placeholder={placeholder} - disabled={disabled || readOnly} - className={cn( - "rounded-r-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0", - prefix && "!pl-7", - inputClassName - )} - /> -
    + /> - {/* Frequency dropdown button */} !readOnly && !disabled && setPopoverOpen(isOpen)}> - +
    - {/* One-time option */} - {/* Fixed interval options */} {normalizedUnits.map((unitOption) => (
    - {/* Custom interval option */} -
    +
    Custom
    - { const val = parseInt(e.target.value, 10); @@ -247,28 +247,19 @@ export function RepeatingInput({ } }} /> - + options={normalizedUnits.map((u) => ({ + value: u, + label: `${u}${(effectiveSelection === 'custom' ? intervalCount : 1) !== 1 ? 's' : ''}`, + }))} + size="sm" + className="min-w-0 flex-1" + />
    @@ -276,4 +267,3 @@ export function RepeatingInput({
    ); } - diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx index 901ea7ca72..b200fed73d 100644 --- a/apps/dashboard/src/components/ui/dropdown-menu.tsx +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -143,7 +143,10 @@ const DropdownMenuItem = forwardRefIfNeeded< return React.ReactNode): (props: React.SVGProps) => React.ReactNode { diff --git a/examples/demo/src/app/payments-demo/page.tsx b/examples/demo/src/app/payments-demo/page.tsx index 740dcfbb8b..10a155f815 100644 --- a/examples/demo/src/app/payments-demo/page.tsx +++ b/examples/demo/src/app/payments-demo/page.tsx @@ -144,7 +144,7 @@ function CheckoutButton(props: { return ( diff --git a/packages/dashboard-ui-components/src/components/dialog.tsx b/packages/dashboard-ui-components/src/components/dialog.tsx index f0fc7c4238..b7dc51782f 100644 --- a/packages/dashboard-ui-components/src/components/dialog.tsx +++ b/packages/dashboard-ui-components/src/components/dialog.tsx @@ -106,7 +106,10 @@ export function DesignDialog({ const resolvedOverlayClass = cn(dialogOverlayClasses.get(variant), overlayClassName); const shouldRenderTopHeaderRow = Icon != null || title != null || description != null; const shouldRenderHeader = customHeader != null || shouldRenderTopHeaderRow || headerContent != null; - const shouldRenderBody = React.Children.count(children) > 0; + // Use toArray + filter(Boolean) instead of Children.count so that + // expressions like `{condition && }` resolving to `false` don't + // produce an empty DialogBody (which would still render padding/borders). + const shouldRenderBody = React.Children.toArray(children).filter(Boolean).length > 0; const hasStandardTitle = title != null; const needsAccessibleTitleFallback = !hasStandardTitle && customHeader == null; diff --git a/packages/stack-ui/src/components/ui/popover.tsx b/packages/stack-ui/src/components/ui/popover.tsx index bd4ea68bf1..d7fdcc0a28 100644 --- a/packages/stack-ui/src/components/ui/popover.tsx +++ b/packages/stack-ui/src/components/ui/popover.tsx @@ -22,7 +22,7 @@ const PopoverContent = forwardRefIfNeeded< align={align} sideOffset={sideOffset} className={cn( - "stack-scope z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 z-50 pointer-events-auto w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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", className )} {...props}