feat: 🎸 frontend for new vercel
This commit is contained in:
@@ -100,6 +100,24 @@ const itemsHaveSameInterval = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an interval discriminator suffix so two items sharing the same
|
||||||
|
* feature/entitlement/price-id but differing in billing cadence (e.g. a
|
||||||
|
* monthly entitlement and a one-off price for the same feature) hash to
|
||||||
|
* distinct ids. Without this, the plan editor sheet retargets whichever
|
||||||
|
* item happens to come first in the list and the second item becomes
|
||||||
|
* uneditable. `null`/`undefined` interval is one-off; `interval_count` of
|
||||||
|
* `1`/missing is treated the same as omitted.
|
||||||
|
*/
|
||||||
|
const intervalSuffix = (item: ProductItem): string => {
|
||||||
|
if (!item.interval) return "-oneoff";
|
||||||
|
const count =
|
||||||
|
item.interval_count && item.interval_count !== 1
|
||||||
|
? `x${item.interval_count}`
|
||||||
|
: "";
|
||||||
|
return `-${item.interval}${count}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const getItemId = ({
|
export const getItemId = ({
|
||||||
item,
|
item,
|
||||||
itemIndex,
|
itemIndex,
|
||||||
@@ -107,12 +125,14 @@ export const getItemId = ({
|
|||||||
item: ProductItem;
|
item: ProductItem;
|
||||||
itemIndex: number;
|
itemIndex: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (item.entitlement_id) return `ent-${item.entitlement_id}`;
|
const interval = intervalSuffix(item);
|
||||||
if (item.price_id) return `price-${item.price_id}`;
|
if (item.entitlement_id) return `ent-${item.entitlement_id}${interval}`;
|
||||||
|
if (item.price_id) return `price-${item.price_id}${interval}`;
|
||||||
if (item.feature_id) {
|
if (item.feature_id) {
|
||||||
return item.entity_feature_id
|
const base = item.entity_feature_id
|
||||||
? `feature-${item.feature_id}-${item.entity_feature_id}`
|
? `feature-${item.feature_id}-${item.entity_feature_id}`
|
||||||
: `feature-${item.feature_id}`;
|
: `feature-${item.feature_id}`;
|
||||||
|
return `${base}${interval}`;
|
||||||
}
|
}
|
||||||
return `item-${itemIndex}`;
|
return `item-${itemIndex}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ type VercelConfigState = {
|
|||||||
client_integration_id: string;
|
client_integration_id: string;
|
||||||
client_secret: string;
|
client_secret: string;
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
custom_payment_method: string;
|
|
||||||
marketplace_mode: VercelMarketplaceMode;
|
marketplace_mode: VercelMarketplaceMode;
|
||||||
allowed_product_ids_live: string[];
|
allowed_product_ids_live: string[];
|
||||||
allowed_product_ids_sandbox: string[];
|
allowed_product_ids_sandbox: string[];
|
||||||
@@ -59,7 +58,6 @@ export const ConfigureVercel = () => {
|
|||||||
client_integration_id: "",
|
client_integration_id: "",
|
||||||
client_secret: "",
|
client_secret: "",
|
||||||
webhook_url: "",
|
webhook_url: "",
|
||||||
custom_payment_method: "",
|
|
||||||
marketplace_mode: "" as VercelMarketplaceMode,
|
marketplace_mode: "" as VercelMarketplaceMode,
|
||||||
allowed_product_ids_live: [],
|
allowed_product_ids_live: [],
|
||||||
allowed_product_ids_sandbox: [],
|
allowed_product_ids_sandbox: [],
|
||||||
@@ -105,8 +103,6 @@ export const ConfigureVercel = () => {
|
|||||||
org?.processor_configs?.vercel?.client_integration_id;
|
org?.processor_configs?.vercel?.client_integration_id;
|
||||||
const currentClientSecret = org?.processor_configs?.vercel?.client_secret;
|
const currentClientSecret = org?.processor_configs?.vercel?.client_secret;
|
||||||
const currentWebhookUrl = org?.processor_configs?.vercel?.webhook_url;
|
const currentWebhookUrl = org?.processor_configs?.vercel?.webhook_url;
|
||||||
const currentCustomPaymentMethod =
|
|
||||||
org?.processor_configs?.vercel?.custom_payment_method;
|
|
||||||
|
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
@@ -144,12 +140,6 @@ export const ConfigureVercel = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vercelConfig.custom_payment_method?.trim()) {
|
|
||||||
filteredConfig.custom_payment_method = {
|
|
||||||
[env]: vercelConfig.custom_payment_method.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vercelConfig.marketplace_mode) {
|
if (vercelConfig.marketplace_mode) {
|
||||||
filteredConfig.marketplace_mode = vercelConfig.marketplace_mode;
|
filteredConfig.marketplace_mode = vercelConfig.marketplace_mode;
|
||||||
}
|
}
|
||||||
@@ -175,7 +165,6 @@ export const ConfigureVercel = () => {
|
|||||||
client_integration_id: "",
|
client_integration_id: "",
|
||||||
client_secret: "",
|
client_secret: "",
|
||||||
webhook_url: "",
|
webhook_url: "",
|
||||||
custom_payment_method: "",
|
|
||||||
marketplace_mode:
|
marketplace_mode:
|
||||||
filteredConfig.marketplace_mode as VercelMarketplaceMode,
|
filteredConfig.marketplace_mode as VercelMarketplaceMode,
|
||||||
}));
|
}));
|
||||||
@@ -245,38 +234,6 @@ export const ConfigureVercel = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
|
||||||
<FormLabel className="mb-1">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Stripe Custom Payment Method ID
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<p className="text-tertiary-foreground text-sm mb-2">
|
|
||||||
Create a custom payment method in{" "}
|
|
||||||
<a
|
|
||||||
href="https://dashboard.stripe.com/settings/custom_payment_methods"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary"
|
|
||||||
>
|
|
||||||
Stripe
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
value={vercelConfig.custom_payment_method || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVercelConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
custom_payment_method: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
currentCustomPaymentMethod ||
|
|
||||||
"eg. cpmt_Yij7OBT6Fxu0UOa12XguA0vGB"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<FormLabel className="mb-1">
|
<FormLabel className="mb-1">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
|
|||||||
168
vite/tests/utils/get-item-id.test.ts
Normal file
168
vite/tests/utils/get-item-id.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `getItemId` — the single source of truth for plan-editor item
|
||||||
|
* identity. Two regressions to protect against here:
|
||||||
|
*
|
||||||
|
* 1. Same `feature_id` with different intervals must produce DISTINCT ids
|
||||||
|
* (this turn's bug — the per-item edit sheet retargeted the wrong row).
|
||||||
|
* 2. Item identity must NOT depend on list position. Charlie's earlier fixes
|
||||||
|
* (commits 709e1fd23 + 5ee6eeb3a) replaced index-based ids with stable,
|
||||||
|
* item-intrinsic ids to prevent React from reusing DOM rows on delete
|
||||||
|
* ("ghost item" bug). The new id format must still be position-invariant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { ProductItem } from "@autumn/shared";
|
||||||
|
import { getItemId } from "@/utils/product/productItemUtils";
|
||||||
|
|
||||||
|
const make = (overrides: Partial<ProductItem>): ProductItem =>
|
||||||
|
({
|
||||||
|
feature_id: undefined,
|
||||||
|
entitlement_id: undefined,
|
||||||
|
price_id: undefined,
|
||||||
|
entity_feature_id: undefined,
|
||||||
|
interval: undefined,
|
||||||
|
interval_count: undefined,
|
||||||
|
...overrides,
|
||||||
|
}) as ProductItem;
|
||||||
|
|
||||||
|
describe("getItemId — interval discriminator (turn fix)", () => {
|
||||||
|
test("two items with same feature_id but different intervals get distinct ids", () => {
|
||||||
|
const monthly = make({ feature_id: "action1", interval: "month" });
|
||||||
|
const oneOff = make({ feature_id: "action1", interval: undefined });
|
||||||
|
|
||||||
|
const monthlyId = getItemId({ item: monthly, itemIndex: 0 });
|
||||||
|
const oneOffId = getItemId({ item: oneOff, itemIndex: 1 });
|
||||||
|
|
||||||
|
expect(monthlyId).not.toBe(oneOffId);
|
||||||
|
expect(monthlyId).toBe("feature-action1-month");
|
||||||
|
expect(oneOffId).toBe("feature-action1-oneoff");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same feature_id + same interval but different interval_count get distinct ids", () => {
|
||||||
|
const oneMonth = make({
|
||||||
|
feature_id: "action1",
|
||||||
|
interval: "month",
|
||||||
|
interval_count: 1,
|
||||||
|
});
|
||||||
|
const threeMonths = make({
|
||||||
|
feature_id: "action1",
|
||||||
|
interval: "month",
|
||||||
|
interval_count: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = getItemId({ item: oneMonth, itemIndex: 0 });
|
||||||
|
const b = getItemId({ item: threeMonths, itemIndex: 1 });
|
||||||
|
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(a).toBe("feature-action1-month"); // count=1 is implicit
|
||||||
|
expect(b).toBe("feature-action1-monthx3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entity-scoped feature items still get distinct ids per interval", () => {
|
||||||
|
const monthly = make({
|
||||||
|
feature_id: "seats",
|
||||||
|
entity_feature_id: "team",
|
||||||
|
interval: "month",
|
||||||
|
});
|
||||||
|
const oneOff = make({
|
||||||
|
feature_id: "seats",
|
||||||
|
entity_feature_id: "team",
|
||||||
|
interval: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getItemId({ item: monthly, itemIndex: 0 })).toBe(
|
||||||
|
"feature-seats-team-month",
|
||||||
|
);
|
||||||
|
expect(getItemId({ item: oneOff, itemIndex: 1 })).toBe(
|
||||||
|
"feature-seats-team-oneoff",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entitlement-keyed items also discriminate by interval", () => {
|
||||||
|
const monthly = make({ entitlement_id: "ent_x", interval: "month" });
|
||||||
|
const oneOff = make({ entitlement_id: "ent_x", interval: undefined });
|
||||||
|
|
||||||
|
expect(getItemId({ item: monthly, itemIndex: 0 })).toBe(
|
||||||
|
"ent-ent_x-month",
|
||||||
|
);
|
||||||
|
expect(getItemId({ item: oneOff, itemIndex: 1 })).toBe("ent-ent_x-oneoff");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("price-keyed items also discriminate by interval", () => {
|
||||||
|
const monthly = make({ price_id: "pr_x", interval: "month" });
|
||||||
|
const oneOff = make({ price_id: "pr_x", interval: undefined });
|
||||||
|
|
||||||
|
expect(getItemId({ item: monthly, itemIndex: 0 })).toBe("price-pr_x-month");
|
||||||
|
expect(getItemId({ item: oneOff, itemIndex: 1 })).toBe("price-pr_x-oneoff");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getItemId — position-invariance (Charlie's earlier fix)", () => {
|
||||||
|
test("feature-based id does not depend on itemIndex", () => {
|
||||||
|
const item = make({ feature_id: "action1", interval: "month" });
|
||||||
|
expect(getItemId({ item, itemIndex: 0 })).toBe(
|
||||||
|
getItemId({ item, itemIndex: 5 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entitlement-based id does not depend on itemIndex", () => {
|
||||||
|
const item = make({ entitlement_id: "ent_x", interval: "month" });
|
||||||
|
expect(getItemId({ item, itemIndex: 0 })).toBe(
|
||||||
|
getItemId({ item, itemIndex: 9 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("price-based id does not depend on itemIndex", () => {
|
||||||
|
const item = make({ price_id: "pr_x", interval: "month" });
|
||||||
|
expect(getItemId({ item, itemIndex: 0 })).toBe(
|
||||||
|
getItemId({ item, itemIndex: 9 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleting a sibling does not change other items' ids", () => {
|
||||||
|
// Simulates the ghost-item scenario: build a list, drop an item from
|
||||||
|
// the middle, and verify the remaining items still hash to their
|
||||||
|
// original ids (which is what prevents React from reusing DOM rows).
|
||||||
|
const items = [
|
||||||
|
make({ feature_id: "messages", interval: "month" }),
|
||||||
|
make({ feature_id: "to-be-deleted", interval: "month" }),
|
||||||
|
make({ feature_id: "seats", interval: undefined }),
|
||||||
|
];
|
||||||
|
const idsBefore = items.map((item, i) =>
|
||||||
|
getItemId({ item, itemIndex: i }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterDelete = items.filter((_, i) => i !== 1);
|
||||||
|
const idsAfter = afterDelete.map((item, i) =>
|
||||||
|
getItemId({ item, itemIndex: i }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(idsAfter).toEqual([idsBefore[0]!, idsBefore[2]!]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index-only fallback fires only when no identifying ids are present", () => {
|
||||||
|
const orphan = make({ interval: "month" });
|
||||||
|
expect(getItemId({ item: orphan, itemIndex: 7 })).toBe("item-7");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getItemId — branch precedence", () => {
|
||||||
|
test("entitlement_id wins over price_id and feature_id", () => {
|
||||||
|
const item = make({
|
||||||
|
entitlement_id: "ent_x",
|
||||||
|
price_id: "pr_x",
|
||||||
|
feature_id: "action1",
|
||||||
|
interval: "month",
|
||||||
|
});
|
||||||
|
expect(getItemId({ item, itemIndex: 0 })).toBe("ent-ent_x-month");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("price_id wins over feature_id when no entitlement_id", () => {
|
||||||
|
const item = make({
|
||||||
|
price_id: "pr_x",
|
||||||
|
feature_id: "action1",
|
||||||
|
interval: "month",
|
||||||
|
});
|
||||||
|
expect(getItemId({ item, itemIndex: 0 })).toBe("price-pr_x-month");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user