+ const expiresSelectOptions = useMemo(() => EXPIRES_OPTIONS
+ .filter(option => !option.requiresRepeat || hasRepeat)
+ .map(option => ({ value: option.value, label: option.label })), [hasRepeat]);
- {/* Quantity */}
-
-
-
{
- setQuantity(e.target.value);
- if (errors.quantity) {
+ 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}
+
+ )}
+
- {/* Repeat */}
-
-
-
{
+ {/* 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 */}
+
+
+ {
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 && (
+
+
+
+ Repeat Interval
-
-
- {hasRepeat && (
-
-
-
- Repeat Interval
-
-
-
-
{
+
+ {
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={REPEAT_UNIT_OPTIONS}
+ size="md"
+ className="min-w-0 flex-1"
+ />
- )}
-
+ {errors.repeatCount && (
+
+ {errors.repeatCount}
+
+ )}
+
+ )}
+
- {/* Expiration */}
-
-
-
- Expiration
-
-
-
-
-
-
-
-
-
+ {/* 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..efc800edb8 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' },
+];
+
const COLOR_CLASSES = {
blue: {
hover: 'hover:border-blue-500/40 hover:shadow-[0_0_12px_rgba(59,130,246,0.1)]',
@@ -341,6 +346,17 @@ 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]);
+
// Computed values
const existingProducts = typedEntries(paymentsConfig.products)
.map(([id, product]) => ({
@@ -610,11 +626,19 @@ ${Object.entries(prices).map(([id, price]) => {
onBack={handleBack}
actions={
<>
-
+
{isInlineProduct ? (
-
+
) : (
-
+
-
+ Create Product
+
-
-
+
-
+
}
onClick={() => {
const code = generateInlineProductCode();
runAsynchronouslyWithAlert(async () => {
@@ -661,12 +691,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 +703,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 +717,13 @@ ${Object.entries(prices).map(([id, price]) => {
{/* Left side - Configuration form */}
-
+
{/* Display Name and Product ID - same row */}
{/* Display Name */}
-
+
Display Name
- {
@@ -717,15 +744,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 +756,9 @@ ${Object.entries(prices).map(([id, price]) => {
{/* Product ID */}
-
+
Product ID
-
{
@@ -752,15 +774,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 +791,7 @@ ${Object.entries(prices).map(([id, price]) => {
{/* Pricing Section */}
- Pricing
+ Pricing
{
@@ -795,15 +815,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 +841,44 @@ ${Object.entries(prices).map(([id, price]) => {
-
+
{getItemDisplay(itemId, item, existingItems)}
-
{itemId}
+
{itemId}
-
-
-
+
+
))}
-
+
)}
{/* Options Section - Two column grid */}
- Options
+ Options
{/* Stackable */}
@@ -979,7 +1006,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={FREE_TRIAL_UNIT_OPTIONS}
+ size="sm"
+ className="w-28 shrink-0"
+ />
)}
@@ -1010,7 +1031,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 +1068,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 +1083,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..d935e5bf6b 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,26 @@
"use client";
-import { EditableGrid } from "@/components/editable-grid";
import { RepeatingInput } from "@/components/repeating-input";
import {
- Button,
+ DesignButton,
+ DesignDialog,
+ DesignDialogClose,
+ DesignInput,
+ DesignSelectorDropdown,
+ designFieldTriggerClasses,
+ designPopoverSurfaceClasses,
+} 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,6 +68,15 @@ 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);
@@ -82,187 +86,201 @@ export function PriceEditDialog({
const amountError = editingPrice ? validateEditingPriceAmount(editingPrice) : null;
return (
-
+ >
+ )}
+
);
}
@@ -319,4 +337,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
-
Add Price
-
+
) : (
@@ -100,33 +102,38 @@ export function PricingSection({
- handleEditClick(id)}
>
Edit
-
-
+ handleRemovePrice(id)}
>
-
-
+
+
))}
)}
@@ -170,13 +177,16 @@ export function PricingSection({
{freePriceId}
- onPricesChange({})}
>
-
-
+
+
);
@@ -193,22 +203,26 @@ export function PricingSection({
No prices configured yet
-
Add Price
-
+
{onMakeFree && (
-
Make Free
-
+
)}
@@ -244,42 +258,50 @@ export function PricingSection({
{priceId}
- handleEditClick(priceId)}
>
Edit
-
-
+ handleRemovePrice(priceId)}
>
-
-
+
+
);
})}
-
Add Price
-
+
{onMakeFree && (
-
Make Free
-
+
)}
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 && (
-
-
- Cancel
-
- runAsynchronouslyWithAlert(handleSave)} disabled={saving}>
- {saving ? "Saving..." : "Save Changes"}
-
-
- )}
-
-
-
- {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.
-
-
-
-
+
+ runAsynchronouslyWithAlert(setupPayments)} size="sm" className="gap-1.5">
Connect Stripe
-
-
-
-
+
+ }
+ />
+
);
}
- // 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}
-
- ))}
-
- )}
-
-
-
+
+ runAsynchronouslyWithAlert(setupPayments)} size="sm" variant="outline" className="gap-1.5">
Continue setup
-
-
-
-
+
+ }
+ />
+
);
}
- // 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..31fbb91e34 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 isTestModeOn = 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."
- }
-
-
+
+
+
+
+
+
+
+
+ {isTestModeOn ? "Test mode is active" : "Test mode is disabled"}
+
+
+ {isTestModeOn
+ ? "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}
-
- ))}
-
- )}
-
-
+ {isTestModeOn && (
+
+ {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..fc2cbcbb04 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";
@@ -728,7 +734,7 @@ function SaveCancelButtons({ state, size = "sm" }: { state: RuleEditorState, siz
function ConditionsPanel({ state }: { state: RuleEditorState }) {
return (
-
+
);
@@ -737,7 +743,7 @@ function ConditionsPanel({ state }: { state: RuleEditorState }) {
function NumberedStep({ n, title, children }: { n: number, title: string, children: React.ReactNode }) {
return (
-
+
{n}
@@ -758,7 +764,7 @@ function RuleEditor(props: {
const state = useRuleEditorState(props);
return (
-
+
-
-
- {orderLabel}
-
-
- {switchControl}
-
-
-
-
{switchControl}
+
+ {switchControl}
+
-
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 +962,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" },
]}
/>
@@ -1494,31 +1489,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 +1611,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 +1622,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 +1633,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 +1670,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 +1907,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/design-tokens.ts b/apps/dashboard/src/components/design-components/design-tokens.ts
new file mode 100644
index 0000000000..4c7bcdcbe1
--- /dev/null
+++ b/apps/dashboard/src/components/design-components/design-tokens.ts
@@ -0,0 +1,15 @@
+export const designFieldTriggerClasses = [
+ "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",
+].join(" ");
+
+export const designPopoverSurfaceClasses = [
+ "rounded-xl border border-black/[0.08] dark:border-white/[0.08]",
+ "bg-white/95 dark:bg-background/95 backdrop-blur-xl",
+ "shadow-lg ring-1 ring-black/[0.06] dark:ring-white/[0.06]",
+].join(" ");
diff --git a/apps/dashboard/src/components/design-components/index.ts b/apps/dashboard/src/components/design-components/index.ts
index e92fd172b1..92bc3c05fa 100644
--- a/apps/dashboard/src/components/design-components/index.ts
+++ b/apps/dashboard/src/components/design-components/index.ts
@@ -1,5 +1,19 @@
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 "./design-tokens";
export * from "./editable-grid";
export * from "./list";
export * from "./menu";
diff --git a/apps/dashboard/src/components/payments/item-dialog.tsx b/apps/dashboard/src/components/payments/item-dialog.tsx
index 47c4739b43..d921bde744 100644
--- a/apps/dashboard/src/components/payments/item-dialog.tsx
+++ b/apps/dashboard/src/components/payments/item-dialog.tsx
@@ -1,9 +1,17 @@
"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 { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useEffect, useState } from "react";
type ItemDialogProps = {
@@ -19,6 +27,12 @@ type ItemDialogProps = {
forceCustomerType?: 'user' | 'team' | 'custom',
};
+const CUSTOMER_TYPE_OPTIONS: { value: 'user' | 'team' | 'custom', label: string }[] = [
+ { value: 'user', label: 'User' },
+ { value: 'team', label: 'Team' },
+ { value: 'custom', label: 'Custom' },
+];
+
export function ItemDialog({
open,
onOpenChange,
@@ -35,7 +49,6 @@ export function ItemDialog({
const validateAndSave = async () => {
const newErrors: Record = {};
- // Validate item ID
if (!itemId.trim()) {
newErrors.itemId = "Item ID is required";
} else if (!isValidUserSpecifiedId(itemId)) {
@@ -44,7 +57,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 +92,112 @@ export function ItemDialog({
};
return (
-