@lukoweb/apitogo-plugin-dev-portal-billing 0.1.37
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/dist/index.d.mts +17 -0
- package/dist/index.mjs +571 -0
- package/package.json +56 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as _lukoweb_apitogo0 from "@lukoweb/apitogo";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
type DevPortalBillingConfig = {
|
|
5
|
+
pricing?: {
|
|
6
|
+
title?: string;
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/DevPortalBillingPlugin.d.ts
|
|
12
|
+
declare const PRICING_PATH = "/pricing";
|
|
13
|
+
declare const CHECKOUT_PATH = "/checkout";
|
|
14
|
+
declare const SUBSCRIPTIONS_PATH = "/account/subscriptions";
|
|
15
|
+
declare const devPortalBillingPlugin: (options?: DevPortalBillingConfig | undefined) => _lukoweb_apitogo0.ApitogoPlugin;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { CHECKOUT_PATH, PRICING_PATH, SUBSCRIPTIONS_PATH, devPortalBillingPlugin };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { cn, createPlugin } from "@lukoweb/apitogo";
|
|
2
|
+
import { ShieldIcon, StarsIcon } from "@lukoweb/apitogo/icons";
|
|
3
|
+
import { Button, ClientOnly, Head, Heading } from "@lukoweb/apitogo/components";
|
|
4
|
+
import { Link, Outlet, useNavigate, useSearchParams } from "@lukoweb/apitogo/router";
|
|
5
|
+
import { createContext, useContext, useEffect } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { useAuth } from "@lukoweb/apitogo/hooks";
|
|
8
|
+
import { useQuery } from "@lukoweb/apitogo/react-query";
|
|
9
|
+
import { Alert, AlertAction, AlertDescription } from "@lukoweb/apitogo/ui/Alert";
|
|
10
|
+
import { Button as Button$1 } from "@lukoweb/apitogo/ui/Button";
|
|
11
|
+
|
|
12
|
+
//#region src/DevPortalBillingContext.tsx
|
|
13
|
+
const DevPortalBillingContext = createContext({});
|
|
14
|
+
const DevPortalBillingProvider = ({ options, children }) => /* @__PURE__ */ jsx(DevPortalBillingContext.Provider, {
|
|
15
|
+
value: options,
|
|
16
|
+
children
|
|
17
|
+
});
|
|
18
|
+
const useDevPortalBillingConfig = () => useContext(DevPortalBillingContext);
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/DevPortalBillingWrapper.tsx
|
|
22
|
+
const DevPortalBillingWrapper = ({ options = {} }) => /* @__PURE__ */ jsx(DevPortalBillingProvider, {
|
|
23
|
+
options,
|
|
24
|
+
children: /* @__PURE__ */ jsx(ClientOnly, { children: /* @__PURE__ */ jsx(Outlet, {}) })
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/components/RedirectPage.tsx
|
|
29
|
+
const RedirectPage = ({ icon: Icon, title, description, url, children }) => {
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (url) window.location.assign(url);
|
|
32
|
+
}, [url]);
|
|
33
|
+
return /* @__PURE__ */ jsx("div", {
|
|
34
|
+
className: "flex min-h-screen items-center justify-center bg-muted",
|
|
35
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
36
|
+
className: "flex max-w-md flex-col items-center space-y-6 text-center",
|
|
37
|
+
children: [
|
|
38
|
+
/* @__PURE__ */ jsx("div", {
|
|
39
|
+
className: "relative",
|
|
40
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
41
|
+
className: "flex h-24 w-24 items-center justify-center rounded-full bg-foreground/10",
|
|
42
|
+
children: /* @__PURE__ */ jsx(Icon, { className: "h-12 w-12 text-foreground" })
|
|
43
|
+
})
|
|
44
|
+
}),
|
|
45
|
+
children,
|
|
46
|
+
/* @__PURE__ */ jsxs("div", {
|
|
47
|
+
className: "space-y-2",
|
|
48
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
49
|
+
className: "text-2xl font-bold text-card-foreground",
|
|
50
|
+
children: title
|
|
51
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
52
|
+
className: "text-muted-foreground",
|
|
53
|
+
children: description
|
|
54
|
+
})]
|
|
55
|
+
}),
|
|
56
|
+
/* @__PURE__ */ jsxs("div", {
|
|
57
|
+
className: "flex space-x-2",
|
|
58
|
+
children: [
|
|
59
|
+
/* @__PURE__ */ jsx("div", { className: "h-3 w-3 animate-pulse rounded-full bg-primary [animation-delay:-0.3s]" }),
|
|
60
|
+
/* @__PURE__ */ jsx("div", { className: "h-3 w-3 animate-pulse rounded-full bg-primary [animation-delay:-0.15s]" }),
|
|
61
|
+
/* @__PURE__ */ jsx("div", { className: "h-3 w-3 animate-pulse rounded-full bg-primary" })
|
|
62
|
+
]
|
|
63
|
+
})
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/utils/dev-portal-api.ts
|
|
71
|
+
const DEV_PORTAL_PLACEHOLDER_API_URL = "https://dp-example.azurecontainerapps.io";
|
|
72
|
+
function resolveDevPortalApiBaseUrl(configuredUrl) {
|
|
73
|
+
const fromConfig = configuredUrl?.trim();
|
|
74
|
+
if (fromConfig) return fromConfig.replace(/\/+$/, "");
|
|
75
|
+
const envUrl = typeof VITE_APITOGO_DEV_PORTAL_API_URL === "string" ? VITE_APITOGO_DEV_PORTAL_API_URL.trim() : "";
|
|
76
|
+
return envUrl ? envUrl.replace(/\/+$/, "") : void 0;
|
|
77
|
+
}
|
|
78
|
+
function isPlaceholderDevPortalApiUrl(apiBaseUrl) {
|
|
79
|
+
if (!apiBaseUrl) return true;
|
|
80
|
+
return apiBaseUrl === DEV_PORTAL_PLACEHOLDER_API_URL || apiBaseUrl.includes("dp-example.azurecontainerapps.io");
|
|
81
|
+
}
|
|
82
|
+
function parseMockPlans() {
|
|
83
|
+
const raw = typeof VITE_APITOGO_DEV_PORTAL_MOCK_PLANS === "string" ? VITE_APITOGO_DEV_PORTAL_MOCK_PLANS.trim() : "";
|
|
84
|
+
if (!raw) return { plans: [] };
|
|
85
|
+
try {
|
|
86
|
+
return { plans: JSON.parse(raw).map((plan) => ({
|
|
87
|
+
sku: plan.sku,
|
|
88
|
+
displayName: plan.displayName,
|
|
89
|
+
isFree: plan.isFree ?? false,
|
|
90
|
+
priceAmount: plan.priceAmount ?? 0,
|
|
91
|
+
priceCurrency: plan.priceCurrency ?? "usd",
|
|
92
|
+
billingPeriod: plan.billingPeriod ?? "monthly",
|
|
93
|
+
limitsJson: plan.limitsJson ?? null
|
|
94
|
+
})) };
|
|
95
|
+
} catch {
|
|
96
|
+
return { plans: [] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function formatPlanPrice(plan) {
|
|
100
|
+
if (plan.isFree || plan.priceAmount <= 0) return "Free";
|
|
101
|
+
const amount = new Intl.NumberFormat(void 0, {
|
|
102
|
+
style: "currency",
|
|
103
|
+
currency: plan.priceCurrency.toUpperCase(),
|
|
104
|
+
minimumFractionDigits: plan.priceAmount % 1 === 0 ? 0 : 2
|
|
105
|
+
}).format(plan.priceAmount);
|
|
106
|
+
const period = plan.billingPeriod.toLowerCase();
|
|
107
|
+
if (period === "monthly" || period === "month") return `${amount}/mo`;
|
|
108
|
+
if (period === "annual" || period === "year") return `${amount}/yr`;
|
|
109
|
+
return amount;
|
|
110
|
+
}
|
|
111
|
+
function formatBillingPeriod(period) {
|
|
112
|
+
const normalized = period.toLowerCase();
|
|
113
|
+
if (normalized === "month" || normalized === "monthly") return "Monthly";
|
|
114
|
+
if (normalized === "year" || normalized === "annual") return "Annual";
|
|
115
|
+
return period;
|
|
116
|
+
}
|
|
117
|
+
function formatRateLimit(rate) {
|
|
118
|
+
if (!rate || typeof rate !== "object") return null;
|
|
119
|
+
const { calls, renewalPeriodSeconds } = rate;
|
|
120
|
+
if (calls == null || renewalPeriodSeconds == null) return null;
|
|
121
|
+
const window = renewalPeriodSeconds === 60 ? "minute" : `${renewalPeriodSeconds} seconds`;
|
|
122
|
+
return `${calls} request${calls === 1 ? "" : "s"} per ${window}`;
|
|
123
|
+
}
|
|
124
|
+
function summarizeLimits(limitsJson) {
|
|
125
|
+
if (!limitsJson?.trim()) return null;
|
|
126
|
+
try {
|
|
127
|
+
const limits = JSON.parse(limitsJson);
|
|
128
|
+
const rateSummary = formatRateLimit(limits.rateLimit);
|
|
129
|
+
if (rateSummary) return rateSummary;
|
|
130
|
+
const entries = Object.entries(limits).filter(([, value]) => value != null && typeof value !== "object");
|
|
131
|
+
if (entries.length === 0) return null;
|
|
132
|
+
return entries.slice(0, 3).map(([key, value]) => `${key}: ${String(value)}`).join(" · ");
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function isActiveSubscription(status) {
|
|
138
|
+
return status.toLowerCase() === "active";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/hooks/useDevPortalApiBaseUrl.ts
|
|
143
|
+
function useDevPortalApiBaseUrl() {
|
|
144
|
+
const providerData = useAuth().providerData;
|
|
145
|
+
if (providerData?.type === "dev-portal" && providerData.apiBaseUrl) return providerData.apiBaseUrl;
|
|
146
|
+
return resolveDevPortalApiBaseUrl();
|
|
147
|
+
}
|
|
148
|
+
function useDevPortalPreviewMode() {
|
|
149
|
+
const auth = useAuth();
|
|
150
|
+
const apiBaseUrl = useDevPortalApiBaseUrl();
|
|
151
|
+
if (auth.authMode === "preview" || auth.isBackendAvailable === false) return true;
|
|
152
|
+
return isPlaceholderDevPortalApiUrl(apiBaseUrl);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/pages/CheckoutPage.tsx
|
|
157
|
+
const CheckoutPage = () => {
|
|
158
|
+
const [searchParams] = useSearchParams();
|
|
159
|
+
const planSku = searchParams.get("planSku") ?? "";
|
|
160
|
+
const navigate = useNavigate();
|
|
161
|
+
const auth = useAuth();
|
|
162
|
+
const apiBaseUrl = useDevPortalApiBaseUrl();
|
|
163
|
+
const isPreview = useDevPortalPreviewMode();
|
|
164
|
+
const checkoutQuery = useQuery({
|
|
165
|
+
queryKey: [
|
|
166
|
+
"dev-portal-checkout",
|
|
167
|
+
apiBaseUrl,
|
|
168
|
+
planSku,
|
|
169
|
+
auth.profile?.sub
|
|
170
|
+
],
|
|
171
|
+
enabled: Boolean(planSku) && !isPreview && auth.isAuthenticated && Boolean(apiBaseUrl),
|
|
172
|
+
retry: false,
|
|
173
|
+
queryFn: async () => {
|
|
174
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/subscriptions/checkout`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: {
|
|
177
|
+
Accept: "application/json",
|
|
178
|
+
"Content-Type": "application/json"
|
|
179
|
+
},
|
|
180
|
+
credentials: "include",
|
|
181
|
+
body: JSON.stringify({ planSku })
|
|
182
|
+
});
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
const detail = await response.text();
|
|
185
|
+
throw new Error(detail || "Checkout failed.");
|
|
186
|
+
}
|
|
187
|
+
return await response.json();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
const freeProvisioned = checkoutQuery.data?.isFree === true && checkoutQuery.data.entitlementProvisioned === true;
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (freeProvisioned) navigate("/account/subscriptions?checkout=success", { replace: true });
|
|
193
|
+
}, [freeProvisioned, navigate]);
|
|
194
|
+
if (!planSku) return /* @__PURE__ */ jsxs("div", {
|
|
195
|
+
className: "mx-auto max-w-lg px-4 py-16 text-center text-muted-foreground",
|
|
196
|
+
children: [
|
|
197
|
+
"Missing plan SKU.",
|
|
198
|
+
" ",
|
|
199
|
+
/* @__PURE__ */ jsx(Link, {
|
|
200
|
+
to: "/pricing",
|
|
201
|
+
className: "text-primary underline",
|
|
202
|
+
children: "Back to pricing"
|
|
203
|
+
})
|
|
204
|
+
]
|
|
205
|
+
});
|
|
206
|
+
if (isPreview) return /* @__PURE__ */ jsxs("div", {
|
|
207
|
+
className: "mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-4 text-center",
|
|
208
|
+
children: [
|
|
209
|
+
/* @__PURE__ */ jsx("h1", {
|
|
210
|
+
className: "text-2xl font-semibold tracking-tight",
|
|
211
|
+
children: "Checkout unlocks after publish"
|
|
212
|
+
}),
|
|
213
|
+
/* @__PURE__ */ jsx("p", {
|
|
214
|
+
className: "text-muted-foreground",
|
|
215
|
+
children: "Subscribe and billing flows require a live dev-portal backend. Publish your portal to APItoGo, then return here to complete checkout."
|
|
216
|
+
}),
|
|
217
|
+
/* @__PURE__ */ jsx(Button$1, {
|
|
218
|
+
asChild: true,
|
|
219
|
+
variant: "outline",
|
|
220
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
221
|
+
to: "/pricing",
|
|
222
|
+
children: "Back to pricing"
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
]
|
|
226
|
+
});
|
|
227
|
+
if (freeProvisioned) return null;
|
|
228
|
+
return /* @__PURE__ */ jsx(RedirectPage, {
|
|
229
|
+
icon: ShieldIcon,
|
|
230
|
+
title: "Establishing secure checkout…",
|
|
231
|
+
description: "Redirecting you to complete your subscription.",
|
|
232
|
+
url: checkoutQuery.data?.checkoutUrl ?? void 0,
|
|
233
|
+
children: checkoutQuery.isError ? /* @__PURE__ */ jsxs(Alert, {
|
|
234
|
+
variant: "destructive",
|
|
235
|
+
children: [/* @__PURE__ */ jsx(AlertDescription, {
|
|
236
|
+
className: "first-letter:uppercase",
|
|
237
|
+
children: checkoutQuery.error.message
|
|
238
|
+
}), /* @__PURE__ */ jsx(AlertAction, { children: /* @__PURE__ */ jsx(Button$1, {
|
|
239
|
+
variant: "outline",
|
|
240
|
+
size: "xs",
|
|
241
|
+
asChild: true,
|
|
242
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
243
|
+
to: "/pricing",
|
|
244
|
+
children: "Back to pricing"
|
|
245
|
+
})
|
|
246
|
+
}) })]
|
|
247
|
+
}) : null
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/components/PricingCard.tsx
|
|
253
|
+
const PricingCard = ({ plan, isSubscribed = false }) => {
|
|
254
|
+
const limitsSummary = summarizeLimits(plan.limitsJson);
|
|
255
|
+
return /* @__PURE__ */ jsxs("article", {
|
|
256
|
+
className: cn("flex w-full max-w-sm flex-col rounded-xl border bg-card/60 p-6 shadow-xs"),
|
|
257
|
+
"data-testid": `pricing-card-${plan.sku}`,
|
|
258
|
+
children: [
|
|
259
|
+
/* @__PURE__ */ jsx("h2", {
|
|
260
|
+
className: "text-xl font-semibold",
|
|
261
|
+
children: plan.displayName
|
|
262
|
+
}),
|
|
263
|
+
/* @__PURE__ */ jsx("p", {
|
|
264
|
+
className: "mt-2 text-3xl font-bold",
|
|
265
|
+
children: formatPlanPrice(plan)
|
|
266
|
+
}),
|
|
267
|
+
/* @__PURE__ */ jsx("p", {
|
|
268
|
+
className: "mt-1 text-sm text-muted-foreground",
|
|
269
|
+
children: formatBillingPeriod(plan.billingPeriod)
|
|
270
|
+
}),
|
|
271
|
+
limitsSummary ? /* @__PURE__ */ jsx("p", {
|
|
272
|
+
className: "mt-4 text-sm text-muted-foreground",
|
|
273
|
+
children: limitsSummary
|
|
274
|
+
}) : null,
|
|
275
|
+
/* @__PURE__ */ jsx("div", {
|
|
276
|
+
className: "mt-6",
|
|
277
|
+
children: isSubscribed ? /* @__PURE__ */ jsx(Button, {
|
|
278
|
+
variant: "secondary",
|
|
279
|
+
className: "w-full",
|
|
280
|
+
asChild: true,
|
|
281
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
282
|
+
to: "/account/subscriptions",
|
|
283
|
+
children: "Manage subscription"
|
|
284
|
+
})
|
|
285
|
+
}) : /* @__PURE__ */ jsx(Button, {
|
|
286
|
+
variant: "default",
|
|
287
|
+
className: "w-full",
|
|
288
|
+
asChild: true,
|
|
289
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
290
|
+
to: `/checkout?planSku=${encodeURIComponent(plan.sku)}`,
|
|
291
|
+
children: plan.isFree ? "Get started" : "Subscribe"
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
]
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/hooks/useMySubscriptions.ts
|
|
301
|
+
function useMySubscriptions() {
|
|
302
|
+
const auth = useAuth();
|
|
303
|
+
const apiBaseUrl = useDevPortalApiBaseUrl();
|
|
304
|
+
const isPreview = useDevPortalPreviewMode();
|
|
305
|
+
return useQuery({
|
|
306
|
+
queryKey: ["dev-portal-me", apiBaseUrl],
|
|
307
|
+
enabled: auth.isAuthenticated && !isPreview && Boolean(apiBaseUrl),
|
|
308
|
+
queryFn: async () => {
|
|
309
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/auth/me`, {
|
|
310
|
+
method: "GET",
|
|
311
|
+
headers: { Accept: "application/json" },
|
|
312
|
+
credentials: "include"
|
|
313
|
+
});
|
|
314
|
+
if (!response.ok) throw new Error("Failed to load subscription status.");
|
|
315
|
+
return await response.json();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/hooks/usePlans.ts
|
|
322
|
+
function usePlans() {
|
|
323
|
+
const apiBaseUrl = useDevPortalApiBaseUrl();
|
|
324
|
+
const isPreview = useDevPortalPreviewMode();
|
|
325
|
+
return useQuery({
|
|
326
|
+
queryKey: [
|
|
327
|
+
"dev-portal-plans",
|
|
328
|
+
apiBaseUrl,
|
|
329
|
+
isPreview
|
|
330
|
+
],
|
|
331
|
+
queryFn: async () => {
|
|
332
|
+
if (isPreview) return parseMockPlans();
|
|
333
|
+
if (!apiBaseUrl) return { plans: [] };
|
|
334
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/plans`, { headers: { Accept: "application/json" } });
|
|
335
|
+
if (!response.ok) throw new Error("Failed to load plans.");
|
|
336
|
+
return await response.json();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/pages/PricingPage.tsx
|
|
343
|
+
const PricingPage = () => {
|
|
344
|
+
const { pricing } = useDevPortalBillingConfig();
|
|
345
|
+
const auth = useAuth();
|
|
346
|
+
const isPreview = useDevPortalPreviewMode();
|
|
347
|
+
const { data: catalog, isLoading, isError } = usePlans();
|
|
348
|
+
const { data: me } = useMySubscriptions();
|
|
349
|
+
const activeSkus = new Set((me?.subscriptions ?? []).filter((sub) => isActiveSubscription(sub.status)).map((sub) => sub.planSku));
|
|
350
|
+
const title = pricing?.title ?? "Pricing";
|
|
351
|
+
const subtitle = pricing?.subtitle ?? "Compare plans and subscribe when you are ready to integrate.";
|
|
352
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
353
|
+
className: "w-full px-4 pt-(--padding-content-top) pb-(--padding-content-bottom)",
|
|
354
|
+
children: [
|
|
355
|
+
/* @__PURE__ */ jsxs(Head, { children: [/* @__PURE__ */ jsx("title", { children: title }), /* @__PURE__ */ jsx("meta", {
|
|
356
|
+
name: "description",
|
|
357
|
+
content: subtitle
|
|
358
|
+
})] }),
|
|
359
|
+
/* @__PURE__ */ jsxs("div", {
|
|
360
|
+
className: "mb-12 space-y-4 text-center",
|
|
361
|
+
children: [/* @__PURE__ */ jsx(Heading, {
|
|
362
|
+
level: 1,
|
|
363
|
+
"data-testid": "title",
|
|
364
|
+
children: title
|
|
365
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
366
|
+
className: "text-muted-foreground",
|
|
367
|
+
"data-testid": "subtitle",
|
|
368
|
+
children: subtitle
|
|
369
|
+
})]
|
|
370
|
+
}),
|
|
371
|
+
isPreview && !auth.isAuthenticated ? /* @__PURE__ */ jsxs("div", {
|
|
372
|
+
className: "mx-auto mb-8 max-w-2xl rounded-xl border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground",
|
|
373
|
+
children: [
|
|
374
|
+
"Local preview uses mock plans from",
|
|
375
|
+
" ",
|
|
376
|
+
/* @__PURE__ */ jsx("code", {
|
|
377
|
+
className: "rounded bg-muted px-1.5 py-0.5",
|
|
378
|
+
children: "VITE_APITOGO_DEV_PORTAL_MOCK_PLANS"
|
|
379
|
+
}),
|
|
380
|
+
". Checkout activates after the portal is published."
|
|
381
|
+
]
|
|
382
|
+
}) : null,
|
|
383
|
+
isLoading ? /* @__PURE__ */ jsx("p", {
|
|
384
|
+
className: "text-center text-muted-foreground",
|
|
385
|
+
children: "Loading plans…"
|
|
386
|
+
}) : null,
|
|
387
|
+
isError ? /* @__PURE__ */ jsx("p", {
|
|
388
|
+
className: "text-center text-destructive",
|
|
389
|
+
children: "Unable to load plans."
|
|
390
|
+
}) : null,
|
|
391
|
+
catalog && catalog.plans.length > 0 ? /* @__PURE__ */ jsx("div", {
|
|
392
|
+
className: "grid grid-cols-1 justify-center gap-6 sm:grid-cols-[repeat(auto-fit,minmax(280px,max-content))]",
|
|
393
|
+
children: catalog.plans.map((plan) => /* @__PURE__ */ jsx(PricingCard, {
|
|
394
|
+
plan,
|
|
395
|
+
isSubscribed: activeSkus.has(plan.sku)
|
|
396
|
+
}, plan.sku))
|
|
397
|
+
}) : !isLoading && !isError ? /* @__PURE__ */ jsx("div", {
|
|
398
|
+
className: "mx-auto max-w-lg rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground",
|
|
399
|
+
children: "No plans are available yet. Sync plans from your APItoGo manifest or platform gateway."
|
|
400
|
+
}) : null,
|
|
401
|
+
auth.isAuthenticated ? /* @__PURE__ */ jsx("div", {
|
|
402
|
+
className: "mt-10 text-center",
|
|
403
|
+
children: /* @__PURE__ */ jsx(Button$1, {
|
|
404
|
+
variant: "outline",
|
|
405
|
+
asChild: true,
|
|
406
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
407
|
+
to: "/account/plans",
|
|
408
|
+
children: "Manage billing"
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
}) : null
|
|
412
|
+
]
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/pages/SubscriptionsPage.tsx
|
|
418
|
+
const SubscriptionsPage = () => {
|
|
419
|
+
const auth = useAuth();
|
|
420
|
+
const [searchParams] = useSearchParams();
|
|
421
|
+
const checkoutSuccess = searchParams.get("checkout") === "success";
|
|
422
|
+
const { data: me, isLoading } = useMySubscriptions();
|
|
423
|
+
const { data: catalog } = usePlans();
|
|
424
|
+
const planBySku = new Map((catalog?.plans ?? []).map((plan) => [plan.sku, plan]));
|
|
425
|
+
const subscriptions = me?.subscriptions ?? [];
|
|
426
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
427
|
+
className: "mx-auto max-w-screen-lg px-4 pb-16 pt-(--padding-content-top) lg:px-8",
|
|
428
|
+
children: [
|
|
429
|
+
/* @__PURE__ */ jsx(Head, { children: /* @__PURE__ */ jsx("title", { children: "My subscriptions" }) }),
|
|
430
|
+
/* @__PURE__ */ jsxs("div", {
|
|
431
|
+
className: "mb-8 space-y-2",
|
|
432
|
+
children: [/* @__PURE__ */ jsx("h1", {
|
|
433
|
+
className: "text-3xl font-semibold tracking-tight",
|
|
434
|
+
children: "My subscriptions"
|
|
435
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
436
|
+
className: "text-muted-foreground",
|
|
437
|
+
children: "Review your active plans and manage billing."
|
|
438
|
+
})]
|
|
439
|
+
}),
|
|
440
|
+
checkoutSuccess ? /* @__PURE__ */ jsx("div", {
|
|
441
|
+
className: "mb-6 rounded-xl border border-green-500/30 bg-green-500/10 p-4 text-sm",
|
|
442
|
+
children: "Your subscription is active. API credentials are available from your account dashboard."
|
|
443
|
+
}) : null,
|
|
444
|
+
isLoading ? /* @__PURE__ */ jsx("p", {
|
|
445
|
+
className: "text-muted-foreground",
|
|
446
|
+
children: "Loading subscriptions…"
|
|
447
|
+
}) : null,
|
|
448
|
+
!isLoading && subscriptions.length === 0 ? /* @__PURE__ */ jsxs("div", {
|
|
449
|
+
className: "rounded-xl border border-dashed p-8 text-center",
|
|
450
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
451
|
+
className: "text-muted-foreground",
|
|
452
|
+
children: "You do not have an active subscription yet."
|
|
453
|
+
}), /* @__PURE__ */ jsx(Button$1, {
|
|
454
|
+
className: "mt-4",
|
|
455
|
+
asChild: true,
|
|
456
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
457
|
+
to: "/pricing",
|
|
458
|
+
children: "View plans"
|
|
459
|
+
})
|
|
460
|
+
})]
|
|
461
|
+
}) : null,
|
|
462
|
+
subscriptions.length > 0 ? /* @__PURE__ */ jsx("div", {
|
|
463
|
+
className: "space-y-4",
|
|
464
|
+
children: subscriptions.map((subscription) => {
|
|
465
|
+
const plan = planBySku.get(subscription.planSku);
|
|
466
|
+
return /* @__PURE__ */ jsxs("article", {
|
|
467
|
+
className: "rounded-xl border bg-card/60 p-6",
|
|
468
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
469
|
+
className: "flex flex-wrap items-start justify-between gap-4",
|
|
470
|
+
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h2", {
|
|
471
|
+
className: "text-lg font-medium",
|
|
472
|
+
children: plan?.displayName ?? subscription.planSku
|
|
473
|
+
}), plan ? /* @__PURE__ */ jsxs("p", {
|
|
474
|
+
className: "mt-1 text-sm text-muted-foreground",
|
|
475
|
+
children: [
|
|
476
|
+
formatPlanPrice(plan),
|
|
477
|
+
" ·",
|
|
478
|
+
" ",
|
|
479
|
+
formatBillingPeriod(plan.billingPeriod)
|
|
480
|
+
]
|
|
481
|
+
}) : null] }), /* @__PURE__ */ jsx("span", {
|
|
482
|
+
className: isActiveSubscription(subscription.status) ? "rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary" : "rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground",
|
|
483
|
+
children: subscription.status
|
|
484
|
+
})]
|
|
485
|
+
}), subscription.currentPeriodEnd ? /* @__PURE__ */ jsxs("p", {
|
|
486
|
+
className: "mt-3 text-sm text-muted-foreground",
|
|
487
|
+
children: [
|
|
488
|
+
"Current period ends",
|
|
489
|
+
" ",
|
|
490
|
+
new Date(subscription.currentPeriodEnd).toLocaleDateString()
|
|
491
|
+
]
|
|
492
|
+
}) : null]
|
|
493
|
+
}, `${subscription.planSku}-${subscription.status}`);
|
|
494
|
+
})
|
|
495
|
+
}) : null,
|
|
496
|
+
/* @__PURE__ */ jsxs("div", {
|
|
497
|
+
className: "mt-8 flex flex-wrap gap-3",
|
|
498
|
+
children: [
|
|
499
|
+
/* @__PURE__ */ jsx(Button$1, {
|
|
500
|
+
variant: "outline",
|
|
501
|
+
asChild: true,
|
|
502
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
503
|
+
to: "/pricing",
|
|
504
|
+
children: "Change plan"
|
|
505
|
+
})
|
|
506
|
+
}),
|
|
507
|
+
/* @__PURE__ */ jsx(Button$1, {
|
|
508
|
+
variant: "outline",
|
|
509
|
+
asChild: true,
|
|
510
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
511
|
+
to: "/account/plans",
|
|
512
|
+
children: "Billing settings"
|
|
513
|
+
})
|
|
514
|
+
}),
|
|
515
|
+
auth.isAuthenticated ? /* @__PURE__ */ jsx(Button$1, {
|
|
516
|
+
variant: "secondary",
|
|
517
|
+
asChild: true,
|
|
518
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
519
|
+
to: "/account/dashboard",
|
|
520
|
+
children: "Account dashboard"
|
|
521
|
+
})
|
|
522
|
+
}) : null
|
|
523
|
+
]
|
|
524
|
+
})
|
|
525
|
+
]
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/DevPortalBillingPlugin.tsx
|
|
531
|
+
const PRICING_PATH = "/pricing";
|
|
532
|
+
const CHECKOUT_PATH = "/checkout";
|
|
533
|
+
const SUBSCRIPTIONS_PATH = "/account/subscriptions";
|
|
534
|
+
const devPortalBillingPlugin = createPlugin((options = {}) => ({
|
|
535
|
+
transformConfig: ({ config, merge }) => merge({ header: { navigation: [...config.header?.navigation ?? [], {
|
|
536
|
+
label: "Pricing",
|
|
537
|
+
to: PRICING_PATH
|
|
538
|
+
}] } }),
|
|
539
|
+
getProfileMenuItems: () => [{
|
|
540
|
+
label: "My Subscriptions",
|
|
541
|
+
path: SUBSCRIPTIONS_PATH,
|
|
542
|
+
icon: StarsIcon,
|
|
543
|
+
category: "middle",
|
|
544
|
+
weight: 25
|
|
545
|
+
}],
|
|
546
|
+
getRoutes: () => [{
|
|
547
|
+
element: /* @__PURE__ */ jsx(DevPortalBillingWrapper, { options }),
|
|
548
|
+
handle: { layout: "none" },
|
|
549
|
+
children: [
|
|
550
|
+
{
|
|
551
|
+
path: PRICING_PATH,
|
|
552
|
+
handle: { layout: "default" },
|
|
553
|
+
element: /* @__PURE__ */ jsx(PricingPage, {})
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
path: CHECKOUT_PATH,
|
|
557
|
+
handle: { layout: "default" },
|
|
558
|
+
element: /* @__PURE__ */ jsx(CheckoutPage, {})
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
path: SUBSCRIPTIONS_PATH,
|
|
562
|
+
handle: { layout: "default" },
|
|
563
|
+
element: /* @__PURE__ */ jsx(SubscriptionsPage, {})
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
}],
|
|
567
|
+
getProtectedRoutes: () => [CHECKOUT_PATH, SUBSCRIPTIONS_PATH]
|
|
568
|
+
}));
|
|
569
|
+
|
|
570
|
+
//#endregion
|
|
571
|
+
export { CHECKOUT_PATH, PRICING_PATH, SUBSCRIPTIONS_PATH, devPortalBillingPlugin };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lukoweb/apitogo-plugin-dev-portal-billing",
|
|
3
|
+
"version": "0.1.37",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/zuplo/zudoku",
|
|
7
|
+
"directory": "packages/plugin-dev-portal-billing"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.mjs",
|
|
11
|
+
"types": "./dist/index.d.mts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./dist/index.mjs",
|
|
15
|
+
"types": "./dist/index.d.mts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsdown",
|
|
23
|
+
"dev": "tsdown --watch",
|
|
24
|
+
"prepublishOnly": "pnpm typecheck && pnpm build",
|
|
25
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"main": "./dist/index.mjs",
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"import": "./dist/index.mjs",
|
|
34
|
+
"types": "./dist/index.d.mts"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@testing-library/dom": "10.4.1",
|
|
41
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
42
|
+
"@testing-library/react": "16.3.2",
|
|
43
|
+
"@types/react": "19.2.14",
|
|
44
|
+
"@types/react-dom": "19.2.3",
|
|
45
|
+
"happy-dom": "20.8.3",
|
|
46
|
+
"react": "19.2.4",
|
|
47
|
+
"react-dom": "19.2.4",
|
|
48
|
+
"tsdown": "0.20.3",
|
|
49
|
+
"@lukoweb/apitogo": "0.1.37"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"react": ">=19.2.0",
|
|
53
|
+
"react-dom": ">=19.2.0",
|
|
54
|
+
"@lukoweb/apitogo": "*"
|
|
55
|
+
}
|
|
56
|
+
}
|