Files
cfw-autumn/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx
2026-05-18 18:37:10 +01:00

403 lines
11 KiB
TypeScript

import type {
BillingInterval,
FrontendProduct,
ProductItem,
UpdatePlanOp,
} from "@autumn/shared";
import { productV2ToBasePrice } from "@autumn/shared";
import {
CurrencyCircleDollarIcon,
GitBranchIcon,
PlusIcon,
} from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { DASHED_BUTTON_CLASS } from "../shared/AddButton";
import {
migrationItemToProductItem,
productItemToMigrationItem,
} from "../shared/migrationItemUtils";
import { buildPlanSuggestions } from "../shared/planSuggestions";
import { RemoveButton } from "../shared/RemoveButton";
import { ValuePicker } from "../shared/ValuePicker";
import { ItemSummaryRow } from "./ItemSummaryRow";
import {
MigrationOperationSheet,
type OperationSheetMode,
} from "./MigrationOperationSheet";
import { RemoveItemRows } from "./RemoveItemRows";
function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) {
const { products } = useProductsQuery({ allVersions: true });
const targetIds = extractPlanIds(planFilter.plan_id);
const idSet = new Set(targetIds);
const matchingProducts =
idSet.size > 0 ? products.filter((p) => idSet.has(p.id)) : [];
const seen = new Set<number>();
return matchingProducts
.filter((p) => {
if (seen.has(p.version)) return false;
seen.add(p.version);
return true;
})
.map((p) => ({
value: String(p.version),
label: `v${p.version}`,
}))
.sort((a, b) => Number(a.value) - Number(b.value));
}
export function UpdatePlanOpForm({
value,
onChange,
onRemove,
defaultOpenPicker = false,
}: {
value: UpdatePlanOp;
onChange: (value: UpdatePlanOp) => void;
onRemove: () => void;
defaultOpenPicker?: boolean;
}) {
const { products } = useProductsQuery();
const versionOptions = useVersionOptions(value.plan_filter);
const { features } = useFeaturesQuery();
const [sheetOpen, setSheetOpen] = useState(false);
const [sheetMode, setSheetMode] = useState<OperationSheetMode>("add-feature");
const [editingItemIndex, setEditingItemIndex] = useState<number | null>(null);
const update = (patch: Partial<UpdatePlanOp>) =>
onChange({ ...value, ...patch });
const planSuggestions = useMemo(
() => buildPlanSuggestions(products),
[products],
);
const selectedPlanIds = extractPlanIds(value.plan_filter.plan_id);
const handlePlanToggle = (planId: string) => {
const next = selectedPlanIds.includes(planId)
? selectedPlanIds.filter((id) => id !== planId)
: [...selectedPlanIds, planId];
update({
plan_filter: { ...value.plan_filter, plan_id: toPlanIdMatcher(next) },
});
};
const handlePlanRemove = (planId: string) => {
const next = selectedPlanIds.filter((id) => id !== planId);
update({
plan_filter: { ...value.plan_filter, plan_id: toPlanIdMatcher(next) },
});
};
const customize = value.customize;
const addItems = customize?.add_items ?? [];
const openSheet = (mode: OperationSheetMode, itemIndex?: number) => {
setSheetMode(mode);
setEditingItemIndex(itemIndex ?? null);
setSheetOpen(true);
};
const editItem: ProductItem | undefined =
editingItemIndex !== null
? migrationItemToProductItem(addItems[editingItemIndex], features)
: undefined;
const initialProduct = buildInitialProduct(value);
const handleSheetSave = (product: FrontendProduct) => {
if (sheetMode === "edit-price") {
const basePrice = productV2ToBasePrice({ product });
if (basePrice) {
const amount =
((basePrice as Record<string, unknown>).price as number) ??
basePrice.tiers?.[0]?.amount ??
0;
update({
customize: {
...customize,
price: {
amount,
interval: (basePrice.interval as BillingInterval) ?? "month",
},
},
});
} else if (product.planType === "free") {
update({
customize: {
...customize,
price: undefined,
},
});
}
} else {
const newItems = (product.items ?? [])
.filter((pi) => pi.feature_id)
.map(productItemToMigrationItem);
if (newItems.length === 0) return;
const items = [...addItems];
if (editingItemIndex !== null) {
items[editingItemIndex] = newItems[0];
} else {
items.push(...newItems);
}
update({ customize: { ...customize, add_items: items } });
}
};
const handleRemoveItem = (index: number) => {
const items = addItems.filter((_, i) => i !== index);
update({
customize: {
...customize,
add_items: items.length > 0 ? items : undefined,
},
});
};
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between group/row">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-t1">
{selectedPlanIds.length > 1 ? "Update Plans" : "Update Plan"}
</span>
{selectedPlanIds.length > 0 && (
<span className="text-xs text-t3">
{selectedPlanIds.length}{" "}
{selectedPlanIds.length === 1 ? "plan" : "plans"}
</span>
)}
</div>
<RemoveButton onClick={onRemove} />
</div>
<div className="flex items-center gap-2 group/row">
<ValuePicker
suggestions={planSuggestions}
selectedValues={selectedPlanIds}
onToggle={handlePlanToggle}
onRemove={handlePlanRemove}
placeholder="Select plans..."
className="flex-1"
defaultOpen={defaultOpenPicker}
/>
</div>
{value.version !== undefined && (
<div className="flex items-center gap-2 group/row">
<span className="text-xs text-t4 w-14 shrink-0 select-none">
Version
</span>
<Select
value={String(value.version)}
onValueChange={(v) => update({ version: Number(v) })}
items={Object.fromEntries(
versionOptions.map((o) => [o.value, o.label]),
)}
>
<SelectTrigger className="h-8 rounded-xl flex-1">
<GitBranchIcon
size={16}
weight="duotone"
className="text-violet-500 shrink-0"
/>
<span className="flex-1 text-left text-sm">
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
{versionOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<RemoveButton onClick={() => update({ version: undefined })} />
</div>
)}
{customize?.price !== undefined && (
<div className="flex items-center gap-2 group/row">
<span className="text-xs text-t4 w-14 shrink-0 select-none">
Price
</span>
<button
type="button"
onClick={() => openSheet("edit-price")}
className="flex items-center gap-2 h-8 px-3 rounded-xl input-base input-state-open-tiny cursor-pointer flex-1 min-w-0"
>
<CurrencyCircleDollarIcon
size={16}
weight="duotone"
className="text-yellow-500 shrink-0"
/>
<span className="text-body">
${customize.price?.amount ?? 0} per{" "}
{customize.price?.interval ?? "month"}
</span>
</button>
<RemoveButton
onClick={() =>
update({ customize: { ...customize, price: undefined } })
}
/>
</div>
)}
{addItems.map((item, index) => (
<div key={`add-${index}`} className="flex items-center gap-2 group/row">
<span className="text-xs text-t4 w-14 shrink-0 select-none">Add</span>
<ItemSummaryRow
item={item}
onClick={() => openSheet("edit-feature", index)}
/>
<RemoveButton onClick={() => handleRemoveItem(index)} />
</div>
))}
{(customize?.remove_items ?? []).map((item, index) => (
<RemoveItemRows
key={`remove-${index}`}
item={item}
onChange={(updated) => {
const items = [...(customize?.remove_items ?? [])];
items[index] = updated;
update({ customize: { ...customize, remove_items: items } });
}}
onRemove={() => {
const items = (customize?.remove_items ?? []).filter(
(_, i) => i !== index,
);
update({
customize: {
...customize,
remove_items: items.length > 0 ? items : undefined,
},
});
}}
/>
))}
<DropdownMenu>
<DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
<PlusIcon size={10} />
Add modification
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
{value.version === undefined && (
<DropdownMenuItem
closeOnClick
onClick={() => update({ version: 1 })}
>
Version
</DropdownMenuItem>
)}
{(!customize || customize.price === undefined) && (
<DropdownMenuItem
closeOnClick
onClick={() => openSheet("edit-price")}
>
Base Price
</DropdownMenuItem>
)}
<DropdownMenuItem
closeOnClick
onClick={() => openSheet("add-feature")}
>
Add Item
</DropdownMenuItem>
<DropdownMenuItem
closeOnClick
onClick={() =>
update({
customize: {
...customize,
remove_items: [
...(customize?.remove_items ?? []),
{} as Record<string, unknown>,
],
},
})
}
>
Remove Item
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MigrationOperationSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
mode={sheetMode}
initialProduct={initialProduct}
editItem={editItem}
onSave={handleSheetSave}
/>
</div>
);
}
function extractPlanIds(
planId: UpdatePlanOp["plan_filter"]["plan_id"],
): string[] {
if (!planId) return [];
if (typeof planId === "string") return planId ? [planId] : [];
if (planId.$in)
return planId.$in.filter((v): v is string => typeof v === "string");
if (planId.$eq) return typeof planId.$eq === "string" ? [planId.$eq] : [];
return [];
}
function toPlanIdMatcher(
ids: string[],
): UpdatePlanOp["plan_filter"]["plan_id"] {
if (ids.length === 0) return undefined;
if (ids.length === 1) return ids[0];
return { $in: ids };
}
function buildInitialProduct(value: UpdatePlanOp): Partial<FrontendProduct> {
const items: ProductItem[] = [];
if (value.customize?.price) {
const amount = value.customize.price.amount ?? 0;
items.push({
price: amount,
tiers: [{ to: "inf", amount }],
interval: value.customize.price.interval ?? "month",
billing_units: 1,
} as ProductItem);
}
return {
version: value.version ?? 1,
planType: value.customize?.price ? "paid" : "free",
basePriceType: "recurring",
items,
};
}