@startsimpli/billing 0.4.21 → 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.21",
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
  ))}