@crediball/react 0.6.0 → 0.8.0
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type PackageOption, type SubscriptionPlanOption } from "./PaywallModal";
|
|
3
|
-
import { type UsagePeriod } from "@crediball/core";
|
|
3
|
+
import { type UsagePeriod, type PackagesPayload, type CrediballUiContent } from "@crediball/core";
|
|
4
4
|
export interface CrediballProviderProps {
|
|
5
5
|
children: ReactNode;
|
|
6
6
|
/**
|
|
@@ -73,12 +73,26 @@ export interface CrediballProviderProps {
|
|
|
73
73
|
customMin?: number;
|
|
74
74
|
/** Override the custom-amount maximum (defaults to the dashboard's configured maximum). */
|
|
75
75
|
customMax?: number;
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Currency symbol shown in the paywall. Auto-derived (via Intl) from the ISO
|
|
78
|
+
* currency you configured on the dashboard's Packages page once packages load,
|
|
79
|
+
* so it never drifts out of sync if you switch currencies there. Pass this only
|
|
80
|
+
* to force a specific symbol instead. Falls back to "€" before the first fetch.
|
|
81
|
+
*/
|
|
77
82
|
currencySymbol?: string;
|
|
78
83
|
title?: string;
|
|
79
84
|
description?: string;
|
|
80
85
|
/** Override the accent color (defaults to Action Blue). */
|
|
81
86
|
accentColor?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Apply the theme (colors, radii, font) the developer published on the
|
|
89
|
+
* Crediball dashboard's UI-patterns page — served alongside packages — as
|
|
90
|
+
* `--crediball-*` CSS variables, so preview edits reflect here with no code
|
|
91
|
+
* change. On by default; the published theme takes precedence over any
|
|
92
|
+
* `--crediball-*` vars you set in your own CSS. Set false to opt out and
|
|
93
|
+
* theme entirely from your own stylesheet.
|
|
94
|
+
*/
|
|
95
|
+
applyRemoteTheme?: boolean;
|
|
82
96
|
}
|
|
83
97
|
interface CrediballContextValue {
|
|
84
98
|
/**
|
|
@@ -111,6 +125,12 @@ interface CrediballContextValue {
|
|
|
111
125
|
topUp: (amount: number) => Promise<void>;
|
|
112
126
|
/** Refetch balance, usage, costs, and packages immediately. No-op without publishableKey/userId. */
|
|
113
127
|
refresh: () => Promise<void>;
|
|
128
|
+
/** The live packages payload (top-up options, plans, custom-topup, currency, ui). null until loaded. */
|
|
129
|
+
packages: PackagesPayload | null;
|
|
130
|
+
/** Currency symbol derived from the dashboard's configured currency (e.g. "€", "$"). */
|
|
131
|
+
currencySymbol: string;
|
|
132
|
+
/** Developer-published default copy from the dashboard, used as a label fallback. null when unconfigured. */
|
|
133
|
+
content: CrediballUiContent | null;
|
|
114
134
|
}
|
|
115
135
|
/**
|
|
116
136
|
* Pattern B, automated. Wrap your app once:
|
|
@@ -132,7 +152,7 @@ interface CrediballContextValue {
|
|
|
132
152
|
* SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
|
|
133
153
|
* the watcher scans each chunk for credit errors, so no manual wiring is needed.
|
|
134
154
|
*/
|
|
135
|
-
export declare function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts, onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, }: CrediballProviderProps): import("react").JSX.Element;
|
|
155
|
+
export declare function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts, onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme, }: CrediballProviderProps): import("react").JSX.Element;
|
|
136
156
|
/**
|
|
137
157
|
* Access the paywall + live data from anywhere inside <CrediballProvider>.
|
|
138
158
|
*
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
4
4
|
import { PaywallModal } from "./PaywallModal";
|
|
5
5
|
import { subscribeFetchWatcher } from "./fetchWatcher";
|
|
6
6
|
import { subscribeInsufficientCredits } from "./creditEvent";
|
|
7
|
-
import { getCrediballClient, notifyBalanceChanged } from "@crediball/core";
|
|
7
|
+
import { getCrediballClient, notifyBalanceChanged, } from "@crediball/core";
|
|
8
8
|
const CrediballContext = createContext(null);
|
|
9
9
|
const EMPTY_SNAPSHOT = {
|
|
10
10
|
balance: null,
|
|
@@ -18,6 +18,66 @@ const EMPTY_SNAPSHOT = {
|
|
|
18
18
|
};
|
|
19
19
|
const noopSubscribe = () => () => { };
|
|
20
20
|
const getEmptySnapshot = () => EMPTY_SNAPSHOT;
|
|
21
|
+
/**
|
|
22
|
+
* Map an ISO 4217 code (as returned by the dashboard's packages config) to a
|
|
23
|
+
* display symbol via Intl, so the paywall doesn't need a hand-maintained
|
|
24
|
+
* currency → symbol table that can fall out of sync with the dashboard.
|
|
25
|
+
*/
|
|
26
|
+
function symbolForCurrency(iso) {
|
|
27
|
+
try {
|
|
28
|
+
const part = new Intl.NumberFormat(undefined, { style: "currency", currency: iso })
|
|
29
|
+
.formatToParts(0)
|
|
30
|
+
.find((p) => p.type === "currency");
|
|
31
|
+
return part?.value ?? iso;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return iso;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Turn a published theme into the `--crediball-*` inline style the provider
|
|
39
|
+
* spreads onto a wrapping element. Only set fields are emitted, so an empty or
|
|
40
|
+
* partial theme leaves the built-in defaults (and any host CSS vars) in place.
|
|
41
|
+
*/
|
|
42
|
+
function themeToCssVars(theme) {
|
|
43
|
+
if (!theme)
|
|
44
|
+
return {};
|
|
45
|
+
const vars = {};
|
|
46
|
+
if (theme.accent)
|
|
47
|
+
vars["--crediball-accent"] = theme.accent;
|
|
48
|
+
if (theme.onAccent)
|
|
49
|
+
vars["--crediball-on-accent"] = theme.onAccent;
|
|
50
|
+
if (theme.ink)
|
|
51
|
+
vars["--crediball-ink"] = theme.ink;
|
|
52
|
+
if (theme.canvas)
|
|
53
|
+
vars["--crediball-canvas"] = theme.canvas;
|
|
54
|
+
if (theme.hairline)
|
|
55
|
+
vars["--crediball-hairline"] = theme.hairline;
|
|
56
|
+
if (theme.radius != null)
|
|
57
|
+
vars["--crediball-radius"] = `${theme.radius}px`;
|
|
58
|
+
if (theme.radiusCard != null)
|
|
59
|
+
vars["--crediball-radius-card"] = `${theme.radiusCard}px`;
|
|
60
|
+
if (theme.fontStack)
|
|
61
|
+
vars["--crediball-font"] = theme.fontStack;
|
|
62
|
+
return vars;
|
|
63
|
+
}
|
|
64
|
+
/** Inject a webfont stylesheet <link> once (idempotent by href), for a published theme's fontUrl. */
|
|
65
|
+
function loadRemoteFont(url) {
|
|
66
|
+
if (typeof document === "undefined")
|
|
67
|
+
return;
|
|
68
|
+
const existing = document.querySelector(`link[data-crediball-font]`);
|
|
69
|
+
if (existing?.href === url)
|
|
70
|
+
return;
|
|
71
|
+
if (existing)
|
|
72
|
+
existing.href = url;
|
|
73
|
+
else {
|
|
74
|
+
const link = document.createElement("link");
|
|
75
|
+
link.rel = "stylesheet";
|
|
76
|
+
link.href = url;
|
|
77
|
+
link.setAttribute("data-crediball-font", "");
|
|
78
|
+
document.head.appendChild(link);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
21
81
|
/**
|
|
22
82
|
* Pattern B, automated. Wrap your app once:
|
|
23
83
|
*
|
|
@@ -38,7 +98,7 @@ const getEmptySnapshot = () => EMPTY_SNAPSHOT;
|
|
|
38
98
|
* SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
|
|
39
99
|
* the watcher scans each chunk for credit errors, so no manual wiring is needed.
|
|
40
100
|
*/
|
|
41
|
-
export function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts = [5, 10, 20], onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch = true, allowCustom, customMin, customMax, currencySymbol
|
|
101
|
+
export function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts = [5, 10, 20], onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch = true, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme = true, }) {
|
|
42
102
|
const [open, setOpen] = useState(false);
|
|
43
103
|
const showPaywall = useCallback(() => setOpen(true), []);
|
|
44
104
|
const hidePaywall = useCallback(() => setOpen(false), []);
|
|
@@ -68,6 +128,20 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
68
128
|
return () => client.stop();
|
|
69
129
|
}, [client]);
|
|
70
130
|
const snapshot = useSyncExternalStore(client ? client.subscribe : noopSubscribe, client ? client.getSnapshot : getEmptySnapshot, client ? client.getSnapshot : getEmptySnapshot);
|
|
131
|
+
const pkgs = snapshot.packages;
|
|
132
|
+
const uiTheme = pkgs?.ui?.theme ?? null;
|
|
133
|
+
const uiContent = pkgs?.ui?.content ?? null;
|
|
134
|
+
// Follow the dashboard's configured currency once packages load, unless the
|
|
135
|
+
// host pins an explicit symbol. Falls back to "€" only until the first fetch
|
|
136
|
+
// resolves, so switching currencies on the Packages page never requires a
|
|
137
|
+
// matching code change/redeploy here.
|
|
138
|
+
const currencySymbol = currencySymbolProp ?? (pkgs?.currency ? symbolForCurrency(pkgs.currency) : "€");
|
|
139
|
+
// Load the published theme's webfont, if any, so --crediball-font renders.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!applyRemoteTheme || !uiTheme?.fontUrl)
|
|
142
|
+
return;
|
|
143
|
+
loadRemoteFont(uiTheme.fontUrl);
|
|
144
|
+
}, [applyRemoteTheme, uiTheme?.fontUrl]);
|
|
71
145
|
const costOf = useCallback((event) => client?.costOf(event), [client]);
|
|
72
146
|
const refresh = useCallback(async () => {
|
|
73
147
|
await client?.refresh();
|
|
@@ -91,7 +165,10 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
91
165
|
costOf,
|
|
92
166
|
topUp,
|
|
93
167
|
refresh,
|
|
94
|
-
|
|
168
|
+
packages: pkgs,
|
|
169
|
+
currencySymbol,
|
|
170
|
+
content: uiContent,
|
|
171
|
+
}), [showPaywall, hidePaywall, open, snapshot, costOf, topUp, refresh, pkgs, currencySymbol, uiContent]);
|
|
95
172
|
// The page to return to after Stripe checkout — the one the user is on now.
|
|
96
173
|
function currentReturnPath() {
|
|
97
174
|
if (typeof window === "undefined")
|
|
@@ -192,7 +269,6 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
192
269
|
await onCancelSubscription?.(subscriptionId);
|
|
193
270
|
await client?.refresh();
|
|
194
271
|
}
|
|
195
|
-
const pkgs = snapshot.packages;
|
|
196
272
|
// Auto-derive the custom-amount field from the dashboard's custom-topup config,
|
|
197
273
|
// so a developer who enabled it doesn't also have to pass allowCustom/min/max.
|
|
198
274
|
// Explicit props still win (allowCustom={false} force-hides it).
|
|
@@ -200,7 +276,13 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
200
276
|
const effectiveAllowCustom = allowCustom ?? customCfg?.enabled ?? false;
|
|
201
277
|
const effectiveCustomMin = customMin ?? customCfg?.minEur;
|
|
202
278
|
const effectiveCustomMax = customMax ?? customCfg?.maxEur ?? undefined;
|
|
203
|
-
|
|
279
|
+
// Published theme → --crediball-* CSS vars, applied on a display:contents
|
|
280
|
+
// wrapper so it themes every descendant (and the paywall) without adding a
|
|
281
|
+
// layout box. Host props/CSS are overridden by design (dashboard wins).
|
|
282
|
+
const themeVars = applyRemoteTheme ? themeToCssVars(uiTheme) : {};
|
|
283
|
+
const hasThemeVars = Object.keys(themeVars).length > 0;
|
|
284
|
+
const inner = (_jsxs(_Fragment, { children: [children, _jsx(PaywallModal, { open: open, packages: pkgs?.packages, onSelectPackage: handleSelectPackage, subscriptionPlans: pkgs?.subscriptionPlans, onSelectPlan: handleSelectPlan, activeSubscription: pkgs?.activeSubscription, onCancelSubscription: onCancelSubscription ? handleCancelSubscription : undefined, amounts: amounts, amountPrefix: `Add ${currencySymbol}`, onAdd: handleAdd, onClose: hidePaywall, allowCustom: effectiveAllowCustom, customMin: effectiveCustomMin, customMax: effectiveCustomMax, currencySymbol: currencySymbol, balance: snapshot.balance ?? undefined, balanceRate: pkgs?.customTopup.enabled ? pkgs.customTopup.rate : undefined, currency: pkgs?.currency, usage: snapshot.usage?.usedCredits ?? undefined, title: title ?? uiContent?.paywallTitle, description: description ?? uiContent?.paywallDescription, addButtonText: uiContent?.paywallButtonText, accentColor: accentColor })] }));
|
|
285
|
+
return (_jsx(CrediballContext.Provider, { value: ctx, children: hasThemeVars ? _jsx("div", { style: { display: "contents", ...themeVars }, children: inner }) : inner }));
|
|
204
286
|
}
|
|
205
287
|
/**
|
|
206
288
|
* Access the paywall + live data from anywhere inside <CrediballProvider>.
|
package/dist/CreditIndicator.js
CHANGED
|
@@ -7,9 +7,11 @@ import { useCrediballOptional } from "./CrediballProvider";
|
|
|
7
7
|
* Pattern C — Subtle indicator. A quiet balance chip for a host app's top bar;
|
|
8
8
|
* clickable to open a top-up flow. Minimal and non-intrusive.
|
|
9
9
|
*/
|
|
10
|
-
export function CreditIndicator({ balanceLabel, label
|
|
10
|
+
export function CreditIndicator({ balanceLabel, label, onClick, accentColor, style, }) {
|
|
11
11
|
const ctx = useCrediballOptional();
|
|
12
12
|
const resolvedLabel = balanceLabel ?? (ctx?.balance != null ? formatCredits(ctx.balance) : "—");
|
|
13
|
+
// Prefer the host prop, then the dashboard-published default, then the built-in.
|
|
14
|
+
const resolvedText = label ?? ctx?.content?.indicatorLabel ?? "Credits:";
|
|
13
15
|
const resolvedOnClick = onClick ?? ctx?.openPaywall;
|
|
14
16
|
return (_jsxs("button", { onClick: resolvedOnClick, style: {
|
|
15
17
|
display: "inline-flex",
|
|
@@ -26,5 +28,5 @@ export function CreditIndicator({ balanceLabel, label = "Credits:", onClick, acc
|
|
|
26
28
|
padding: "4px 12px",
|
|
27
29
|
...accentStyle(accentColor),
|
|
28
30
|
...style,
|
|
29
|
-
}, children: [_jsx("span", { style: { color: theme.inkMuted }, children:
|
|
31
|
+
}, children: [_jsx("span", { style: { color: theme.inkMuted }, children: resolvedText }), _jsx("span", { style: { fontWeight: 600 }, children: resolvedLabel })] }));
|
|
30
32
|
}
|
package/dist/CreditsBadge.js
CHANGED
|
@@ -9,9 +9,12 @@ import { useCrediballOptional } from "./CrediballProvider";
|
|
|
9
9
|
* as the primary figure and an approximate money value beneath it. No Crediball
|
|
10
10
|
* branding.
|
|
11
11
|
*/
|
|
12
|
-
export function CreditsBadge({ credits, rate, currency = "EUR", label
|
|
12
|
+
export function CreditsBadge({ credits, rate, currency = "EUR", label, onAddCredits, addLabel, accentColor, style, }) {
|
|
13
13
|
const ctx = useCrediballOptional();
|
|
14
14
|
const resolvedCredits = credits ?? ctx?.balance ?? 0;
|
|
15
|
+
// Prefer the host prop, then the dashboard-published default, then the built-in.
|
|
16
|
+
const resolvedLabel = label ?? ctx?.content?.badgeLabel ?? "Credit balance";
|
|
17
|
+
const resolvedAddLabel = addLabel ?? ctx?.content?.badgeAddLabel ?? "Add credits";
|
|
15
18
|
const showMoney = typeof rate === "number" && rate > 0;
|
|
16
19
|
return (_jsxs("div", { style: {
|
|
17
20
|
display: "flex",
|
|
@@ -25,7 +28,7 @@ export function CreditsBadge({ credits, rate, currency = "EUR", label = "Credit
|
|
|
25
28
|
fontFamily: theme.font,
|
|
26
29
|
...accentStyle(accentColor),
|
|
27
30
|
...style,
|
|
28
|
-
}, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 14, color: theme.inkMuted }, children:
|
|
31
|
+
}, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 14, color: theme.inkMuted }, children: resolvedLabel }), _jsxs("div", { style: { fontSize: 28, fontWeight: 600, color: theme.ink, letterSpacing: "-0.01em" }, children: [formatCredits(resolvedCredits), _jsx("span", { style: { fontSize: 16, fontWeight: 400, color: theme.inkMuted, marginLeft: 6 }, children: "credits" })] }), showMoney ? (_jsxs("div", { style: { fontSize: 13, color: theme.inkMuted, marginTop: 2 }, children: ["\u2248 ", formatMoney(resolvedCredits / rate, currency)] })) : null] }), _jsx("button", { onClick: onAddCredits ?? ctx?.openPaywall, style: {
|
|
29
32
|
appearance: "none",
|
|
30
33
|
border: "none",
|
|
31
34
|
cursor: "pointer",
|
|
@@ -35,5 +38,5 @@ export function CreditsBadge({ credits, rate, currency = "EUR", label = "Credit
|
|
|
35
38
|
fontSize: 14,
|
|
36
39
|
padding: "8px 16px",
|
|
37
40
|
borderRadius: theme.radiusPill,
|
|
38
|
-
}, children:
|
|
41
|
+
}, children: resolvedAddLabel })] }));
|
|
39
42
|
}
|
|
@@ -8,13 +8,16 @@ import { useCrediball } from "../CrediballProvider";
|
|
|
8
8
|
*
|
|
9
9
|
* <CrediballLowCreditWarning threshold={20} message="Not enough for one more image." />
|
|
10
10
|
*/
|
|
11
|
-
export function CrediballLowCreditWarning({ className, style, threshold, message
|
|
12
|
-
const { balance, isLow: contextIsLow } = useCrediball();
|
|
13
|
-
|
|
11
|
+
export function CrediballLowCreditWarning({ className, style, threshold, message, children, }) {
|
|
12
|
+
const { balance, isLow: contextIsLow, content } = useCrediball();
|
|
13
|
+
// Prefer the host prop, then the dashboard-published default, then the provider's.
|
|
14
|
+
const effectiveThreshold = threshold ?? content?.lowCreditThreshold;
|
|
15
|
+
const isLow = effectiveThreshold != null && balance != null ? balance < effectiveThreshold : contextIsLow;
|
|
14
16
|
if (!isLow)
|
|
15
17
|
return null;
|
|
16
18
|
if (typeof children === "function") {
|
|
17
19
|
return _jsx(_Fragment, { children: children({ balance, isLow }) });
|
|
18
20
|
}
|
|
19
|
-
|
|
21
|
+
const resolvedMessage = message ?? content?.lowCreditMessage ?? "Low credit balance — top up to keep going.";
|
|
22
|
+
return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children ?? resolvedMessage }));
|
|
20
23
|
}
|
|
@@ -11,7 +11,7 @@ import { theme, accentStyle } from "../theme";
|
|
|
11
11
|
* <CrediballTopUpButton>Get more credits</CrediballTopUpButton>
|
|
12
12
|
*/
|
|
13
13
|
export function CrediballTopUpButton({ className, style, children, onClick, accentColor }) {
|
|
14
|
-
const { openPaywall } = useCrediball();
|
|
14
|
+
const { openPaywall, content } = useCrediball();
|
|
15
15
|
return (_jsx("button", { type: "button", className: className, onClick: onClick ?? openPaywall, style: {
|
|
16
16
|
font: "inherit",
|
|
17
17
|
appearance: "none",
|
|
@@ -23,5 +23,5 @@ export function CrediballTopUpButton({ className, style, children, onClick, acce
|
|
|
23
23
|
padding: "0.5em 1.1em",
|
|
24
24
|
...accentStyle(accentColor),
|
|
25
25
|
...style,
|
|
26
|
-
}, children: children ?? "Add credits" }));
|
|
26
|
+
}, children: children ?? content?.topUpButtonText ?? "Add credits" }));
|
|
27
27
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediball/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Drop-in React components for showing Crediball credits inside your AI app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Filippo Rezzadore <filipporezzadore@gmail.com>",
|
|
7
|
-
"homepage": "https://crediball.
|
|
7
|
+
"homepage": "https://crediball.ai",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/filipporezzadore/Crediball.git",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"react": ">=18"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@crediball/core": "^0.
|
|
54
|
+
"@crediball/core": "^0.3.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "^18.3.11",
|