feat: 🎸 frontend for new vercel

This commit is contained in:
amianthus
2026-05-25 17:33:27 +01:00
parent 68a7c81a09
commit cdc56e3b71
3 changed files with 191 additions and 46 deletions

View File

@@ -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 = ({
item,
itemIndex,
@@ -107,12 +125,14 @@ export const getItemId = ({
item: ProductItem;
itemIndex: number;
}) => {
if (item.entitlement_id) return `ent-${item.entitlement_id}`;
if (item.price_id) return `price-${item.price_id}`;
const interval = intervalSuffix(item);
if (item.entitlement_id) return `ent-${item.entitlement_id}${interval}`;
if (item.price_id) return `price-${item.price_id}${interval}`;
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}`;
return `${base}${interval}`;
}
return `item-${itemIndex}`;
};

View File

@@ -39,7 +39,6 @@ type VercelConfigState = {
client_integration_id: string;
client_secret: string;
webhook_url: string;
custom_payment_method: string;
marketplace_mode: VercelMarketplaceMode;
allowed_product_ids_live: string[];
allowed_product_ids_sandbox: string[];
@@ -59,7 +58,6 @@ export const ConfigureVercel = () => {
client_integration_id: "",
client_secret: "",
webhook_url: "",
custom_payment_method: "",
marketplace_mode: "" as VercelMarketplaceMode,
allowed_product_ids_live: [],
allowed_product_ids_sandbox: [],
@@ -105,8 +103,6 @@ export const ConfigureVercel = () => {
org?.processor_configs?.vercel?.client_integration_id;
const currentClientSecret = org?.processor_configs?.vercel?.client_secret;
const currentWebhookUrl = org?.processor_configs?.vercel?.webhook_url;
const currentCustomPaymentMethod =
org?.processor_configs?.vercel?.custom_payment_method;
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) {
filteredConfig.marketplace_mode = vercelConfig.marketplace_mode;
}
@@ -175,7 +165,6 @@ export const ConfigureVercel = () => {
client_integration_id: "",
client_secret: "",
webhook_url: "",
custom_payment_method: "",
marketplace_mode:
filteredConfig.marketplace_mode as VercelMarketplaceMode,
}));
@@ -245,38 +234,6 @@ export const ConfigureVercel = () => {
}
/>
</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">
<FormLabel className="mb-1">
<span className="text-muted-foreground">

View 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");
});
});