@startsimpli/billing 0.4.29 → 0.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -221,6 +221,19 @@ describe("one purchase at a time (crochet_patterns-j2we6)", () => {
221
221
  });
222
222
  });
223
223
 
224
+ describe("the clicked button spins for a signed-in viewer too (crochet_patterns-j2we6)", () => {
225
+ it("spins Lite while a Free viewer's checkout starts", async () => {
226
+ const onSelectOffer = vi.fn(() => new Promise(() => {}));
227
+ const free = { id: "sub-0", status: "active", offer: product.offers[0] } as unknown as SubscriptionInfo;
228
+ renderPricing({ onSelectOffer, currentPlan: { signedIn: true, subscription: free } });
229
+ await waitFor(() => expect(cta("crochet-free").textContent).toMatch(/current plan/i));
230
+ fireEvent.click(cta("crochet-lite"));
231
+ await waitFor(() => expect(cta("crochet-lite").getAttribute("aria-busy")).toBe("true"));
232
+ expect(cta("crochet-pro").getAttribute("aria-busy")).toBeNull();
233
+ expect(cta("crochet-pro").disabled).toBe(true);
234
+ });
235
+ });
236
+
224
237
  describe("the page names itself (crochet_patterns-ln6hf)", () => {
225
238
  it("uses the host's title and subtitle", async () => {
226
239
  renderPricing({ title: "Pricing", subtitle: "Free to start." });
@@ -75,6 +75,8 @@ export function PricingDetailPage({
75
75
  // A purchase by a viewer with no plan (signed out) skips usePlanChange, so
76
76
  // it is tracked here: the clicked card spins and every other button waits.
77
77
  const [selecting, setSelecting] = React.useState<string | null>(null);
78
+ // The card last clicked, so its button is the one that spins.
79
+ const [clicked, setClicked] = React.useState<string | null>(null);
78
80
 
79
81
  if (loading) {
80
82
  return (
@@ -124,6 +126,7 @@ export function PricingDetailPage({
124
126
  const busy = planChange.busy || hostBusy || selecting !== null;
125
127
  const select = (offer: ProductOffer) => {
126
128
  if (busy || planUnknown) return;
129
+ setClicked(offer.id);
127
130
  if (!currentOffer) {
128
131
  setSelecting(offer.id);
129
132
  void Promise.resolve()
@@ -137,7 +140,7 @@ export function PricingDetailPage({
137
140
  setSelecting(null);
138
141
  void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
139
142
  };
140
- const spinningId = busy ? selecting ?? planChange.pending?.offer.id ?? null : null;
143
+ const spinningId = busy ? clicked : null;
141
144
 
142
145
  // What each tier already has, so a card under "Everything in X, plus:"
143
146
  // lists only what it adds (crochet_patterns-tv7fb).
@@ -390,6 +390,38 @@ describe("SubscriptionManager", () => {
390
390
  );
391
391
  });
392
392
 
393
+ it("a failed Manage Billing click shows an error instead of nothing", async () => {
394
+ const router = mockFetchRouter({
395
+ "/billing/subscription/current/": mockSubscriptionActive,
396
+ "/billing/products/": mockProduct,
397
+ }) as unknown as (url: string, init?: RequestInit) => Promise<unknown>;
398
+ const fetcher = vi.fn((url: string, init?: RequestInit) =>
399
+ url.includes("/billing/offer-portal/")
400
+ ? Promise.resolve({
401
+ ok: false,
402
+ status: 500,
403
+ json: () => Promise.resolve({ detail: "Failed to create portal session" }),
404
+ })
405
+ : router(url, init),
406
+ ) as unknown as typeof fetch;
407
+ const Wrapper = createWrapper(fetcher);
408
+
409
+ render(
410
+ <Wrapper>
411
+ <SubscriptionManager
412
+ productId="raise-simpli"
413
+ returnUrl="https://app.test.com/settings"
414
+ />
415
+ </Wrapper>
416
+ );
417
+
418
+ await userEvent.click(await screen.findByTestId("portal-button"));
419
+
420
+ const alert = await screen.findByTestId("portal-error");
421
+ expect(alert.getAttribute("role")).toBe("alert");
422
+ expect(alert.textContent?.trim().length).toBeGreaterThan(0);
423
+ });
424
+
393
425
  it("shows Manage Billing portal button", async () => {
394
426
  const fetcher = mockFetchRouter({
395
427
  "/billing/subscription/current/": mockSubscriptionActive,
@@ -196,7 +196,7 @@ export function SubscriptionManager({
196
196
  }: SubscriptionManagerProps) {
197
197
  const { subscription, loading: subLoading, error: subError, refetch: refetchSubscription } = useSubscription(productId);
198
198
  const { product, loading: productLoading, error: productError, refetch: refetchProduct } = useProduct(productId);
199
- const { openPortal, loading: portalLoading } = usePortal();
199
+ const { openPortal, loading: portalLoading, error: portalError } = usePortal();
200
200
  const { subscribeFree } = useCheckout();
201
201
  const { usage } = useUsage(productId);
202
202
  const { sync: successSync } = useSuccessSync();
@@ -607,6 +607,11 @@ export function SubscriptionManager({
607
607
  >
608
608
  {portalLoading ? "Loading..." : "Manage Billing"}
609
609
  </button>
610
+ {portalError && (
611
+ <p data-testid="portal-error" role="alert" className="mt-3 text-sm text-red-700">
612
+ {portalError.message}
613
+ </p>
614
+ )}
610
615
  </div>
611
616
  )}
612
617