mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge c3786b81be into 7ed89a2a9c
This commit is contained in:
@@ -520,14 +520,22 @@ Use for:
|
||||
Props:
|
||||
|
||||
- `items` (typed union: `text`, `boolean`, `dropdown`, `custom-dropdown`, `custom-button`, `custom`)
|
||||
- shared item metadata: `itemKey`, `icon`, `name`, optional `description` and `tooltip`
|
||||
- `columns`: `1 | 2`
|
||||
- `size`: `"sm" | "md"`
|
||||
- `deferredSave`, `hasChanges`, `onSave`, `onDiscard`
|
||||
- `externalModifiedKeys`
|
||||
|
||||
Rules:
|
||||
|
||||
- prefer this for config forms that are row-based and editable inline
|
||||
- layout is a flat label/value grid (no per-field cards); editable values use white controls in light mode
|
||||
- use deferred save mode when many fields should be committed together
|
||||
- text values enter an explicit edit state; Enter saves and Escape cancels
|
||||
- omit `onUpdate` or set `readOnly` for a non-editable value
|
||||
- use `description` for persistent context and `tooltip` only for secondary details
|
||||
- `custom-dropdown` owns its popover; provide both `triggerContent` and `popoverContent`
|
||||
- for page-local editable triggers outside the typed union, reuse `designEditableGridControlClassName` / `designEditableGridPopoverClassName`
|
||||
|
||||
### 4.12 `DataGrid` + `useDataSource` + `createDefaultDataGridState`
|
||||
|
||||
|
||||
+17
-11
@@ -536,6 +536,7 @@ export default function PageClient() {
|
||||
type: "text",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
name: "Display Name",
|
||||
description: "The customer-facing name for this product.",
|
||||
value: "Widget Pro",
|
||||
readOnly: false,
|
||||
onUpdate: async () => {
|
||||
@@ -548,6 +549,7 @@ export default function PageClient() {
|
||||
type: "boolean",
|
||||
icon: <StackSimple className="h-4 w-4" />,
|
||||
name: "Active",
|
||||
description: "Controls whether this product can be purchased.",
|
||||
value: true,
|
||||
readOnly: false,
|
||||
trueLabel: "Yes",
|
||||
@@ -562,6 +564,7 @@ export default function PageClient() {
|
||||
type: "dropdown",
|
||||
icon: <Sliders className="h-4 w-4" />,
|
||||
name: "Category",
|
||||
description: "Used to organize this product in the catalog.",
|
||||
value: "hardware",
|
||||
options: [
|
||||
{ value: "hardware", label: "Hardware" },
|
||||
@@ -579,6 +582,7 @@ export default function PageClient() {
|
||||
type: "custom",
|
||||
icon: <Tag className="h-4 w-4" />,
|
||||
name: "Price",
|
||||
description: "Current base price before discounts.",
|
||||
children: <span className="text-sm text-foreground">$29.99</span>,
|
||||
},
|
||||
];
|
||||
@@ -591,6 +595,7 @@ export default function PageClient() {
|
||||
type: "custom-dropdown",
|
||||
icon: <Sparkle className="h-4 w-4" />,
|
||||
name: "Custom Dropdown",
|
||||
description: "A composed control with custom popover content.",
|
||||
triggerContent: <span>Open custom panel</span>,
|
||||
popoverContent: <div>Custom content</div>,
|
||||
disabled: false,
|
||||
@@ -600,6 +605,7 @@ export default function PageClient() {
|
||||
type: "custom-button",
|
||||
icon: <Cube className="h-4 w-4" />,
|
||||
name: "Custom Button",
|
||||
description: "Runs a custom action with built-in loading behavior.",
|
||||
onClick: () => setGridActionLog("Clicked custom button"),
|
||||
children: <span>Run action</span>,
|
||||
disabled: false,
|
||||
@@ -1082,28 +1088,28 @@ export default function PageClient() {
|
||||
if (selected === "editable-grid") {
|
||||
return (
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="rounded-2xl overflow-hidden bg-white/90 dark:bg-[hsl(240,10%,5.5%)] border border-black/[0.12] dark:border-foreground/[0.12] shadow-sm">
|
||||
<div className="overflow-hidden rounded-2xl border border-black/[0.1] bg-white shadow-sm dark:border-white/[0.1] dark:bg-zinc-950">
|
||||
<div className="p-4 sm:p-5">
|
||||
<DesignEditableGrid
|
||||
items={editableItems}
|
||||
columns={gridCols}
|
||||
size={gridSize}
|
||||
editMode={gridEditMode}
|
||||
deferredSave={gridDeferredSave}
|
||||
editMode={gridEditMode}
|
||||
externalModifiedKeys={gridShowModified ? new Set(["display-name", "category"]) : undefined}
|
||||
hasChanges={gridHasChanges}
|
||||
onSave={gridDeferredSave ? async () => {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
setGridActionLog("Saved deferred changes");
|
||||
setGridHasChanges(false);
|
||||
} : undefined}
|
||||
items={editableItems}
|
||||
onDiscard={gridDeferredSave ? () => {
|
||||
setGridActionLog("Discarded deferred changes");
|
||||
setGridHasChanges(false);
|
||||
} : undefined}
|
||||
externalModifiedKeys={gridShowModified ? new Set(["display-name", "category"]) : undefined}
|
||||
onSave={gridDeferredSave ? async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
setGridActionLog("Saved deferred changes");
|
||||
setGridHasChanges(false);
|
||||
} : undefined}
|
||||
size={gridSize}
|
||||
/>
|
||||
{gridActionLog && (
|
||||
<Typography variant="secondary" className="text-xs mt-2">
|
||||
<Typography className="mt-3 text-xs" variant="secondary">
|
||||
Last action: {gridActionLog}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
+293
-160
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { DesignEditableGrid, type DesignEditableGridItem } from "@/components/design-components";
|
||||
import { EditableInput } from "@/components/editable-input";
|
||||
import {
|
||||
DesignBadge,
|
||||
DesignEditableGrid,
|
||||
DesignButton,
|
||||
DesignInput,
|
||||
DesignMenu,
|
||||
DesignSelectorDropdown,
|
||||
type DesignBadgeColor,
|
||||
type DesignEditableGridItem,
|
||||
} from "@/components/design-components";
|
||||
import { Link, StyledLink } from "@/components/link";
|
||||
import { ItemDialog } from "@/components/payments/item-dialog";
|
||||
import { useRouter } from "@/components/router";
|
||||
@@ -20,15 +28,8 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
@@ -42,7 +43,8 @@ import {
|
||||
} from "@/components/ui";
|
||||
import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components";
|
||||
import { useUpdateConfig } from "@/components/config-update";
|
||||
import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, ShoppingCartIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { ArrowLeftIcon, Check, ClockIcon, CopyIcon, CurrencyDollarIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, ShoppingCartIcon, StackIcon, TagIcon, TrashIcon, UsersIcon, X } from "@phosphor-icons/react";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { CreateCheckoutDialog } from "@/components/payments/create-checkout-dialog";
|
||||
import type { CustomerType } from "@/components/payments/customer-selector";
|
||||
import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
|
||||
@@ -51,8 +53,7 @@ import type { DayInterval } from "@hexclave/shared/dist/utils/dates";
|
||||
import { fromNow } from "@hexclave/shared/dist/utils/dates";
|
||||
import { prettyPrintWithMagnitudes } from "@hexclave/shared/dist/utils/numbers";
|
||||
import { typedEntries } from "@hexclave/shared/dist/utils/objects";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Suspense, useMemo, useRef, useState } from "react";
|
||||
import { PageLayout } from "../../../page-layout";
|
||||
import { useAdminApp, useProjectId } from "../../../use-admin-app";
|
||||
import { CreateProductLineDialog } from "../create-product-line-dialog";
|
||||
@@ -71,6 +72,16 @@ const CUSTOMER_TYPE_COLORS = {
|
||||
custom: 'bg-amber-500/15 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400 ring-amber-500/30',
|
||||
} as const;
|
||||
|
||||
const CUSTOMER_TYPE_BADGE_COLORS = new Map<string, DesignBadgeColor>([
|
||||
["user", "blue"],
|
||||
["team", "green"],
|
||||
["custom", "orange"],
|
||||
]);
|
||||
|
||||
function getCustomerTypeBadgeColor(customerType: string): DesignBadgeColor {
|
||||
return CUSTOMER_TYPE_BADGE_COLORS.get(customerType) ?? "blue";
|
||||
}
|
||||
|
||||
export default function PageClient({ productId }: { productId: string }) {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
@@ -113,15 +124,15 @@ function ProductPage({ productId, product, config }: ProductPageProps) {
|
||||
<PageLayout>
|
||||
<div className="flex flex-col gap-6">
|
||||
{canGoBack && (
|
||||
<Button
|
||||
<DesignButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit -ml-2 text-muted-foreground hover:text-foreground"
|
||||
className="w-fit -ml-2 h-8 gap-1.5 rounded-lg px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 mr-1" />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</DesignButton>
|
||||
)}
|
||||
<ProductHeader productId={productId} product={product} productLineName={productLineName} />
|
||||
<Separator />
|
||||
@@ -141,119 +152,248 @@ type ProductHeaderProps = {
|
||||
productLineName: string | null,
|
||||
};
|
||||
|
||||
function ProductTitleEditor({
|
||||
productId,
|
||||
displayName,
|
||||
storedDisplayName,
|
||||
}: {
|
||||
productId: string,
|
||||
displayName: string,
|
||||
storedDisplayName: string,
|
||||
}) {
|
||||
const adminApp = useAdminApp();
|
||||
const updateConfig = useUpdateConfig();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftValue, setDraftValue] = useState(storedDisplayName);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const beginEditing = () => {
|
||||
setDraftValue(storedDisplayName);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setDraftValue(storedDisplayName);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const success = await updateConfig({
|
||||
adminApp,
|
||||
configUpdate: {
|
||||
[`payments.products.${productId}.displayName`]: draftValue || null,
|
||||
},
|
||||
pushable: true,
|
||||
});
|
||||
if (success) {
|
||||
toast({ title: "Product name updated" });
|
||||
setEditing(false);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={editing ? undefined : "button"}
|
||||
tabIndex={editing ? undefined : 0}
|
||||
aria-label={editing ? undefined : "Edit display name"}
|
||||
className={cn(
|
||||
"group flex h-9 min-w-0 flex-1 items-center gap-1.5 rounded-lg border px-2.5",
|
||||
"border-black/[0.1] bg-white cursor-text",
|
||||
"hover:border-black/[0.16] hover:bg-zinc-50",
|
||||
"dark:border-white/[0.1] dark:bg-zinc-950 dark:hover:border-white/[0.18] dark:hover:bg-zinc-900",
|
||||
"transition-colors duration-150 hover:transition-none",
|
||||
editing && "border-black/[0.28] dark:border-white/[0.32]",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!editing) {
|
||||
beginEditing();
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (editing) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
beginEditing();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
aria-label="Display name"
|
||||
autoComplete="off"
|
||||
disabled={isSaving}
|
||||
value={draftValue}
|
||||
placeholder={productId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setDraftValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (draftValue !== storedDisplayName && !isSaving) {
|
||||
runAsynchronouslyWithAlert(save());
|
||||
}
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 bg-transparent text-lg font-semibold leading-none tracking-tight text-foreground outline-none placeholder:text-muted-foreground/60 disabled:opacity-50"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="min-w-0 flex-1 truncate text-lg font-semibold leading-none tracking-tight text-foreground">
|
||||
{displayName}
|
||||
</h1>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="flex shrink-0 items-center gap-0.5" onClick={(event) => event.stopPropagation()}>
|
||||
<DesignButton
|
||||
aria-label="Save display name"
|
||||
className="h-6 w-6 rounded-md p-0 text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400"
|
||||
disabled={draftValue === storedDisplayName || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={save}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Check aria-hidden className="h-3.5 w-3.5" weight="bold" />
|
||||
</DesignButton>
|
||||
<DesignButton
|
||||
aria-label="Cancel editing display name"
|
||||
className="h-6 w-6 rounded-md p-0 text-red-600 hover:bg-red-500/10 hover:text-red-700 dark:text-red-400"
|
||||
disabled={isSaving}
|
||||
onClick={cancelEditing}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X aria-hidden className="h-3.5 w-3.5" weight="bold" />
|
||||
</DesignButton>
|
||||
</div>
|
||||
) : (
|
||||
<PencilSimpleIcon
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors duration-150 group-hover:text-foreground group-hover:transition-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductHeader({ productId, product, productLineName }: ProductHeaderProps) {
|
||||
const projectId = useProjectId();
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
const router = useRouter();
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const displayName = product.displayName || productId;
|
||||
const isAddOn = product.isAddOnTo !== false && typeof product.isAddOnTo === 'object';
|
||||
const isAddOn = product.isAddOnTo !== false && typeof product.isAddOnTo === "object";
|
||||
|
||||
// Find add-on parent products
|
||||
const addOnParents = useMemo(() => {
|
||||
if (product.isAddOnTo === false || typeof product.isAddOnTo !== 'object') return [];
|
||||
return Object.keys(product.isAddOnTo).map((parentId: string) => ({
|
||||
if (product.isAddOnTo === false || typeof product.isAddOnTo !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(product.isAddOnTo).map((parentId) => ({
|
||||
id: parentId,
|
||||
displayName: config.payments.products[parentId].displayName || parentId,
|
||||
}));
|
||||
}, [product.isAddOnTo, config.payments.products]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className={cn(
|
||||
"flex h-16 w-16 items-center justify-center rounded-xl shrink-0",
|
||||
"bg-gradient-to-br from-primary/20 to-primary/5",
|
||||
"border border-primary/20"
|
||||
)}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-black/[0.1] bg-white text-muted-foreground dark:border-white/[0.1] dark:bg-zinc-950">
|
||||
{isAddOn ? (
|
||||
<PuzzlePieceIcon className="h-8 w-8 text-primary" />
|
||||
<PuzzlePieceIcon className="h-7 w-7" />
|
||||
) : (
|
||||
<PackageIcon className="h-8 w-8 text-primary" />
|
||||
<PackageIcon className="h-7 w-7" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0 flex flex-col">
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<EditableInput
|
||||
value={displayName}
|
||||
initialEditValue={product.displayName || ""}
|
||||
placeholder={productId}
|
||||
shiftTextToLeft
|
||||
inputClassName="text-2xl font-semibold tracking-tight w-full"
|
||||
onUpdate={async (newName) => {
|
||||
const success = await updateConfig({ adminApp, configUpdate: {
|
||||
[`payments.products.${productId}.displayName`]: newName || null,
|
||||
}, pushable: true });
|
||||
if (success) {
|
||||
toast({ title: "Product name updated" });
|
||||
}
|
||||
}}
|
||||
<ProductTitleEditor
|
||||
productId={productId}
|
||||
displayName={displayName}
|
||||
storedDisplayName={product.displayName || ""}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<CreateCheckoutDialog
|
||||
open={isCheckoutOpen}
|
||||
onOpenChange={setIsCheckoutOpen}
|
||||
customerType={product.customerType as CustomerType}
|
||||
productId={productId}
|
||||
lockProduct
|
||||
/>
|
||||
<DesignMenu
|
||||
variant="actions"
|
||||
trigger="icon"
|
||||
triggerLabel="Product actions"
|
||||
align="end"
|
||||
withIcons
|
||||
items={[
|
||||
{
|
||||
id: "checkout",
|
||||
label: "Create checkout",
|
||||
icon: <ShoppingCartIcon className="h-4 w-4" />,
|
||||
onClick: () => setIsCheckoutOpen(true),
|
||||
},
|
||||
{
|
||||
id: "product-lines",
|
||||
label: "View in Product Lines",
|
||||
icon: <FolderOpenIcon className="h-4 w-4" />,
|
||||
onClick: () => router.push(`/projects/${projectId}/payments/product-lines#product-${productId}`),
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit full details",
|
||||
icon: <PencilSimpleIcon className="h-4 w-4" />,
|
||||
onClick: () => router.push(`/projects/${projectId}/payments/products/${productId}/edit`),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => router.push(`/projects/${projectId}/payments/products/${productId}/edit`)}
|
||||
>
|
||||
<PencilSimpleIcon className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<CreateCheckoutDialog
|
||||
open={isCheckoutOpen}
|
||||
onOpenChange={setIsCheckoutOpen}
|
||||
customerType={product.customerType as CustomerType}
|
||||
productId={productId}
|
||||
lockProduct
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="shrink-0">
|
||||
<DotsThreeIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem icon={<ShoppingCartIcon className="h-4 w-4" />} onClick={() => setIsCheckoutOpen(true)}>
|
||||
Create checkout
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/projects/${projectId}/payments/product-lines#product-${productId}`)}>
|
||||
View in Product Lines
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide ring-1",
|
||||
CUSTOMER_TYPE_COLORS[product.customerType as keyof typeof CUSTOMER_TYPE_COLORS]
|
||||
)}>
|
||||
{product.customerType}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DesignBadge
|
||||
label={product.customerType.toUpperCase()}
|
||||
color={getCustomerTypeBadgeColor(product.customerType)}
|
||||
size="sm"
|
||||
contentMode="text"
|
||||
/>
|
||||
<span className="inline-flex items-center rounded-md border border-black/[0.08] bg-white px-2 py-0.5 font-mono text-[11px] text-muted-foreground dark:border-white/[0.1] dark:bg-zinc-950">
|
||||
ID: {productId}
|
||||
</span>
|
||||
<span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">ID: {productId}</span>
|
||||
{productLineName && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Product Line: {productLineName}</span>
|
||||
</>
|
||||
{productLineName != null && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
Product Line: <span className="font-medium text-foreground">{productLineName}</span>
|
||||
</span>
|
||||
)}
|
||||
{addOnParents.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
Add-on to{' '}
|
||||
{addOnParents.map((p: { id: string, displayName: string }, i: number) => (
|
||||
<span key={p.id}>
|
||||
{i > 0 && ", "}
|
||||
<StyledLink href={`/projects/${adminApp.projectId}/payments/products/${p.id}`}>
|
||||
{p.displayName}
|
||||
</StyledLink>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
<span className="inline-flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
Add-on to{" "}
|
||||
{addOnParents.map((parent, index) => (
|
||||
<span key={parent.id} className="inline-flex items-center">
|
||||
{index > 0 && <span className="mr-1 text-muted-foreground/50">,</span>}
|
||||
<StyledLink href={`/projects/${adminApp.projectId}/payments/products/${parent.id}`}>
|
||||
{parent.displayName}
|
||||
</StyledLink>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,6 +698,14 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec
|
||||
setFreeTrialPopoverOpen(false);
|
||||
};
|
||||
|
||||
const handleFreeTrialPopoverOpenChange = (open: boolean) => {
|
||||
setFreeTrialPopoverOpen(open);
|
||||
if (open) {
|
||||
setFreeTrialCount(localFreeTrial ? localFreeTrial[0] : 7);
|
||||
setFreeTrialUnit(localFreeTrial ? localFreeTrial[1] : 'day');
|
||||
}
|
||||
};
|
||||
|
||||
// ===== PRICES HANDLERS (for deferred save) =====
|
||||
const handlePricesChange = (newPrices: Product['prices']) => {
|
||||
// Clear the "needs at least one price" error as soon as the user adds one back.
|
||||
@@ -647,69 +795,54 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec
|
||||
) : 'No',
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
type: 'custom-dropdown',
|
||||
itemKey: 'freeTrial',
|
||||
icon: <ClockIcon size={16} />,
|
||||
name: "Free Trial",
|
||||
tooltip: "Free trial period before billing starts. Customers won't be charged during this period.",
|
||||
children: (
|
||||
<Popover open={freeTrialPopoverOpen} onOpenChange={setFreeTrialPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full px-1 py-0 h-[unset] border-transparent rounded text-left text-foreground",
|
||||
"hover:ring-1 hover:ring-slate-300 dark:hover:ring-gray-500 hover:bg-slate-50 dark:hover:bg-gray-800 hover:cursor-pointer",
|
||||
"focus:outline-none focus-visible:ring-1 focus-visible:ring-slate-500 dark:focus-visible:ring-gray-50",
|
||||
"transition-colors duration-150 hover:transition-none"
|
||||
)}
|
||||
open: freeTrialPopoverOpen,
|
||||
onOpenChange: handleFreeTrialPopoverOpenChange,
|
||||
triggerContent: localFreeTrialDisplayText,
|
||||
popoverContent: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DesignInput
|
||||
className="w-20 bg-white dark:bg-zinc-950"
|
||||
type="number"
|
||||
min={1}
|
||||
value={freeTrialCount}
|
||||
onChange={(e) => setFreeTrialCount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
/>
|
||||
<DesignSelectorDropdown
|
||||
className="w-28"
|
||||
triggerClassName="bg-white dark:bg-zinc-950 border-black/[0.1] dark:border-white/[0.1]"
|
||||
value={freeTrialUnit}
|
||||
onValueChange={(v) => setFreeTrialUnit(v as DayInterval[1])}
|
||||
options={DEFAULT_INTERVAL_UNITS.map((unit) => ({
|
||||
value: unit,
|
||||
label: `${unit}${freeTrialCount !== 1 ? 's' : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<DesignButton
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleFreeTrialSave(freeTrialCount, freeTrialUnit)}
|
||||
>
|
||||
{localFreeTrialDisplayText}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
value={freeTrialCount}
|
||||
onChange={(e) => setFreeTrialCount(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<Select value={freeTrialUnit} onValueChange={(v) => setFreeTrialUnit(v as DayInterval[1])}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_INTERVAL_UNITS.map((unit) => (
|
||||
<SelectItem key={unit} value={unit}>
|
||||
{unit}{freeTrialCount !== 1 ? 's' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleFreeTrialSave(freeTrialCount, freeTrialUnit)}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
{localFreeTrial && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRemoveFreeTrial}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
Apply
|
||||
</DesignButton>
|
||||
{localFreeTrial && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRemoveFreeTrial}
|
||||
>
|
||||
Remove
|
||||
</DesignButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -614,7 +614,7 @@ function SidebarContent({
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"sticky bottom-0 border-t border-black/[0.06] dark:border-foreground/10 py-3 transition-all duration-200 dark:backdrop-blur-xl",
|
||||
"sticky bottom-0 border-t border-black/[0.06] dark:border-foreground/10 py-3 transition-all duration-200 bg-black/[0.03] dark:bg-foreground/[0.06] dark:backdrop-blur-xl",
|
||||
!isDrawer && "dark:rounded-b-2xl",
|
||||
isCollapsed ? "px-2" : "px-3",
|
||||
)}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,17 @@ export type {
|
||||
} from "../../../../../packages/dashboard-ui-components/src/components/dialog";
|
||||
export * from "./analytics-card";
|
||||
export * from "./design-tokens";
|
||||
export * from "./editable-grid";
|
||||
export {
|
||||
DesignEditableGrid,
|
||||
designEditableGridControlClassName,
|
||||
designEditableGridPopoverClassName,
|
||||
} from "./editable-grid";
|
||||
export type {
|
||||
DesignEditableGridDropdownOption,
|
||||
DesignEditableGridItem,
|
||||
DesignEditableGridProps,
|
||||
DesignEditableGridSize,
|
||||
} from "./editable-grid";
|
||||
export * from "./list";
|
||||
export * from "./menu";
|
||||
export * from "./select";
|
||||
|
||||
Reference in New Issue
Block a user