@startsimpli/billing 0.4.20 → 0.4.22

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.20",
3
+ "version": "0.4.22",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The tick or cross beside an offer feature. A feature an offer declares as
3
+ * `false` is one that plan does not include, so it gets a cross, never a tick.
4
+ */
5
+ export function FeatureIcon({ value, size = "w-4 h-4" }: { value: unknown; size?: string }) {
6
+ if (value === false) {
7
+ return (
8
+ <svg role="img" aria-label="Not included" className={`${size} text-gray-300 shrink-0`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
9
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
10
+ </svg>
11
+ );
12
+ }
13
+ return (
14
+ <svg role="img" aria-label="Included" className={`${size} text-green-500 shrink-0`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
15
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
16
+ </svg>
17
+ );
18
+ }
@@ -12,6 +12,7 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer, OfferFeature } from "../types";
14
14
  import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
+ import { FeatureIcon } from "./FeatureIcon";
15
16
 
16
17
  export interface PricingDetailPageProps {
17
18
  /** Product slug (e.g., "raise-simpli") */
@@ -190,21 +191,6 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
190
191
  );
191
192
  }
192
193
 
193
- function FeatureIcon({ value }: { value: unknown }) {
194
- if (value === false) {
195
- return (
196
- <svg role="img" aria-label="Not included" className="w-4 h-4 text-gray-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
197
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
198
- </svg>
199
- );
200
- }
201
- return (
202
- <svg role="img" aria-label="Included" className="w-4 h-4 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
203
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
204
- </svg>
205
- );
206
- }
207
-
208
194
  interface ComparisonTableProps {
209
195
  offers: ProductOffer[];
210
196
  featureKeys: string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A feature an offer declares as `false` is a feature that plan does NOT have.
3
+ * The pricing cards ticked every feature, so crochet's Free plan read as including
4
+ * "AI pattern designs" (crochet_patterns-utk6).
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { render, screen, waitFor, within } from "@testing-library/react";
8
+ import React from "react";
9
+ import { BillingProvider } from "../hooks/BillingProvider";
10
+ import { PricingPage } from "./PricingPage";
11
+ import type { BillingProduct } from "../types";
12
+
13
+ const product: BillingProduct = {
14
+ id: "prod-1",
15
+ slug: "crochet-patterns",
16
+ name: "Crochet",
17
+ description: "",
18
+ offers: [
19
+ {
20
+ id: "free", name: "Free", slug: "crochet-free", unitPrice: "0.00", currency: "USD",
21
+ pricingModel: "flat", intervalUnit: "month", intervalCount: 1, intervalDisplay: "per month",
22
+ isRecurring: true, isFree: true,
23
+ features: [
24
+ { key: "ai_designs", name: "AI pattern designs", value: false, limit: null },
25
+ { key: "save_patterns", name: "Save patterns", value: true, limit: null },
26
+ ],
27
+ isActive: true, isFeatured: false, trialDays: 0, sortOrder: 0, ctaText: "Start free",
28
+ },
29
+ ],
30
+ };
31
+
32
+ function renderModal() {
33
+ const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(product) }) as unknown as typeof fetch;
34
+ render(
35
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
36
+ <PricingPage productId="crochet-patterns" />
37
+ </BillingProvider>
38
+ );
39
+ }
40
+
41
+ describe("PricingPage feature list", () => {
42
+ it("marks a false-valued feature as not included", async () => {
43
+ renderModal();
44
+ const row = (await screen.findByText("AI pattern designs")).closest("li")!;
45
+ expect(within(row).getByRole("img", { name: "Not included" })).toBeDefined();
46
+ expect(within(row).queryByRole("img", { name: "Included" })).toBeNull();
47
+ });
48
+
49
+ it("still ticks an included feature", async () => {
50
+ renderModal();
51
+ const row = (await screen.findByText("Save patterns")).closest("li")!;
52
+ await waitFor(() => expect(within(row).getByRole("img", { name: "Included" })).toBeDefined());
53
+ });
54
+ });
@@ -11,6 +11,7 @@
11
11
  import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
+ import { FeatureIcon } from "./FeatureIcon";
14
15
 
15
16
  export interface PricingPageProps {
16
17
  /** Product slug (e.g., "raise-simpli") */
@@ -127,9 +128,7 @@ function PricingCard({ offer, onSelect }: PricingCardProps) {
127
128
  key={feature.key}
128
129
  className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
129
130
  >
130
- <svg className="w-4 h-4 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
131
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
132
- </svg>
131
+ <FeatureIcon value={feature.value} />
133
132
  <span>{feature.name}</span>
134
133
  </li>
135
134
  ))}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A feature an offer declares as `false` is a feature that plan does NOT have.
3
+ * The modal ticked every feature, so crochet's Free plan read as including
4
+ * "AI pattern designs" (crochet_patterns-utk6).
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { render, screen, waitFor, within } from "@testing-library/react";
8
+ import React from "react";
9
+ import { BillingProvider } from "../hooks/BillingProvider";
10
+ import { UpgradeModal } from "./UpgradeModal";
11
+ import type { BillingProduct } from "../types";
12
+
13
+ const product: BillingProduct = {
14
+ id: "prod-1",
15
+ slug: "crochet-patterns",
16
+ name: "Crochet",
17
+ description: "",
18
+ offers: [
19
+ {
20
+ id: "free", name: "Free", slug: "crochet-free", unitPrice: "0.00", currency: "USD",
21
+ pricingModel: "flat", intervalUnit: "month", intervalCount: 1, intervalDisplay: "per month",
22
+ isRecurring: true, isFree: true,
23
+ features: [
24
+ { key: "ai_designs", name: "AI pattern designs", value: false, limit: null },
25
+ { key: "save_patterns", name: "Save patterns", value: true, limit: null },
26
+ ],
27
+ isActive: true, isFeatured: false, trialDays: 0, sortOrder: 0, ctaText: "Start free",
28
+ },
29
+ ],
30
+ };
31
+
32
+ function renderModal() {
33
+ const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(product) }) as unknown as typeof fetch;
34
+ render(
35
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
36
+ <UpgradeModal productId="crochet-patterns" open={true} onClose={() => {}} />
37
+ </BillingProvider>
38
+ );
39
+ }
40
+
41
+ describe("UpgradeModal feature list", () => {
42
+ it("marks a false-valued feature as not included", async () => {
43
+ renderModal();
44
+ const row = (await screen.findByText("AI pattern designs")).closest("li")!;
45
+ expect(within(row).getByRole("img", { name: "Not included" })).toBeDefined();
46
+ expect(within(row).queryByRole("img", { name: "Included" })).toBeNull();
47
+ });
48
+
49
+ it("still ticks an included feature", async () => {
50
+ renderModal();
51
+ const row = (await screen.findByText("Save patterns")).closest("li")!;
52
+ await waitFor(() => expect(within(row).getByRole("img", { name: "Included" })).toBeDefined());
53
+ });
54
+ });
@@ -12,6 +12,7 @@
12
12
  import React, { useEffect, useRef, useCallback } from "react";
13
13
  import { useProduct } from "../hooks/useProduct";
14
14
  import type { ProductOffer } from "../types";
15
+ import { FeatureIcon } from "./FeatureIcon";
15
16
 
16
17
  export interface UpgradeModalProps {
17
18
  productId: string;
@@ -162,9 +163,7 @@ export function UpgradeModal({
162
163
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
163
164
  {offer.features.map((f) => (
164
165
  <li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
165
- <svg className="w-3.5 h-3.5 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
166
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
167
- </svg>
166
+ <FeatureIcon value={f.value} size="w-3.5 h-3.5" />
168
167
  {f.name}
169
168
  </li>
170
169
  ))}
@@ -181,4 +181,24 @@ describe("useSubscription", () => {
181
181
  expect(result.current.subscription?.id).toBe("sub-2")
182
182
  );
183
183
  });
184
+
185
+ it("fetches again when refetchKey changes (the signed-in user arrives after the first fetch)", async () => {
186
+ // First call: the session isn't ready yet, so the API says 401. Second: signed in.
187
+ const mockFetch = vi.fn()
188
+ .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: "Not authenticated" }) })
189
+ .mockResolvedValue({ ok: true, status: 200, json: async () => mockSubscription }) as unknown as typeof fetch;
190
+ const wrapper = createWrapper(mockFetch);
191
+
192
+ const { result, rerender } = renderHook(({ user }) => useSubscription(undefined, { refetchKey: user }), {
193
+ wrapper,
194
+ initialProps: { user: undefined as string | undefined },
195
+ });
196
+ await waitFor(() => expect(result.current.loading).toBe(false));
197
+ expect(result.current.subscription).toBeNull();
198
+
199
+ rerender({ user: "user-1" });
200
+ await waitFor(() => expect(result.current.subscription?.id).toBe("sub-123"));
201
+ expect(result.current.error).toBeNull();
202
+ });
184
203
  });
204
+
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * Pass the app's product slug so another app's plan is never returned
5
5
  * (startsim-oyr3a.2).
6
+ *
7
+ * Pass the signed-in user's id as `refetchKey`. The first fetch can run before
8
+ * the session is ready (straight back from sign-in it answers 401), and a plan
9
+ * read once and never again showed paying users as Free until they reloaded.
6
10
  */
7
11
 
8
12
  import { useCallback, useEffect, useState } from "react";
@@ -16,7 +20,13 @@ interface UseSubscriptionResult {
16
20
  refetch: () => void;
17
21
  }
18
22
 
19
- export function useSubscription(productId?: string): UseSubscriptionResult {
23
+ export interface UseSubscriptionOptions {
24
+ /** Fetch again whenever this changes, e.g. the signed-in user's id. */
25
+ refetchKey?: unknown;
26
+ }
27
+
28
+ export function useSubscription(productId?: string, options: UseSubscriptionOptions = {}): UseSubscriptionResult {
29
+ const { refetchKey } = options;
20
30
  const { client } = useBillingContext();
21
31
  const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
22
32
  const [loading, setLoading] = useState(true);
@@ -37,7 +47,7 @@ export function useSubscription(productId?: string): UseSubscriptionResult {
37
47
 
38
48
  useEffect(() => {
39
49
  fetchSubscription();
40
- }, [fetchSubscription]);
50
+ }, [fetchSubscription, refetchKey]);
41
51
 
42
52
  return { subscription, loading, error, refetch: fetchSubscription };
43
53
  }