@startsimpli/billing 0.4.12 → 0.4.14

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests for <LimitNudge /> — startsim-r9d.
3
+ */
4
+
5
+ import React from "react";
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { render, screen, waitFor } from "@testing-library/react";
8
+ import { BillingProvider } from "../hooks/BillingProvider";
9
+ import { LimitNudge } from "./LimitNudge";
10
+ import type { UsageMap } from "../types";
11
+
12
+ function mockFetcher(usage: UsageMap | "error") {
13
+ return vi.fn((url: string) => {
14
+ if (url.includes("/billing/usage/")) {
15
+ if (usage === "error") {
16
+ return Promise.resolve({
17
+ ok: false, status: 500,
18
+ json: () => Promise.resolve({ detail: "boom" }),
19
+ });
20
+ }
21
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(usage) });
22
+ }
23
+ return Promise.resolve({ ok: false, status: 404 });
24
+ }) as unknown as typeof fetch;
25
+ }
26
+
27
+ function wrap(fetcher: typeof fetch) {
28
+ return ({ children }: { children: React.ReactNode }) => (
29
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
30
+ {children}
31
+ </BillingProvider>
32
+ );
33
+ }
34
+
35
+ describe("<LimitNudge />", () => {
36
+ it("renders nothing when usage is loading / empty", async () => {
37
+ const fetcher = mockFetcher({});
38
+ const Wrapper = wrap(fetcher);
39
+ render(
40
+ <Wrapper>
41
+ <LimitNudge productSlug="vault" featureKey="environments" featureLabel="environments" />
42
+ </Wrapper>,
43
+ );
44
+ // After fetch resolves we have {} so nudge stays hidden — wait a tick.
45
+ await waitFor(() => {
46
+ expect(screen.queryByTestId("limit-nudge-environments")).toBeNull();
47
+ });
48
+ });
49
+
50
+ it("renders nothing on unlimited (-1) — Pro users don't see a bar (r9d)", async () => {
51
+ const fetcher = mockFetcher({ environments: { used: 99, limit: -1 } });
52
+ const Wrapper = wrap(fetcher);
53
+ render(
54
+ <Wrapper>
55
+ <LimitNudge productSlug="vault" featureKey="environments" featureLabel="environments" />
56
+ </Wrapper>,
57
+ );
58
+ // Give the hook time to populate.
59
+ await new Promise((r) => setTimeout(r, 20));
60
+ expect(screen.queryByTestId("limit-nudge-environments")).toBeNull();
61
+ });
62
+
63
+ it("renders gray 'ok' state below 80% (r9d)", async () => {
64
+ const fetcher = mockFetcher({ environments: { used: 1, limit: 3 } });
65
+ const Wrapper = wrap(fetcher);
66
+ render(
67
+ <Wrapper>
68
+ <LimitNudge productSlug="vault" featureKey="environments" featureLabel="environments" />
69
+ </Wrapper>,
70
+ );
71
+ const el = await screen.findByTestId("limit-nudge-environments");
72
+ expect(el.getAttribute("data-state")).toBe("ok");
73
+ expect(el.textContent).toMatch(/1 \/ 3 environments used/i);
74
+ expect(screen.queryByText(/upgrade/i)).toBeNull();
75
+ });
76
+
77
+ it("renders yellow 'approaching' state at >=80% (r9d)", async () => {
78
+ const fetcher = mockFetcher({ environments: { used: 8, limit: 10 } });
79
+ const Wrapper = wrap(fetcher);
80
+ render(
81
+ <Wrapper>
82
+ <LimitNudge productSlug="vault" featureKey="environments" featureLabel="environments" />
83
+ </Wrapper>,
84
+ );
85
+ const el = await screen.findByTestId("limit-nudge-environments");
86
+ expect(el.getAttribute("data-state")).toBe("approaching");
87
+ expect(screen.getByText(/approaching limit/i)).toBeDefined();
88
+ });
89
+
90
+ it("renders red 'at-cap' state at limit with upgrade link (r9d)", async () => {
91
+ const fetcher = mockFetcher({ environments: { used: 3, limit: 3 } });
92
+ const Wrapper = wrap(fetcher);
93
+ render(
94
+ <Wrapper>
95
+ <LimitNudge
96
+ productSlug="vault"
97
+ featureKey="environments"
98
+ featureLabel="environments"
99
+ upgradeHref="/foo/bar/billing"
100
+ />
101
+ </Wrapper>,
102
+ );
103
+ const el = await screen.findByTestId("limit-nudge-environments");
104
+ expect(el.getAttribute("data-state")).toBe("at-cap");
105
+ const link = screen.getByText(/upgrade for unlimited/i);
106
+ expect(link.getAttribute("href")).toBe("/foo/bar/billing");
107
+ });
108
+
109
+ it("renders nothing if the feature key isn't in the usage map", async () => {
110
+ const fetcher = mockFetcher({ environments: { used: 0, limit: 3 } });
111
+ const Wrapper = wrap(fetcher);
112
+ render(
113
+ <Wrapper>
114
+ <LimitNudge productSlug="vault" featureKey="missing_key" featureLabel="things" />
115
+ </Wrapper>,
116
+ );
117
+ await new Promise((r) => setTimeout(r, 20));
118
+ expect(screen.queryByTestId("limit-nudge-missing_key")).toBeNull();
119
+ });
120
+
121
+ it("hides on backend error (usage endpoint fails) — never blocks the page", async () => {
122
+ const fetcher = mockFetcher("error");
123
+ const Wrapper = wrap(fetcher);
124
+ render(
125
+ <Wrapper>
126
+ <LimitNudge productSlug="vault" featureKey="environments" featureLabel="environments" />
127
+ </Wrapper>,
128
+ );
129
+ await new Promise((r) => setTimeout(r, 20));
130
+ expect(screen.queryByTestId("limit-nudge-environments")).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * <LimitNudge /> — startsim-r9d.
3
+ *
4
+ * Drop-in usage indicator for any data-bearing list page (envs, secrets,
5
+ * uploads, leads). Reads usage from useUsage(productSlug) and renders:
6
+ * - "X / Y used" with a slim progress bar
7
+ * - At >=80% util: warning (orange) — "Approaching limit"
8
+ * - At >=100% util: danger (red) + "Upgrade for more" link
9
+ *
10
+ * Numeric-limit features only. Boolean / unlimited (-1) / missing usage
11
+ * render NOTHING (Pro users don't see a "0 / 999 envs used" bar — the
12
+ * whole point of paying is to stop seeing the bar). Apps wire it once:
13
+ *
14
+ * <LimitNudge productSlug="vault" featureKey="environments"
15
+ * featureLabel="environments" upgradeHref="/settings/billing" />
16
+ */
17
+ "use client";
18
+
19
+ import React from "react";
20
+ import { useUsage } from "../hooks/useUsage";
21
+
22
+ export interface LimitNudgeProps {
23
+ /** Slug to call /api/billing/usage/<slug>/ — e.g. "vault" */
24
+ productSlug: string;
25
+ /** Feature key matching ProductOffer.features[].key — e.g. "environments" */
26
+ featureKey: string;
27
+ /** Human plural label used in the visible copy — e.g. "environments" */
28
+ featureLabel: string;
29
+ /** Where to send the user to upgrade — typically /settings/billing */
30
+ upgradeHref?: string;
31
+ }
32
+
33
+ export function LimitNudge({ productSlug, featureKey, featureLabel, upgradeHref = "/settings/billing" }: LimitNudgeProps) {
34
+ const { usage } = useUsage(productSlug);
35
+ const u = usage[featureKey];
36
+ // Render nothing if:
37
+ // - no usage data yet (loading or no adapter)
38
+ // - no limit declared (Pro / unlimited features don't get a bar)
39
+ // - limit < 0 (unlimited marker)
40
+ if (!u) return null;
41
+ if (typeof u.limit !== "number" || u.limit < 0) return null;
42
+
43
+ const used = Math.max(0, u.used);
44
+ const limit = u.limit;
45
+ const pct = limit === 0 ? 100 : Math.min(100, Math.round((used / limit) * 100));
46
+ const atCap = used >= limit;
47
+ const approaching = !atCap && pct >= 80;
48
+
49
+ const wrap =
50
+ atCap ? "bg-red-50 border-red-200 text-red-800"
51
+ : approaching ? "bg-yellow-50 border-yellow-200 text-yellow-900"
52
+ : "bg-gray-50 border-gray-200 text-gray-700";
53
+ const bar =
54
+ atCap ? "bg-red-500" : approaching ? "bg-yellow-500" : "bg-blue-600";
55
+
56
+ return (
57
+ <div
58
+ data-testid={`limit-nudge-${featureKey}`}
59
+ data-state={atCap ? "at-cap" : approaching ? "approaching" : "ok"}
60
+ className={`flex items-center gap-4 px-4 py-3 rounded-lg border ${wrap}`}
61
+ >
62
+ <div className="flex-1 min-w-0">
63
+ <div className="flex items-baseline justify-between gap-3 text-sm">
64
+ <span className="font-medium tabular-nums">
65
+ {used} / {limit} {featureLabel} used
66
+ </span>
67
+ {atCap && (
68
+ <a href={upgradeHref} className="font-semibold underline shrink-0">
69
+ Upgrade for unlimited
70
+ </a>
71
+ )}
72
+ {approaching && (
73
+ <a href={upgradeHref} className="font-medium underline shrink-0">
74
+ Approaching limit — upgrade
75
+ </a>
76
+ )}
77
+ </div>
78
+ <div className="mt-2 w-full bg-white rounded-full h-2 overflow-hidden" aria-hidden="true">
79
+ <div className={`h-2 rounded-full transition-all ${bar}`} style={{ width: `${Math.max(pct, 4)}%` }} />
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }