feat: add volume flat amount ui
This commit is contained in:
21
bun.lock
21
bun.lock
@@ -199,6 +199,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.9",
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
"@autumn/ksuid": "workspace:*",
|
||||
"@autumn/shared": "workspace:*",
|
||||
"@aws-sdk/client-sqs": "^3.958.0",
|
||||
"@axiomhq/pino": "^1.3.1",
|
||||
@@ -4572,6 +4573,10 @@
|
||||
|
||||
"@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
|
||||
|
||||
"@autumn/openapi/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@autumn/server/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@autumn/server/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||
|
||||
"@autumn/server/autumn-js": ["autumn-js@0.1.76", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call", "convex"] }, "sha512-WAJ+7NNPTli7swoeL5cgHuZCUiFsrjhM2Coc0UXzN9YCfTmaN8VeAOsfV+vtsyp5Pk37VJD8iW7Uyd5GgOn4IA=="],
|
||||
@@ -4580,6 +4585,8 @@
|
||||
|
||||
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
|
||||
|
||||
"@autumn/shared/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@autumn/vite/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
|
||||
|
||||
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
@@ -5796,6 +5803,10 @@
|
||||
|
||||
"@asyncapi/parser/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@autumn/openapi/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"@autumn/server/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"@autumn/server/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@autumn/server/autumn-js/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
@@ -5820,6 +5831,8 @@
|
||||
|
||||
"@autumn/server/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
|
||||
|
||||
"@autumn/shared/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
@@ -6636,12 +6649,16 @@
|
||||
|
||||
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@autumn/openapi/@types/bun/bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||
|
||||
"@autumn/server/ink/@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"@autumn/server/ink/cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"@autumn/server/ink/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"@autumn/shared/@types/bun/bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
@@ -6944,8 +6961,12 @@
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@autumn/openapi/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@autumn/server/ink/cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"@autumn/shared/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@4.1.3", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { FeatureType, TierBehavior } from "@autumn/shared";
|
||||
import {
|
||||
DropSimpleIcon,
|
||||
PencilSimpleIcon,
|
||||
RulerIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import {
|
||||
@@ -11,13 +7,6 @@ import {
|
||||
useProduct,
|
||||
useSheet,
|
||||
} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/v2/selects/Select";
|
||||
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { getFeature } from "@/utils/product/entitlementUtils";
|
||||
@@ -25,10 +14,15 @@ import { isFeaturePriceItem } from "@/utils/product/getItemType";
|
||||
import UpdateFeatureSheet from "@/views/products/features/components/UpdateFeatureSheet";
|
||||
import UpdateCreditSystemSheet from "@/views/products/features/credit-systems/components/UpdateCreditSystemSheet";
|
||||
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
|
||||
import {
|
||||
cleanTiersForMode,
|
||||
type VolumePricingMode,
|
||||
} from "../../utils/tierUtils";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { BillingType } from "./BillingType";
|
||||
import { IncludedUsage } from "./IncludedUsage";
|
||||
import { PricedFeatureSettings } from "./PricedFeatureSettings";
|
||||
import { PriceSectionTitle } from "./PriceSectionTitle";
|
||||
import { PriceTiers } from "./PriceTiers";
|
||||
import { SheetFooterActions } from "./SheetFooterActions";
|
||||
import { UsageReset } from "./UsageReset";
|
||||
@@ -45,6 +39,44 @@ export function EditPlanFeatureSheet({
|
||||
const hasItemChanges = useHasItemChanges();
|
||||
const [editFeatureOpen, setEditFeatureOpen] = useState(false);
|
||||
|
||||
// Infer initial mode from tier data: if any tier has flat_amount > 0, default to flat
|
||||
const [volumePricingMode, setVolumePricingMode] = useState<VolumePricingMode>(
|
||||
() => {
|
||||
const hasFlatAmount = item?.tiers?.some(
|
||||
(t) => t.flat_amount != null && t.flat_amount > 0,
|
||||
);
|
||||
return hasFlatAmount ? "flat" : "per_unit";
|
||||
},
|
||||
);
|
||||
|
||||
const isVolumeBased = item?.tier_behavior === TierBehavior.VolumeBased;
|
||||
const isMultiTier = (item?.tiers?.length ?? 0) > 1;
|
||||
const showVolumePricingToggle = isVolumeBased && isMultiTier;
|
||||
|
||||
const handleTierBehaviorChange = (val: string) => {
|
||||
const newBehavior = val as TierBehavior;
|
||||
const newItem = { ...item, tier_behavior: newBehavior };
|
||||
|
||||
// When switching away from volume-based, reset mode and clear flat_amount
|
||||
if (newBehavior !== TierBehavior.VolumeBased) {
|
||||
setVolumePricingMode("per_unit");
|
||||
if (newItem.tiers) {
|
||||
newItem.tiers = newItem.tiers.map((tier) => ({
|
||||
...tier,
|
||||
flat_amount: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setItem(newItem);
|
||||
};
|
||||
|
||||
const handleBeforeCommit = () => {
|
||||
if (!showVolumePricingToggle) return;
|
||||
const cleaned = cleanTiersForMode({ item, mode: volumePricingMode });
|
||||
setItem(cleaned);
|
||||
};
|
||||
|
||||
const handleFeatureUpdateSuccess = async (oldId: string, newId: string) => {
|
||||
if (oldId !== newId && product.items) {
|
||||
// Wait for features to be refetched to avoid race condition
|
||||
@@ -121,53 +153,15 @@ export function EditPlanFeatureSheet({
|
||||
<SheetSection
|
||||
title={
|
||||
item.tiers && item.tiers.length > 1 ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>Price</span>
|
||||
<Select
|
||||
value={item.tier_behavior ?? TierBehavior.Graduated}
|
||||
onValueChange={(val) =>
|
||||
setItem({
|
||||
...item,
|
||||
tier_behavior: val as TierBehavior,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40 h-6 text-xs" size="sm">
|
||||
<SelectValue>
|
||||
{item.tier_behavior === TierBehavior.VolumeBased ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<DropSimpleIcon
|
||||
className="size-3.5"
|
||||
weight="regular"
|
||||
/>
|
||||
Volume-based
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<RulerIcon
|
||||
className="size-3.5"
|
||||
weight="regular"
|
||||
/>
|
||||
Graduated
|
||||
</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={TierBehavior.Graduated}>
|
||||
<RulerIcon className="size-4" weight="regular" />
|
||||
Graduated
|
||||
</SelectItem>
|
||||
<SelectItem value={TierBehavior.VolumeBased}>
|
||||
<DropSimpleIcon
|
||||
className="size-4"
|
||||
weight="regular"
|
||||
/>
|
||||
Volume-based
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<PriceSectionTitle
|
||||
tierBehavior={
|
||||
item.tier_behavior ?? TierBehavior.Graduated
|
||||
}
|
||||
volumePricingMode={volumePricingMode}
|
||||
showVolumePricingToggle={showVolumePricingToggle}
|
||||
onTierBehaviorChange={handleTierBehaviorChange}
|
||||
onVolumePricingModeChange={setVolumePricingMode}
|
||||
/>
|
||||
) : (
|
||||
"Price"
|
||||
)
|
||||
@@ -175,7 +169,11 @@ export function EditPlanFeatureSheet({
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<PriceTiers />
|
||||
<PriceTiers
|
||||
volumePricingMode={
|
||||
showVolumePricingToggle ? volumePricingMode : undefined
|
||||
}
|
||||
/>
|
||||
<UsageReset showBillingLabel={true} />
|
||||
</div>
|
||||
<PricedFeatureSettings />
|
||||
@@ -198,7 +196,10 @@ export function EditPlanFeatureSheet({
|
||||
</div>
|
||||
|
||||
{/* Footer stays at bottom */}
|
||||
<SheetFooterActions hasChanges={hasChanges} />
|
||||
<SheetFooterActions
|
||||
hasChanges={hasChanges}
|
||||
onBeforeCommit={handleBeforeCommit}
|
||||
/>
|
||||
|
||||
{/* Edit Feature Sheet */}
|
||||
{feature?.type === FeatureType.CreditSystem ? (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TierBehavior } from "@autumn/shared";
|
||||
import {
|
||||
CoinsIcon,
|
||||
DropSimpleIcon,
|
||||
RulerIcon,
|
||||
StackIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/v2/selects/Select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { VolumePricingMode } from "../../utils/tierUtils";
|
||||
|
||||
export function PriceSectionTitle({
|
||||
tierBehavior,
|
||||
volumePricingMode,
|
||||
showVolumePricingToggle,
|
||||
onTierBehaviorChange,
|
||||
onVolumePricingModeChange,
|
||||
}: {
|
||||
tierBehavior: TierBehavior;
|
||||
volumePricingMode: VolumePricingMode;
|
||||
showVolumePricingToggle: boolean;
|
||||
onTierBehaviorChange: (val: string) => void;
|
||||
onVolumePricingModeChange: (mode: VolumePricingMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>Price</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{showVolumePricingToggle && (
|
||||
<div className="flex items-center">
|
||||
<IconCheckbox
|
||||
icon={<CoinsIcon />}
|
||||
iconOrientation="left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
checked={volumePricingMode === "per_unit"}
|
||||
onCheckedChange={() => onVolumePricingModeChange("per_unit")}
|
||||
className={cn(
|
||||
"rounded-r-none",
|
||||
volumePricingMode !== "per_unit" && "border-r-0",
|
||||
)}
|
||||
>
|
||||
Per Unit
|
||||
</IconCheckbox>
|
||||
<IconCheckbox
|
||||
icon={<StackIcon />}
|
||||
iconOrientation="left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
checked={volumePricingMode === "flat"}
|
||||
onCheckedChange={() => onVolumePricingModeChange("flat")}
|
||||
className={cn(
|
||||
"rounded-l-none",
|
||||
volumePricingMode !== "flat" && "border-l-0",
|
||||
)}
|
||||
>
|
||||
Flat Amount
|
||||
</IconCheckbox>
|
||||
</div>
|
||||
)}
|
||||
<Select value={tierBehavior} onValueChange={onTierBehaviorChange}>
|
||||
<SelectTrigger className="w-40 h-6 text-xs" size="sm">
|
||||
<SelectValue>
|
||||
{tierBehavior === TierBehavior.VolumeBased ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<DropSimpleIcon className="size-3.5" weight="regular" />
|
||||
Volume-based
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<RulerIcon className="size-3.5" weight="regular" />
|
||||
Graduated
|
||||
</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={TierBehavior.Graduated}>
|
||||
<RulerIcon className="size-4" weight="regular" />
|
||||
Graduated
|
||||
</SelectItem>
|
||||
<SelectItem value={TierBehavior.VolumeBased}>
|
||||
<DropSimpleIcon className="size-4" weight="regular" />
|
||||
Volume-based
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
} from "@/components/v2/inputs/InputGroup";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
|
||||
import { addTier, removeTier, updateTier } from "../../utils/tierUtils";
|
||||
import {
|
||||
addTier,
|
||||
removeTier,
|
||||
updateTier,
|
||||
type VolumePricingMode,
|
||||
} from "../../utils/tierUtils";
|
||||
import { BillingUnits } from "./BillingUnits";
|
||||
|
||||
const getTierToDisplay = ({
|
||||
@@ -82,7 +87,11 @@ const TierToInput = ({ index }: { index: number }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export function PriceTiers() {
|
||||
export function PriceTiers({
|
||||
volumePricingMode,
|
||||
}: {
|
||||
volumePricingMode?: VolumePricingMode;
|
||||
}) {
|
||||
const { item, setItem } = useProductItemContext();
|
||||
const { org } = useOrg();
|
||||
const currency = org?.default_currency?.toUpperCase() ?? "USD";
|
||||
@@ -119,7 +128,7 @@ export function PriceTiers() {
|
||||
|
||||
const handleInputBlur = (
|
||||
key: string,
|
||||
field: "amount" | "to",
|
||||
field: "amount" | "to" | "flat_amount",
|
||||
tierIndex?: number,
|
||||
) => {
|
||||
const rawValue = editingValues[key] || "";
|
||||
@@ -133,7 +142,7 @@ export function PriceTiers() {
|
||||
const handleInputChange = (
|
||||
key: string,
|
||||
value: string,
|
||||
field: "amount" | "to",
|
||||
field: "amount" | "to" | "flat_amount",
|
||||
tierIndex?: number,
|
||||
) => {
|
||||
setEditingValues((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -208,7 +217,10 @@ export function PriceTiers() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{tiers.map((tier: PriceTier, index: number) => {
|
||||
const amountKey = `tier-${index}-amount`;
|
||||
const isFlatMode = volumePricingMode === "flat";
|
||||
const amountField = isFlatMode ? "flat_amount" : "amount";
|
||||
const amountKey = `tier-${index}-${amountField}`;
|
||||
const amountValue = isFlatMode ? (tier.flat_amount ?? 0) : tier.amount;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex gap-2 w-full items-center">
|
||||
@@ -222,11 +234,16 @@ export function PriceTiers() {
|
||||
|
||||
<InputGroup className="min-w-0 w-26 shrink-0">
|
||||
<InputGroupInput
|
||||
value={getDisplayValue(amountKey, tier.amount)}
|
||||
onFocus={() => handleInputFocus(amountKey, tier.amount)}
|
||||
onBlur={() => handleInputBlur(amountKey, "amount", index)}
|
||||
value={getDisplayValue(amountKey, amountValue)}
|
||||
onFocus={() => handleInputFocus(amountKey, amountValue)}
|
||||
onBlur={() => handleInputBlur(amountKey, amountField, index)}
|
||||
onChange={(e) =>
|
||||
handleInputChange(amountKey, e.target.value, "amount", index)
|
||||
handleInputChange(
|
||||
amountKey,
|
||||
e.target.value,
|
||||
amountField,
|
||||
index,
|
||||
)
|
||||
}
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
@@ -236,7 +253,7 @@ export function PriceTiers() {
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<BillingUnits />
|
||||
{!isFlatMode && <BillingUnits />}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<IconButton
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useSheet } from "@/components/v2/inline-custom-plan-editor/PlanEditorCo
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
|
||||
|
||||
export function SheetFooterActions({ hasChanges }: { hasChanges: boolean }) {
|
||||
export function SheetFooterActions({
|
||||
hasChanges,
|
||||
onBeforeCommit,
|
||||
}: {
|
||||
hasChanges: boolean;
|
||||
onBeforeCommit?: () => void;
|
||||
}) {
|
||||
const { setItem, handleUpdateProductItem } = useProductItemContext();
|
||||
const { initialItem } = useSheet();
|
||||
|
||||
@@ -15,6 +21,7 @@ export function SheetFooterActions({ hasChanges }: { hasChanges: boolean }) {
|
||||
};
|
||||
|
||||
const handleUpdateItem = async () => {
|
||||
onBeforeCommit?.();
|
||||
await handleUpdateProductItem();
|
||||
};
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export const updateTier = ({
|
||||
item: ProductItem;
|
||||
setItem: (item: ProductItem) => void;
|
||||
index: number;
|
||||
field: "to" | "amount";
|
||||
field: "to" | "amount" | "flat_amount";
|
||||
value: string;
|
||||
}) => {
|
||||
if (!item.tiers) return;
|
||||
@@ -116,7 +116,7 @@ export const updateTier = ({
|
||||
newTiers[index + 1].to = numValue;
|
||||
}
|
||||
}
|
||||
} else if (field === "amount") {
|
||||
} else if (field === "amount" || field === "flat_amount") {
|
||||
// Handle empty string or numeric values
|
||||
let numValue: number;
|
||||
if (value === "") {
|
||||
@@ -125,7 +125,29 @@ export const updateTier = ({
|
||||
const parsed = parseFloat(value);
|
||||
numValue = Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
newTiers[index] = { ...newTiers[index], amount: numValue };
|
||||
newTiers[index] = { ...newTiers[index], [field]: numValue };
|
||||
}
|
||||
setItem({ ...item, tiers: newTiers });
|
||||
};
|
||||
|
||||
export type VolumePricingMode = "flat" | "per_unit";
|
||||
|
||||
/** Cleans tier data based on the active pricing mode before committing. */
|
||||
export const cleanTiersForMode = ({
|
||||
item,
|
||||
mode,
|
||||
}: {
|
||||
item: ProductItem;
|
||||
mode: VolumePricingMode;
|
||||
}): ProductItem => {
|
||||
if (!item.tiers) return item;
|
||||
|
||||
const cleanedTiers = item.tiers.map((tier) => {
|
||||
if (mode === "flat") {
|
||||
return { ...tier, amount: 0 };
|
||||
}
|
||||
return { ...tier, flat_amount: null };
|
||||
});
|
||||
|
||||
return { ...item, tiers: cleanedTiers };
|
||||
};
|
||||
|
||||
229
vite/tests/views/products/plan/utils/tierUtils.test.ts
Normal file
229
vite/tests/views/products/plan/utils/tierUtils.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Infinite, type ProductItem } from "@autumn/shared";
|
||||
import {
|
||||
cleanTiersForMode,
|
||||
updateTier,
|
||||
} from "@/views/products/plan/utils/tierUtils";
|
||||
|
||||
const makeTier = ({
|
||||
to = 100,
|
||||
amount = 5,
|
||||
flat_amount = null,
|
||||
}: {
|
||||
to?: number | typeof Infinite;
|
||||
amount?: number;
|
||||
flat_amount?: number | null;
|
||||
}) => ({ to, amount, flat_amount });
|
||||
|
||||
const makeItem = ({
|
||||
tiers,
|
||||
}: {
|
||||
tiers: ReturnType<typeof makeTier>[] | null;
|
||||
}): ProductItem =>
|
||||
({
|
||||
feature_id: "feat_test",
|
||||
tiers,
|
||||
}) as ProductItem;
|
||||
|
||||
describe("cleanTiersForMode", () => {
|
||||
test("flat mode zeroes out amount and preserves flat_amount", () => {
|
||||
const item = makeItem({
|
||||
tiers: [
|
||||
makeTier({ to: 100, amount: 1.5, flat_amount: 50 }),
|
||||
makeTier({ to: Infinite, amount: 2, flat_amount: 100 }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = cleanTiersForMode({ item, mode: "flat" });
|
||||
|
||||
expect(result.tiers).toHaveLength(2);
|
||||
expect(result.tiers![0].amount).toBe(0);
|
||||
expect(result.tiers![0].flat_amount).toBe(50);
|
||||
expect(result.tiers![1].amount).toBe(0);
|
||||
expect(result.tiers![1].flat_amount).toBe(100);
|
||||
});
|
||||
|
||||
test("per_unit mode nulls out flat_amount and preserves amount", () => {
|
||||
const item = makeItem({
|
||||
tiers: [
|
||||
makeTier({ to: 100, amount: 1.5, flat_amount: 50 }),
|
||||
makeTier({ to: Infinite, amount: 2, flat_amount: 100 }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = cleanTiersForMode({ item, mode: "per_unit" });
|
||||
|
||||
expect(result.tiers).toHaveLength(2);
|
||||
expect(result.tiers![0].amount).toBe(1.5);
|
||||
expect(result.tiers![0].flat_amount).toBeNull();
|
||||
expect(result.tiers![1].amount).toBe(2);
|
||||
expect(result.tiers![1].flat_amount).toBeNull();
|
||||
});
|
||||
|
||||
test("returns item unchanged when tiers is null", () => {
|
||||
const item = makeItem({ tiers: null });
|
||||
const result = cleanTiersForMode({ item, mode: "flat" });
|
||||
expect(result.tiers).toBeNull();
|
||||
});
|
||||
|
||||
test("flat mode handles tiers where flat_amount is null/undefined", () => {
|
||||
const item = makeItem({
|
||||
tiers: [
|
||||
makeTier({ to: 100, amount: 3, flat_amount: null }),
|
||||
makeTier({ to: Infinite, amount: 0, flat_amount: null }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = cleanTiersForMode({ item, mode: "flat" });
|
||||
|
||||
expect(result.tiers![0].amount).toBe(0);
|
||||
expect(result.tiers![0].flat_amount).toBeNull();
|
||||
expect(result.tiers![1].amount).toBe(0);
|
||||
});
|
||||
|
||||
test("per_unit mode handles tiers that already have no flat_amount", () => {
|
||||
const item = makeItem({
|
||||
tiers: [makeTier({ to: 100, amount: 5, flat_amount: null })],
|
||||
});
|
||||
|
||||
const result = cleanTiersForMode({ item, mode: "per_unit" });
|
||||
|
||||
expect(result.tiers![0].amount).toBe(5);
|
||||
expect(result.tiers![0].flat_amount).toBeNull();
|
||||
});
|
||||
|
||||
test("does not mutate the original item", () => {
|
||||
const item = makeItem({
|
||||
tiers: [makeTier({ to: 100, amount: 5, flat_amount: 50 })],
|
||||
});
|
||||
|
||||
cleanTiersForMode({ item, mode: "flat" });
|
||||
|
||||
expect(item.tiers![0].amount).toBe(5);
|
||||
expect(item.tiers![0].flat_amount).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTier — flat_amount field", () => {
|
||||
test("sets flat_amount on the correct tier", () => {
|
||||
let captured: ProductItem | null = null;
|
||||
const item = makeItem({
|
||||
tiers: [
|
||||
makeTier({ to: 100, amount: 1 }),
|
||||
makeTier({ to: Infinite, amount: 0 }),
|
||||
],
|
||||
});
|
||||
|
||||
updateTier({
|
||||
item,
|
||||
setItem: (i) => {
|
||||
captured = i;
|
||||
},
|
||||
index: 0,
|
||||
field: "flat_amount",
|
||||
value: "25.50",
|
||||
});
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.tiers![0].flat_amount).toBe(25.5);
|
||||
expect(captured!.tiers![0].amount).toBe(1);
|
||||
});
|
||||
|
||||
test("sets flat_amount to 0 for empty string", () => {
|
||||
let captured: ProductItem | null = null;
|
||||
const item = makeItem({
|
||||
tiers: [makeTier({ to: 100, amount: 1, flat_amount: 50 })],
|
||||
});
|
||||
|
||||
updateTier({
|
||||
item,
|
||||
setItem: (i) => {
|
||||
captured = i;
|
||||
},
|
||||
index: 0,
|
||||
field: "flat_amount",
|
||||
value: "",
|
||||
});
|
||||
|
||||
expect(captured!.tiers![0].flat_amount).toBe(0);
|
||||
});
|
||||
|
||||
test("sets flat_amount to 0 for non-numeric string", () => {
|
||||
let captured: ProductItem | null = null;
|
||||
const item = makeItem({
|
||||
tiers: [makeTier({ to: 100, amount: 1, flat_amount: 50 })],
|
||||
});
|
||||
|
||||
updateTier({
|
||||
item,
|
||||
setItem: (i) => {
|
||||
captured = i;
|
||||
},
|
||||
index: 0,
|
||||
field: "flat_amount",
|
||||
value: "abc",
|
||||
});
|
||||
|
||||
expect(captured!.tiers![0].flat_amount).toBe(0);
|
||||
});
|
||||
|
||||
test("does not affect other tiers", () => {
|
||||
let captured: ProductItem | null = null;
|
||||
const item = makeItem({
|
||||
tiers: [
|
||||
makeTier({ to: 100, amount: 1, flat_amount: 10 }),
|
||||
makeTier({ to: Infinite, amount: 2, flat_amount: 20 }),
|
||||
],
|
||||
});
|
||||
|
||||
updateTier({
|
||||
item,
|
||||
setItem: (i) => {
|
||||
captured = i;
|
||||
},
|
||||
index: 1,
|
||||
field: "flat_amount",
|
||||
value: "99",
|
||||
});
|
||||
|
||||
expect(captured!.tiers![0].flat_amount).toBe(10);
|
||||
expect(captured!.tiers![1].flat_amount).toBe(99);
|
||||
});
|
||||
|
||||
test("amount field still works as before", () => {
|
||||
let captured: ProductItem | null = null;
|
||||
const item = makeItem({
|
||||
tiers: [makeTier({ to: 100, amount: 1, flat_amount: 50 })],
|
||||
});
|
||||
|
||||
updateTier({
|
||||
item,
|
||||
setItem: (i) => {
|
||||
captured = i;
|
||||
},
|
||||
index: 0,
|
||||
field: "amount",
|
||||
value: "7.25",
|
||||
});
|
||||
|
||||
expect(captured!.tiers![0].amount).toBe(7.25);
|
||||
expect(captured!.tiers![0].flat_amount).toBe(50);
|
||||
});
|
||||
|
||||
test("does nothing when tiers is null", () => {
|
||||
let captured: ProductItem | null = null;
|
||||
const item = makeItem({ tiers: null });
|
||||
|
||||
updateTier({
|
||||
item,
|
||||
setItem: (i) => {
|
||||
captured = i;
|
||||
},
|
||||
index: 0,
|
||||
field: "flat_amount",
|
||||
value: "10",
|
||||
});
|
||||
|
||||
expect(captured).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user