diff --git a/AGENTS.md b/AGENTS.md index 608a0158d8..54be6f1d55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,9 +73,9 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - Environment variables are pre-configured in `.env.development` files - Always run typecheck, lint, and test to make sure your changes are working as expected. You can save time by only linting and testing the files you've changed (and/or related E2E tests). - The project uses a custom route handler system in the backend for consistent API responses -- Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass. - When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. - Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). +- Animations: Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition (e.g. no fade-in when hovering a button) — it makes the UI feel sluggish. Instead, apply transitions after the action, like a smooth fade-out when the hover ends. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts b/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts index 959627ce1b..cf240d88aa 100644 --- a/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts @@ -1,8 +1,8 @@ import { getStackStripe } from "@/lib/stripe"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; export const GET = createSmartRouteHandler({ metadata: { @@ -32,11 +32,7 @@ export const GET = createSmartRouteHandler({ }); if (!project?.stripeAccountId) { - return { - statusCode: 200, - bodyType: "json", - body: null, - }; + throw new KnownErrors.StripeAccountInfoNotFound(); } const stripe = getStackStripe(); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx index f47123c4b6..1b97c982f8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -182,8 +182,9 @@ export function GlobeSection({ countryData, totalUsers, children }: {countryData } const controls = current.controls(); controls.maxDistance = 1000; - controls.minDistance = 400; + controls.minDistance = 200; controls.dampingFactor = 0.2; + current.camera().position.z = 500; // even though rendering is resumed by default, we want to pause it after 200ms, so call resumeRender() resumeRender(); }} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx index 9210610413..0746d839bd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx @@ -12,9 +12,9 @@ export function PageLayout(props: { width?: number, })) { return ( -
+
{props.actions}
-
+
{props.children}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/create-group-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/create-group-dialog.tsx new file mode 100644 index 0000000000..2e517a017e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/create-group-dialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Typography, SimpleTooltip } from "@stackframe/stack-ui"; +import { useState } from "react"; + +type CreateGroupDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + onCreate: (group: { id: string, displayName: string }) => void, +}; + +export function CreateGroupDialog({ open, onOpenChange, onCreate }: CreateGroupDialogProps) { + const [groupId, setGroupId] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [errors, setErrors] = useState<{ id?: string, displayName?: string }>({}); + + const validateAndCreate = () => { + const newErrors: { id?: string, displayName?: string } = {}; + + // Validate group ID + if (!groupId.trim()) { + newErrors.id = "Group ID is required"; + } else if (!/^[a-z0-9-]+$/.test(groupId)) { + newErrors.id = "Group ID must contain only lowercase letters, numbers, and hyphens"; + } + + // Validate display name + if (!displayName.trim()) { + newErrors.displayName = "Display name is required"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + onCreate({ id: groupId.trim(), displayName: displayName.trim() }); + + // Reset form + setGroupId(""); + setDisplayName(""); + setErrors({}); + onOpenChange(false); + }; + + const handleClose = () => { + setGroupId(""); + setDisplayName(""); + setErrors({}); + onOpenChange(false); + }; + + return ( + + + + Create Offer Group + + Offer groups allow you to organize related offers. Customers can only have one active offer from each group at a time (except for add-ons). + + + +
+
+ + { + setGroupId(e.target.value); + setErrors(prev => ({ ...prev, id: undefined })); + }} + placeholder="e.g., pricing-tiers" + className={errors.id ? "border-destructive" : ""} + /> + {errors.id && ( + + {errors.id} + + )} +
+ +
+ + { + setDisplayName(e.target.value); + setErrors(prev => ({ ...prev, displayName: undefined })); + }} + placeholder="e.g., Pricing Tiers" + className={errors.displayName ? "border-destructive" : ""} + /> + {errors.displayName && ( + + {errors.displayName} + + )} +
+
+ + + + + +
+
+ ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/dummy-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/dummy-data.tsx new file mode 100644 index 0000000000..64438ba473 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/dummy-data.tsx @@ -0,0 +1,278 @@ +// Dummy data for development +export const DUMMY_PAYMENTS_CONFIG: any = { + groups: { + "basic-plans": { displayName: "Basic Plans" }, + "pro-plans": { displayName: "Professional Plans" }, + "enterprise": { displayName: "Enterprise" }, + "add-ons": { displayName: "Add-ons" }, + }, + offers: { + "free-trial": { + displayName: "Free Trial", + customerType: "user" as const, + groupId: "basic-plans", + freeTrial: [14, "day"] as [number, "day"], + stackable: false, + serverOnly: false, + prices: "include-by-default" as const, + includedItems: { + "basic-features": { quantity: 1 }, + "cloud-storage-5gb": { quantity: 1 }, + }, + }, + "starter": { + displayName: "Starter", + customerType: "user" as const, + groupId: "basic-plans", + stackable: false, + serverOnly: false, + prices: { + "monthly": { USD: "9.99", interval: [1, "month"] as [number, "month"] }, + "yearly": { USD: "99.90", interval: [1, "year"] as [number, "year"] }, + }, + includedItems: { + "basic-features": { quantity: 1 }, + "cloud-storage-10gb": { quantity: 1 }, + "email-support": { quantity: 1 }, + "api-calls": { quantity: 1000 }, + }, + }, + "professional": { + displayName: "Professional", + customerType: "user" as const, + groupId: "pro-plans", + stackable: false, + serverOnly: false, + prices: { + "monthly": { USD: "29.99", interval: [1, "month"] as [number, "month"] }, + "yearly": { USD: "299.90", interval: [1, "year"] as [number, "year"] }, + "quarterly": { USD: "89.97", interval: [3, "month"] as [number, "month"] }, + }, + includedItems: { + "pro-features": { quantity: 1 }, + "cloud-storage-100gb": { quantity: 1 }, + "priority-support": { quantity: 1 }, + "api-calls": { quantity: 10000 }, + "team-members": { quantity: 5 }, + "custom-domain": { quantity: 1 }, + }, + }, + "business": { + displayName: "Business", + customerType: "team" as const, + groupId: "pro-plans", + stackable: false, + serverOnly: false, + prices: { + "monthly": { USD: "99.99", interval: [1, "month"] as [number, "month"] }, + "yearly": { USD: "999.90", interval: [1, "year"] as [number, "year"] }, + }, + includedItems: { + "pro-features": { quantity: 1 }, + "cloud-storage-1tb": { quantity: 1 }, + "priority-support": { quantity: 1 }, + "api-calls": { quantity: 100000 }, + "team-members": { quantity: 20 }, + "custom-domain": { quantity: 3 }, + "advanced-analytics": { quantity: 1 }, + "sso": { quantity: 1 }, + }, + }, + "enterprise-standard": { + displayName: "Enterprise Standard", + customerType: "team" as const, + groupId: "enterprise", + stackable: false, + serverOnly: false, + prices: { + "yearly": { USD: "2999.00", interval: [1, "year"] as [number, "year"] }, + }, + includedItems: { + "enterprise-features": { quantity: 1 }, + "cloud-storage-unlimited": { quantity: 1 }, + "dedicated-support": { quantity: 1 }, + "api-calls": { quantity: 1000000 }, + "team-members": { quantity: 100 }, + "custom-domain": { quantity: 10 }, + "advanced-analytics": { quantity: 1 }, + "sso": { quantity: 1 }, + "audit-logs": { quantity: 1 }, + "sla": { quantity: 1 }, + }, + }, + "enterprise-plus": { + displayName: "Enterprise Plus", + customerType: "custom" as const, + groupId: "enterprise", + stackable: false, + serverOnly: false, + prices: { + "custom": { USD: "0.00" }, + }, + includedItems: { + "enterprise-features": { quantity: 1 }, + "cloud-storage-unlimited": { quantity: 1 }, + "white-glove-support": { quantity: 1 }, + "api-calls": { quantity: 999999 }, + "team-members": { quantity: 999 }, + "custom-domain": { quantity: 999 }, + "advanced-analytics": { quantity: 1 }, + "sso": { quantity: 1 }, + "audit-logs": { quantity: 1 }, + "sla": { quantity: 1 }, + "custom-integrations": { quantity: 1 }, + "dedicated-infrastructure": { quantity: 1 }, + }, + }, + "extra-storage": { + displayName: "Extra Storage", + customerType: "user" as const, + groupId: "add-ons", + stackable: true, + serverOnly: false, + prices: { + "monthly": { USD: "4.99", interval: [1, "month"] as [number, "month"] }, + }, + includedItems: { + "cloud-storage-50gb": { quantity: 1 }, + }, + }, + "additional-api-calls": { + displayName: "API Call Pack", + customerType: "user" as const, + groupId: "add-ons", + stackable: true, + serverOnly: false, + prices: { + "monthly": { USD: "9.99", interval: [1, "month"] as [number, "month"] }, + }, + includedItems: { + "api-calls": { quantity: 5000 }, + }, + }, + "team-member-addon": { + displayName: "Extra Team Member", + customerType: "team" as const, + groupId: "add-ons", + stackable: true, + serverOnly: false, + prices: { + "monthly": { USD: "14.99", interval: [1, "month"] as [number, "month"] }, + }, + includedItems: { + "team-members": { quantity: 1 }, + }, + }, + "premium-support": { + displayName: "Premium Support", + customerType: "team" as const, + stackable: false, + serverOnly: false, + prices: { + "monthly": { USD: "299.00", interval: [1, "month"] as [number, "month"] }, + }, + includedItems: { + "24-7-support": { quantity: 1 }, + "dedicated-account-manager": { quantity: 1 }, + }, + }, + }, + items: { + "basic-features": { + displayName: "Basic Features", + customerType: "user" as const, + }, + "pro-features": { + displayName: "Professional Features", + customerType: "user" as const, + }, + "enterprise-features": { + displayName: "Enterprise Features", + customerType: "team" as const, + }, + "cloud-storage-5gb": { + displayName: "5GB Cloud Storage", + customerType: "user" as const, + }, + "cloud-storage-10gb": { + displayName: "10GB Cloud Storage", + customerType: "user" as const, + }, + "cloud-storage-50gb": { + displayName: "50GB Cloud Storage", + customerType: "user" as const, + }, + "cloud-storage-100gb": { + displayName: "100GB Cloud Storage", + customerType: "user" as const, + }, + "cloud-storage-1tb": { + displayName: "1TB Cloud Storage", + customerType: "team" as const, + }, + "cloud-storage-unlimited": { + displayName: "Unlimited Cloud Storage", + customerType: "team" as const, + }, + "email-support": { + displayName: "Email Support", + customerType: "user" as const, + }, + "priority-support": { + displayName: "Priority Support", + customerType: "user" as const, + }, + "dedicated-support": { + displayName: "Dedicated Support", + customerType: "team" as const, + }, + "white-glove-support": { + displayName: "White Glove Support", + customerType: "custom" as const, + }, + "24-7-support": { + displayName: "24/7 Phone Support", + customerType: "team" as const, + }, + "api-calls": { + displayName: "API Calls", + customerType: "user" as const, + }, + "team-members": { + displayName: "Team Members", + customerType: "team" as const, + }, + "custom-domain": { + displayName: "Custom Domain", + customerType: "user" as const, + }, + "advanced-analytics": { + displayName: "Advanced Analytics", + customerType: "team" as const, + }, + "sso": { + displayName: "Single Sign-On (SSO)", + customerType: "team" as const, + }, + "audit-logs": { + displayName: "Audit Logs", + customerType: "team" as const, + }, + "sla": { + displayName: "Service Level Agreement", + customerType: "team" as const, + }, + "custom-integrations": { + displayName: "Custom Integrations", + customerType: "custom" as const, + }, + "dedicated-infrastructure": { + displayName: "Dedicated Infrastructure", + customerType: "custom" as const, + }, + "dedicated-account-manager": { + displayName: "Dedicated Account Manager", + customerType: "team" as const, + }, + }, +}; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/included-item-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/included-item-dialog.tsx new file mode 100644 index 0000000000..c3739d14a1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/included-item-dialog.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { cn } from "@/lib/utils"; +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 "@stackframe/stack-ui"; +import { useState } from "react"; + +type Interval = [number, 'day' | 'week' | 'month' | 'year'] | 'never'; +type ExpiresOption = 'never' | 'when-purchase-expires' | 'when-repeated'; + +type Offer = CompleteConfig['payments']['offers'][string]; +type IncludedItem = Offer['includedItems'][string]; +type Price = (Offer['prices'] & object)[string]; + +type IncludedItemDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + onSave: (itemId: string, item: IncludedItem) => void, + editingItemId?: string, + editingItem?: IncludedItem & { displayName?: string }, + existingItems: Array<{ id: string, displayName: string, customerType: string }>, + existingIncludedItemIds?: string[], + onCreateNewItem?: () => void, +}; + +const EXPIRES_OPTIONS = [ + { + value: 'never' as const, + label: 'Never expires', + description: 'The item remains with the customer indefinitely' + }, + { + value: 'when-purchase-expires' as const, + label: 'When purchase expires', + description: 'The item is removed when the subscription ends or expires' + }, + { + value: 'when-repeated' as const, + label: 'When repeated', + description: 'The item expires when it\'s granted again (only available with repeat)', + requiresRepeat: true + } +]; + +export function IncludedItemDialog({ + open, + onOpenChange, + onSave, + editingItemId, + editingItem, + existingItems, + existingIncludedItemIds = [], + onCreateNewItem +}: IncludedItemDialogProps) { + const [selectedItemId, setSelectedItemId] = useState(editingItemId || ""); + const [quantity, setQuantity] = useState(editingItem?.quantity.toString() || "1"); + const [hasRepeat, setHasRepeat] = useState(editingItem?.repeat !== undefined && editingItem.repeat !== 'never'); + const [repeatCount, setRepeatCount] = useState(() => { + if (editingItem?.repeat && editingItem.repeat !== 'never') { + return editingItem.repeat[0].toString(); + } + return "1"; + }); + const [repeatUnit, setRepeatUnit] = useState<'day' | 'week' | 'month' | 'year'>(() => { + if (editingItem?.repeat && editingItem.repeat !== 'never') { + return editingItem.repeat[1]; + } + return "month"; + }); + const [expires, setExpires] = useState(editingItem?.expires || 'never'); + const [errors, setErrors] = useState>({}); + + const validateAndSave = () => { + const newErrors: Record = {}; + + // Validate item selection + if (!selectedItemId) { + newErrors.itemId = "Please select an item"; + } else if (!editingItem && existingIncludedItemIds.includes(selectedItemId)) { + newErrors.itemId = "This item is already included in the offer"; + } + + // Validate quantity + const parsedQuantity = parseInt(quantity); + if (!quantity || isNaN(parsedQuantity) || parsedQuantity < 1) { + newErrors.quantity = "Quantity must be a positive number"; + } + + // Validate repeat + if (hasRepeat) { + const parsedRepeatCount = parseInt(repeatCount); + if (!repeatCount || isNaN(parsedRepeatCount) || parsedRepeatCount < 1) { + newErrors.repeatCount = "Repeat interval must be a positive number"; + } + } + + // Validate expires option + if (expires === 'when-repeated' && !hasRepeat) { + newErrors.expires = "Cannot use 'when-repeated' without setting a repeat interval"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + const item: IncludedItem = { + quantity: parsedQuantity, + repeat: hasRepeat ? [parseInt(repeatCount), repeatUnit] : 'never', + expires: expires !== 'never' ? expires : 'never' + }; + + onSave(selectedItemId, item); + handleClose(); + }; + + const handleClose = () => { + if (!editingItem) { + setSelectedItemId(""); + setQuantity("1"); + setHasRepeat(false); + setRepeatCount("1"); + setRepeatUnit("month"); + setExpires('never'); + } + setErrors({}); + onOpenChange(false); + }; + + const selectedItem = existingItems.find(item => item.id === selectedItemId); + + return ( + + + + {editingItem ? "Edit Included Item" : "Add Included Item"} + + Configure which items are included with this offer and how they behave. + + + +
+ {/* Item Selection */} +
+ + + {errors.itemId && ( + + {errors.itemId} + + )} +
+ + {/* Quantity */} +
+ + { + setQuantity(e.target.value); + if (errors.quantity) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.quantity; + return newErrors; + }); + } + }} + className={errors.quantity ? "border-destructive" : ""} + /> + {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') { + setExpires('never'); + if (errors.expires) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.expires; + return newErrors; + }); + } + } + }} + /> + +
+ + {hasRepeat && ( +
+ +
+ { + setRepeatCount(e.target.value); + if (errors.repeatCount) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.repeatCount; + return newErrors; + }); + } + }} + className={cn("w-24", errors.repeatCount ? "border-destructive" : "")} + /> + +
+ {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()}) + + )} + +
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/item-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/item-dialog.tsx new file mode 100644 index 0000000000..35d247552e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/item-dialog.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Typography } from "@stackframe/stack-ui"; +import { useState } from "react"; + +type ItemDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + onSave: (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => Promise, + editingItem?: { + id: string, + displayName: string, + customerType: 'user' | 'team' | 'custom', + }, + existingItemIds?: string[], +}; + +export function ItemDialog({ + open, + onOpenChange, + onSave, + editingItem, + existingItemIds = [] +}: ItemDialogProps) { + const [itemId, setItemId] = useState(editingItem?.id || ""); + const [displayName, setDisplayName] = useState(editingItem?.displayName || ""); + const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(editingItem?.customerType || 'user'); + const [errors, setErrors] = useState>({}); + + const validateAndSave = async () => { + const newErrors: Record = {}; + + // Validate item ID + if (!itemId.trim()) { + newErrors.itemId = "Item ID is required"; + } else if (!/^[a-z0-9-]+$/.test(itemId)) { + newErrors.itemId = "Item ID must contain only lowercase letters, numbers, and hyphens"; + } else if (!editingItem && existingItemIds.includes(itemId)) { + newErrors.itemId = "This item ID already exists"; + } + + // Validate display name + if (!displayName.trim()) { + newErrors.displayName = "Display name is required"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + await onSave({ + id: itemId.trim(), + displayName: displayName.trim(), + customerType + }); + + handleClose(); + }; + + const handleClose = () => { + if (!editingItem) { + setItemId(""); + setDisplayName(""); + setCustomerType('user'); + } + setErrors({}); + onOpenChange(false); + }; + + return ( + + + + {editingItem ? "Edit Item" : "Create Item"} + + Items are features or services that customers receive. They appear as rows in your pricing table. + + + +
+ {/* Item ID */} +
+ + { + setItemId(e.target.value); + if (errors.itemId) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.itemId; + return newErrors; + }); + } + }} + placeholder="e.g., api-calls" + disabled={!!editingItem} + className={cn(errors.itemId ? "border-destructive" : "")} + /> + {errors.itemId && ( + + {errors.itemId} + + )} +
+ + {/* 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(errors.displayName ? "border-destructive" : "")} + /> + {errors.displayName && ( + + {errors.displayName} + + )} +
+ + {/* Customer Type */} +
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx index 9bb59067a1..c1f4b27667 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx @@ -1,5 +1,3 @@ -import { devFeaturesEnabledForProject } from "@/lib/utils"; -import { notFound } from "next/navigation"; import PageClient from "./page-client"; export const metadata = { @@ -11,10 +9,6 @@ type Params = { }; export default async function Page({ params }: { params: Promise }) { - const { projectId } = await params; - if (!devFeaturesEnabledForProject(projectId)) { - notFound(); - } return ( ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx index 625a693f95..1a0a889d79 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx @@ -6,7 +6,7 @@ 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 { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Typography } from "@stackframe/stack-ui"; +import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Typography } from "@stackframe/stack-ui"; import { ConnectNotificationBanner } from "@stripe/react-connect-js"; import { ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react"; import { useState } from "react"; @@ -68,7 +68,7 @@ export default function PaymentsLayout({ children }: { children: React.ReactNode {!stripeAccountInfo.details_submitted && (
- Incomplete setup + Incomplete setup Stripe account is not fully setup. You can test your application, but please{" "} @@ -104,41 +104,152 @@ export default function PaymentsLayout({ children }: { children: React.ReactNode } function SetupPaymentsButton({ setupPayments }: { setupPayments: () => Promise }) { - return ( - ( - - ), - }), - })} - cancelButton - okButton={{ label: "Continue" }} - trigger={ - + } + onSubmit={async (values): Promise<"prevent-close"> => { + handleCountrySubmit(values.country); + return "prevent-close"; + }} + /> + ); + } + + if (screen === "us-selected") { + return ( + <> + - } - onSubmit={async (values) => { - if (values.country !== "US") { - alert("Payments are currently only available for businesses or individuals in the United States."); - return "prevent-close"; - } - await setupPayments(); - }} - /> + { + setIsOpen(open); + if (!open) resetAndClose(); + }} + title="Payments is available in your country!" + description="You will be redirected to Stripe, our partner for payment processing, to connect your bank account. Or, you can do this later, and test Stack Auth Payments without setting up Stripe, but you will be limited to test transactions." + cancelButton={false} + okButton={false} + > +
+ +
+ + +
+
+
+ + ); + } + + // Handle other-selected screen + return ( + <> + + { + setIsOpen(open); + if (!open) resetAndClose(); + }} + title="Sorry :(" + cancelButton={false} + okButton={false} + > +
+ Stack Auth Payments is currently only available in the US. If you'd like to be notified when we expand to other countries, please reach out to us on our{" "} + + Feedback platform + + . +
+
+ +
+
+ ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/list-section.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/list-section.tsx new file mode 100644 index 0000000000..ff6d4606aa --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/list-section.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Button, Input, SimpleTooltip } from "@stackframe/stack-ui"; +import { Plus, Search } from "lucide-react"; +import React, { ReactNode, useState } from "react"; + +export type ListSectionProps = { + title: React.ReactNode, + titleTooltip?: string, + onAddClick?: () => void, + children: ReactNode, + hasTitleBorder?: boolean, + searchValue?: string, + onSearchChange?: (value: string) => void, + searchPlaceholder?: string, +}; + +export function ListSection({ + title, + titleTooltip, + onAddClick, + children, + hasTitleBorder = true, + searchValue, + onSearchChange, + searchPlaceholder = "Search..." +}: ListSectionProps) { + const [isSearchFocused, setIsSearchFocused] = useState(false); + + return ( +
+
+
+
+

{title}

+ {titleTooltip && ( + + )} +
+ {onSearchChange && ( +
+
+ + onSearchChange(e.target.value)} + onFocus={() => setIsSearchFocused(true)} + onBlur={() => setIsSearchFocused(false)} + className={cn( + "pl-8 bg-secondary/30 border-transparent focus:bg-secondary/50 transition-all duration-200", + isSearchFocused ? "h-7 text-sm" : "h-6 text-xs" + )} + /> +
+
+ )} + {onAddClick && ( + + )} +
+ {hasTitleBorder &&
} +
+
+ {children} +
+
+ ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offer-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offer-dialog.tsx new file mode 100644 index 0000000000..d67b995ae8 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offer-dialog.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { Stepper, StepperPage } from "@/components/stepper"; +import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { Button, Card, CardDescription, CardHeader, CardTitle, Checkbox, Dialog, DialogContent, DialogFooter, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Typography } from "@stackframe/stack-ui"; +import { ArrowLeft, ArrowRight, CreditCard, Package, Plus, Repeat, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { CreateGroupDialog } from "./create-group-dialog"; +import { IncludedItemDialog } from "./included-item-dialog"; +import { ListSection } from "./list-section"; +import { PriceDialog } from "./price-dialog"; + +type Template = 'one-time' | 'subscription' | 'addon' | 'scratch'; + +type Offer = CompleteConfig['payments']['offers'][string]; +type IncludedItem = Offer['includedItems'][string]; +type Price = (Offer['prices'] & object)[string]; + +type OfferDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + onSave: (offerId: string, offer: Offer) => Promise, + editingOfferId?: string, + editingOffer?: Offer, + existingOffers: Array<{ id: string, displayName: string, groupId?: string, customerType: string }>, + existingGroups: Record, + existingItems: Array<{ id: string, displayName: string, customerType: string }>, + onCreateNewItem?: () => void, +}; + +const TEMPLATE_CONFIGS: Record> = { + 'one-time': { + displayName: 'One-Time Purchase', + stackable: false, + }, + 'subscription': { + displayName: 'Monthly Subscription', + stackable: false, + }, + 'addon': { + displayName: 'Add-on', + isAddOnTo: {}, + stackable: true, + }, + 'scratch': {} +}; + +export function OfferDialog({ + open, + onOpenChange, + onSave, + editingOfferId, + editingOffer, + existingOffers, + existingGroups, + existingItems, + onCreateNewItem +}: OfferDialogProps) { + const [currentStep, setCurrentStep] = useState(editingOffer ? 1 : 0); + + // Form state + const [offerId, setOfferId] = useState(editingOfferId ?? ""); + const [displayName, setDisplayName] = useState(editingOffer?.displayName || ""); + const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(editingOffer?.customerType || 'user'); + const [groupId, setGroupId] = useState(editingOffer?.groupId || ""); + const [isAddOn, setIsAddOn] = useState(!!editingOffer?.isAddOnTo); + const [isAddOnTo, setIsAddOnTo] = useState(editingOffer?.isAddOnTo !== false ? Object.keys(editingOffer?.isAddOnTo || {}) : []); + const [stackable, setStackable] = useState(editingOffer?.stackable || false); + const [freeByDefault, setFreeByDefault] = useState(editingOffer?.prices === "include-by-default" || false); + const [prices, setPrices] = useState>(editingOffer?.prices === "include-by-default" ? {} : editingOffer?.prices || {}); + const [includedItems, setIncludedItems] = useState(editingOffer?.includedItems || {}); + const [freeTrial, setFreeTrial] = useState(editingOffer?.freeTrial || undefined); + const [serverOnly, setServerOnly] = useState(editingOffer?.serverOnly || false); + + // Dialog states + const [showGroupDialog, setShowGroupDialog] = useState(false); + const [showPriceDialog, setShowPriceDialog] = useState(false); + const [editingPriceId, setEditingPriceId] = useState(); + const [showItemDialog, setShowItemDialog] = useState(false); + const [editingItemId, setEditingItemId] = useState(); + + // Validation errors + const [errors, setErrors] = useState>({}); + + const applyTemplate = (template: Template) => { + const config = TEMPLATE_CONFIGS[template]; + if (config.displayName) setDisplayName(config.displayName); + if (config.isAddOnTo !== undefined) setIsAddOn(config.isAddOnTo !== false); + if (config.stackable !== undefined) setStackable(config.stackable); + + // Add template-specific prices + if (template === 'one-time') { + setPrices({ + 'one-time': { + USD: '99.00', + serverOnly: false, + } + }); + } else if (template === 'subscription') { + setPrices({ + 'monthly': { + USD: '9.99', + interval: [1, 'month'], + serverOnly: false, + }, + 'annual': { + USD: '99.00', + interval: [1, 'year'], + serverOnly: false, + } + }); + } + + setCurrentStep(1); + }; + + const validateGeneralInfo = () => { + const newErrors: Record = {}; + + if (!offerId.trim()) { + newErrors.offerId = "Offer ID is required"; + } else if (!/^[a-z0-9-]+$/.test(offerId)) { + newErrors.offerId = "Offer ID must contain only lowercase letters, numbers, and hyphens"; + } else if (!editingOffer && existingOffers.some(o => o.id === offerId)) { + newErrors.offerId = "This offer ID already exists"; + } + + if (!displayName.trim()) { + newErrors.displayName = "Display name is required"; + } + + if (isAddOn && isAddOnTo.length === 0) { + newErrors.isAddOnTo = "Please select at least one offer this is an add-on to"; + } + + if (isAddOn && isAddOnTo.length > 0) { + const addOnGroups = new Set( + isAddOnTo.map(offerId => existingOffers.find(o => o.id === offerId)?.groupId) + ); + if (addOnGroups.size > 1) { + newErrors.isAddOnTo = "All selected offers must be in the same group"; + } + } + + return newErrors; + }; + + const handleNext = () => { + if (currentStep === 1) { + const validationErrors = validateGeneralInfo(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + } + + setErrors({}); + setCurrentStep(prev => Math.min(prev + 1, 3)); + }; + + const handleBack = () => { + setCurrentStep(prev => Math.max(prev - 1, editingOffer ? 1 : 0)); + }; + + const handleSave = async () => { + const offer: Offer = { + displayName, + customerType, + groupId: groupId || undefined, + isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, + stackable, + prices: freeByDefault ? "include-by-default" : prices, + includedItems, + serverOnly, + freeTrial, + }; + + await onSave(offerId, offer); + handleClose(); + }; + + const handleClose = () => { + // Reset form + if (!editingOffer) { + setCurrentStep(0); + setOfferId(""); + setDisplayName(""); + setCustomerType('user'); + setGroupId(""); + setIsAddOn(false); + setIsAddOnTo([]); + setStackable(false); + setFreeByDefault(false); + setPrices({}); + setIncludedItems({}); + } + setErrors({}); + onOpenChange(false); + }; + + const addPrice = (priceId: string, price: Price) => { + setPrices(prev => ({ + ...prev, + [priceId]: price, + })); + }; + + const editPrice = (priceId: string, price: Price) => { + setPrices(prev => ({ + ...prev, + [priceId]: price, + })); + }; + + const removePrice = (priceId: string) => { + setPrices(prev => { + const newPrices = { ...prev }; + delete newPrices[priceId]; + return newPrices; + }); + }; + + const addIncludedItem = (itemId: string, item: IncludedItem) => { + setIncludedItems(prev => ({ ...prev, [itemId]: item })); + }; + + const editIncludedItem = (itemId: string, item: IncludedItem) => { + setIncludedItems(prev => { + const newItems = { ...prev }; + newItems[itemId] = item; + return newItems; + }); + }; + + const removeIncludedItem = (itemId: string) => { + setIncludedItems(prev => { + const newItems = { ...prev }; + delete newItems[itemId]; + return newItems; + }); + }; + + const formatPriceDisplay = (price: Price) => { + let display = `$${price.USD}`; + if (price.interval) { + const [count, unit] = price.interval; + display += count === 1 ? ` / ${unit}` : ` / ${count} ${unit}s`; + } + if (price.freeTrial) { + const [count, unit] = price.freeTrial; + display += ` (${count} ${unit}${count > 1 ? 's' : ''} free)`; + } + return display; + }; + + const getItemDisplay = (itemId: string, item: IncludedItem) => { + const itemData = existingItems.find(i => i.id === itemId); + if (!itemData) return itemId; + + let display = `${item.quantity}× ${itemData.displayName || itemData.id}`; + if (item.repeat !== 'never') { + const [count, unit] = item.repeat; + display += ` every ${count} ${unit}${count > 1 ? 's' : ''}`; + } + return display; + }; + + const isFirstOffer = existingOffers.length === 0; + + return ( + <> + + + + {/* Step 0: Template Selection (only for new offers) */} + {!editingOffer && ( + +
+
+ Choose a starting template + + Select a template to get started quickly, or create from scratch + +
+ +
+ applyTemplate('one-time')} + > + +
+
+ +
+
+ One-time Purchase + + A single payment for lifetime access to features + +
+
+
+
+ + applyTemplate('subscription')} + > + +
+
+ +
+
+ Subscription + + Recurring payments for continuous access + +
+
+
+
+ + {!isFirstOffer && applyTemplate('addon')} + > + +
+
+ +
+
+ Add-on + + Additional features that complement existing offers + +
+
+
+
} + + applyTemplate('scratch')} + > + +
+
+ +
+
+ Create from Scratch + + Start with a blank offer and configure everything yourself + +
+
+
+
+
+
+
+ )} + + {/* Step 1: General Information */} + +
+
+ General Information + + Configure the basic details of your offer + +
+ +
+ {/* Offer ID */} +
+ + { + setOfferId(e.target.value); + if (errors.offerId) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.offerId; + return newErrors; + }); + } + }} + placeholder="e.g., pro-plan" + disabled={!!editingOffer} + className={errors.offerId ? "border-destructive" : ""} + /> + {errors.offerId ? ( + + {errors.offerId} + + ) : ( + + Unique identifier used to reference this offer in code + + )} +
+ + {/* Display Name */} +
+ + { + setDisplayName(e.target.value); + if (errors.displayName) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.displayName; + return newErrors; + }); + } + }} + placeholder="e.g., Pro Plan" + className={errors.displayName ? "border-destructive" : ""} + /> + {errors.displayName ? ( + + {errors.displayName} + + ) : ( + + How this offer will be displayed to customers + + )} +
+ + {/* Customer Type */} +
+ + + + The type of customer this offer is for + +
+ + {/* Group */} +
+ + + + Customers can only have one active offer per group (except add-ons) + +
+ + {/* Stackable */} +
+ setStackable(checked as boolean)} + /> + +
+ + Allow customers to purchase this offer multiple times + + + {/* Add-on (only if not the first offer) */} + {!isFirstOffer && ( + <> +
+ { + setIsAddOn(checked as boolean); + if (!checked) { + setIsAddOnTo([]); + if (errors.isAddOnTo) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.isAddOnTo; + return newErrors; + }); + } + } + }} + /> + +
+ + {isAddOn && ( +
+ +
+ {existingOffers.filter(o => !o.id.startsWith('addon')).map(offer => ( +
+ { + if (checked) { + setIsAddOnTo(prev => [...prev, offer.id]); + } else { + setIsAddOnTo(prev => prev.filter(id => id !== offer.id)); + } + if (errors.isAddOnTo) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.isAddOnTo; + return newErrors; + }); + } + }} + /> + +
+ ))} +
+ {errors.isAddOnTo && ( + + {errors.isAddOnTo} + + )} + + Customers must have one of these offers to purchase this add-on + +
+ )} + + )} +
+
+
+ + {/* Step 2: Prices */} + +
+
+ Pricing + + Configure how customers will pay for this offer + +
+ +
+ {/* Free by default */} +
+ { + setFreeByDefault(checked as boolean); + if (checked) { + setPrices({}); + } + }} + /> + +
+ + This offer will be automatically included for all customers at no cost + + + {/* Prices list */} + {!freeByDefault && ( +
+ { + setEditingPriceId(undefined); + setShowPriceDialog(true); + }} + > + {Object.values(prices).length === 0 ? ( +
+ No prices configured yet + + Click the + button to add your first price + +
+ ) : ( +
+ {Object.entries(prices).map(([id, price]) => ( +
+
+
{formatPriceDisplay(price)}
+
+ ID: {id} + {price.serverOnly && ' • Server-only'} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ )} +
+
+
+ + {/* Step 3: Included Items */} + +
+
+ Included Items + + Select which items customers receive with this offer + +
+ +
+ { + setEditingItemId(undefined); + setShowItemDialog(true); + }} + > + {Object.keys(includedItems).length === 0 ? ( +
+ No items included yet + + Click the + button to include items with this offer + +
+ ) : ( +
+ {Object.entries(includedItems).map(([itemId, item]) => ( +
+
+
{getItemDisplay(itemId, item)}
+
+ {item.expires !== 'never' && `Expires: ${item.expires.replace('-', ' ')}`} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+
+
+
+ + +
+ {currentStep > (editingOffer ? 1 : 0) && ( + + )} +
+ {currentStep > 0 &&
+ + {currentStep < 3 ? ( + + ) : ( + + )} +
} +
+
+
+ + {/* Sub-dialogs */} + { + // In a real app, you'd save the group to the backend + setGroupId(group.id); + setShowGroupDialog(false); + }} + /> + + { + if (editingPriceId) { + editPrice(editingPriceId, price); + } else { + addPrice(priceId, price); + } + setShowPriceDialog(false); + }} + editingPriceId={editingPriceId} + editingPrice={editingPriceId ? prices[editingPriceId] : undefined} + existingPriceIds={Object.keys(prices)} + /> + + { + if (editingItemId !== undefined) { + editIncludedItem(editingItemId, item); + } else { + addIncludedItem(itemId, item); + } + setShowItemDialog(false); + }} + editingItemId={editingItemId} + editingItem={editingItemId !== undefined ? includedItems[editingItemId] : undefined} + existingItems={existingItems} + existingIncludedItemIds={Object.keys(includedItems)} + onCreateNewItem={() => { + setShowItemDialog(false); + onCreateNewItem?.(); + }} + /> + + ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx index ec3ea24924..10aee460f4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx @@ -1,5 +1,3 @@ -import { devFeaturesEnabledForProject } from "@/lib/utils"; -import { notFound } from "next/navigation"; import PageClient from "./page-client"; export const metadata = { @@ -11,10 +9,6 @@ type Params = { }; export default async function Page({ params }: { params: Promise }) { - const { projectId } = await params; - if (!devFeaturesEnabledForProject(projectId)) { - notFound(); - } return ( ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx index 92b6998f86..a21d580d2b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx @@ -1,18 +1,946 @@ "use client"; -import { ConnectPayments } from "@stripe/react-connect-js"; +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, Checkbox, 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 { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; +import { DUMMY_PAYMENTS_CONFIG } from "./dummy-data"; +import { ItemDialog } from "./item-dialog"; +import { ListSection } from "./list-section"; +import { OfferDialog } from "./offer-dialog"; + +type Offer = CompleteConfig['payments']['offers'][keyof CompleteConfig['payments']['offers']]; +type Item = CompleteConfig['payments']['items'][keyof CompleteConfig['payments']['items']]; + +// Custom action menu component +type ActionMenuItem = '-' | { item: React.ReactNode, onClick: () => void | Promise, danger?: boolean }; + +function ActionMenu({ items }: { items: ActionMenuItem[] }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + {items.map((item, index) => { + if (item === '-') { + return ; + } + + return ( + + {item.item} + + ); + })} + + + ); +} + + +type ListItemProps = { + id: string, + displayName?: string, + customerType: string, + subtitle?: ReactNode, + onClick?: () => void, + onMouseEnter?: () => void, + onMouseLeave?: () => void, + isEven?: boolean, + isHighlighted?: boolean, + itemRef?: React.RefObject, + actionItems?: ActionMenuItem[], +}; + +function ListItem({ + id, + displayName, + customerType, + subtitle, + onClick, + onMouseEnter, + onMouseLeave, + isEven, + isHighlighted, + itemRef, + actionItems +}: ListItemProps) { + const itemRefBackup = useRef(null); + itemRef ??= itemRefBackup; + const [isMenuHovered, setIsMenuHovered] = useState(false); + const isHovered = useHover(itemRef); -export default function PageClient() { return ( - -
-
- +
+
+ {customerType} + + {id} +
+
+ {displayName || id} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ {actionItems && ( +
e.stopPropagation()} + onMouseEnter={() => setIsMenuHovered(true)} + onMouseLeave={() => setIsMenuHovered(false)} + > + +
+ )} +
+ ); +} + +type GroupedListProps = { + children: ReactNode, +}; + +function GroupedList({ children }: GroupedListProps) { + return
{children}
; +} + +type ListGroupProps = { + title?: string, + children: ReactNode, +}; + +function ListGroup({ title, children }: ListGroupProps) { + return ( +
+ {title && ( +
+

+ {title} +

+ )} +
+ +
+
+ {children}
- +
+ ); +} + +// Connection line component +type ConnectionLineProps = { + fromRef: React.RefObject, + toRef: React.RefObject, + containerRef: React.RefObject, + quantity?: number, +}; + +function ConnectionLine({ fromRef, toRef, containerRef, quantity }: ConnectionLineProps) { + const [path, setPath] = useState(""); + const [midpoint, setMidpoint] = useState<{ x: number, y: number } | null>(null); + + useEffect(() => { + if (!fromRef.current || !toRef.current || !containerRef.current) return; + + const updatePath = () => { + const container = containerRef.current; + const from = fromRef.current; + const to = toRef.current; + + if (!container || !from || !to) return; + + const containerRect = container.getBoundingClientRect(); + const fromRect = from.getBoundingClientRect(); + const toRect = to.getBoundingClientRect(); + + // Calculate positions relative to container + const fromY = fromRect.top - containerRect.top + fromRect.height / 2; + const fromX = fromRect.right - containerRect.left - 6; + const toY = toRect.top - containerRect.top + toRect.height / 2; + const toX = toRect.left - containerRect.left + 6; + + // Create a curved path + const midX = (fromX + toX) / 2; + const midY = (fromY + toY) / 2; + const pathStr = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; + + setPath(pathStr); + setMidpoint({ x: midX, y: midY }); + }; + + updatePath(); + window.addEventListener('resize', updatePath); + window.addEventListener('scroll', updatePath, true); + + return () => { + window.removeEventListener('resize', updatePath); + window.removeEventListener('scroll', updatePath, true); + }; + }, [fromRef, toRef, containerRef]); + + if (!path) return null; + + return ( + + + + {quantity && quantity > 0 && midpoint && ( + <> + + + ×{prettyPrintWithMagnitudes(quantity)} + + + )} + + + ); +} + +// Price formatting utilities +function formatInterval(interval: DayInterval): string { + const [count, unit] = interval; + const unitShort = unit === 'month' ? 'mo' : unit === 'year' ? 'yr' : unit === 'week' ? 'wk' : unit; + return count > 1 ? `${count}${unitShort}` : unitShort; +} + +function formatPrice(price: (Offer['prices'] & object)[string]): string | null { + if (typeof price === 'string') return null; + + const amounts = []; + const interval = price.interval; + + // Check for USD amounts + if (price.USD) { + const amount = `$${(+price.USD).toFixed(2).replace(/\.00$/, '')}`; + if (interval) { + amounts.push(`${amount}/${formatInterval(interval)}`); + } else { + amounts.push(amount); + } + } + + return amounts.join(', ') || null; +} + +function formatOfferPrices(prices: Offer['prices']): string { + if (prices === 'include-by-default') return 'Free'; + if (typeof prices !== 'object') return ''; + + const formattedPrices = Object.values(prices) + .map(formatPrice) + .filter(Boolean) + .slice(0, 4); // Show max 4 prices + + return formattedPrices.join(', '); +} + +// OffersList component with props +type OffersListProps = { + groupedOffers: Map>, + paymentsGroups: any, + hoveredItemId: string | null, + getConnectedOffers: (itemId: string) => string[], + offerRefs?: Record>, + onOfferMouseEnter: (offerId: string) => void, + onOfferMouseLeave: () => void, + onOfferAdd?: () => void, + setEditingOffer: (offer: any) => void, + setShowOfferDialog: (show: boolean) => void, +}; + +function OffersList({ + groupedOffers, + paymentsGroups, + hoveredItemId, + getConnectedOffers, + offerRefs, + onOfferMouseEnter, + onOfferMouseLeave, + onOfferAdd, + setEditingOffer, + setShowOfferDialog, +}: OffersListProps) { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const [searchQuery, setSearchQuery] = useState(""); + let globalIndex = 0; + + // Filter offers based on search query + const filteredGroupedOffers = useMemo(() => { + if (!searchQuery) return groupedOffers; + + const filtered = new Map>(); + + groupedOffers.forEach((offers, groupId) => { + const filteredOffers = offers.filter(({ id, offer }) => { + const query = searchQuery.toLowerCase(); + return ( + id.toLowerCase().includes(query) || + offer.displayName?.toLowerCase().includes(query) || + offer.customerType?.toLowerCase().includes(query) + ); + }); + + if (filteredOffers.length > 0) { + filtered.set(groupId, filteredOffers); + } + }); + + return filtered; + }, [groupedOffers, searchQuery]); + + return ( + + Offers + } + titleTooltip="Offers are the products, plans, or pricing tiers you sell to your customers. They are the columns in a pricing table." + onAddClick={() => onOfferAdd?.()} + hasTitleBorder={false} + searchValue={searchQuery} + onSearchChange={setSearchQuery} + searchPlaceholder="Search offers..." + > + + {[...filteredGroupedOffers.entries()].map(([groupId, offers]) => { + const group = groupId ? paymentsGroups[groupId] : undefined; + const groupName = group?.displayName; + + return ( + + {offers.map(({ id, offer }) => { + const isEven = globalIndex % 2 === 0; + globalIndex++; + const connectedItems = hoveredItemId ? getConnectedOffers(hoveredItemId) : []; + const isHighlighted = hoveredItemId ? connectedItems.includes(id) : false; + + return ( + onOfferMouseEnter(id)} + onMouseLeave={onOfferMouseLeave} + actionItems={[ + { + item: "Edit", + onClick: () => { + setEditingOffer(offer); + setShowOfferDialog(true); + }, + }, + '-', + { + item: "Delete", + onClick: async () => { + if (confirm(`Are you sure you want to delete the offer "${offer.displayName}"?`)) { + await project.updateConfig({ [`payments.offers.${id}`]: null }); + toast({ title: "Offer deleted" }); + } + }, + danger: true, + }, + ]} + /> + ); + })} + + ); + })} + + + ); +} + +// ItemsList component with props +type ItemsListProps = { + items: CompleteConfig['payments']['items'], + hoveredOfferId: string | null, + getConnectedItems: (offerId: string) => string[], + itemRefs?: Record>, + onItemMouseEnter: (itemId: string) => void, + onItemMouseLeave: () => void, + onItemAdd?: () => void, + setEditingItem: (item: any) => void, + setShowItemDialog: (show: boolean) => void, +}; + +function ItemsList({ + items, + hoveredOfferId, + getConnectedItems, + itemRefs, + onItemMouseEnter, + onItemMouseLeave, + onItemAdd, + setEditingItem, + setShowItemDialog, +}: ItemsListProps) { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const [searchQuery, setSearchQuery] = useState(""); + + // Sort items by customer type, then by ID + const sortedItems = useMemo(() => { + const customerTypePriority = { user: 1, team: 2, custom: 3 }; + return Object.entries(items).sort(([aId, aItem]: [string, any], [bId, bItem]: [string, any]) => { + const priorityA = customerTypePriority[aItem.customerType as keyof typeof customerTypePriority] || 4; + const priorityB = customerTypePriority[bItem.customerType as keyof typeof customerTypePriority] || 4; + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + // If same customer type, sort by ID + return stringCompare(aId, bId); + }); + }, [items]); + + // Filter items based on search query + const filteredItems = useMemo(() => { + if (!searchQuery) return sortedItems; + + const query = searchQuery.toLowerCase(); + return sortedItems.filter(([id, item]) => { + return ( + id.toLowerCase().includes(query) || + (item.displayName && item.displayName.toLowerCase().includes(query)) || + item.customerType.toLowerCase().includes(query) + ); + }); + }, [sortedItems, searchQuery]); + + return ( + onItemAdd?.()} + searchValue={searchQuery} + onSearchChange={setSearchQuery} + searchPlaceholder="Search items..." + > + + {filteredItems.map(([id, item]: [string, any], index) => { + const connectedOffers = hoveredOfferId ? getConnectedItems(hoveredOfferId) : []; + const isHighlighted = hoveredOfferId ? connectedOffers.includes(id) : false; + + return ( + onItemMouseEnter(id)} + onMouseLeave={onItemMouseLeave} + actionItems={[ + { + item: "Edit", + onClick: () => { + setEditingItem({ + id, + displayName: item.displayName, + customerType: item.customerType + }); + setShowItemDialog(true); + }, + }, + '-', + { + item: "Delete", + onClick: async () => { + if (confirm(`Are you sure you want to delete the item "${item.displayName}"?`)) { + await project.updateConfig({ [`payments.items.${id}`]: null }); + toast({ title: "Item deleted" }); + } + }, + danger: true, + }, + ]} + /> + ); + })} + + + ); +} + +function WelcomeScreen({ onCreateOffer }: { onCreateOffer: () => void }) { + return ( +
+ + {/* Simple pricing table representation */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )} + title="Welcome to Payments!" + description={[ + <>Stack Auth Payments is built on two primitives: offers and items., + <>Offers are what customers buy — the columns of your pricing table. Each offer 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 offer to get started!, + ]} + /> + +
+ ); +} + +export default function PageClient() { + const [activeTab, setActiveTab] = useState<"offers" | "items">("offers"); + const [hoveredOfferId, setHoveredOfferId] = useState(null); + const [hoveredItemId, setHoveredItemId] = useState(null); + const [showOfferDialog, setShowOfferDialog] = useState(false); + const [editingOffer, setEditingOffer] = useState(null); + const [showItemDialog, setShowItemDialog] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const [shouldUseDummyData, setShouldUseDummyData] = useState(false); + + const paymentsConfig = shouldUseDummyData ? DUMMY_PAYMENTS_CONFIG : config.payments; + + // Refs for offers and items + const containerRef = useRef(null); + + // Create refs for all offers and items + const offerRefs = useMemo(() => { + const refs = Object.fromEntries( + Object.keys(paymentsConfig.offers) + .map(id => [id, React.createRef()]) + ); + return refs; + }, [paymentsConfig.offers]); + + const itemRefs = useMemo(() => { + const refs = Object.fromEntries( + Object.keys(paymentsConfig.items) + .map(id => [id, React.createRef()]) + ); + return refs; + }, [paymentsConfig.items]); + + // Group offers by groupId and sort by customer type priority + const groupedOffers = useMemo(() => { + const groups = new Map>(); + + // Group offers + Object.entries(paymentsConfig.offers).forEach(([id, offer]: [string, any]) => { + const groupId = offer.groupId; + if (!groups.has(groupId)) { + groups.set(groupId, []); + } + groups.get(groupId)!.push({ id, offer }); + }); + + // Sort offers within each group by customer type, then by ID + const customerTypePriority = { user: 1, team: 2, custom: 3 }; + groups.forEach((offers) => { + offers.sort((a, b) => { + const priorityA = customerTypePriority[a.offer.customerType as keyof typeof customerTypePriority] || 4; + const priorityB = customerTypePriority[b.offer.customerType as keyof typeof customerTypePriority] || 4; + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + // If same customer type, sort addons last + if (a.offer.isAddOnTo !== b.offer.isAddOnTo) { + return a.offer.isAddOnTo ? 1 : -1; + } + // If same customer type and addons, sort by lowest price + const getPricePriority = (offer: Offer) => { + if (offer.prices === 'include-by-default') return 0; + if (typeof offer.prices !== 'object') return 0; + return Math.min(...Object.values(offer.prices).map(price => +(price.USD ?? Infinity))); + }; + const priceA = getPricePriority(a.offer); + const priceB = getPricePriority(b.offer); + if (priceA !== priceB) { + return priceA - priceB; + } + // Otherwise, sort by ID + return stringCompare(a.id, b.id); + }); + }); + + // Sort groups by their predominant customer type + const sortedGroups = new Map>(); + + // Helper to get group priority + const getGroupPriority = (groupId: string | undefined) => { + if (!groupId) return 999; // Ungrouped always last + + const offers = groups.get(groupId) || []; + if (offers.length === 0) return 999; + + // Get the most common customer type in the group + const typeCounts = offers.reduce((acc, { offer }) => { + const type = offer.customerType; + acc[type] = (acc[type] || 0) + 1; + return acc; + }, {} as Record); + + // Find predominant type + const predominantType = Object.entries(typeCounts) + .sort(([, a], [, b]) => b - a)[0]?.[0]; + + return customerTypePriority[predominantType as keyof typeof customerTypePriority] || 4; + }; + + // Sort group entries + const sortedEntries = Array.from(groups.entries()).sort(([aId], [bId]) => { + const priorityA = getGroupPriority(aId); + const priorityB = getGroupPriority(bId); + return priorityA - priorityB; + }); + + // Rebuild map in sorted order + sortedEntries.forEach(([groupId, offers]) => { + sortedGroups.set(groupId, offers); + }); + + return sortedGroups; + }, [paymentsConfig]); + + // Get connected items for an offer + const getConnectedItems = (offerId: string) => { + const offer = paymentsConfig.offers[offerId]; + return Object.keys(offer.includedItems); + }; + + // Get item quantity for an offer + const getItemQuantity = (offerId: string, itemId: string) => { + const offer = paymentsConfig.offers[offerId]; + if (!(itemId in offer.includedItems)) return 0; + return offer.includedItems[itemId].quantity; + }; + + // Get connected offers for an item + const getConnectedOffers = (itemId: string) => { + return Object.entries(paymentsConfig.offers) + .filter(([_, offer]: [string, any]) => itemId in offer.includedItems) + .map(([id]) => id); + }; + + // Check if there are no offers and no items + const hasNoOffersAndNoItems = Object.keys(paymentsConfig.offers).length === 0 && Object.keys(paymentsConfig.items).length === 0; + + // Handler for create offer button + const handleCreateOffer = () => { + setShowOfferDialog(true); + }; + + // Handler for create item button + const handleCreateItem = () => { + setShowItemDialog(true); + }; + + // Handler for saving offer + const handleSaveOffer = async (offerId: string, offer: Offer) => { + await project.updateConfig({ [`payments.offers.${offerId}`]: offer }); + setShowOfferDialog(false); + toast({ title: editingOffer ? "Offer updated" : "Offer created" }); + }; + + // Handler for saving item + const handleSaveItem = async (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => { + await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }); + setShowItemDialog(false); + setEditingItem(null); + toast({ title: editingItem ? "Item updated" : "Item created" }); + }; + + // Prepare data for offer dialog - update when items change + const existingOffersList = Object.entries(paymentsConfig.offers).map(([id, offer]: [string, any]) => ({ + id, + displayName: offer.displayName, + groupId: offer.groupId, + customerType: offer.customerType + })); + + const existingItemsList = Object.entries(paymentsConfig.items).map(([id, item]: [string, any]) => ({ + id, + displayName: item.displayName, + customerType: item.customerType + })); + + // If no offers and items, show welcome screen instead of everything + let innerContent; + if (hasNoOffersAndNoItems) { + innerContent = ; + } else { + innerContent = ( + + setShouldUseDummyData(s => !s)} + id="use-dummy-data" + /> + +
+ )}> + {/* Mobile tabs */} +
+
+ + +
+
+ + {/* Content */} +
+ {/* Desktop two-column layout */} + + +
+ setHoveredOfferId(null)} + onOfferAdd={handleCreateOffer} + setEditingOffer={setEditingOffer} + setShowOfferDialog={setShowOfferDialog} + /> +
+
+
+ +
+ setHoveredItemId(null)} + onItemAdd={handleCreateItem} + setEditingItem={setEditingItem} + setShowItemDialog={setShowItemDialog} + /> +
+
+ + {/* Connection lines */} + {hoveredOfferId && getConnectedItems(hoveredOfferId).map(itemId => ( + + ))} + + {hoveredItemId && getConnectedOffers(hoveredItemId).map(offerId => ( + + ))} + + + {/* Mobile single column with tabs */} +
+ {activeTab === "offers" ? ( + setHoveredOfferId(null)} + onOfferAdd={handleCreateOffer} + setEditingOffer={setEditingOffer} + setShowOfferDialog={setShowOfferDialog} + /> + ) : ( + setHoveredItemId(null)} + onItemAdd={handleCreateItem} + setEditingItem={setEditingItem} + setShowItemDialog={setShowItemDialog} + /> + )} +
+
+ + ); + } + + return ( + <> + {innerContent} + + {/* Offer Dialog */} + { + setShowOfferDialog(open); + if (!open) { + setEditingOffer(null); + } + }} + onSave={async (offerId, offer) => await handleSaveOffer(offerId, offer)} + editingOffer={editingOffer} + existingOffers={existingOffersList} + existingGroups={paymentsConfig.groups} + existingItems={existingItemsList} + onCreateNewItem={handleCreateItem} + /> + + {/* Item Dialog */} + { + setShowItemDialog(open); + if (!open) { + setEditingItem(null); + } + }} + onSave={async (item) => await handleSaveItem(item)} + editingItem={editingItem} + existingItemIds={Object.keys(paymentsConfig.items)} + /> + ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx index 2052f90a6d..27ccbd4f86 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx @@ -1,6 +1,4 @@ -import { devFeaturesEnabledForProject } from "@/lib/utils"; import PageClient from "./page-client"; -import { notFound } from "next/navigation"; export const metadata = { title: "Payments", @@ -11,10 +9,6 @@ type Params = { }; export default async function Page({ params }: { params: Promise }) { - const { projectId } = await params; - if (!devFeaturesEnabledForProject(projectId)) { - notFound(); - } return ( ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/price-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/price-dialog.tsx new file mode 100644 index 0000000000..d2f449e789 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/price-dialog.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { cn } from "@/lib/utils"; +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 "@stackframe/stack-ui"; +import { useState } from "react"; + +const SUPPORTED_CURRENCIES = [ + { code: 'USD', symbol: '$', name: 'US Dollar' } +]; + +type Offer = CompleteConfig['payments']['offers'][string]; +type IncludedItem = Offer['includedItems'][string]; +type Price = (Offer['prices'] & object)[string]; + +type PriceDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + onSave: (priceId: string, price: Price) => void, + editingPriceId?: string, + editingPrice?: Price, + existingPriceIds?: string[], +}; + +export function PriceDialog({ + open, + onOpenChange, + onSave, + editingPriceId, + editingPrice, + existingPriceIds = [] +}: PriceDialogProps) { + const [priceId, setPriceId] = useState(editingPriceId || ""); + const [amount, setAmount] = useState(editingPrice?.USD || ""); + const [isRecurring, setIsRecurring] = useState(!!editingPrice?.interval); + const [intervalCount, setIntervalCount] = useState(editingPrice?.interval?.[0]?.toString() || "1"); + const [intervalUnit, setIntervalUnit] = useState<'day' | 'week' | 'month' | 'year'>(editingPrice?.interval?.[1] || "month"); + const [hasFreeTrial, setHasFreeTrial] = useState(!!editingPrice?.freeTrial); + const [freeTrialCount, setFreeTrialCount] = useState(editingPrice?.freeTrial?.[0]?.toString() || "7"); + const [freeTrialUnit, setFreeTrialUnit] = useState<'day' | 'week' | 'month' | 'year'>(editingPrice?.freeTrial?.[1] || "day"); + const [serverOnly, setServerOnly] = useState(editingPrice?.serverOnly || false); + const [errors, setErrors] = useState>({}); + + const validateAndSave = () => { + const newErrors: Record = {}; + + // Validate price ID + if (!priceId.trim()) { + newErrors.priceId = "Price ID is required"; + } else if (!/^[a-z0-9-]+$/.test(priceId)) { + newErrors.priceId = "Price ID must contain only lowercase letters, numbers, and hyphens"; + } else if (!editingPrice && existingPriceIds.includes(priceId)) { + newErrors.priceId = "This price ID already exists"; + } + + // Validate amount + const parsedAmount = parseFloat(amount); + if (!amount || isNaN(parsedAmount) || parsedAmount < 0) { + newErrors.amount = "Please enter a valid positive amount"; + } + + // Validate interval + if (isRecurring) { + const parsedIntervalCount = parseInt(intervalCount); + if (!intervalCount || isNaN(parsedIntervalCount) || parsedIntervalCount < 1) { + newErrors.intervalCount = "Interval count must be a positive number"; + } + } + + // Validate free trial + if (hasFreeTrial && isRecurring) { + const parsedFreeTrialCount = parseInt(freeTrialCount); + if (!freeTrialCount || isNaN(parsedFreeTrialCount) || parsedFreeTrialCount < 1) { + newErrors.freeTrialCount = "Free trial duration must be a positive number"; + } + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + const price: Price = { + USD: parsedAmount.toFixed(2), + serverOnly + }; + + if (isRecurring) { + price.interval = [parseInt(intervalCount), intervalUnit]; + if (hasFreeTrial) { + price.freeTrial = [parseInt(freeTrialCount), freeTrialUnit]; + } + } + + onSave(priceId, price); + handleClose(); + }; + + const handleClose = () => { + if (!editingPrice) { + setPriceId(""); + setAmount(""); + setIsRecurring(false); + setIntervalCount("1"); + setIntervalUnit("month"); + setHasFreeTrial(false); + setFreeTrialCount("7"); + setFreeTrialUnit("day"); + setServerOnly(false); + } + setErrors({}); + onOpenChange(false); + }; + + const formatPricePreview = () => { + const parsedAmount = parseFloat(amount); + if (isNaN(parsedAmount)) return ""; + + let preview = `$${parsedAmount.toFixed(2)}`; + + if (isRecurring) { + const count = parseInt(intervalCount); + if (count === 1) { + preview += ` / ${intervalUnit}`; + } else { + preview += ` / ${count} ${intervalUnit}s`; + } + } else { + preview += " (one-time)"; + } + + if (hasFreeTrial && isRecurring) { + const trialCount = parseInt(freeTrialCount); + if (trialCount === 1) { + preview += ` with ${trialCount} ${freeTrialUnit} free trial`; + } else { + preview += ` with ${trialCount} ${freeTrialUnit}s free trial`; + } + } + + return preview; + }; + + return ( + + + + {editingPrice ? "Edit Price" : "Add Price"} + + Configure the pricing for this offer. You can create one-time or recurring prices with optional free trials. + + + +
+ {/* Price ID */} +
+ + { + setPriceId(e.target.value); + if (errors.priceId) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.priceId; + return newErrors; + }); + } + }} + placeholder="e.g., monthly-pro" + disabled={!!editingPrice} + className={errors.priceId ? "border-destructive" : ""} + /> + {errors.priceId && ( + + {errors.priceId} + + )} +
+ + {/* Amount */} +
+ +
+ $ + { + setAmount(e.target.value); + if (errors.amount) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.amount; + return newErrors; + }); + } + }} + placeholder="0.00" + className={cn("pl-8", errors.amount ? "border-destructive" : "")} + /> +
+ {errors.amount && ( + + {errors.amount} + + )} +
+ + {/* Recurring */} +
+ setIsRecurring(checked as boolean)} + /> + +
+ + {/* Billing Interval */} + {isRecurring && ( +
+ +
+ { + setIntervalCount(e.target.value); + if (errors.intervalCount) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.intervalCount; + return newErrors; + }); + } + }} + className={cn("w-24", errors.intervalCount ? "border-destructive" : "")} + /> + +
+ {errors.intervalCount && ( + + {errors.intervalCount} + + )} +
+ )} + + {/* Free Trial */} + {isRecurring && ( + <> +
+ setHasFreeTrial(checked as boolean)} + /> + +
+ + {hasFreeTrial && ( +
+ +
+ { + setFreeTrialCount(e.target.value); + if (errors.freeTrialCount) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.freeTrialCount; + return newErrors; + }); + } + }} + className={cn("w-24", errors.freeTrialCount ? "border-destructive" : "")} + /> + +
+ {errors.freeTrialCount && ( + + {errors.freeTrialCount} + + )} +
+ )} + + )} + + {/* Server Only */} +
+ setServerOnly(checked as boolean)} + /> + +
+ + {/* Price Preview */} + {amount && ( +
+ + Price preview: + + + {formatPricePreview()} + +
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index a62cc598bf..12964ee8d4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -217,7 +217,6 @@ const navigationItems: (Label | Item | Hidden)[] = [ { name: "Payments", type: 'label', - requiresDevFeatureFlag: true, }, { name: "Payments", @@ -225,7 +224,6 @@ const navigationItems: (Label | Item | Hidden)[] = [ regex: /^\/projects\/[^\/]+\/payments$/, icon: CreditCard, type: 'item', - requiresDevFeatureFlag: true, }, { name: "Offers", @@ -571,7 +569,7 @@ export default function SidebarLayout(props: { projectId: string, children?: Rea
{/* Content Body - Normal scrolling */} -
+
{props.children}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index b2feeff8bd..acad69060f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -96,7 +96,7 @@ function EditableInput({ placeholder={placeholder} tabIndex={readOnly ? -1 : undefined} className={cn( - "w-full px-1 py-0 h-[unset] border-transparent hover:ring-1 hover:ring-ring", + "w-full px-1 py-0 h-[unset] border-transparent hover:ring-1 hover:ring-gray-300 dark:hover:ring-gray-500 focus-visible:ring-gray-500 dark:focus-visible:ring-gray-50", readOnly && "focus-visible:ring-0 hover:ring-0", shiftTextToLeft && "ml-[-7px]", inputClassName, diff --git a/apps/dashboard/src/components/illustrated-info.tsx b/apps/dashboard/src/components/illustrated-info.tsx new file mode 100644 index 0000000000..56ec3d088f --- /dev/null +++ b/apps/dashboard/src/components/illustrated-info.tsx @@ -0,0 +1,31 @@ +import { Typography } from "@stackframe/stack-ui"; +import React from "react"; + +export function IllustratedInfo(options: { + illustration: React.ReactNode, + title: React.ReactNode, + description: React.ReactNode[], +}) { + return ( +
+ {/* Pricing Table Illustration */} +
+ {options.illustration} +
+ + {/* Title */} + + {options.title} + + + {/* Subtitle */} +
+ {options.description.map((description, index) => ( + + {description} + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/payments/offer-dialog.tsx b/apps/dashboard/src/components/payments/offer-dialog.tsx index fdb984659b..351bbb37f7 100644 --- a/apps/dashboard/src/components/payments/offer-dialog.tsx +++ b/apps/dashboard/src/components/payments/offer-dialog.tsx @@ -7,7 +7,7 @@ import { PriceEditorField } from "@/components/payments/price-editor"; import { AdminProject } from "@stackframe/stack"; import { offerSchema, priceOrIncludeByDefaultSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, FormLabel, FormItem, FormMessage, toast, FormField, Checkbox, FormControl } from "@stackframe/stack-ui"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, FormLabel, FormItem, FormMessage, toast, FormField, Checkbox, FormControl, SimpleTooltip } from "@stackframe/stack-ui"; import * as yup from "yup"; type Props = { @@ -100,14 +100,20 @@ export function OfferDialog({ open, onOpenChange, project, mode, initial }: Prop + Server Only + + } /> + Stackable + + } />
- Include by default + + Include by default + -

- The default offer that is included in the group. -

diff --git a/apps/dashboard/src/components/stepper.tsx b/apps/dashboard/src/components/stepper.tsx new file mode 100644 index 0000000000..25c213fe3d --- /dev/null +++ b/apps/dashboard/src/components/stepper.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; + +type StepperContextType = { + currentStep: number, + totalSteps: number, + goToStep: (step: number) => void, + nextStep: () => void, + previousStep: () => void, + direction: 'forward' | 'backward', +}; + +const StepperContext = createContext(null); + +export function useStepperContext() { + const context = useContext(StepperContext); + if (!context) { + throw new Error("useStepperContext must be used within a Stepper"); + } + return context; +} + +type StepperProps = { + children: React.ReactNode, + currentStep: number, + onStepChange: (step: number) => void, + className?: string, +}; + +export function Stepper({ children, currentStep, onStepChange, className }: StepperProps) { + const [direction, setDirection] = useState<'forward' | 'backward'>('forward'); + const [dimensions, setDimensions] = useState<{ width: number, height: number }>({ width: 0, height: 0 }); + const containerRef = useRef(null); + const contentRef = useRef(null); + const previousStepRef = useRef(currentStep); + + const childrenArray = React.Children.toArray(children); + const totalSteps = childrenArray.length; + + useEffect(() => { + if (currentStep > previousStepRef.current) { + setDirection('forward'); + } else if (currentStep < previousStepRef.current) { + setDirection('backward'); + } + previousStepRef.current = currentStep; + }, [currentStep]); + + useEffect(() => { + const updateDimensions = () => { + if (contentRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + setDimensions({ + width: rect.width, + height: rect.height, + }); + } + }; + + updateDimensions(); + + // Use ResizeObserver for smooth size transitions + const resizeObserver = new ResizeObserver(updateDimensions); + if (contentRef.current) { + resizeObserver.observe(contentRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [currentStep]); + + const goToStep = (step: number) => { + if (step >= 0 && step < totalSteps) { + onStepChange(step); + } + }; + + const nextStep = () => { + goToStep(currentStep + 1); + }; + + const goToPreviousStep = () => { + goToStep(currentStep - 1); + }; + + const contextValue: StepperContextType = { + currentStep, + totalSteps, + goToStep, + nextStep, + previousStep: goToPreviousStep, + direction, + }; + + return ( + +
+
+ {childrenArray.map((child, index) => ( +
+ {index === currentStep && child} +
+ ))} +
+
+
+ ); +} + +type StepperPageProps = { + children: React.ReactNode, + className?: string, +}; + +export function StepperPage({ children, className }: StepperPageProps) { + return ( +
+ {children} +
+ ); +} + diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index a3a1bcb176..4f46e1d5a3 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -8,7 +8,7 @@ import { isShallowEqual } from "../utils/arrays"; import { SUPPORTED_CURRENCIES } from "../utils/currency-constants"; import { StackAssertionError } from "../utils/errors"; import { allProviders } from "../utils/oauth"; -import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, deleteKey, filterUndefined, get, has, isObjectLike, mapValues, set, typedAssign, typedEntries, typedFromEntries, typedKeys } from "../utils/objects"; +import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, deleteKey, filterUndefined, get, has, isObjectLike, mapValues, set, typedAssign, typedEntries, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; import { Config, NormalizationError, NormalizesTo, assertNormalized, getInvalidConfigReason, normalize } from "./format"; @@ -238,7 +238,7 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment" // BEGIN 2025-07-28: domains.trustedDomains can no longer be an array if (isEnvironmentOrHigher) { - res = mapProperty(res, "domains.trustedDomains", (value) => { + res = mapProperty(res, p => p.join(".") === "domains.trustedDomains", (value) => { if (Array.isArray(value)) { return typedFromEntries(value.map((v, i) => [`${i}`, v])); } @@ -249,21 +249,27 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment" // BEGIN 2025-07-28: themeList and templateList have been renamed (this was before the email release, so they're safe to remove) if (isBranchOrHigher) { - res = removeProperty(res, "emails.themeList"); - res = removeProperty(res, "emails.templateList"); + res = removeProperty(res, p => p.join(".") === "emails.themeList"); + res = removeProperty(res, p => p.join(".") === "emails.templateList"); } // END // BEGIN 2025-07-28: sourceOfTruth was mistakenly written to the environment config in some cases, so let's remove it if (type === "environment") { - res = removeProperty(res, "sourceOfTruth"); + res = removeProperty(res, p => p.join(".") === "sourceOfTruth"); } // END // BEGIN 2025-08-25: stripeAccountId and stripeAccountSetupComplete are unused, so let's remove them if (type === "environment") { - res = removeProperty(res, "payments.stripeAccountId"); - res = removeProperty(res, "payments.stripeAccountSetupComplete"); + res = removeProperty(res, p => p.join(".") === "payments.stripeAccountId"); + res = removeProperty(res, p => p.join(".") === "payments.stripeAccountSetupComplete"); + } + // END + + // BEGIN 2025-08-25: payments.items.default is no longer used, so let's remove it + if (isBranchOrHigher) { + res = removeProperty(res, p => p.length === 4 && p[0] === "payments" && p[1] === "items" && p[3] === "default"); } // END @@ -271,41 +277,41 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment" return res; }; -function removeProperty(obj: any, path: string): any { - return mapProperty(obj, path, () => undefined); +function removeProperty(obj: Record, pathCond: (path: (string | symbol)[]) => boolean): any { + return mapProperty(obj, pathCond, () => undefined); } -function mapProperty(obj: any, path: string, mapper: (value: any) => any): any { - const keyParts = path.split("."); - - for (let i = 0; i < keyParts.length; i++) { - const pathPrefix = keyParts.slice(0, i).join("."); - const pathSuffix = keyParts.slice(i).join("."); - if (has(obj, pathPrefix) && isObjectLike(get(obj, pathPrefix))) { - const newValue = mapProperty(get(obj, pathPrefix), pathSuffix, mapper); - set(obj, pathPrefix, newValue); - } - } - if (has(obj, path)) { - const newValue = mapper(get(obj, path)); - if (newValue !== undefined) { - set(obj, path, newValue); +function mapProperty(obj: Record, pathCond: (path: string[]) => boolean, mapper: (value: any) => any): any { + const res: Record = Array.isArray(obj) ? [] : {}; + for (const [key, value] of typedEntries(obj)) { + const path = key.split("."); + if (pathCond(path)) { + const newValue = mapper(value); + if (newValue !== undefined) { + set(res, key, newValue); + } else { + // do nothing + } + } else if (isObjectLike(value)) { + set(res, key, mapProperty(value, p => pathCond([...path, ...p]), mapper)); } else { - deleteKey(obj, path); + set(res, key, value); } } - - return obj; + return res; } import.meta.vitest?.test("mapProperty - basic property mapping", ({ expect }) => { - expect(mapProperty({ a: { b: { c: 1 } } }, "a.b.c", (value) => value + 1)).toEqual({ a: { b: { c: 2 } } }); - expect(mapProperty({ a: { b: { c: 1 } } }, "a.b.d", (value) => value + 1)).toEqual({ a: { b: { c: 1 } } }); - expect(mapProperty({ x: 5 }, "x", (value) => value * 2)).toEqual({ x: 10 }); - expect(mapProperty({ a: { b: { c: 1 } } }, "b.c", (value) => value * 10)).toEqual({ a: { b: { c: 1 } } }); - expect(mapProperty({ a: 1 }, "b.c", (value) => value)).toEqual({ a: 1 }); + expect(mapProperty({ a: { b: { c: 1 } } }, p => p.join(".") === "a.b.c", (value) => value + 1)).toEqual({ a: { b: { c: 2 } } }); + expect(mapProperty({ a: { b: { c: 1 } } }, p => p.join(".") === "a.b.d", (value) => value + 1)).toEqual({ a: { b: { c: 1 } } }); + expect(mapProperty({ x: 5 }, p => p.join(".") === "x", (value) => value * 2)).toEqual({ x: 10 }); + expect(mapProperty({ a: { b: { c: 1 } } }, p => p.join(".") === "b.c", (value) => value * 10)).toEqual({ a: { b: { c: 1 } } }); + expect(mapProperty({ a: 1 }, p => p.join(".") === "b.c", (value) => value)).toEqual({ a: 1 }); + expect(mapProperty({ "a.b": { c: 1 } }, p => p.join(".") === "a.b.c", (value) => value + 1)).toEqual({ "a.b": { c: 2 } }); + + expect(mapProperty({ a: { b: { c: 1 } } }, p => p.length === 3 && p[0] === "a" && p[1] === "b", (value) => value + 1)).toEqual({ a: { b: { c: 2 } } }); }); -function renameProperty(obj: any, oldPath: string, newPath: string): any { +function renameProperty(obj: Record, oldPath: string, newPath: string): any { const oldKeyParts = oldPath.split("."); const newKeyParts = newPath.split("."); if (!isShallowEqual(oldKeyParts.slice(0, -1), newKeyParts.slice(0, -1))) throw new StackAssertionError(`oldPath and newPath must have the same prefix. Provided: ${oldPath} and ${newPath}`); diff --git a/packages/stack-shared/src/hooks/use-hover.tsx b/packages/stack-shared/src/hooks/use-hover.tsx new file mode 100644 index 0000000000..1ed6c4d218 --- /dev/null +++ b/packages/stack-shared/src/hooks/use-hover.tsx @@ -0,0 +1,88 @@ +import { useLayoutEffect } from "react"; +import { useRefState } from "../utils/react"; + +export function useHover( + ref: React.RefObject, + options: { + onMouseEnter?: () => void, + onMouseLeave?: () => void, + } = {}, +): boolean { + // Internal counter: mouseenter++ / mouseleave-- (isHovering = counter > 0) + const counter = useRefState(0); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let incr = 0; + let prevInside = false; + + const contains = (r: DOMRect, x: number, y: number) => + x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; + + const enter = () => { + incr++; + counter.set(c => c + 1); + if (counter.current === 1) { + options.onMouseEnter?.(); + } + }; + + const leave = () => { + incr--; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + counter.set(c => c - 1); + if (counter.current === 0) { + options.onMouseLeave?.(); + } + }); + }); + }; + + const topMatchesTarget = (x: number, y: number) => { + const top = document.elementFromPoint(x, y); + return !!(top && (top === el || el.contains(top))); + }; + + const processPoint = (x: number, y: number) => { + const rect = el.getBoundingClientRect(); + + // True “hoverability”: inside rect AND not occluded by others + const inside = contains(rect, x, y) && topMatchesTarget(x, y); + if (inside && !prevInside) { + enter(); + } else if (!inside && prevInside) { + leave(); + } + prevInside = inside; + }; + + const onMove = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; // keep it hover-only + // Use coalesced points when available + const batch = e.getCoalescedEvents(); + if (batch.length) { + for (let eventIndex = 0; eventIndex < batch.length - 1; eventIndex++) { + const e1 = batch[eventIndex]; + const e2 = batch[eventIndex + 1]; + const steps = 10; + for (let i = 0; i <= steps; i++) { + processPoint(e1.clientX + (e2.clientX - e1.clientX) * i / steps, e1.clientY + (e2.clientY - e1.clientY) * i / steps); + } + } + } else { + processPoint(e.clientX, e.clientY); + } + }; + + window.addEventListener("pointermove", onMove, { passive: true }); + + return () => { + window.removeEventListener("pointermove", onMove); + counter.set(c => c - incr); + }; + }, []); + + return counter.current > 0; +} diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index fffbbcc033..ccdedb8e0f 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -1,4 +1,6 @@ -import { InternalSession } from "../sessions"; +import { KnownErrors } from "../known-errors"; +import { AccessToken, InternalSession, RefreshToken } from "../sessions"; +import { Result } from "../utils/results"; import { ConfigCrud, ConfigOverrideCrud } from "./crud/config"; import { InternalEmailsCrud } from "./crud/emails"; import { InternalApiKeysCrud } from "./crud/internal-api-keys"; @@ -56,6 +58,32 @@ export class StackAdminInterface extends StackServerInterface { ); } + protected async sendAdminRequestAndCatchKnownError( + path: string, + requestOptions: RequestInit, + tokenStoreOrNull: InternalSession | null, + errorsToCatch: readonly E[], + ): Promise + >> { + try { + return Result.ok(await this.sendAdminRequest(path, requestOptions, tokenStoreOrNull)); + } catch (e) { + for (const errorType of errorsToCatch) { + if (errorType.isInstance(e)) { + return Result.error(e as InstanceType); + } + } + throw e; + } + } + async getProject(): Promise { const response = await this.sendAdminRequest( "/internal/projects/current", @@ -501,13 +529,17 @@ export class StackAdminInterface extends StackServerInterface { return await response.json(); } - async getStripeAccountInfo(): Promise<{ account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean }> { - const response = await this.sendAdminRequest( + async getStripeAccountInfo(): Promise { + const response = await this.sendAdminRequestAndCatchKnownError( "/internal/payments/stripe/account-info", {}, null, + [KnownErrors.StripeAccountInfoNotFound], ); - return await response.json(); + if (response.status === "error") { + return null; + } + return await response.data.json(); } async createStripeWidgetAccountSession(): Promise<{ client_secret: string }> { diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 91ac39c690..10c0a73c59 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1511,6 +1511,16 @@ const ItemQuantityInsufficientAmount = createKnownErrorConstructor( (json) => [json.item_id, json.customer_id, json.quantity] as const, ); +const StripeAccountInfoNotFound = createKnownErrorConstructor( + KnownError, + "STRIPE_ACCOUNT_INFO_NOT_FOUND", + () => [ + 404, + "Stripe account information not found. Please make sure the user has onboarded with Stripe.", + ] as const, + () => [] as const, +); + export type KnownErrors = { [K in keyof typeof KnownErrors]: InstanceType; @@ -1633,6 +1643,7 @@ export const KnownErrors = { OfferDoesNotExist, OfferCustomerTypeDoesNotMatch, ItemQuantityInsufficientAmount, + StripeAccountInfoNotFound, } satisfies Record>; diff --git a/packages/stack-shared/src/utils/numbers.tsx b/packages/stack-shared/src/utils/numbers.tsx index d5c1807e0f..fc9733ea8b 100644 --- a/packages/stack-shared/src/utils/numbers.tsx +++ b/packages/stack-shared/src/utils/numbers.tsx @@ -21,6 +21,7 @@ export function prettyPrintWithMagnitudes(num: number): string { } import.meta.vitest?.test("prettyPrintWithMagnitudes", ({ expect }) => { // Test different magnitudes + expect(prettyPrintWithMagnitudes(999)).toBe("999"); expect(prettyPrintWithMagnitudes(1000)).toBe("1k"); expect(prettyPrintWithMagnitudes(1500)).toBe("1.5k"); expect(prettyPrintWithMagnitudes(1000000)).toBe("1M"); diff --git a/packages/stack-shared/src/utils/react.tsx b/packages/stack-shared/src/utils/react.tsx index 614b62597d..f191012619 100644 --- a/packages/stack-shared/src/utils/react.tsx +++ b/packages/stack-shared/src/utils/react.tsx @@ -139,13 +139,17 @@ export function useRefState(initialValue: T): RefState { const ref = React.useRef(initialValue); const setValue = React.useCallback((updater: SetStateAction) => { const value: T = typeof updater === "function" ? (updater as any)(ref.current) : updater; - setState(value); + console.log("setValue", ref.current); ref.current = value; + console.log("setValue", ref.current); + setState(value); }, []); const res = React.useMemo(() => ({ - current: ref.current, + get current() { + return ref.current; + }, set: setValue, - }), [ref.current, setValue]); + }), [setValue]); return res; } diff --git a/packages/stack-ui/src/components/simple-tooltip.tsx b/packages/stack-ui/src/components/simple-tooltip.tsx index 059ffa0461..9f69f69aae 100644 --- a/packages/stack-ui/src/components/simple-tooltip.tsx +++ b/packages/stack-ui/src/components/simple-tooltip.tsx @@ -1,5 +1,6 @@ +import { TooltipPortal } from "@radix-ui/react-tooltip"; import { CircleAlert, Info } from "lucide-react"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from ".."; +import { Tooltip, TooltipContent, TooltipTrigger, cn } from ".."; export function SimpleTooltip(props: { tooltip: React.ReactNode, @@ -9,7 +10,7 @@ export function SimpleTooltip(props: { className?: string, disabled?: boolean, }) { - const iconClassName = cn("w-4 h-4 text-zinc-500", props.inline && "inline"); + const iconClassName = cn("w-4 h-4 text-muted-foreground", props.inline && "inline"); const icon = props.type === 'warning' ? : props.type === 'info' ? @@ -21,25 +22,25 @@ export function SimpleTooltip(props: { ); return ( - - - - {props.inline ? ( - - {trigger} - - ) : ( -
- {trigger} -
- )} -
- {props.tooltip && + + + {props.inline ? ( + + {trigger} + + ) : ( +
+ {trigger} +
+ )} +
+ {props.tooltip && +
{props.tooltip}
-
} -
-
+ + } + ); } diff --git a/packages/stack-ui/src/components/ui/card.tsx b/packages/stack-ui/src/components/ui/card.tsx index 3c9be176de..913b8de6d1 100644 --- a/packages/stack-ui/src/components/ui/card.tsx +++ b/packages/stack-ui/src/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = forwardRefIfNeeded<
( if (prefixItem) { return ( -
+
{prefixItem}
@@ -27,7 +27,7 @@ export const Input = forwardRefIfNeeded( ); } else { return ( -
+
{ + async getStripeAccountInfo(): Promise { return await this._interface.getStripeAccountInfo(); } diff --git a/packages/template/src/providers/theme-provider.tsx b/packages/template/src/providers/theme-provider.tsx index 18551671fd..b302385462 100644 --- a/packages/template/src/providers/theme-provider.tsx +++ b/packages/template/src/providers/theme-provider.tsx @@ -1,6 +1,7 @@ 'use client'; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import { TooltipProvider } from "@stackframe/stack-ui"; import Color from "color"; import React from "react"; import { globalCSS } from "../generated/global-css"; @@ -102,7 +103,9 @@ export function StackTheme({ __html: globalCSS + "\n" + convertColorsToCSS(themeValue), }} /> - {children} + + {children} + ); }