diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx new file mode 100644 index 0000000000..06e55fde19 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx @@ -0,0 +1,704 @@ +"use client"; + +import { Link } from "@/components/link"; +import { ItemDialog } from "@/components/payments/item-dialog"; +import { useRouter } from "@/components/router"; +import { + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SimpleTooltip, + toast, + Typography, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ClockIcon, HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react"; +import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { useState } from "react"; +import { useAdminApp, useProjectId } from "../../../../use-admin-app"; +import { CreateProductLineDialog } from "../../create-product-line-dialog"; +import { IncludedItemDialog } from "../../included-item-dialog"; +import { PricingSection } from "../../pricing-section"; +import { ProductCardPreview } from "../../product-card-preview"; +import { + generateUniqueId, + type Price, + type Product, +} from "../../utils"; + +type IncludedItem = Product['includedItems'][string]; + +const CUSTOMER_TYPE_COLORS = { + user: 'bg-blue-500/15 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400 ring-blue-500/30', + team: 'bg-emerald-500/15 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400 ring-emerald-500/30', + custom: 'bg-amber-500/15 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400 ring-amber-500/30', +} as const; + +function getItemDisplay(itemId: string, item: IncludedItem, existingItems: Array<{ id: string, displayName: string, customerType: string }>) { + 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; +} + +export default function PageClient({ productId }: { productId: string }) { + const projectId = useProjectId(); + const router = useRouter(); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const paymentsConfig: CompleteConfig['payments'] = config.payments; + + const existingProduct = paymentsConfig.products[productId] as Product | undefined; + + // If product not found, show error + if (!existingProduct) { + return ( +
+ + Product not found + +
+ ); + } + + return ; +} + +function EditProductForm({ productId, existingProduct }: { productId: string, existingProduct: Product }) { + const projectId = useProjectId(); + const router = useRouter(); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const paymentsConfig: CompleteConfig['payments'] = config.payments; + + // Customer type is fixed from the existing product (cannot be changed) + const customerType = existingProduct.customerType; + + // Parse existing product data + const existingIsAddOn = existingProduct.isAddOnTo !== false; + const existingIsAddOnTo = existingIsAddOn + ? Object.keys(existingProduct.isAddOnTo as Record) + : []; + const existingPrices = existingProduct.prices === 'include-by-default' + ? {} + : existingProduct.prices; + const existingFreeByDefault = existingProduct.prices === 'include-by-default'; + + // Form state - initialized from existing product + const [displayName, setDisplayName] = useState(existingProduct.displayName || ''); + const [productLineId, setProductLineId] = useState(existingProduct.productLineId || ''); + const [isAddOn, setIsAddOn] = useState(existingIsAddOn); + const [isAddOnTo, setIsAddOnTo] = useState(existingIsAddOnTo); + const [stackable, setStackable] = useState(existingProduct.stackable); + const [serverOnly, setServerOnly] = useState(existingProduct.serverOnly); + const [freeByDefault, setFreeByDefault] = useState(existingFreeByDefault); + const [prices, setPrices] = useState>(existingPrices); + const [includedItems, setIncludedItems] = useState(existingProduct.includedItems); + const [freeTrial, setFreeTrial] = useState(existingProduct.freeTrial); + + // Dialog states + const [showProductLineDialog, setShowProductLineDialog] = useState(false); + const [showItemDialog, setShowItemDialog] = useState(false); + const [editingItemId, setEditingItemId] = useState(); + const [showNewItemDialog, setShowNewItemDialog] = useState(false); + + // Validation errors + const [errors, setErrors] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + + // Computed values + const existingProducts = typedEntries(paymentsConfig.products) + .filter(([id]) => id !== productId) // Exclude self + .map(([id, product]) => ({ + id, + displayName: product.displayName, + productLineId: product.productLineId, + customerType: product.customerType + })); + + const existingItems = typedEntries(paymentsConfig.items).map(([id, item]) => ({ + id, + displayName: item.displayName, + customerType: item.customerType + })); + + // Validate that the selected productLineId matches the current customerType + const effectiveProductLineId = productLineId && paymentsConfig.productLines[productLineId].customerType === customerType + ? productLineId + : ""; + + // Build product object for preview + const previewProduct: Product = { + displayName: displayName || 'Product', + customerType, + productLineId: effectiveProductLineId || undefined, + isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, + stackable, + prices: freeByDefault ? 'include-by-default' : prices, + includedItems, + serverOnly, + freeTrial, + }; + + const handleCreateProductLine = async (productLine: { id: string, displayName: string }) => { + await project.updateConfig({ + [`payments.productLines.${productLine.id}`]: { + displayName: productLine.displayName || null, + customerType, + }, + }); + setProductLineId(productLine.id); + }; + + const validateForm = () => { + const newErrors: Record = {}; + + if (!displayName.trim()) { + newErrors.displayName = "Display name is required"; + } + + if (isAddOn && isAddOnTo.length === 0) { + newErrors.isAddOnTo = "Please select at least one product this is an add-on to"; + } + + if (isAddOn && isAddOnTo.length > 0) { + const addOnProductLines = new Set( + isAddOnTo.map(pid => existingProducts.find(o => o.id === pid)?.productLineId) + ); + if (addOnProductLines.size > 1) { + newErrors.isAddOnTo = "All selected products must be in the same product line"; + } + } + + if (!freeByDefault && Object.keys(prices).length === 0) { + newErrors.prices = "Add at least one price or enable 'Include by default'"; + } + + return newErrors; + }; + + const handleSave = async () => { + const validationErrors = validateForm(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + setIsSaving(true); + try { + const product: Product = { + displayName, + customerType, + productLineId: effectiveProductLineId || undefined, + isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false, + stackable, + prices: freeByDefault ? 'include-by-default' : prices, + includedItems, + serverOnly, + freeTrial, + }; + + await project.updateConfig({ [`payments.products.${productId}`]: product }); + toast({ title: "Product updated" }); + router.push(`/projects/${projectId}/payments/products`); + } finally { + setIsSaving(false); + } + }; + + 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 handleCancel = () => { + if (window.history.length > 1) { + window.history.back(); + } else { + router.push(`/projects/${projectId}/payments/products`); + } + }; + + const canSave = !!(displayName.trim() && (freeByDefault || Object.keys(prices).length > 0)); + + return ( +
+ {/* Header */} +
+
+ + Edit Product +
+
+ + + + +
+
+ + {/* Main content - form on left, preview on right */} +
+ {/* Left side - Configuration form */} +
+
+ {/* Display Name and Product ID - same row */} +
+ {/* 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={cn( + "h-8 rounded-lg text-sm", + "bg-foreground/[0.03] border-border/50 dark:border-foreground/[0.1]", + "focus:ring-1 focus:ring-cyan-500/30 focus:border-cyan-500/50", + "transition-all duration-150 hover:transition-none", + errors.displayName && "border-destructive focus:ring-destructive/30" + )} + /> + Visible to customers during checkout + {errors.displayName && ( + + {errors.displayName} + + )} +
+ + {/* Product ID - Read Only */} +
+ + + Product ID cannot be changed +
+
+ + {/* Pricing Section */} +
+ Pricing + { + setPrices(newPrices); + if (errors.prices && Object.keys(newPrices).length > 0) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.prices; + return newErrors; + }); + } + }} + hasError={!!errors.prices} + errorMessage={errors.prices} + variant="form" + isFree={freeByDefault || (Object.keys(prices).length === 1 && Object.values(prices)[0].USD === '0.00')} + freeByDefault={freeByDefault} + onMakeFree={() => { + setPrices({}); + setFreeByDefault(true); + }} + onMakePaid={() => { + setFreeByDefault(false); + }} + onFreeByDefaultChange={(checked) => { + setFreeByDefault(checked); + if (!checked) { + const newPriceId = generateUniqueId('price'); + setPrices({ [newPriceId]: { USD: '0.00', serverOnly: false } }); + } else { + setPrices({}); + } + }} + /> +
+ + {/* Included Items Section */} +
+ Included Items + + {Object.entries(includedItems).length === 0 ? ( +
+

+ No items included yet +

+ +
+ ) : ( +
+ {Object.entries(includedItems).map(([itemId, item]) => ( +
+
+
{getItemDisplay(itemId, item, existingItems)}
+
{itemId}
+
+
+ + +
+
+ ))} + +
+ )} +
+ + {/* Options Section - Two column grid */} +
+ Options + +
+ {/* Customer Type - Read Only */} + Customer type +
+ + {customerType} + + (cannot be changed) +
+ + {/* Stackable */} + Can this be purchased multiple times? + + + {/* Server Only */} + Restrict to server-side purchases only? + + + {/* Add-on */} + {existingProducts.length > 0 && ( + <> + Require another product to be purchased first? +
+ + + {isAddOn && ( +
+ Add-on to: +
+ {existingProducts.map(product => ( + + ))} +
+ {errors.isAddOnTo && ( + + {errors.isAddOnTo} + + )} +
+ )} +
+ + )} + + {/* Free Trial */} + Offer a free trial period? +
+ + {freeTrial && ( +
+ { + const val = parseInt(e.target.value) || 1; + setFreeTrial([val, freeTrial[1]]); + }} + className="h-7 w-16 text-sm rounded-md" + /> + +
+ )} +
+ + {/* Product Line */} + Part of a mutually exclusive group? +
+ +
+
+
+
+
+ + {/* Right side - Preview */} +
+
+ + Preview + +
+
+ +
+
+
+ + {/* Dialogs */} + + + { + if (editingItemId) { + editIncludedItem(itemId, item); + } else { + addIncludedItem(itemId, item); + } + }} + onCreateNewItem={() => setShowNewItemDialog(true)} + /> + + { + await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }); + toast({ title: "Item created" }); + }} + existingItemIds={Object.keys(paymentsConfig.items)} + forceCustomerType={customerType} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page.tsx new file mode 100644 index 0000000000..418c5afac8 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page.tsx @@ -0,0 +1,16 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Edit Product", +}; + +export default async function Page({ + params, +}: { + params: Promise<{ productId: string }>, +}) { + const awaitedParams = await params; + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 008fcb3c50..031f64e941 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -186,12 +186,15 @@ function ProductHeader({ productId, product, productLineName }: ProductHeaderPro }} /> - {isAddOn && ( - Add-on - )} - {product.stackable && ( - Stackable - )} + - )} - - ); - }) - ) : ( - PRODUCT_TOGGLE_OPTIONS - .filter(b => b.visible !== false) - .filter(b => b.active) - .map((b) => { - return ( - - - {b.icon} - {getLabel(b, false)} - - - ); - }) - ); + return PRODUCT_TOGGLE_OPTIONS + .filter(b => b.visible !== false) + .filter(b => b.active) + .map((b) => ( + + + {b.icon} + {getLabel(b)} + + + )); }; - const editingContent = ( -
- {/* Header */} -
-

- {isDraft ? "New product" : "Edit product"} -

-
- - {/* Content */} -
-
- {/* Name, ID & Type Fields */} -
-
- - Offer Name - - { - const value = event.target.value; - setDraft(prev => ({ ...prev, displayName: value })); - }} - placeholder="e.g., Pro Plan" - /> -
-
- - Offer ID - - - { - const value = event.target.value.toLowerCase().replace(/[^a-z0-9_\-]/g, '-'); - setLocalProductId(value); - }} - placeholder="e.g., pro-plan" - disabled={!isDraft} - /> - -
-
- - Customer Type - - - - - - -
- {(['user', 'team', 'custom'] as const).map((type) => { - const isSelected = draft.customerType === type; - const descriptions = { - user: 'For individual users', - team: 'For teams or organizations', - custom: 'Server-side managed customers', - }; - return ( - - ); - })} -
-
-
-
-
- - {/* Toggle Options */} -
- {renderToggleButtons('editing')} -
- - {/* Prices Section */} - -
- {renderPrimaryPrices('editing')} - {!editingPricesIsFreeMode && ( -
- - {!hasExistingPrices && ( - <> - or - - - )} -
- )} -
- - {/* Includes Section */} - - {itemsList.length === 0 ? ( -
- No items yet -
- ) : ( -
- {itemsList.map(([itemId, item]) => { - const itemMeta = existingItems.find(i => i.id === itemId); - const itemLabel = itemMeta ? itemMeta.displayName : 'Select item'; - return ( - id !== itemId)} - startEditing={true} - readOnly={false} - onSave={(id, updated) => handleAddOrEditIncludedItem(id, updated)} - onChangeItemId={(newItemId) => { - setDraft(prev => { - if (Object.prototype.hasOwnProperty.call(prev.includedItems, newItemId)) { - toast({ title: "Item already included" }); - return prev; - } - const next: Product['includedItems'] = { ...prev.includedItems }; - const value = next[itemId]; - delete next[itemId]; - next[newItemId] = value; - return { ...prev, includedItems: next }; - }); - }} - onRemove={() => handleRemoveIncludedItem(itemId)} - onCreateNewItem={onCreateNewItem} - /> - ); - })} -
- )} - -
-
- - {/* Footer */} -
- -
- - - - -
-
-
- ); - const handleCardClick = (e: React.MouseEvent) => { // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement; @@ -1329,15 +939,31 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete {customerType} - ID: {localProductId} + ID: {id} {/* Product name */}

- {draft.isAddOnTo !== false && } - {draft.displayName || "Untitled Product"} + {product.isAddOnTo !== false && } + {product.displayName || "Untitled Product"}

+ {/* Drag handle - appears on hover */} + {dragHandleProps && ( +
+ +
+ )} + {/* Action menu - appears on hover */}
@@ -1354,13 +980,25 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete View Details - } onClick={() => { - setIsEditing(true); - setDraft(product); - }}> + } + onClick={() => router.push(`/projects/${projectId}/payments/products/${id}/edit`)} + > Edit - } onClick={() => { onDuplicate(product); }}> + } + onClick={() => { + // Store product data for duplication and navigate to create page + const duplicateKey = `duplicate-${Date.now()}`; + const duplicateData = { + ...product, + displayName: `${product.displayName || id} Copy`, + }; + sessionStorage.setItem(duplicateKey, JSON.stringify(duplicateData)); + router.push(`/projects/${projectId}/payments/products/new?duplicate=${duplicateKey}`); + }} + > Duplicate @@ -1379,7 +1017,7 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete {/* Toggle badges */} {PRODUCT_TOGGLE_OPTIONS.some(b => b.visible !== false && b.active) && (
- {renderToggleButtons('view')} + {renderToggleButtons()}
)} @@ -1388,7 +1026,7 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete "border-t border-border/20 dark:border-foreground/[0.06] px-5 py-4", itemsList.length === 0 && "flex-1" )}> - {renderPrimaryPrices('view')} + {renderPrimaryPrices()}
{/* Items section - grows to fill available space */} @@ -1462,16 +1100,13 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete return (
- {isEditing ? editingContent : viewingContent} + {viewingContent} { await onDelete(id); - setIsEditing(false); } }} cancelButton @@ -1495,7 +1129,7 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete type ProductLineViewProps = { groupedProducts: Map>, - groups: Record, + groups: Record, existingItems: Array<{ id: string, displayName: string, customerType: string }>, onSaveProduct: (id: string, product: Product) => Promise, onDeleteProduct: (id: string) => Promise, @@ -1508,6 +1142,9 @@ type ProductLineViewProps = { createDraftRequestId?: string, draftCustomerType: 'user' | 'team' | 'custom', onDraftHandled?: () => void, + // Drag and drop support + paymentsConfig: CompleteConfig['payments'], + onMoveProduct: (productId: string, targetProductLineId: string | undefined) => Promise, }; // Combined key for productLine + customer type grouping @@ -1520,7 +1157,7 @@ function productLineTypeKeyToString(key: ProductLineTypeKey): string { return `${key.productLineId ?? '__none__'}::${key.customerType}`; } -function ProductLineView({ groupedProducts, groups, existingItems, onSaveProduct, onDeleteProduct, onCreateNewItem, onOpenProductDetails, onSaveProductWithGroup, onCreateProductLine, onUpdateProductLine, onDeleteProductLine, createDraftRequestId, draftCustomerType, onDraftHandled }: ProductLineViewProps) { +function ProductLineView({ groupedProducts, groups, existingItems, onSaveProduct, onDeleteProduct, onCreateNewItem, onOpenProductDetails, onSaveProductWithGroup, onCreateProductLine, onUpdateProductLine, onDeleteProductLine, createDraftRequestId, draftCustomerType, onDraftHandled, paymentsConfig, onMoveProduct }: ProductLineViewProps) { const projectId = useProjectId(); const [drafts, setDrafts] = useState>([]); const [creatingGroupKey, setCreatingGroupKey] = useState(undefined); @@ -1670,79 +1307,167 @@ function ProductLineView({ groupedProducts, groups, existingItems, onSaveProduct return drafts.filter(d => d.productLineId === undefined); }, [drafts]); + // Drag and drop state + const [activeDragId, setActiveDragId] = useState(null); + const [isMovingProduct, setIsMovingProduct] = useState(false); + const activeDragProduct = activeDragId ? paymentsConfig.products[activeDragId] : null; + + const handleDragStart = (event: DragStartEvent) => { + setActiveDragId(event.active.id as string); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + setActiveDragId(null); + + const { active, over } = event; + if (!over) return; + + const draggedProductId = active.id as string; + const draggedProduct = paymentsConfig.products[draggedProductId] as Product | undefined; + if (!draggedProduct) return; + + // Parse the drop target - it's either a productLineId or "no-product-line" + const targetProductLineId = over.id === "no-product-line" ? undefined : (over.id as string); + + // Normalize productLineId values for comparison (treat null, undefined, and empty string as "no product line") + const currentProductLineId = draggedProduct.productLineId || undefined; + const normalizedTargetProductLineId = targetProductLineId || undefined; + + // Don't do anything if dropped on the same product line + if (normalizedTargetProductLineId === currentProductLineId) return; + + // Get the target product line's customer type + const targetCustomerType = targetProductLineId + ? paymentsConfig.productLines[targetProductLineId].customerType + : undefined; + + // Validate customer type compatibility: + // - Can always drop to "No product line" + // - Can drop to a product line if it has the same customer type as the product + if (targetProductLineId && targetCustomerType !== draggedProduct.customerType) { + toast({ + title: "Cannot move product", + description: `This product has customer type "${draggedProduct.customerType}" but the target product line is for "${targetCustomerType}" customers.`, + }); + return; + } + + // Show loading state and update the product's productLineId + setIsMovingProduct(true); + try { + await onMoveProduct(draggedProductId, targetProductLineId); + + toast({ + title: "Product moved", + description: targetProductLineId + ? `Moved to "${paymentsConfig.productLines[targetProductLineId].displayName || targetProductLineId}"` + : "Removed from product line", + }); + } finally { + setIsMovingProduct(false); + } + }; + return ( -
- {productLinesToRender.map((productLine) => { - const productLineId = productLine.id; - const customerType = productLine.customerType; - const groupName = productLine.displayName; - - // Get products for this product line - const products = getProductsForProductLine(productLineId); - - // Filter drafts for this product line - const matchingDrafts = getDraftsForProductLine(productLineId); - - // Separate non-add-on and add-on products for pricing table layout - const nonAddOnProducts = products.filter(({ product }) => product.isAddOnTo === false); - const addOnProducts = products.filter(({ product }) => product.isAddOnTo !== false); - const nonAddOnDrafts = matchingDrafts.filter(d => d.product.isAddOnTo === false); - const addOnDrafts = matchingDrafts.filter(d => d.product.isAddOnTo !== false); - - const hasNonAddOns = nonAddOnProducts.length > 0 || nonAddOnDrafts.length > 0; - - return ( -
-
-
-

{groupName}

-
- - + }} + className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] transition-colors duration-150 hover:transition-none" + aria-label="Edit product line" + > + + + +
-
-
- + - {customerType ?? "unknown customer type"} - - - Products in this product line are mutually exclusive (except add-ons) - + {customerType ?? "unknown customer type"} + + + Products in this product line are mutually exclusive (except add-ons) + +
-
-
-
-
- {/* Non-add-on products as a pricing table */} - {nonAddOnProducts.length > 0 && ( -
- {nonAddOnProducts.map(({ id, product }, index) => ( - +
+
+
+ {/* Non-add-on products as a pricing table */} + {nonAddOnProducts.length > 0 && ( +
+ {nonAddOnProducts.map(({ id, product }, index) => ( + onOpenProductDetails(o)} + isColumnInTable + isFirstColumn={index === 0} + isLastColumn={index === nonAddOnProducts.length - 1} + /> + ))} +
+ )} + + + {/* Add-on products as separate cards */} + {addOnProducts.map(({ id, product }) => ( + { - const key = generateProductId("product"); - const duplicated: Product = { - ...srcProduct, - displayName: `${srcProduct.displayName || id} Copy`, - }; - setDrafts(prev => [...prev, { key, productLineId, product: duplicated }]); - }} onCreateNewItem={onCreateNewItem} onOpenDetails={(o) => onOpenProductDetails(o)} - isColumnInTable - isFirstColumn={index === 0} - isLastColumn={index === nonAddOnProducts.length - 1} /> ))} -
- )} - - {/* Non-add-on drafts as separate cards (since they're always in edit mode) */} - {nonAddOnDrafts.map((d) => ( - { - const newId = specifiedId && specifiedId.trim() && isValidUserSpecifiedId(specifiedId.trim()) && !usedIds.has(specifiedId.trim()) - ? specifiedId.trim() - : generateProductId('product'); - await onSaveProduct(newId, product); - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - onDelete={async () => { - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - onDuplicate={() => { - const cloneKey = `${d.key}-copy`; - setDrafts(prev => ([...prev, { key: cloneKey, productLineId: d.productLineId, product: { ...d.product, displayName: `${d.product.displayName} Copy` } }])); - }} - onCreateNewItem={onCreateNewItem} - onOpenDetails={(o) => onOpenProductDetails(o)} - onCancelDraft={() => { - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - /> - ))} - {/* Add-on products as separate cards */} - {addOnProducts.map(({ id, product }) => ( - + + +
+
+
+ +
+ ); + })} + + {/* No product line section - shows all products without a productLine, regardless of customer type */} +
+
+

No product line

+

+ Products that are not in a product line are not mutually exclusive +

+
+ +
+
+
+ {noProductLineProducts.map(({ id, product }) => ( + { - const key = generateProductId("product"); - const duplicated: Product = { - ...srcProduct, - displayName: `${srcProduct.displayName || id} Copy`, - }; - setDrafts(prev => [...prev, { key, productLineId, product: duplicated }]); - }} - onCreateNewItem={onCreateNewItem} - onOpenDetails={(o) => onOpenProductDetails(o)} - /> - ))} - {addOnDrafts.map((d) => ( - { - const newId = specifiedId && specifiedId.trim() && isValidUserSpecifiedId(specifiedId.trim()) && !usedIds.has(specifiedId.trim()) - ? specifiedId.trim() - : generateProductId('product'); - await onSaveProduct(newId, product); - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - onDelete={async () => { - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - onDuplicate={() => { - const cloneKey = `${d.key}-copy`; - setDrafts(prev => ([...prev, { key: cloneKey, productLineId: d.productLineId, product: { ...d.product, displayName: `${d.product.displayName} Copy` } }])); - }} onCreateNewItem={onCreateNewItem} onOpenDetails={(o) => onOpenProductDetails(o)} - onCancelDraft={() => { - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} /> ))} - - {/* Add product button */} - +
-
- ); - })} - - {/* No product line section - shows all products without a productLine, regardless of customer type */} -
-
-

No product line

-

- Products that are not in a product line are not mutually exclusive -

-
-
-
-
- {noProductLineProducts.map(({ id, product }) => ( - { - const key = generateProductId("product"); - const duplicated: Product = { - ...srcProduct, - displayName: `${srcProduct.displayName || id} Copy`, - }; - setDrafts(prev => [...prev, { key, productLineId: undefined, product: duplicated }]); - }} - onCreateNewItem={onCreateNewItem} - onOpenDetails={(o) => onOpenProductDetails(o)} - /> - ))} - {noProductLineDrafts.map((d) => ( - { - const newId = specifiedId && specifiedId.trim() && isValidUserSpecifiedId(specifiedId.trim()) && !usedIds.has(specifiedId.trim()) - ? specifiedId.trim() - : generateProductId('product'); - await onSaveProduct(newId, product); - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - onDelete={async () => { - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - onDuplicate={() => { - const cloneKey = `${d.key}-copy`; - setDrafts(prev => ([...prev, { key: cloneKey, productLineId: undefined, product: { ...d.product, displayName: `${d.product.displayName} Copy` } }])); - }} - onCreateNewItem={onCreateNewItem} - onOpenDetails={(o) => onOpenProductDetails(o)} - onCancelDraft={() => { - setDrafts(prev => prev.filter(x => x.key !== d.key)); - }} - /> - ))} - - - -
-
+
-
- {/* New product line button with customer type selector */} - - - - - -
-
-

- A product line groups products that are mutually exclusive — besides add-ons, customers can only have one active product from each product line at a time. -

-
-
- - { - const value = e.target.value; + > +
+ + New product line +
+ + + +
+
+

+ A product line groups products that are mutually exclusive — besides add-ons, customers can only have one active product from each product line at a time. +

+
+
+ + { + const value = e.target.value; setNewProductLineDisplayName(value); // Auto-generate ID from display name if not manually edited if (!hasManuallyEditedProductLineId) { setNewProductLineId(toIdFormat(value)); } - }} - placeholder="e.g., Pricing Plans" - className="h-9" - /> -
-
- - { - const value = e.target.value.toLowerCase().replace(/[^a-z0-9_\-]/g, '-'); + }} + placeholder="e.g., Pricing Plans" + className="h-9" + /> +
+
+ + { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9_\-]/g, '-'); setNewProductLineId(value); setHasManuallyEditedProductLineId(true); - }} - placeholder="e.g., pricing-plans" - className="h-9 font-mono text-sm" - /> -
-
- -
- {(['user', 'team', 'custom'] as const).map((type) => ( -
+
+ +
+ {(['user', 'team', 'custom'] as const).map((type) => ( + - ))} + > + {type} + + ))} +
-
- -
-
- - - {/* Edit productLine dialog */} - !open && setEditingProductLineId(null)}> - - - Edit Product Line - - Update the display name for this product line. - - -
-
- - setEditingProductLineDisplayName(e.target.value)} - placeholder="e.g., Pricing Plans" - /> + }} + > + Create Product Line +
-
- - - + - -
-
- - {/* Delete productLine confirmation dialog */} - !open && setDeletingProductLineId(null)} - title="Delete Product Line" - danger - okButton={{ - label: "Delete", - onClick: async () => { - if (deletingProductLineId) { - await onDeleteProductLine(deletingProductLineId); + } + }} + disabled={!editingProductLineDisplayName.trim()} + > + Save + + + + + + {/* Delete productLine confirmation dialog */} + !open && setDeletingProductLineId(null)} + title="Delete Product Line" + danger + okButton={{ + label: "Delete", + onClick: async () => { + if (deletingProductLineId) { + await onDeleteProductLine(deletingProductLineId); toast({ title: "Product line deleted" }); setDeletingProductLineId(null); + } } - } - }} - cancelButton - > - Are you sure you want to delete this product line? All products in this product line will be moved to "No product line". - -
+ }} + cancelButton + > + Are you sure you want to delete this product line? All products in this product line will be moved to "No product line". + + + {/* Drag overlay for visual feedback */} + + {activeDragId && activeDragProduct ? ( +
+
+
+ + {activeDragProduct.customerType} + +

+ {activeDragProduct.displayName || activeDragId} +

+
+
+
+ ) : null} +
+ + {/* Loading overlay when moving product */} + {isMovingProduct && ( +
+
+ + Moving product... +
+
+ )} +
+ ); } @@ -2384,6 +2020,19 @@ export default function PageClient({ createDraftRequestId, draftCustomerType = ' createDraftRequestId={createDraftRequestId} draftCustomerType={draftCustomerType} onDraftHandled={onDraftHandled} + paymentsConfig={paymentsConfig} + onMoveProduct={async (productId, targetProductLineId) => { + const currentProduct = paymentsConfig.products[productId]; + + // Update the entire product object with the new productLineId + // Using undefined instead of null to properly clear the value + await project.updateConfig({ + [`payments.products.${productId}`]: { + ...currentProduct, + productLineId: targetProductLineId ?? undefined, + }, + }); + }} />
); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx index 7deb08e532..a59c82e458 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx @@ -550,7 +550,7 @@ export function ProductDialog({
- {existingProducts.filter(o => !o.id.startsWith('addon')).map(product => ( + {existingProducts.map(product => (