diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx new file mode 100644 index 0000000000..fa02732b29 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx @@ -0,0 +1,218 @@ +import { cn } from "@/lib/utils"; +import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { + Button, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, +} from "@stackframe/stack-ui"; +import { ChevronsUpDown } from "lucide-react"; +import { useState } from "react"; +import { DEFAULT_INTERVAL_UNITS, intervalLabel } from "./utils"; + +// ============================================================================ +// Small UI Components +// ============================================================================ + +/** + * OR separator with lines on both sides + */ +export function OrSeparator() { + return ( +
+
+ +
+
OR
+
+ +
+
+ ); +} + +/** + * Section heading with horizontal lines + */ +export function SectionHeading({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + +// ============================================================================ +// Interval Popover Component +// ============================================================================ + +type IntervalPopoverProps = { + readOnly?: boolean, + intervalText: string | null, + intervalSelection: 'one-time' | 'custom' | DayInterval[1], + unit: DayInterval[1] | undefined, + count: number, + setIntervalSelection: (s: 'one-time' | 'custom' | DayInterval[1]) => void, + setUnit: (u: DayInterval[1] | undefined) => void, + setCount: (n: number) => void, + onChange: (interval: DayInterval | null) => void, + noneLabel?: string, + allowedUnits?: DayInterval[1][], + triggerClassName?: string, + useDurationLabels?: boolean, +}; + +/** + * Reusable interval selector with preset options and custom input + */ +export function IntervalPopover({ + readOnly, + intervalText, + intervalSelection, + unit, + count, + setIntervalSelection, + setUnit, + setCount, + onChange, + noneLabel = 'one time', + allowedUnits, + triggerClassName, + useDurationLabels = false, +}: IntervalPopoverProps) { + const [open, setOpen] = useState(false); + + const buttonLabels: Record = useDurationLabels ? { + day: '1 day', + week: '1 week', + month: '1 month', + year: '1 year', + } : { + day: 'daily', + week: 'weekly', + month: 'monthly', + year: 'yearly', + }; + + const units = allowedUnits ?? DEFAULT_INTERVAL_UNITS; + const normalizedUnits = units.length > 0 ? units : DEFAULT_INTERVAL_UNITS; + const defaultUnit = (normalizedUnits[0] ?? 'month') as DayInterval[1]; + const effectiveUnit = unit && normalizedUnits.includes(unit) ? unit : defaultUnit; + const isIntervalUnit = intervalSelection !== 'custom' && intervalSelection !== 'one-time'; + const effectiveSelection: 'one-time' | 'custom' | DayInterval[1] = + isIntervalUnit && !normalizedUnits.includes(intervalSelection) + ? 'custom' + : intervalSelection; + + const selectOneTime = () => { + setIntervalSelection('one-time'); + setUnit(undefined); + setCount(1); + if (!readOnly) onChange(null); + setOpen(false); + }; + + const selectFixed = (unitOption: DayInterval[1]) => { + if (!normalizedUnits.includes(unitOption)) return; + setIntervalSelection(unitOption); + setUnit(unitOption); + setCount(1); + if (!readOnly) onChange([1, unitOption]); + setOpen(false); + }; + + const applyCustom = (countValue: number, maybeUnit?: DayInterval[1]) => { + const safeUnit = maybeUnit && normalizedUnits.includes(maybeUnit) ? maybeUnit : defaultUnit; + setIntervalSelection('custom'); + setUnit(safeUnit); + setCount(countValue); + if (!readOnly) onChange([countValue, safeUnit]); + }; + + const triggerLabel = intervalText || noneLabel; + const triggerClasses = triggerClassName ?? "text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground cursor-pointer select-none flex items-center gap-1"; + + return ( + + +
+ {triggerLabel} + +
+
+ +
+ {/* One-time option */} + + + {/* Fixed interval options */} + {normalizedUnits.map((unitOption) => ( + + ))} + + {/* Custom interval option */} + +
+
Custom
+
+ { + const val = parseInt(e.target.value, 10); + if (val > 0) { + applyCustom(val, effectiveUnit); + } + }} + /> + +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx index 297fc64f76..64b9f934a9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx @@ -71,7 +71,8 @@ export function CreateCatalogDialog({ open, onOpenChange, onCreate }: CreateCata id="catalog-id" value={catalogId} onChange={(e) => { - setCatalogId(e.target.value); + const value = e.target.value.toLowerCase().replace(/[^a-z0-9_\-]/g, '-'); + setCatalogId(value); setErrors(prev => ({ ...prev, id: undefined })); }} placeholder="e.g., pricing-tiers" diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx index ae0982ad6a..143ad86590 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx @@ -5,10 +5,10 @@ import { SelectField } from "@/components/form-fields"; import { Link } from "@/components/link"; import { StripeConnectProvider } from "@/components/payments/stripe-connect-provider"; import { cn } from "@/lib/utils"; -import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { ActionDialog, Button, Card, CardContent, Typography } from "@stackframe/stack-ui"; import { ConnectNotificationBanner } from "@stripe/react-connect-js"; -import { AlertTriangle, ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react"; +import { AlertTriangle, ArrowRight, BarChart3, FlaskConical, Repeat, Shield, Wallet, Webhook } from "lucide-react"; import { useState } from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../../app-enabled-guard"; @@ -26,6 +26,8 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) { const [bannerHasItems, setBannerHasItems] = useState(false); const stackAdminApp = useAdminApp(); const stripeAccountInfo = stackAdminApp.useStripeAccountInfo(); + const project = stackAdminApp.useProject(); + const paymentsConfig = project.useConfig().payments; const setupPayments = async () => { const { url } = await stackAdminApp.setupPayments(); @@ -33,6 +35,10 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) { await wait(2000); }; + const handleDisableTestMode = async () => { + await project.updateConfig({ "payments.testMode": false }); + }; + if (!stripeAccountInfo) { return (
@@ -74,7 +80,35 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) { return ( - {!stripeAccountInfo.details_submitted && ( + {paymentsConfig.testMode ? ( +
+
+
+
+
+ + + You are currently in test mode + +
+ + All purchases are currently free and no money will be deducted. + +
+
+ +
+
+
+
+ ) : !stripeAccountInfo.details_submitted && (
@@ -103,7 +137,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
- {normalizedUnits.map((unitOption) => ( - - ))} - - - - {effectiveSelection === 'custom' && ( -
-
Custom
-
-
every
-
- { - const v = e.target.value; - if (!/^\d*$/.test(v)) return; - const n = v === '' ? 0 : parseInt(v, 10); - applyCustom(n, effectiveUnit); - }} - /> -
-
- -
-
-
- )} -
- - - ); +import { ProductPriceRow } from "./product-price-row"; +import { + generateUniqueId, + intervalLabel, + shortIntervalLabel, + type Price, + type PricesObject, + type Product +} from "./utils"; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Generates a unique product/item ID + */ +function generateProductId(prefix: string): string { + return generateUniqueId(prefix); } +// ============================================================================ +// Product Editable Input Component +// ============================================================================ type ProductEditableInputProps = { value: string, - onUpdate?: (value: string) => void | Promise, + onUpdate?: (value: string) => void, readOnly?: boolean, placeholder?: string, inputClassName?: string, @@ -283,7 +101,7 @@ function ProductEditableInput({ onChange={(event) => { const rawValue = event.target.value; const nextValue = transform ? transform(rawValue) : rawValue; - void onUpdate?.(nextValue); + onUpdate?.(nextValue); }} placeholder={placeholder} autoComplete="off" @@ -298,124 +116,9 @@ function ProductEditableInput({ ); } - -function ProductPriceRow({ - priceId, - price, - readOnly, - startEditing, - onSave, - onRemove, - existingPriceIds, -}: { - priceId: string, - price: (Product['prices'] & object)[string], - readOnly?: boolean, - startEditing?: boolean, - onSave: (newId: string | undefined, price: (Product['prices'] & object)[string]) => void, - onRemove?: () => void, - existingPriceIds: string[], -}) { - const [isEditing, setIsEditing] = useState(!!startEditing && !readOnly); - const [amount, setAmount] = useState(price.USD || '0.00'); - const [priceInterval, setPriceInterval] = useState(price.interval?.[1]); - const [intervalCount, setIntervalCount] = useState(price.interval?.[0] || 1); - const [intervalSelection, setIntervalSelection] = useState<'one-time' | 'custom' | DayInterval[1]>( - price.interval ? (price.interval[0] === 1 ? price.interval[1] : 'custom') : 'one-time' - ); - - const niceAmount = +amount; - - useEffect(() => { - if (isEditing) return; - setAmount(price.USD || '0.00'); - setPriceInterval(price.interval?.[1]); - setIntervalCount(price.interval?.[0] || 1); - setIntervalSelection(price.interval ? (price.interval[0] === 1 ? price.interval[1] : 'custom') : 'one-time'); - }, [price, isEditing]); - - - useEffect(() => { - if (!readOnly && startEditing) setIsEditing(true); - if (readOnly) setIsEditing(false); - }, [startEditing, readOnly]); - - - const intervalText = intervalLabel(price.interval); - - return ( -
- {isEditing ? ( - <> -
- $ - { - const v = e.target.value; - if (v === '' || /^\d*(?:\.?\d{0,2})?$/.test(v)) setAmount(v); - if (!readOnly) { - const normalized = v === '' ? '0.00' : (Number.isNaN(parseFloat(v)) ? '0.00' : parseFloat(v).toFixed(2)); - const intervalObj = intervalSelection === 'one-time' ? undefined : ([ - intervalSelection === 'custom' ? intervalCount : 1, - (intervalSelection === 'custom' ? (priceInterval || 'month') : intervalSelection) as DayInterval[1] - ] as DayInterval); - const updated: Price = { - USD: normalized, - serverOnly: !!price.serverOnly, - ...(intervalObj ? { interval: intervalObj } : {}), - }; - onSave(undefined, updated); - } - }} - /> -
- -
- { - if (readOnly) return; - const normalized = amount === '' ? '0.00' : (Number.isNaN(parseFloat(amount)) ? '0.00' : parseFloat(amount).toFixed(2)); - const updated: Price = { - USD: normalized, - serverOnly: !!price.serverOnly, - ...(interval ? { interval } : {}), - }; - onSave(undefined, updated); - }} - /> -
- - {onRemove && ( - - )} - - ) : ( - <> -
${niceAmount}
-
{intervalText ?? 'one-time'}
- - )} -
- ); -} +// ============================================================================ +// Product Item Row Component +// ============================================================================ const EXPIRES_OPTIONS: Array<{ value: Product["includedItems"][string]["expires"], label: string, description: string }> = [ { @@ -426,7 +129,7 @@ const EXPIRES_OPTIONS: Array<{ value: Product["includedItems"][string]["expires" { value: 'when-purchase-expires' as const, label: 'When purchase expires', - description: 'items granted are removed when subscription ends' + description: 'Items granted are removed when subscription ends' }, { value: 'when-repeated' as const, @@ -460,7 +163,7 @@ function ProductItemRow({ allItems: Array<{ id: string, displayName: string, customerType: string }>, existingIncludedItemIds: string[], onChangeItemId: (newItemId: string) => void, - onCreateNewItem: () => void, + onCreateNewItem: (customerType?: 'user' | 'team' | 'custom') => void, }) { const [isEditing, setIsEditing] = useState(false); const [isOpen, setIsOpen] = useState(false); @@ -496,68 +199,74 @@ function ProductItemRow({ if (isEditing) { return ( -
-
- - -
- - {itemDisplayName} - - -
-
- -
- {allItems.map((opt) => { - const isSelected = opt.id === itemId; - const isUsed = existingIncludedItemIds.includes(opt.id) && !isSelected; - return ( +
+
+
+ + + + + + +
+ {allItems.filter(opt => opt.customerType === activeType).map((opt) => { + const isSelected = opt.id === itemId; + const isUsed = existingIncludedItemIds.includes(opt.id) && !isSelected; + return ( + + ); + })} +
- ); - })} -
- +
-
-
-
-
+ + +
+
+ { @@ -566,34 +275,37 @@ function ProductItemRow({ if (!readOnly && (v === '' || /^\d*$/.test(v))) updateParent(v); }} /> - {onRemove && ( - - )}
-
-
+ +
+
+ -
- {item.expires === 'never' ? 'Never expires' : `${EXPIRES_OPTIONS.find(o => o.value === item.expires)?.label.toLowerCase()}`} - -
+
{EXPIRES_OPTIONS.map((option) => ( - + @@ -603,83 +315,97 @@ function ProductItemRow({
- { - if (readOnly) return; - const updated: Product['includedItems'][string] = { - ...item, - repeat: interval ? interval : 'never', - }; - onSave(itemId, updated); - }} - /> +
+ + { + if (readOnly) return; + const updated: Product['includedItems'][string] = { + ...item, + repeat: interval ? interval : 'never', + }; + onSave(itemId, updated); + }} + /> +
+ + {onRemove && ( + + )}
); - } - - return ( -
-
- -
- - - -
{itemDisplayName}
-
{prettyPrintWithMagnitudes(item.quantity)}
-
-
{shortRepeatText}
-
- { - !readOnly && ( - <> - - {onRemove && ( - + +
{itemDisplayName}
+
{prettyPrintWithMagnitudes(item.quantity)}
+
+
{shortRepeatText}
+
+ { + !readOnly && ( + <> + - )} - - ) - } -
- -
-
{item.expires !== 'never' ? `Expires: ${String(item.expires).replace(/-/g, ' ')}` : 'Never expires'}
-
+ {onRemove && ( + + )} + + ) + } +
+ +
+
{item.expires !== 'never' ? `Expires: ${String(item.expires).replace(/-/g, ' ')}` : 'Never expires'}
-
-
-
+ + +
-
- ); + ); + } } @@ -692,7 +418,7 @@ type ProductCardProps = { onSave: (id: string, product: Product) => Promise, onDelete: (id: string) => Promise, onDuplicate: (product: Product) => void, - onCreateNewItem: () => void, + onCreateNewItem: (customerType?: 'user' | 'team' | 'custom') => void, onOpenDetails: (product: Product) => void, isDraft?: boolean, onCancelDraft?: () => void, @@ -703,14 +429,22 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa const [draft, setDraft] = useState(product); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [editingPriceId, setEditingPriceId] = useState(undefined); + const [editingPricesIsFreeMode, setEditingPricesIsFreeMode] = useState(false); const cardRef = useRef(null); const [hasAutoScrolled, setHasAutoScrolled] = useState(false); const [localProductId, setLocalProductId] = useState(id); + const [currentHash, setCurrentHash] = useState(null); + const hashAnchor = `#product-${id}`; + const isHashTarget = currentHash === hashAnchor; useEffect(() => { - setDraft(product); - setLocalProductId(id); - }, [product, id]); + // Only sync draft with product prop when not actively editing + // This prevents losing unsaved changes when other parts of the config update + if (!isEditing) { + setDraft(product); + setLocalProductId(id); + } + }, [product, id, isEditing]); useEffect(() => { if (isDraft && !hasAutoScrolled && cardRef.current) { @@ -719,17 +453,53 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa } }, [isDraft, hasAutoScrolled]); - const pricesObject: PricesObject = typeof draft.prices === 'object' ? draft.prices : {}; + useEffect(() => { + const updateFromHash = () => { + const h = window.location.hash; + if (h !== currentHash) setCurrentHash(h); + }; + updateFromHash(); + window.addEventListener('hashchange', updateFromHash); + + const removeHashTarget = () => { + if (isHashTarget && window.location.hash === hashAnchor) { + window.history.replaceState(null, "", window.location.pathname + window.location.search); + } + }; + window.addEventListener("click", removeHashTarget, { capture: true }); + + return () => { + window.removeEventListener('hashchange', updateFromHash); + window.removeEventListener("click", removeHashTarget, { capture: true }); + }; + }, [hashAnchor, isHashTarget, currentHash]); + + const getPricesObject = (draft: Product): PricesObject => { + if (draft.prices === 'include-by-default') { + return { + "free": { + USD: '0.00', + serverOnly: false, + }, + }; + } + return draft.prices; + }; + + const pricesObject: PricesObject = getPricesObject(draft); const priceCount = Object.keys(pricesObject).length; const hasExistingPrices = priceCount > 0; + useEffect(() => { + setEditingPricesIsFreeMode(hasExistingPrices && (editingPricesIsFreeMode || draft.prices === 'include-by-default')); + }, [editingPricesIsFreeMode, draft.prices, hasExistingPrices]); + const canSaveProduct = draft.prices === 'include-by-default' || (typeof draft.prices === 'object' && hasExistingPrices); const saveDisabledReason = canSaveProduct ? undefined : "Add at least one price or set Include by default"; const handleRemovePrice = (priceId: string) => { setDraft(prev => { - if (typeof prev.prices !== 'object') return prev; - const nextPrices: PricesObject = { ...prev.prices }; + const nextPrices: PricesObject = typeof prev.prices !== 'object' ? {} : { ...prev.prices }; delete nextPrices[priceId]; return { ...prev, prices: nextPrices }; }); @@ -754,31 +524,34 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa }); }; - const renderPrimaryPrices = () => { - if (draft.prices === 'include-by-default') { - return ( -
Free
- ); - } + const renderPrimaryPrices = (mode: 'editing' | 'view') => { const entries = Object.entries(pricesObject); if (entries.length === 0) { return null; } return ( -
+
{entries.map(([pid, price], index) => ( k).filter(k => k !== pid)} onSave={(newId, newPrice) => { const finalId = newId || pid; setDraft(prev => { - const prevPrices: PricesObject = typeof prev.prices === 'object' ? prev.prices : {}; + if (newPrice === 'include-by-default') { + return { ...prev, prices: 'include-by-default' }; + } + const prevPrices: PricesObject = getPricesObject(prev); const nextPrices: PricesObject = { ...prevPrices }; if (newId && newId !== pid) { if (Object.prototype.hasOwnProperty.call(nextPrices, newId)) { @@ -796,7 +569,7 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa }} onRemove={() => handleRemovePrice(pid)} /> - {index < entries.length - 1 && } + {((mode !== "view" && !editingPricesIsFreeMode) || index < entries.length - 1) && } ))}
@@ -816,7 +589,7 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa visible: true, icon: , onToggle: () => setDraft(prev => ({ ...prev, serverOnly: !prev.serverOnly })), - wrapButton: (button: React.ReactNode) => button, + wrapButton: (button: ReactNode) => button, }, { key: 'stackable' as const, label: 'Stackable', @@ -825,7 +598,7 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa visible: true, icon: , onToggle: () => setDraft(prev => ({ ...prev, stackable: !prev.stackable })), - wrapButton: (button: React.ReactNode) => button, + wrapButton: (button: ReactNode) => button, }, { key: 'addon' as const, label: 'Add-on', @@ -834,7 +607,7 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa active: draft.isAddOnTo !== false, icon: , onToggle: isAddOnTo.length === 0 && draft.isAddOnTo !== false ? () => setDraft(prev => ({ ...prev, isAddOnTo: false })) : undefined, - wrapButton: (button: React.ReactNode) => isAddOnTo.length === 0 && draft.isAddOnTo !== false ? button : ( + wrapButton: (button: ReactNode) => isAddOnTo.length === 0 && draft.isAddOnTo !== false ? button : ( {button} @@ -863,114 +636,187 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa ), }] as const; - return ( -
{ + if (isDraft && onCancelDraft) { + onCancelDraft(); + return; + } + setIsEditing(false); + setDraft(product); + setLocalProductId(id); + setEditingPriceId(undefined); + }; + + const handleSaveEdit = async () => { + const trimmed = localProductId.trim(); + const validId = trimmed && /^[a-z0-9-]+$/.test(trimmed) ? trimmed : id; + if (validId !== id) { + await onSave(validId, draft); + await onDelete(id); + } else { + await onSave(id, draft); + } + setIsEditing(false); + setEditingPriceId(undefined); + }; + + const renderToggleButtons = (mode: 'editing' | 'view') => { + const getLabel = (b: typeof PRODUCT_TOGGLE_OPTIONS[number], editing: boolean) => { + if (b.key === "addon" && isAddOnTo.length > 0) { + return + Add-on to {isAddOnTo.map((o, i) => ( + <> + {i > 0 && ", "} + {editing ? o.product.displayName : ( + + {o.product.displayName} + + )} + + ))} + ; + } + return b.label; + }; + return mode === 'editing' ? ( + PRODUCT_TOGGLE_OPTIONS + .filter(b => b.visible !== false) + .map((b) => { + const wrap = b.wrapButton; + return ( + + {wrap( + + )} + + ); + }) + ) : ( + PRODUCT_TOGGLE_OPTIONS + .filter(b => b.visible !== false) + .filter(b => b.active) + .map((b) => { + return + {b.icon} + {getLabel(b, false)} + ; + }) + ); + }; + + const editingContent = ( +
-
-
- setLocalProductId(value)} - readOnly={!isDraft || !isEditing} - placeholder={"Product ID"} - inputClassName="text-xs font-mono text-center text-muted-foreground" - transform={(value) => value.toLowerCase()} - /> - setDraft(prev => ({ ...prev, displayName: value }))} - readOnly={!isEditing} - placeholder={"Product display name"} - inputClassName="text-lg font-bold text-center w-full" - /> +
+
+
+ {isDraft ? "New product" : "Edit product"} +
- {!isEditing && ( -
- - - - - - - { - setIsEditing(true); - setDraft(product); - }}> - Edit - - { onDuplicate(product); }}> - Duplicate - - - { setShowDeleteDialog(true); }}> - Delete - - - + +
+
+ + { + const value = event.target.value; + setDraft(prev => ({ ...prev, displayName: value })); + }} + placeholder="Offer name" + />
- )} -
- {/* Toggles row */} -
- {PRODUCT_TOGGLE_OPTIONS.filter(b => b.visible !== false).filter(b => isEditing || b.active).map((b) => ( - - {(isEditing ? b.wrapButton : ((x: any) => x))( -
+
+ +
+ {renderToggleButtons('editing')} +
+ + +
+ {renderPrimaryPrices('editing')} + {!editingPricesIsFreeMode && ( +
+ - )} - - ))} -
-
- {renderPrimaryPrices()} - {isEditing && draft.prices !== 'include-by-default' && ( - <> - {hasExistingPrices && } - - - )} -
+ + {hasExistingPrices ? "Add alternative price" : "Add price"} + + { + !hasExistingPrices && ( + <> + OR + + + ) + } +
+ )} +
-
+ {itemsList.length === 0 ? ( -
No items yet
+
+ No items yet +
) : ( -
+
{itemsList.map(([itemId, item]) => { const itemMeta = existingItems.find(i => i.id === itemId); const itemLabel = itemMeta ? itemMeta.id : 'Select item'; @@ -983,8 +829,8 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa itemDisplayName={itemLabel} allItems={existingItems} existingIncludedItemIds={Object.keys(draft.includedItems).filter(id => id !== itemId)} - startEditing={isEditing} - readOnly={!isEditing} + startEditing={true} + readOnly={false} onSave={(id, updated) => handleAddOrEditIncludedItem(id, updated)} onChangeItemId={(newItemId) => { setDraft(prev => { @@ -999,90 +845,191 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa return { ...prev, includedItems: next }; }); }} - onRemove={isEditing ? () => handleRemoveIncludedItem(itemId) : undefined} + onRemove={() => handleRemoveIncludedItem(itemId)} onCreateNewItem={onCreateNewItem} /> ); })}
)} -
- { - isEditing && ( -
- -
- ) - } - {isEditing && ( -
-
+ + +
+ +
- )} - {!isEditing && activeType !== "custom" && ( -
+
+
+ ); + + const viewingContent = ( +
+
+
+ setLocalProductId(value)} + readOnly + placeholder={"Product ID"} + inputClassName="text-xs font-mono text-center text-muted-foreground" + transform={(value) => value.toLowerCase()} + /> + setDraft(prev => ({ ...prev, displayName: value }))} + readOnly + placeholder={"Product display name"} + inputClassName="text-lg font-bold text-center w-full" + /> +
+
+ + + + + + + { + setIsEditing(true); + setDraft(product); + }}> + Edit + + { onDuplicate(product); }}> + Duplicate + + + { setShowDeleteDialog(true); }}> + Delete + + + +
+
+
+ {renderToggleButtons('view')} +
+
+ {renderPrimaryPrices('view')} +
+ +
+ {itemsList.length === 0 ? ( +
Grants no items
+ ) : ( +
+ {itemsList.map(([itemId, item]) => { + const itemMeta = existingItems.find(i => i.id === itemId); + const itemLabel = itemMeta ? itemMeta.id : 'Select item'; + return ( + id !== itemId)} + startEditing={false} + readOnly + onSave={(id, updated) => handleAddOrEditIncludedItem(id, updated)} + onChangeItemId={(_newItemId) => { }} + onRemove={undefined} + onCreateNewItem={onCreateNewItem} + /> + ); + })} +
+ )} +
+ {activeType !== "custom" && ( +
)} +
+ ); + + return ( +
+ {isEditing ? editingContent : viewingContent} , onSaveProduct: (id: string, product: Product) => Promise, onDeleteProduct: (id: string) => Promise, - onCreateNewItem: () => void, + onCreateNewItem: (customerType?: 'user' | 'team' | 'custom') => void, onOpenProductDetails: (product: Product) => void, onSaveProductWithGroup: (catalogId: string, productId: string, product: Product) => Promise, createDraftRequestId?: string, @@ -1119,7 +1066,8 @@ type CatalogViewProps = { }; function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, onDeleteProduct, onCreateNewItem, onOpenProductDetails, onSaveProductWithGroup, createDraftRequestId, draftCustomerType, onDraftHandled }: CatalogViewProps) { - const [activeType, setActiveType] = useState<'user' | 'team' | 'custom'>('user'); + const [activeTypeUnfiltered, setActiveType] = useQueryState('catalog_type', 'user'); + const activeType = typedIncludes(['user', 'team', 'custom'] as const, activeTypeUnfiltered) ? activeTypeUnfiltered : 'user'; const [drafts, setDrafts] = useState>([]); const [creatingGroupKey, setCreatingGroupKey] = useState(undefined); const [newCatalogId, setNewCatalogId] = useState(""); @@ -1189,7 +1137,7 @@ function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, on setActiveType(draftCustomerType); setDrafts((prev) => [...prev, { key: candidate, catalogId: undefined, product: newProduct }]); onDraftHandled?.(); - }, [createDraftRequestId, draftCustomerType, onDraftHandled, usedIds]); + }, [createDraftRequestId, draftCustomerType, onDraftHandled, usedIds, setActiveType]); const generateProductId = (base: string) => { let id = base; @@ -1209,7 +1157,7 @@ function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, on return (
-
+
{(['user', 'team', 'custom'] as const).map(t => ( + Create product
-
+ )}
@@ -1372,59 +1324,53 @@ function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, on
); })} - {/* TODO: Add new catalog is temporarily disabled, uncomment this to enable it -
+ + Create catalog
-
- */} +
); } type CatalogViewPageProps = { - onViewChange: (view: "list" | "catalogs") => void, createDraftRequestId?: string, draftCustomerType?: 'user' | 'team' | 'custom', onDraftHandled?: () => void, }; -export default function PageClient({ onViewChange, createDraftRequestId, draftCustomerType = 'user', onDraftHandled }: CatalogViewPageProps) { +export default function PageClient({ createDraftRequestId, draftCustomerType = 'user', onDraftHandled }: CatalogViewPageProps) { const [showProductDialog, setShowProductDialog] = useState(false); const [editingProduct, setEditingProduct] = useState(null); const [showItemDialog, setShowItemDialog] = useState(false); const [editingItem, setEditingItem] = useState<{ id: string, displayName: string, customerType: 'user' | 'team' | 'custom' } | null>(null); + const [newItemCustomerType, setNewItemCustomerType] = useState<'user' | 'team' | 'custom' | undefined>(undefined); const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); const config = project.useConfig(); - const switchId = useId(); - const testModeSwitchId = useId(); const paymentsConfig: CompleteConfig['payments'] = config.payments; @@ -1509,14 +1455,9 @@ export default function PageClient({ onViewChange, createDraftRequestId, draftCu return sortedGroups; }, [paymentsConfig]); - // Check if there are no products and no items - // Handler for create product button - const handleCreateProduct = () => { - setShowProductDialog(true); - }; - // Handler for create item button - const handleCreateItem = () => { + const handleCreateItem = (customerType?: 'user' | 'team' | 'custom') => { + setNewItemCustomerType(customerType); setShowItemDialog(true); }; @@ -1559,60 +1500,31 @@ export default function PageClient({ onViewChange, createDraftRequestId, draftCu toast({ title: "Product deleted" }); }; - const handleToggleTestMode = async (enabled: boolean) => { - await project.updateConfig({ "payments.testMode": enabled }); - toast({ title: enabled ? "Test mode enabled" : "Test mode disabled" }); - }; - - - // If no products and items, show welcome screen instead of everything const innerContent = ( - -
- - onViewChange("list")} /> - -
- -
- - handleToggleTestMode(checked)} - /> -
-
- } - > -
- { - setEditingProduct(product); - setShowProductDialog(true); - }} - onSaveProductWithGroup={async (catalogId, productId, product) => { - await project.updateConfig({ - [`payments.catalogs.${catalogId}`]: {}, - [`payments.products.${productId}`]: product, - }); - toast({ title: "Product created" }); - }} - createDraftRequestId={createDraftRequestId} - draftCustomerType={draftCustomerType} - onDraftHandled={onDraftHandled} - /> -
- +
+ { + setEditingProduct(product); + setShowProductDialog(true); + }} + onSaveProductWithGroup={async (catalogId, productId, product) => { + await project.updateConfig({ + [`payments.catalogs.${catalogId}`]: {}, + [`payments.products.${productId}`]: product, + }); + toast({ title: "Product created" }); + }} + createDraftRequestId={createDraftRequestId} + draftCustomerType={draftCustomerType} + onDraftHandled={onDraftHandled} + /> +
); return ( @@ -1643,11 +1555,13 @@ export default function PageClient({ onViewChange, createDraftRequestId, draftCu setShowItemDialog(open); if (!open) { setEditingItem(null); + setNewItemCustomerType(undefined); } }} onSave={async (item) => await handleSaveItem(item)} editingItem={editingItem ?? undefined} existingItemIds={Object.keys(paymentsConfig.items)} + forceCustomerType={newItemCustomerType} /> ); 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 83f4ad5bba..2d77184e7b 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 @@ -1,17 +1,17 @@ "use client"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { cn } from "@/lib/utils"; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { useHover } from "@stackframe/stack-shared/dist/hooks/use-hover"; import { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; import { prettyPrintWithMagnitudes } from "@stackframe/stack-shared/dist/utils/numbers"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Label, Separator, Switch, toast } from "@stackframe/stack-ui"; -import { MoreVertical } from "lucide-react"; -import React, { ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; -import { PageLayout } from "../../page-layout"; +import { Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, toast } from "@stackframe/stack-ui"; +import { MoreVertical, Plus } from "lucide-react"; +import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { IllustratedInfo } from "../../../../../../../components/illustrated-info"; import { useAdminApp } from "../../use-admin-app"; -import { ItemDialog } from "@/components/payments/item-dialog"; import { ListSection } from "./list-section"; import { ProductDialog } from "./product-dialog"; @@ -535,7 +535,59 @@ function ItemsList({ ); } -export default function PageClient({ onViewChange }: { onViewChange: (view: "list" | "catalogs") => void }) { +function WelcomeScreen({ onCreateProduct }: { onCreateProduct: () => void }) { + return ( +
+ + {/* Simple pricing table representation */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )} + title="Welcome to Payments!" + description={[ + <>Stack Auth Payments is built on two primitives: products and items., + <>Products are what customers buy — the columns of your pricing table. Each product has one or more prices and may or may not include items., + <>Items are what customers receive — the rows of your pricing table. A user can hold multiple of the same item. Items are powerful; they can unlock feature access, raise limits, or meter consumption for usage-based billing., + <>Create your first product to get started!, + ]} + /> + +
+ ); +} + +export default function PageClient() { const [activeTab, setActiveTab] = useState<"products" | "items">("products"); const [hoveredProductId, setHoveredProductId] = useState(null); const [hoveredItemId, setHoveredItemId] = useState(null); @@ -546,9 +598,6 @@ export default function PageClient({ onViewChange }: { onViewChange: (view: "lis const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); const config = project.useConfig(); - const switchId = useId(); - const testModeSwitchId = useId(); - const [isUpdatingTestMode, setIsUpdatingTestMode] = useState(false); const paymentsConfig = config.payments; // Refs for products and items @@ -698,18 +747,6 @@ export default function PageClient({ onViewChange }: { onViewChange: (view: "lis toast({ title: editingItem ? "Item updated" : "Item created" }); }; - const handleToggleTestMode = async (enabled: boolean) => { - setIsUpdatingTestMode(true); - try { - await project.updateConfig({ "payments.testMode": enabled }); - toast({ title: enabled ? "Test mode enabled" : "Test mode disabled" }); - } catch (_error) { - toast({ title: "Failed to update test mode", variant: "destructive" }); - } finally { - setIsUpdatingTestMode(false); - } - }; - // Prepare data for product dialog - update when items change const existingProductsList = Object.entries(paymentsConfig.products).map(([id, product]: [string, any]) => ({ id, @@ -725,30 +762,9 @@ export default function PageClient({ onViewChange }: { onViewChange: (view: "lis })); const innerContent = ( - -
- - onViewChange("catalogs")} /> - -
- -
- - void handleToggleTestMode(checked)} - /> -
-
- } - > + <> {/* Mobile tabs */} -
+ < div className="lg:hidden mb-4" >
-
+
{/* Content */} -
+
{/* Desktop two-column layout */} - +
{/* Connection lines */} - {hoveredProductId && - getConnectedItems(hoveredProductId).map((itemId) => ( + { + hoveredProductId && getConnectedItems(hoveredProductId).map(itemId => ( - ))} + )) + } - {hoveredItemId && - getConnectedProducts(hoveredItemId).map((productId) => ( + { + hoveredItemId && getConnectedProducts(hoveredItemId).map(productId => ( - ))} - + )) + } + {/* Mobile single column with tabs */} -
+ < div className="lg:hidden w-full" > {activeTab === "products" ? ( )} -
-
- +
+
+ ); return ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx index 01cf046493..e58800e77e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx @@ -1,11 +1,12 @@ "use client"; -import { useMemo, useState } from "react"; +import { useQueryState } from "@stackframe/stack-shared/dist/utils/react"; +import { Button, Label, Separator, Switch, toast } from "@stackframe/stack-ui"; +import { Plus } from "lucide-react"; +import { useId, useMemo, useState } from "react"; import { IllustratedInfo } from "../../../../../../../components/illustrated-info"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { Button } from "@stackframe/stack-ui"; -import { Plus } from "lucide-react"; import PageClientCatalogsView from "./page-client-catalogs-view"; import PageClientListView from "./page-client-list-view"; @@ -69,10 +70,13 @@ function WelcomeScreen({ onCreateProduct }: { onCreateProduct: () => void }) { } export default function PageClient() { - const [view, setView] = useState("catalogs"); + const [view, setView] = useQueryState("view", "catalogs"); + const isViewList = view === "list"; const [welcomeDismissed, setWelcomeDismissed] = useState(false); const [draftCustomerType, setDraftCustomerType] = useState<'user' | 'team' | 'custom'>("user"); const [draftRequestId, setDraftRequestId] = useState(undefined); + const switchId = useId(); + const testModeSwitchId = useId(); const adminApp = useAdminApp(); const project = adminApp.useProject(); @@ -98,18 +102,48 @@ export default function PageClient() { setDraftRequestId(undefined); }; + + const handleToggleTestMode = async (enabled: boolean) => { + await project.updateConfig({ "payments.testMode": enabled }); + toast({ title: enabled ? "Test mode enabled" : "Test mode disabled" }); + }; + + if (showWelcome) { return ; } - return view === "catalogs" ? ( - - ) : ( - + return ( + +
+ + setView(isViewList ? "catalogs" : "list")} /> + +
+ +
+ + await handleToggleTestMode(checked)} + /> +
+
+ } + > + {isViewList ? ( + + ) : ( + + )} + ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx new file mode 100644 index 0000000000..7d9449c7a0 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx @@ -0,0 +1,322 @@ +import { cn } from "@/lib/utils"; +import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SimpleTooltip, +} from "@stackframe/stack-ui"; +import { X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { IntervalPopover } from "./components"; +import { buildPriceUpdate, DEFAULT_INTERVAL_UNITS, freeTrialLabel, intervalLabel, Price, PRICE_INTERVAL_UNITS, Product } from "./utils"; + +type ProductPriceRowProps = { + priceId: string, + price: (Product['prices'] & object)[string], + includeByDefault: boolean, + isFree: boolean, + readOnly?: boolean, + startEditing?: boolean, + onSave: (newId: string | undefined, price: "include-by-default" | (Product['prices'] & object)[string]) => void, + onRemove?: () => void, + existingPriceIds: string[], +}; + +/** + * Displays and edits a single price for a product + * Handles both free prices (with include-by-default option) and paid prices + */ +export function ProductPriceRow({ + priceId, + price, + includeByDefault, + isFree, + readOnly, + startEditing, + onSave, + onRemove, + existingPriceIds, +}: ProductPriceRowProps) { + // View/Edit mode + const [isEditing, setIsEditing] = useState(!!startEditing && !readOnly); + + // Price state + const [amount, setAmount] = useState(price.USD || '0.00'); + + // Billing frequency state + const [priceInterval, setPriceInterval] = useState(price.interval?.[1]); + const [intervalCount, setIntervalCount] = useState(price.interval?.[0] || 1); + const [intervalSelection, setIntervalSelection] = useState<'one-time' | 'custom' | DayInterval[1]>( + price.interval ? (price.interval[0] === 1 ? price.interval[1] : 'custom') : 'one-time' + ); + + // Free trial state + const [freeTrialUnit, setFreeTrialUnit] = useState(price.freeTrial?.[1]); + const [freeTrialCount, setFreeTrialCount] = useState(price.freeTrial?.[0] || 7); + const [freeTrialSelection, setFreeTrialSelection] = useState<'one-time' | 'custom' | DayInterval[1]>( + price.freeTrial ? (price.freeTrial[0] === 7 && price.freeTrial[1] === 'day' ? 'week' : price.freeTrial[0] === 1 ? price.freeTrial[1] : 'custom') : 'one-time' + ); + + const niceAmount = +amount; + const intervalText = intervalLabel(price.interval); + + // Sync state when price changes externally + useEffect(() => { + if (isEditing) return; + setAmount(price.USD || '0.00'); + setPriceInterval(price.interval?.[1]); + setIntervalCount(price.interval?.[0] || 1); + setIntervalSelection(price.interval ? (price.interval[0] === 1 ? price.interval[1] : 'custom') : 'one-time'); + setFreeTrialUnit(price.freeTrial?.[1]); + setFreeTrialCount(price.freeTrial?.[0] || 7); + setFreeTrialSelection(price.freeTrial ? (price.freeTrial[0] === 7 && price.freeTrial[1] === 'day' ? 'week' : price.freeTrial[0] === 1 ? price.freeTrial[1] : 'custom') : 'one-time'); + }, [price, isEditing]); + + useEffect(() => { + if (!readOnly && startEditing) setIsEditing(true); + if (readOnly) setIsEditing(false); + }, [startEditing, readOnly]); + + // Helper to build and save price updates + const savePriceUpdate = (overrides: Partial> = {}) => { + if (readOnly) return; + const updated = buildPriceUpdate({ + amount, + serverOnly: !!price.serverOnly, + intervalSelection, + intervalCount, + priceInterval, + freeTrialSelection, + freeTrialCount, + freeTrialUnit, + freeTrial: price.freeTrial, + ...overrides, + }); + onSave(undefined, updated); + }; + + return ( +
+ {isEditing ? ( + <> +
+ {isFree ? ( + // Free price - show include by default option +
+ Free +
+
+ { + if (readOnly) return; + onSave(undefined, checked ? "include-by-default" : price); + }} + /> + +
+
+ If enabled, customers get this product automatically when created +
+
+
+ ) : ( + // Paid price - show full editor + <> + {/* Amount */} +
+ +
+ { + const v = e.target.value; + if (v === '' || /^\d*(?:\.?\d{0,2})?$/.test(v)) setAmount(v); + savePriceUpdate(); + }} + /> + + $ + +
+
+ + {/* Billing Frequency */} +
+ + { + savePriceUpdate(); + }} + /> +
+ + {/* Free Trial */} +
+
+ { + if (readOnly) return; + if (checked) { + savePriceUpdate({ freeTrial: [freeTrialCount || 7, freeTrialUnit || 'day'] }); + } else { + savePriceUpdate({ freeTrial: undefined }); + } + }} + /> + +
+ {price.freeTrial && ( +
+
+ { + const v = e.target.value; + if (!/^\d*$/.test(v)) return; + const n = v === '' ? 1 : parseInt(v, 10); + if (n === 0) return; + setFreeTrialCount(n); + savePriceUpdate({ freeTrial: [n, freeTrialUnit || 'day'] }); + }} + placeholder="7" + /> +
+
+ +
+
+ )} +
+ + {/* Server Only */} +
+
+ { + savePriceUpdate({ serverOnly: !!checked }); + }} + /> + +
+
+ Restricts this price to only be purchased from server-side calls +
+
+ + )} +
+ + {onRemove && ( + + )} + + ) : ( + // View mode + <> +
+ {isFree ? 'Free' : `$${niceAmount}`} +
+ {!isFree && ( +
{intervalText ?? 'one-time'}
+ )} + {includeByDefault && ( + +
+ Included by default +
+
+ )} + {!isFree && price.freeTrial && ( +
+ Free trial: {freeTrialLabel(price.freeTrial)} +
+ )} + {!isFree && price.serverOnly && ( +
+ Server only +
+ )} + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts new file mode 100644 index 0000000000..cccdd71450 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts @@ -0,0 +1,137 @@ +import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; + +// ============================================================================ +// Types +// ============================================================================ + +export type Product = CompleteConfig['payments']['products'][keyof CompleteConfig['payments']['products']]; +export type Price = (Product['prices'] & object)[string]; +export type PricesObject = Exclude; + +// ============================================================================ +// Constants +// ============================================================================ + +export const DEFAULT_INTERVAL_UNITS: DayInterval[1][] = ['day', 'week', 'month', 'year']; +export const PRICE_INTERVAL_UNITS: DayInterval[1][] = ['week', 'month', 'year']; + +// ============================================================================ +// Interval Formatting +// ============================================================================ + +/** + * Formats a day interval as a frequency label (e.g., "monthly", "Every 3 weeks") + */ +export function intervalLabel(tuple: DayInterval | undefined): string | null { + if (!tuple) return null; + const [count, unit] = tuple; + if (count === 1) { + return unit === 'year' ? 'yearly' : unit === 'month' ? 'monthly' : unit === 'week' ? 'weekly' : 'daily'; + } + const plural = unit + 's'; + return `Every ${count} ${plural}`; +} + +/** + * Formats a day interval as a short label (e.g., "/mo", "/3wk") + */ +export function shortIntervalLabel(interval: DayInterval | 'never'): string { + if (interval === 'never') return 'once'; + const [count, unit] = interval; + const map: Record = { day: 'd', week: 'wk', month: 'mo', year: 'yr' }; + const suffix = map[unit]; + return `/${count === 1 ? '' : count}${suffix}`; +} + +/** + * Formats a day interval as a duration label (e.g., "7 days", "1 month") + */ +export function freeTrialLabel(tuple: DayInterval | undefined): string | null { + if (!tuple) return null; + const [count, unit] = tuple; + const plural = count === 1 ? unit : unit + 's'; + return `${count} ${plural}`; +} + +// ============================================================================ +// Price Utilities +// ============================================================================ + +/** + * Builds a Price object from current state with all required fields + */ +export function buildPriceUpdate(params: { + amount: string, + serverOnly: boolean, + intervalSelection: 'one-time' | 'custom' | DayInterval[1], + intervalCount: number, + priceInterval: DayInterval[1] | undefined, + freeTrialSelection: 'one-time' | 'custom' | DayInterval[1], + freeTrialCount: number, + freeTrialUnit: DayInterval[1] | undefined, + freeTrial?: DayInterval, +}): Price { + const { amount, serverOnly, intervalSelection, intervalCount, priceInterval, freeTrialSelection, freeTrialCount, freeTrialUnit, freeTrial } = params; + + const normalized = amount === '' ? '0.00' : (Number.isNaN(parseFloat(amount)) ? '0.00' : parseFloat(amount).toFixed(2)); + + const intervalObj = intervalSelection === 'one-time' ? undefined : ([ + intervalSelection === 'custom' ? intervalCount : 1, + (intervalSelection === 'custom' ? (priceInterval || 'month') : intervalSelection) as DayInterval[1] + ] as DayInterval); + + const freeTrialObj = freeTrial || (freeTrialSelection === 'one-time' ? undefined : ([ + freeTrialSelection === 'custom' ? freeTrialCount : 1, + (freeTrialSelection === 'custom' ? (freeTrialUnit || 'day') : freeTrialSelection) as DayInterval[1] + ] as DayInterval)); + + return { + USD: normalized, + serverOnly, + ...(intervalObj ? { interval: intervalObj } : {}), + ...(freeTrialObj ? { freeTrial: freeTrialObj } : {}), + }; +} + +/** + * Converts prices object to array format, handling 'include-by-default' case + */ +export function getPricesObject(draft: Product): PricesObject { + if (draft.prices === 'include-by-default') { + return { + "free": { + USD: '0.00', + serverOnly: false, + }, + }; + } + return draft.prices; +} + +// ============================================================================ +// ID Validation & Generation +// ============================================================================ + +const ID_PATTERN = /^[a-z0-9-]+$/; + +/** + * Validates if an ID matches the required pattern + */ +export function isValidId(id: string): boolean { + return ID_PATTERN.test(id); +} + +/** + * Generates a unique ID with a given prefix + */ +export function generateUniqueId(prefix: string): string { + return `${prefix}-${Date.now().toString(36).slice(2, 8)}`; +} + +/** + * Sanitizes user input into a valid ID format (lowercase, hyphenated) + */ +export function sanitizeId(input: string): string { + return input.toLowerCase().replace(/[^a-z0-9_\-]/g, '-'); +} diff --git a/apps/dashboard/src/components/code-block.tsx b/apps/dashboard/src/components/code-block.tsx index 6cd98e7e1a..069669db42 100644 --- a/apps/dashboard/src/components/code-block.tsx +++ b/apps/dashboard/src/components/code-block.tsx @@ -1,16 +1,16 @@ 'use client'; import { useThemeWatcher } from '@/lib/theme'; +import { cn } from '@/lib/utils'; import { CopyButton, SimpleTooltip } from "@stackframe/stack-ui"; import { Code, Terminal } from "lucide-react"; +import type { ReactNode } from 'react'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'; import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'; import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; import { dark, prism } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import type { ReactNode } from 'react'; -import { cn } from '@/lib/utils'; Object.entries({ tsx, bash, typescript, python }).forEach(([key, value]) => { SyntaxHighlighter.registerLanguage(key, value); @@ -25,6 +25,9 @@ type CodeBlockProps = { maxHeight?: number, compact?: boolean, tooltip?: ReactNode, + fullWidth?: boolean, + neutralBackground?: boolean, + noSeparator?: boolean, }; export function CodeBlock(props: CodeBlockProps) { @@ -43,8 +46,8 @@ export function CodeBlock(props: CodeBlockProps) { } return ( -
-
+
+
{icon} {props.title} @@ -53,7 +56,7 @@ export function CodeBlock(props: CodeBlockProps) { {props.tooltip && ( )} - +
diff --git a/apps/dashboard/src/components/router.tsx b/apps/dashboard/src/components/router.tsx index aa6e1e5171..567fb4e6cc 100644 --- a/apps/dashboard/src/components/router.tsx +++ b/apps/dashboard/src/components/router.tsx @@ -17,13 +17,13 @@ export function useRouter() { const context = useRouterConfirm(); return { - push: (url: string) => { + push: (...args: Parameters) => { if (context.needConfirm && !window.confirm(confirmAlertMessage)) return; - router.push(url); + router.push(...args); }, - replace: (url: string) => { + replace: (...args: Parameters) => { if (context.needConfirm && !window.confirm(confirmAlertMessage)) return; - router.replace(url); + router.replace(...args); }, back: () => { if (context.needConfirm && !window.confirm(confirmAlertMessage)) return; diff --git a/packages/stack-shared/src/utils/react.tsx b/packages/stack-shared/src/utils/react.tsx index cd7ac2de43..6472e93c23 100644 --- a/packages/stack-shared/src/utils/react.tsx +++ b/packages/stack-shared/src/utils/react.tsx @@ -198,6 +198,28 @@ export function mapRefState(refState: RefState, mapper: (value: T) => R }; } +export function useQueryState(key: string, defaultValue?: string) { + const getValue = () => new URLSearchParams(window.location.search).get(key) ?? defaultValue ?? ""; + + const [value, setValue] = React.useState(getValue); + + React.useEffect(() => { + const onPopState = () => setValue(getValue()); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + const update = (next: string) => { + const params = new URLSearchParams(window.location.search); + params.set(key, next); + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.pushState(null, "", newUrl); + setValue(next); + }; + + return [value, update] as const; +} + export function shouldRethrowRenderingError(error: unknown): boolean { return !!error && typeof error === "object" && "digest" in error && error.digest === "BAILOUT_TO_CLIENT_SIDE_RENDERING"; }