Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 52 additions & 9 deletions web/src/app/(app)/billing/page.addon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,10 @@ describe("BillingPage — inbox add-on", () => {
const inc = screen.getByRole("button", { name: "Increase add-on quantity" });
await userEvent.click(inc);
await userEvent.click(inc);
expect(screen.getByText(/New total:/)).toHaveTextContent("$4/mo");
await userEvent.click(screen.getByRole("button", { name: "Buy add-ons" }));
expect(screen.getByText(/New monthly total:/)).toHaveTextContent(
"$4/mo (+2 add-ons, +$4/mo)",
);
await userEvent.click(screen.getByRole("button", { name: "Buy 2 add-ons" }));

await waitFor(() => expect(addonPosts()).toEqual([{ quantity: 2 }]));
// Took the redirect branch: no in-place provisioning notice, no
Expand All @@ -244,7 +246,7 @@ describe("BillingPage — inbox add-on", () => {
expect(screen.getByLabelText("Add-on quantity")).toHaveValue(1);

await userEvent.click(screen.getByRole("button", { name: "Increase add-on quantity" }));
await userEvent.click(screen.getByRole("button", { name: "Update add-ons" }));
await userEvent.click(screen.getByRole("button", { name: "Update to 2 add-ons" }));

// An in-place increase charges immediately → the confirm carries the
// new total.
Expand All @@ -267,12 +269,14 @@ describe("BillingPage — inbox add-on", () => {

await screen.findByText("Inbox add-on");
await userEvent.click(screen.getByRole("button", { name: "Increase add-on quantity" }));
await userEvent.click(screen.getByRole("button", { name: "Update add-ons" }));
await userEvent.click(screen.getByRole("button", { name: "Update to 2 add-ons" }));

expect(window.confirm).toHaveBeenCalled();
expect(addonPosts()).toEqual([]);
// Declining leaves the page interactive.
expect(screen.getByRole("button", { name: "Update add-ons" })).not.toBeDisabled();
// Declining leaves the page interactive, the staged total still named.
expect(
screen.getByRole("button", { name: "Update to 2 add-ons" }),
).not.toBeDisabled();
});

it("disables the action when the desired quantity equals the current one", async () => {
Expand Down Expand Up @@ -335,11 +339,13 @@ describe("BillingPage — inbox add-on", () => {

await screen.findByText("Inbox add-on");
await userEvent.click(screen.getByRole("button", { name: "Decrease add-on quantity" }));
await userEvent.click(screen.getByRole("button", { name: "Update add-ons" }));
await userEvent.click(screen.getByRole("button", { name: "Update to 0 add-ons" }));

await waitFor(() => expect(window.alert).toHaveBeenCalled());
// Failure clears the in-flight state so the user can retry.
expect(screen.getByRole("button", { name: "Update add-ons" })).not.toBeDisabled();
expect(
screen.getByRole("button", { name: "Update to 0 add-ons" }),
).not.toBeDisabled();
});
});

Expand All @@ -354,7 +360,7 @@ describe("BillingPage — add-on provisioning sync", () => {
async function stageAndApply(user: ReturnType<typeof userEvent.setup>) {
await screen.findByText("Inbox add-on");
await user.click(screen.getByRole("button", { name: "Increase add-on quantity" }));
await user.click(screen.getByRole("button", { name: "Update add-ons" }));
await user.click(screen.getByRole("button", { name: "Update to 2 add-ons" }));
await waitFor(() =>
expect(screen.getByText(/Updating your add-ons/)).toBeInTheDocument(),
);
Expand Down Expand Up @@ -445,3 +451,40 @@ describe("BillingPage — owned add-on visibility", () => {
expect(screen.queryByText(/inbox add-on ·/)).not.toBeInTheDocument();
});
});

describe("BillingPage — total-vs-delta clarity", () => {
it("names the staged TOTAL and the delta when adding one more to an owned add-on", async () => {
limitsPayload = PRO_LIMITS;
planPayload = proPlan(1);
renderPage();

await screen.findByText("Inbox add-on");
// The stepper is labeled as the account TOTAL.
expect(screen.getByText("Total add-ons")).toBeInTheDocument();

// Owning 1 and stepping to 2 = buying ONE more — the button and the
// preview both say so, so "2" can't read as "add 2 minimum".
await userEvent.click(screen.getByRole("button", { name: "Increase add-on quantity" }));
expect(
screen.getByRole("button", { name: "Update to 2 add-ons" }),
).toBeInTheDocument();
expect(screen.getByText(/New monthly total:/)).toHaveTextContent(
"$4/mo (+1 add-on, +$2/mo)",
);
});

it("shows a negative delta when reducing", async () => {
limitsPayload = PRO_LIMITS;
planPayload = proPlan(3);
renderPage();

await screen.findByText("Inbox add-on");
await userEvent.click(screen.getByRole("button", { name: "Decrease add-on quantity" }));
expect(
screen.getByRole("button", { name: "Update to 2 add-ons" }),
).toBeInTheDocument();
expect(screen.getByText(/New monthly total:/)).toHaveTextContent(
"$4/mo (\u22121 add-on, \u2212$2/mo)",
);
});
});
35 changes: 30 additions & 5 deletions web/src/app/(app)/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,13 @@ export default function BillingPage() {
)}

<div className="flex items-center gap-3 flex-wrap">
{/* The stepper holds the desired TOTAL, not a delta — a
user who owns 1 and wants one more steps to 2. Say so
on screen: without the label, "2" next to an owned
"1" reads as "buy 2 more". */}
<label className="text-xs text-muted" htmlFor="addon-quantity">
Total add-ons
</label>
<div
role="group"
aria-label="Add-on quantity stepper"
Expand All @@ -964,6 +971,7 @@ export default function BillingPage() {
</button>
<input
id="addon-quantity"
type="number"
inputMode="numeric"
aria-label="Add-on quantity"
Expand Down Expand Up @@ -1016,28 +1024,45 @@ export default function BillingPage() {
onClick={() => applyAddon(addonQty)}
className="px-3 py-1.5 rounded-md text-sm font-medium bg-accent text-white hover:bg-accent/90 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{/* When a change is staged, the label names the TOTAL
being committed ("Update to 2 add-ons"), so the
action can't be misread as buying a delta. */}
{actionPending === "addon"
? addonInPlace
? "Saving…"
: "Opening…"
: addonSync === "pending"
? "Updating…"
: addonQty !== addonServerQty
? `${addonInPlace ? "Update to" : "Buy"} ${formatNumber(
addonQty,
)} add-on${addonQty === 1 ? "" : "s"}`
: addonInPlace
? "Update add-ons"
: "Buy add-ons"}
</button>

{/* Proposed new monthly total, shown the moment the
staged quantity diverges — nobody should commit to a
number they haven't seen. */}
{/* Proposed new monthly total plus the delta from what's
owned, shown the moment the staged quantity diverges —
nobody should commit to a number they haven't seen,
and the delta says what this click actually changes. */}
{addonQty !== addonServerQty && (
<span className="text-xs text-foreground font-medium">
New total:{" "}
New monthly total:{" "}
{addonQty === 0
? "$0/mo"
: formatPrice(
addonQty * planData.addon.monthly_price_cents_per_unit,
)}
)}{" "}
({addonQty > addonServerQty ? "+" : "−"}
{formatNumber(Math.abs(addonQty - addonServerQty))} add-on
{Math.abs(addonQty - addonServerQty) === 1 ? "" : "s"},{" "}
{addonQty > addonServerQty ? "+" : "−"}
{formatPrice(
Math.abs(addonQty - addonServerQty) *
planData.addon.monthly_price_cents_per_unit,
)}
)
</span>
)}

Expand Down
Loading