+
{itemsList.map(([itemId, item]) => {
const itemMeta = existingItems.find(i => i.id === itemId);
const itemLabel = itemMeta ? itemMeta.id : 'Select item';
@@ -983,8 +829,8 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa
itemDisplayName={itemLabel}
allItems={existingItems}
existingIncludedItemIds={Object.keys(draft.includedItems).filter(id => id !== itemId)}
- startEditing={isEditing}
- readOnly={!isEditing}
+ startEditing={true}
+ readOnly={false}
onSave={(id, updated) => handleAddOrEditIncludedItem(id, updated)}
onChangeItemId={(newItemId) => {
setDraft(prev => {
@@ -999,90 +845,191 @@ function ProductCard({ id, activeType, product, allProducts, existingItems, onSa
return { ...prev, includedItems: next };
});
}}
- onRemove={isEditing ? () => handleRemoveIncludedItem(itemId) : undefined}
+ onRemove={() => handleRemoveIncludedItem(itemId)}
onCreateNewItem={onCreateNewItem}
/>
);
})}
)}
-
-
+
+
+
+
+
- )}
- {!isEditing && activeType !== "custom" && (
-
+
+
+ );
+
+ const viewingContent = (
+
+
+
+
setLocalProductId(value)}
+ readOnly
+ placeholder={"Product ID"}
+ inputClassName="text-xs font-mono text-center text-muted-foreground"
+ transform={(value) => value.toLowerCase()}
+ />
+ setDraft(prev => ({ ...prev, displayName: value }))}
+ readOnly
+ placeholder={"Product display name"}
+ inputClassName="text-lg font-bold text-center w-full"
+ />
+
+
+
+
+
+
+
+
+ {
+ setIsEditing(true);
+ setDraft(product);
+ }}>
+ Edit
+
+ { onDuplicate(product); }}>
+ Duplicate
+
+
+ { setShowDeleteDialog(true); }}>
+ Delete
+
+
+
+
+
+
+ {renderToggleButtons('view')}
+
+
+ {renderPrimaryPrices('view')}
+
+
+
+ {itemsList.length === 0 ? (
+
Grants no items
+ ) : (
+
+ {itemsList.map(([itemId, item]) => {
+ const itemMeta = existingItems.find(i => i.id === itemId);
+ const itemLabel = itemMeta ? itemMeta.id : 'Select item';
+ return (
+
id !== itemId)}
+ startEditing={false}
+ readOnly
+ onSave={(id, updated) => handleAddOrEditIncludedItem(id, updated)}
+ onChangeItemId={(_newItemId) => { }}
+ onRemove={undefined}
+ onCreateNewItem={onCreateNewItem}
+ />
+ );
+ })}
+
+ )}
+
+ {activeType !== "custom" && (
+
)}
+
+ );
+
+ return (
+
+ {isEditing ? editingContent : viewingContent}
,
onSaveProduct: (id: string, product: Product) => Promise,
onDeleteProduct: (id: string) => Promise,
- onCreateNewItem: () => void,
+ onCreateNewItem: (customerType?: 'user' | 'team' | 'custom') => void,
onOpenProductDetails: (product: Product) => void,
onSaveProductWithGroup: (catalogId: string, productId: string, product: Product) => Promise,
createDraftRequestId?: string,
@@ -1119,7 +1066,8 @@ type CatalogViewProps = {
};
function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, onDeleteProduct, onCreateNewItem, onOpenProductDetails, onSaveProductWithGroup, createDraftRequestId, draftCustomerType, onDraftHandled }: CatalogViewProps) {
- const [activeType, setActiveType] = useState<'user' | 'team' | 'custom'>('user');
+ const [activeTypeUnfiltered, setActiveType] = useQueryState('catalog_type', 'user');
+ const activeType = typedIncludes(['user', 'team', 'custom'] as const, activeTypeUnfiltered) ? activeTypeUnfiltered : 'user';
const [drafts, setDrafts] = useState>([]);
const [creatingGroupKey, setCreatingGroupKey] = useState(undefined);
const [newCatalogId, setNewCatalogId] = useState("");
@@ -1189,7 +1137,7 @@ function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, on
setActiveType(draftCustomerType);
setDrafts((prev) => [...prev, { key: candidate, catalogId: undefined, product: newProduct }]);
onDraftHandled?.();
- }, [createDraftRequestId, draftCustomerType, onDraftHandled, usedIds]);
+ }, [createDraftRequestId, draftCustomerType, onDraftHandled, usedIds, setActiveType]);
const generateProductId = (base: string) => {
let id = base;
@@ -1209,7 +1157,7 @@ function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, on
return (
-
+
{(['user', 'team', 'custom'] as const).map(t => (
)}
@@ -1372,59 +1324,53 @@ function CatalogView({ groupedProducts, groups, existingItems, onSaveProduct, on
);
})}
- {/* TODO: Add new catalog is temporarily disabled, uncomment this to enable it
-
+
{
+ const tempKey = `__new_catalog__${Date.now().toString(36).slice(2, 8)}`;
+ setCreatingGroupKey(tempKey);
+ setNewCatalogId("");
+ const draftKey = generateProductId("product");
+ const newProduct: Product = {
+ displayName: 'New Product',
+ customerType: activeType,
+ catalogId: tempKey,
+ isAddOnTo: false,
+ stackable: false,
+ prices: {},
+ includedItems: {},
+ serverOnly: false,
+ freeTrial: undefined,
+ };
+ setDrafts(prev => [...prev, { key: draftKey, catalogId: tempKey, product: newProduct }]);
+ }}
+ >
-
{
- const tempKey = `__new_catalog__${Date.now().toString(36).slice(2, 8)}`;
- setCreatingGroupKey(tempKey);
- setNewCatalogId("");
- const draftKey = generateProductId("product");
- const newProduct: Product = {
- displayName: 'New Product',
- customerType: activeType,
- catalogId: tempKey,
- isAddOnTo: false,
- stackable: false,
- prices: {},
- includedItems: {},
- serverOnly: false,
- freeTrial: undefined,
- };
- setDrafts(prev => [...prev, { key: draftKey, catalogId: tempKey, product: newProduct }]);
- }}
- >
-
-
+
Create catalog
-
- */}
+
);
}
type CatalogViewPageProps = {
- onViewChange: (view: "list" | "catalogs") => void,
createDraftRequestId?: string,
draftCustomerType?: 'user' | 'team' | 'custom',
onDraftHandled?: () => void,
};
-export default function PageClient({ onViewChange, createDraftRequestId, draftCustomerType = 'user', onDraftHandled }: CatalogViewPageProps) {
+export default function PageClient({ createDraftRequestId, draftCustomerType = 'user', onDraftHandled }: CatalogViewPageProps) {
const [showProductDialog, setShowProductDialog] = useState(false);
const [editingProduct, setEditingProduct] = useState(null);
const [showItemDialog, setShowItemDialog] = useState(false);
const [editingItem, setEditingItem] = useState<{ id: string, displayName: string, customerType: 'user' | 'team' | 'custom' } | null>(null);
+ const [newItemCustomerType, setNewItemCustomerType] = useState<'user' | 'team' | 'custom' | undefined>(undefined);
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const config = project.useConfig();
- const switchId = useId();
- const testModeSwitchId = useId();
const paymentsConfig: CompleteConfig['payments'] = config.payments;
@@ -1509,14 +1455,9 @@ export default function PageClient({ onViewChange, createDraftRequestId, draftCu
return sortedGroups;
}, [paymentsConfig]);
- // Check if there are no products and no items
- // Handler for create product button
- const handleCreateProduct = () => {
- setShowProductDialog(true);
- };
-
// Handler for create item button
- const handleCreateItem = () => {
+ const handleCreateItem = (customerType?: 'user' | 'team' | 'custom') => {
+ setNewItemCustomerType(customerType);
setShowItemDialog(true);
};
@@ -1559,60 +1500,31 @@ export default function PageClient({ onViewChange, createDraftRequestId, draftCu
toast({ title: "Product deleted" });
};
- const handleToggleTestMode = async (enabled: boolean) => {
- await project.updateConfig({ "payments.testMode": enabled });
- toast({ title: enabled ? "Test mode enabled" : "Test mode disabled" });
- };
-
-
- // If no products and items, show welcome screen instead of everything
const innerContent = (
-
-
-
- onViewChange("list")} />
-
-
-
-
-
- handleToggleTestMode(checked)}
- />
-
-
- }
- >
-
- {
- setEditingProduct(product);
- setShowProductDialog(true);
- }}
- onSaveProductWithGroup={async (catalogId, productId, product) => {
- await project.updateConfig({
- [`payments.catalogs.${catalogId}`]: {},
- [`payments.products.${productId}`]: product,
- });
- toast({ title: "Product created" });
- }}
- createDraftRequestId={createDraftRequestId}
- draftCustomerType={draftCustomerType}
- onDraftHandled={onDraftHandled}
- />
-
-
+
+ {
+ setEditingProduct(product);
+ setShowProductDialog(true);
+ }}
+ onSaveProductWithGroup={async (catalogId, productId, product) => {
+ await project.updateConfig({
+ [`payments.catalogs.${catalogId}`]: {},
+ [`payments.products.${productId}`]: product,
+ });
+ toast({ title: "Product created" });
+ }}
+ createDraftRequestId={createDraftRequestId}
+ draftCustomerType={draftCustomerType}
+ onDraftHandled={onDraftHandled}
+ />
+
);
return (
@@ -1643,11 +1555,13 @@ export default function PageClient({ onViewChange, createDraftRequestId, draftCu
setShowItemDialog(open);
if (!open) {
setEditingItem(null);
+ setNewItemCustomerType(undefined);
}
}}
onSave={async (item) => await handleSaveItem(item)}
editingItem={editingItem ?? undefined}
existingItemIds={Object.keys(paymentsConfig.items)}
+ forceCustomerType={newItemCustomerType}
/>
>
);
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
index 83f4ad5bba..2d77184e7b 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
@@ -1,17 +1,17 @@
"use client";
+import { ItemDialog } from "@/components/payments/item-dialog";
import { cn } from "@/lib/utils";
import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
import { useHover } from "@stackframe/stack-shared/dist/hooks/use-hover";
import { DayInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { prettyPrintWithMagnitudes } from "@stackframe/stack-shared/dist/utils/numbers";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
-import { Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Label, Separator, Switch, toast } from "@stackframe/stack-ui";
-import { MoreVertical } from "lucide-react";
-import React, { ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
-import { PageLayout } from "../../page-layout";
+import { Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, toast } from "@stackframe/stack-ui";
+import { MoreVertical, Plus } from "lucide-react";
+import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react";
+import { IllustratedInfo } from "../../../../../../../components/illustrated-info";
import { useAdminApp } from "../../use-admin-app";
-import { ItemDialog } from "@/components/payments/item-dialog";
import { ListSection } from "./list-section";
import { ProductDialog } from "./product-dialog";
@@ -535,7 +535,59 @@ function ItemsList({
);
}
-export default function PageClient({ onViewChange }: { onViewChange: (view: "list" | "catalogs") => void }) {
+function WelcomeScreen({ onCreateProduct }: { onCreateProduct: () => void }) {
+ return (
+
+
+ {/* Simple pricing table representation */}
+
+
+
+
+ )}
+ title="Welcome to Payments!"
+ description={[
+ <>Stack Auth Payments is built on two primitives: products and items.>,
+ <>Products are what customers buy — the columns of your pricing table. Each product has one or more prices and may or may not include items.>,
+ <>Items are what customers receive — the rows of your pricing table. A user can hold multiple of the same item. Items are powerful; they can unlock feature access, raise limits, or meter consumption for usage-based billing.>,
+ <>Create your first product to get started!>,
+ ]}
+ />
+
+
+ Create Your First Product
+
+
+ );
+}
+
+export default function PageClient() {
const [activeTab, setActiveTab] = useState<"products" | "items">("products");
const [hoveredProductId, setHoveredProductId] = useState