fix(frontend): calmer trial offer, one usage error and clearer limit labels

- Trial billing card: below 80% of the allowance (the meter's Running low
  line) it keeps the price and start date as a plain card with a
  secondary "Start Pro now". "Start it now to keep working today" only
  shows once the trial is running low or used up. The usage card and the
  offer share one status helper.
- Usage that fails to load shows one error in the Usage slot with Try
  again and Contact support, instead of the generic error card (empty
  error box, Report Error, Get Help) plus a second error in the side card.
  The side card stays the normal plan card.
- The limit dialog names the reset time once; the "You can continue
  after" box repeated the headline or description.
- Max captions say "already used" instead of "today", which was wrong for
  weekly limits.
- A cancel-pending trial's limit notice and offer say "Subscribe now",
  matching the plan cards they open.

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
Claude-Session: https://claude.ai/code/session_01StgD1TkCx1ku4bjHh7t4VZ
This commit is contained in:
Claude committed 2026-10-14 11:57:08 -07:00
1 parent e900125ced
commit 950f2fcaaf
14 files changed
+321 -52

No files matched your search

@@ -135,7 +135,7 @@ export function RateLimitResetDialog({
>
{copy.description}
</Text>
{copy.trialSpent ? (
{copy.trialSpent && (
<div className="mb-5">
<UsageMeter
label="Trial allowance"
@@ -143,15 +143,6 @@ export function RateLimitResetDialog({
detail="One allowance for your trial. It doesn’t refresh."
/>
</div>
) : (
copy.resetLabel && (
<div className="mb-5 rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<Text variant="small" className="mb-1 !text-zinc-500">
You can continue after
</Text>
<Text variant="body-medium">{copy.resetLabel}</Text>
</div>
)
)}
{isBillingEnabled && model.targetTier && (
<PlanOffer
@@ -135,6 +135,18 @@ describe("Usage limit dialog", () => {
).toBeDefined();
});
it("names the reset time once instead of repeating it in a box", () => {
render(
<RateLimitResetDialog
isOpen
onClose={vi.fn()}
experience={getUsageExperience({ ...usage, tier: "MAX" })}
/>,
);
expect(screen.getByText(/^You can continue /)).toBeDefined();
expect(screen.queryByText("You can continue after")).toBeNull();
});
it("pays the open trial invoice straight from the dialog", () => {
const onUpgrade = vi.fn();
render(
@@ -211,4 +211,24 @@ describe("Usage notice", () => {
}
expect(screen.queryByRole("button", { name: /^Start/ })).toBeNull();
});
it("asks a cancel-pending trial to subscribe instead of starting Pro", async () => {
setTrialUser();
setup("TRIAL");
server.use(
http.get("*/api/credits/trial", () =>
HttpResponse.json(
trialResponse({
allowance_used_percent: 100,
usage_policy: "lifetime",
cancel_at_period_end: true,
}),
),
),
);
render(<UsageLimitReachedCard />);
expect(
await screen.findByRole("button", { name: "Subscribe now" }),
).toBeDefined();
expect(screen.queryByRole("button", { name: /^Start Pro/ })).toBeNull();
});
});
@@ -0,0 +1,110 @@
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, it } from "vitest";
import { server } from "@/mocks/mock-server";
import { render, screen } from "@/tests/integrations/test-utils";
import {
setTrialUser,
trialResponse,
} from "@/tests/integrations/trial-fixtures";
import { BillingOffer } from "../components/SubscriptionTab/BillingOffer";
import { YourPlanCard } from "../components/SubscriptionTab/YourPlanCard/YourPlanCard";
beforeEach(() => {
setTrialUser("billing-user");
server.use(
http.get("*/api/credits/manage", () =>
HttpResponse.json({ url: "https://billing.stripe.com/test" }),
),
);
});
afterEach(() => setTrialUser(null));
function mockTrialAt(percent: number) {
server.use(
http.get("*/api/credits/subscription", () =>
HttpResponse.json({ tier: "TRIAL", monthly_cost: 0 }),
),
http.get("*/api/credits/trial", () =>
HttpResponse.json(
trialResponse({
usage_policy: "lifetime",
allowance_used_percent: percent,
}),
),
),
http.get("*/api/chat/usage", () =>
HttpResponse.json({ tier: "TRIAL", daily: null, weekly: null }),
),
);
}
it.each([0, 64, 79])(
"keeps the price and start date without pushing an early start at %s%%",
async (percent) => {
mockTrialAt(percent);
render(<BillingOffer />);
expect(
await screen.findByRole("heading", { name: "Pro starts on Sep 17" }),
).toBeDefined();
expect(
screen.getByText(
"Your trial keeps going until then. You can also start today.",
),
).toBeDefined();
expect(screen.getByRole("button", { name: "Start Pro now" })).toBeDefined();
expect(screen.getByText("$20")).toBeDefined();
expect(screen.queryByText(/keep working today/)).toBeNull();
},
);
it.each([80, 100])(
"offers starting Pro today once the trial is running low at %s%%",
async (percent) => {
mockTrialAt(percent);
render(<BillingOffer />);
expect(
await screen.findByRole("button", { name: "Start Pro now · $20" }),
).toBeDefined();
expect(
screen.getByText(
"Pro starts on its own on Sep 17. Start it now to keep working today.",
),
).toBeDefined();
},
);
it("shows one usage error and keeps the normal plan card when usage fails", async () => {
server.use(
http.get("*/api/credits/subscription", () =>
HttpResponse.json({
tier: "PRO",
monthly_cost: 5000,
tier_costs: { PRO: 5000, MAX: 32000 },
tier_multipliers: { PRO: 1, MAX: 8.5 },
billing_cycle: "monthly",
has_active_stripe_subscription: true,
}),
),
http.get("*/api/credits/trial", () =>
HttpResponse.json(trialResponse({ active: false, converted: true })),
),
http.get("*/api/chat/usage", () => HttpResponse.json({}, { status: 500 })),
);
render(<YourPlanCard showUsage />);
expect(await screen.findByText("We couldn’t load your usage")).toBeDefined();
expect(screen.getAllByText("We couldn’t load your usage")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Try again" })).toBeDefined();
expect(screen.getByRole("link", { name: "Contact support" })).toBeDefined();
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(
screen.queryByRole("button", { name: /Report Error|Get Help/ }),
).toBeNull();
expect(
await screen.findByRole("heading", { name: "Upgrade to Max" }),
).toBeDefined();
expect(
screen.getByText(
"What you’ve already used counts toward Max’s higher limit.",
),
).toBeDefined();
});
@@ -5,13 +5,13 @@ import { Clock01Icon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/atoms/Icon/Icon";
import { Text } from "@/components/atoms/Text/Text";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { formatUsagePercent } from "@/services/usageExperience/helpers";
import { getSectionMotionProps } from "../../../helpers";
import {
useAutopilotUsageCard,
type UsageWindowView,
} from "./useAutopilotUsageCard";
import { UsageUnavailable } from "./UsageUnavailable";
interface Props {
index?: number;
@@ -25,14 +25,7 @@ export function AutopilotUsageCard({ index = 0 }: Props) {
!state.inactive &&
(state.isError || (!state.hasUsage && !state.unlimited))
)
return (
<ErrorCard
context="usage"
hint="Your usage couldn’t be loaded. Try again in a moment."
onRetry={state.retry}
className="h-full"
/>
);
return <UsageUnavailable onRetry={() => void state.retry()} />;
return (
<motion.section
{...getSectionMotionProps(index, Boolean(reduceMotion))}
@@ -0,0 +1,51 @@
import { Alert02Icon, RefreshIcon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/atoms/Button/Button";
import { Icon } from "@/components/atoms/Icon/Icon";
import { Text } from "@/components/atoms/Text/Text";
import { ENTERPRISE_CONTACT_URL } from "../YourPlanCard/useYourPlanCard";
interface Props {
onRetry: () => void;
}
export function UsageUnavailable({ onRetry }: Props) {
return (
<section
aria-label="Usage"
className="flex h-full min-h-80 flex-col rounded-2xl border border-zinc-200 bg-white p-6"
>
<Text variant="large-medium" as="h2">
Usage
</Text>
<div className="my-auto py-8">
<span className="mb-4 flex size-9 items-center justify-center rounded-xl bg-zinc-100 text-zinc-600">
<Icon icon={Alert02Icon} size={18} />
</span>
<Text variant="large-medium" className="mb-2">
We couldn’t load your usage
</Text>
<Text variant="small" tone="secondary" className="leading-6">
Your plan and your work aren’t affected. Try again in a moment.
</Text>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button
variant="secondary"
size="small"
onClick={onRetry}
leftIcon={<Icon icon={RefreshIcon} size={14} aria-hidden="true" />}
>
Try again
</Button>
<Button
as="NextLink"
href={ENTERPRISE_CONTACT_URL}
variant="ghost"
size="small"
>
Contact support
</Button>
</div>
</div>
</section>
);
}
@@ -1,5 +1,9 @@
"use client";
import {
getFeaturedUsagePercent,
getUsageStatus,
} from "@/services/usageExperience/helpers";
import { useUsageExperience } from "@/services/usageExperience/useUsageExperience";
import { formatRelativeReset, formatShortDate } from "../../../helpers";
@@ -45,13 +49,8 @@ export function useAutopilotUsageCard() {
? week
: today;
const secondary = showTrialWindow ? null : featured === week ? today : week;
const percent = featured?.percent ?? 0;
const status =
percent >= 100
? { label: "Allowance reached", className: "text-orange-700" }
: percent >= 80
? { label: "Running low", className: "text-amber-700" }
: { label: "Plenty left", className: "text-zinc-500" };
USAGE_STATUS[getUsageStatus(getFeaturedUsagePercent(experience, usage))];
return {
status,
featured,
@@ -74,3 +73,9 @@ export function useAutopilotUsageCard() {
),
};
}
const USAGE_STATUS = {
reached: { label: "Allowance reached", className: "text-orange-700" },
low: { label: "Running low", className: "text-amber-700" },
plenty: { label: "Plenty left", className: "text-zinc-500" },
};
@@ -7,6 +7,9 @@ import { useUsageExperience } from "@/services/usageExperience/useUsageExperienc
import {
formatOfferPrice,
formatTrialStartDescription,
formatTrialStartTitle,
getFeaturedUsagePercent,
getUsageStatus,
} from "@/services/usageExperience/helpers";
import { formatCents } from "../../helpers";
import {
@@ -20,7 +23,7 @@ interface Props {
}
export function BillingOffer({ controller }: Props) {
const { experience, trial, subscription, isError } = useUsageExperience();
const { experience, trial, subscription, usage } = useUsageExperience();
const activation = useProActivation();
const isTrial = experience.isActiveTrial;
const target = isTrial
@@ -42,16 +45,6 @@ export function BillingOffer({ controller }: Props) {
ready
/>
);
if (isError && experience.tier !== "NO_TIER")
return (
<SupportOffer
eyebrow="Usage"
title="We couldn’t load your usage"
description="We’re having trouble loading your usage. If it keeps happening, our team can help."
label="Contact support"
href={ENTERPRISE_CONTACT_URL}
/>
);
if (!canUpgrade || !target)
return (
<SupportOffer
@@ -99,23 +92,35 @@ export function BillingOffer({ controller }: Props) {
"Experts for content, sales and research, with Otto coordinating",
"Agents that keep working in the background on a schedule",
];
const title = isTrial
? "Start Pro now"
: `${experience.tier === "NO_TIER" ? "Start" : "Upgrade to"} ${target}`;
const quiet =
isTrial &&
getUsageStatus(getFeaturedUsagePercent(experience, usage)) === "plenty";
const title = quiet
? formatTrialStartTitle(experience.trialEndsAt)
: isTrial
? "Start Pro now"
: `${experience.tier === "NO_TIER" ? "Start" : "Upgrade to"} ${target}`;
return (
<BillingPlanOffer
target={target}
title={title}
quiet={quiet}
description={
isTrial && experience.freshProUsage
? formatTrialStartDescription(experience.trialEndsAt)
? quiet
? "Your trial keeps going until then. You can also start today."
: formatTrialStartDescription(experience.trialEndsAt)
: undefined
}
price={price}
isTrialOffer={isTrial}
benefits={benefits}
actionLabel={
isTrial && price ? `${title} · ${price.split(" / ")[0]}` : title
quiet
? "Start Pro now"
: isTrial && price
? `${title} · ${price.split(" / ")[0]}`
: title
}
onAction={
isTrial
@@ -127,7 +132,7 @@ export function BillingOffer({ controller }: Props) {
isTrial
? "Starting now ends your trial early. You’ll review the charge first."
: target === "Max"
? "What you’ve used today counts toward Max’s higher limit."
? "What you’ve already used counts toward Max’s higher limit."
: "Review your options before paying."
}
/>
@@ -6,6 +6,7 @@ import { Text } from "@/components/atoms/Text/Text";
interface Props {
target: string;
title: string;
quiet?: boolean;
description?: string;
price: string | null;
isTrialOffer?: boolean;
@@ -19,6 +20,7 @@ interface Props {
export function BillingPlanOffer({
target,
title,
quiet = false,
description,
price,
isTrialOffer = false,
@@ -31,7 +33,11 @@ export function BillingPlanOffer({
return (
<section
aria-label={`AutoGPT ${target}`}
className="flex h-full flex-col rounded-2xl border border-purple-200 bg-gradient-to-br from-purple-50 via-white to-blue-50 p-6"
className={
quiet
? "flex h-full flex-col rounded-2xl border border-zinc-200 bg-white p-6"
: "flex h-full flex-col rounded-2xl border border-purple-200 bg-gradient-to-br from-purple-50 via-white to-blue-50 p-6"
}
>
<div className="flex items-center gap-2.5">
<span className="flex size-7 items-center justify-center rounded-lg bg-purple-100 text-purple-700">
@@ -83,6 +89,7 @@ export function BillingPlanOffer({
))}
</ul>
<Button
variant={quiet ? "secondary" : "primary"}
size="large"
className="mt-auto w-full"
onClick={onAction}
@@ -102,8 +102,8 @@ export function SwitchTierDialog({
tone="secondary"
className="mt-4 rounded-xl bg-zinc-50 p-4 leading-6"
>
You can keep going once payment goes through. What you’ve used today
counts toward Max’s limit
You can keep going once payment goes through. What you’ve already
used counts toward Max’s limit
{usageMultiplier
? `, which is ${usageMultiplier} times as high as Pro’s.`
: "."}
@@ -122,7 +122,7 @@ export function PlanOffer({
{freshUsage
? "You’ll see the full charge before you pay."
: tier === "MAX"
? "What you’ve used today counts toward Max’s higher limit."
? "What you’ve already used counts toward Max’s higher limit."
: "Review your plan and payment before confirming."}
</Text>
</section>
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { getUsageExperience, formatUsageDate } from "../helpers";
import {
getFeaturedUsagePercent,
getUsageExperience,
getUsageOfferAction,
getUsageStatus,
formatUsageDate,
} from "../helpers";
const usage = {
tier: "PRO",
@@ -134,6 +140,22 @@ describe("usage experience", () => {
});
},
);
it("asks a cancel-pending trial to subscribe rather than start Pro", () => {
const experience = getUsageExperience(
{ ...usage, tier: "TRIAL" },
{ ...trial, offer: { tier: "PRO" }, cancel_at_period_end: true },
);
expect(experience.isCancelPending).toBe(true);
expect(getUsageOfferAction(experience, {})).toBe("Subscribe now");
});
it("keeps Start Pro for a trial that ended without a pending cancellation", () => {
const experience = getUsageExperience(
{ ...usage, tier: "NO_TIER" },
{ ...trial, active: false, status: "expired" },
);
expect(experience.isCancelPending).toBe(false);
expect(getUsageOfferAction(experience, {})).toBe("Start Pro");
});
it("carries the open invoice only for a failed first charge", () => {
const payment_due = {
amount_due: 5000,
@@ -159,4 +181,34 @@ describe("usage experience", () => {
).payment,
).toBeNull();
});
it.each([
[0, "plenty"],
[79.9, "plenty"],
[80, "low"],
[99.9, "low"],
[100, "reached"],
] as const)("reports %s%% as %s", (percent, status) => {
expect(getUsageStatus(percent)).toBe(status);
});
it("features the same window the usage card shows", () => {
const lifetime = getUsageExperience(
{ ...usage, tier: "TRIAL" },
{ ...trial, allowance_used_percent: 64 },
);
expect(getFeaturedUsagePercent(lifetime, usage)).toBe(64);
const weekly = {
...usage,
daily: { ...usage.daily, percent_used: 40 },
weekly: { ...usage.weekly, percent_used: 100 },
};
expect(getFeaturedUsagePercent(getUsageExperience(weekly), weekly)).toBe(
100,
);
const daily = {
...usage,
daily: { ...usage.daily, percent_used: 30 },
weekly: { ...usage.weekly, percent_used: 70 },
};
expect(getFeaturedUsagePercent(getUsageExperience(daily), daily)).toBe(30);
});
});
@@ -83,6 +83,7 @@ export function getUsageExperience(
isActiveTrial &&
!trial?.cancel_at_period_end &&
trial?.offer?.tier === "PRO",
isCancelPending: isActiveTrial && trial?.cancel_at_period_end === true,
trialPercent,
inactiveTrialStatus,
payment:
@@ -106,6 +107,27 @@ export function getUsageExperience(
export type UsageExperience = ReturnType<typeof getUsageExperience>;
export function getUsageStatus(percent: number) {
if (percent >= 100) return "reached" as const;
return percent >= 80 ? ("low" as const) : ("plenty" as const);
}
export function getFeaturedUsagePercent(
experience: UsageExperience,
usage?: Usage | null,
) {
if (
experience.isLifetimeTrial ||
(experience.isActiveTrial && experience.window === "trial")
)
return experience.trialPercent ?? 0;
const featured =
experience.window === "weekly" || !usage?.daily
? usage?.weekly
: usage.daily;
return featured?.percent_used ?? 0;
}
export function formatUsagePercent(value: number) {
if (value > 0 && value < 1) return "<1%";
if (value < 100 && value > 99) return "99%";
@@ -139,6 +161,11 @@ export function formatTrialStartDescription(value: string | Date | null) {
return `Pro starts on its own ${date ? `on ${date}` : "when your trial ends"}. Start it now to keep working today.`;
}
export function formatTrialStartTitle(value: string | Date | null) {
const date = formatTrialDate(value);
return date ? `Pro starts on ${date}` : "Pro starts when your trial ends";
}
export function formatResumeTime(value: string | Date | null) {
if (!value) return null;
const date = new Date(value);
@@ -193,5 +220,6 @@ export function getUsageOfferAction(
const amount = formatOfferPrice(offer.price, offer.currency);
return `Start Pro now${amount ? ` · ${amount}` : ""}`;
}
if (experience.isCancelPending) return "Subscribe now";
return experience.tier === "BASIC" ? "Upgrade to Pro" : "Start Pro";
}
@@ -3,7 +3,6 @@ import {
formatResumeTime,
formatTrialDate,
formatTrialStartDescription,
formatUsageDate,
type UsageExperience,
} from "./helpers";
import { formatPaymentDue } from "./payment";
@@ -35,7 +34,6 @@ export function usagePresentation(
: canceled
? "Your trial won’t turn into a paid subscription. Your chats, agents and results are saved whenever you’re ready to choose a plan."
: "Your chats, agents and results are saved. Choose a plan to keep going.",
resetLabel: null,
supportLabel: "Contact support",
secondary: "Back to my work",
trialSpent: false,
@@ -68,9 +66,6 @@ export function usagePresentation(
: tier === "PRO"
? `${resume}, or upgrade to Max for ${usageMultiplier ? `${usageMultiplier} times the usage` : "more usage"}.`
: `${resume}, or ${tier === "BASIC" ? "upgrade to Pro" : "start Pro"}.`,
resetLabel: experience.resetsAt
? formatUsageDate(experience.resetsAt)
: null,
supportLabel:
tier === "ENTERPRISE"
? "Contact your account team"