@zuplo/zudoku-plugin-monetization 0.0.2

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/LICENSE.md ADDED
@@ -0,0 +1,18 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Zuplo, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction,
7
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
15
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
17
+ OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,10 @@
1
+ import { ZudokuConfig, ZudokuPlugin } from "zudoku";
2
+
3
+ //#region src/ZuploMonetizationPlugin.d.ts
4
+ type ZudokuMonetizationPluginOptions = {
5
+ environmentName: string;
6
+ };
7
+ declare const enableMonetization: (config: ZudokuConfig, options: ZudokuMonetizationPluginOptions) => ZudokuConfig;
8
+ declare const zuploMonetizationPlugin: (options: ZudokuMonetizationPluginOptions) => ZudokuPlugin;
9
+ //#endregion
10
+ export { enableMonetization, zuploMonetizationPlugin };
package/dist/index.mjs ADDED
@@ -0,0 +1,931 @@
1
+ import { ArrowLeftIcon, CheckIcon, ClockIcon, LockIcon, RefreshCwIcon, ShieldIcon, StarsIcon, Trash2Icon } from "zudoku/icons";
2
+ import { Link, Outlet, useNavigate, useParams, useSearchParams } from "zudoku/router";
3
+ import { Button, Heading } from "zudoku/components";
4
+ import { useAuth, useZudoku } from "zudoku/hooks";
5
+ import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, useSuspenseQuery } from "zudoku/react-query";
6
+ import { Alert, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "zudoku/ui/Card";
8
+ import { Separator } from "zudoku/ui/Separator";
9
+ import { cn, joinUrl } from "zudoku";
10
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
+ import { parse } from "tinyduration";
12
+ import { useMemo } from "react";
13
+ import { Button as Button$1 } from "zudoku/ui/Button";
14
+ import { Secret } from "zudoku/ui/Secret";
15
+ import { Item, ItemContent, ItemTitle } from "zudoku/ui/Item";
16
+ import { Progress } from "zudoku/ui/Progress";
17
+
18
+ //#region src/components/FeatureItem.tsx
19
+ const FeatureItem = ({ feature, className }) => {
20
+ return /* @__PURE__ */ jsxs("div", {
21
+ className: cn("flex items-start gap-2", className),
22
+ children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-primary shrink-0 mt-0.5" }), /* @__PURE__ */ jsx("div", {
23
+ className: "text-sm",
24
+ children: feature.value ? /* @__PURE__ */ jsxs(Fragment, { children: [
25
+ /* @__PURE__ */ jsxs("span", {
26
+ className: "font-medium",
27
+ children: [feature.name, ":"]
28
+ }),
29
+ " ",
30
+ feature.value
31
+ ] }) : feature.name
32
+ })]
33
+ });
34
+ };
35
+
36
+ //#endregion
37
+ //#region src/components/QuotaItem.tsx
38
+ const QuotaItem = ({ quota, className }) => {
39
+ return /* @__PURE__ */ jsxs("div", {
40
+ className: cn("flex items-start gap-2", className),
41
+ children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-primary shrink-0 mt-0.5" }), /* @__PURE__ */ jsxs("div", {
42
+ className: "text-sm",
43
+ children: [
44
+ /* @__PURE__ */ jsxs("span", {
45
+ className: "font-medium",
46
+ children: [quota.name, ":"]
47
+ }),
48
+ " ",
49
+ quota.limit.toLocaleString(),
50
+ " / ",
51
+ quota.period,
52
+ quota.overagePrice && /* @__PURE__ */ jsxs("div", {
53
+ className: "text-xs text-muted-foreground mt-0.5",
54
+ children: [
55
+ "+",
56
+ quota.overagePrice,
57
+ " after quota"
58
+ ]
59
+ })
60
+ ]
61
+ })]
62
+ });
63
+ };
64
+
65
+ //#endregion
66
+ //#region src/hooks/usePlans.ts
67
+ const usePlans = (environmentName) => {
68
+ return useSuspenseQuery({ queryKey: [`/v3/zudoku-metering/${environmentName}/pricing-page`] });
69
+ };
70
+
71
+ //#endregion
72
+ //#region src/utils/formatDuration.ts
73
+ const formatDuration = (iso) => {
74
+ try {
75
+ const d = parse(iso);
76
+ if (d.months === 1) return "month";
77
+ if (d.months && d.months > 1) return `${d.months} months`;
78
+ if (d.years === 1) return "year";
79
+ if (d.years && d.years > 1) return `${d.years} years`;
80
+ if (d.weeks === 1) return "week";
81
+ if (d.weeks && d.weeks > 1) return `${d.weeks} weeks`;
82
+ if (d.days === 1) return "day";
83
+ if (d.days && d.days > 1) return `${d.days} days`;
84
+ return iso;
85
+ } catch {
86
+ return iso;
87
+ }
88
+ };
89
+
90
+ //#endregion
91
+ //#region src/utils/categorizeRateCards.ts
92
+ const categorizeRateCards = (rateCards) => {
93
+ const quotas = [];
94
+ const features = [];
95
+ for (const rc of rateCards) {
96
+ const et = rc.entitlementTemplate;
97
+ if (!et) continue;
98
+ if (et.type === "metered" && et.issueAfterReset != null) {
99
+ let overagePrice;
100
+ if (rc.price?.type === "tiered" && rc.price.tiers) {
101
+ const overageTier = rc.price.tiers.find((t) => t.unitPrice?.amount);
102
+ if (overageTier?.unitPrice) overagePrice = `$${parseFloat(overageTier.unitPrice.amount).toFixed(2)}/unit`;
103
+ }
104
+ quotas.push({
105
+ key: rc.featureKey,
106
+ name: rc.name,
107
+ limit: et.issueAfterReset,
108
+ period: et.usagePeriod ? formatDuration(et.usagePeriod) : "Month",
109
+ overagePrice
110
+ });
111
+ } else if (et.type === "boolean") features.push({
112
+ key: rc.featureKey,
113
+ name: rc.name
114
+ });
115
+ else if (et.type === "static" && et.config) try {
116
+ const config = JSON.parse(et.config);
117
+ features.push({
118
+ key: rc.featureKey,
119
+ name: rc.name,
120
+ value: String(config.value)
121
+ });
122
+ } catch {
123
+ features.push({
124
+ key: rc.featureKey,
125
+ name: rc.name
126
+ });
127
+ }
128
+ }
129
+ return {
130
+ quotas,
131
+ features
132
+ };
133
+ };
134
+
135
+ //#endregion
136
+ //#region src/utils/getPriceFromPlan.ts
137
+ const getPriceFromPlan = (plan) => {
138
+ const defaultPhase = plan.phases.at(-1);
139
+ if (!defaultPhase) return {
140
+ monthly: 0,
141
+ yearly: 0
142
+ };
143
+ const flatFeeCard = defaultPhase.rateCards.find((rc) => rc.type === "flat_fee" && rc.price?.type === "flat");
144
+ const monthlyAmount = flatFeeCard?.price.amount ? parseInt(flatFeeCard.price.amount, 10) : 0;
145
+ return {
146
+ monthly: monthlyAmount,
147
+ yearly: monthlyAmount * 12
148
+ };
149
+ };
150
+
151
+ //#endregion
152
+ //#region src/pages/CheckoutConfimPage.tsx
153
+ const formatBillingCycle = (duration) => {
154
+ if (duration === "month") return "monthly";
155
+ if (duration === "year") return "annually";
156
+ if (duration === "week") return "weekly";
157
+ if (duration === "day") return "daily";
158
+ if (duration.includes(" ")) return `every ${duration}`;
159
+ return `every ${duration}`;
160
+ };
161
+ const CheckoutConfirmPage = ({ environmentName }) => {
162
+ const [search] = useSearchParams();
163
+ const planId = search.get("plan");
164
+ const auth = useAuth();
165
+ const zudoku = useZudoku();
166
+ const navigate = useNavigate();
167
+ const { data: plans } = usePlans(environmentName);
168
+ const selectedPlan = plans?.items?.find((plan) => plan.id === planId);
169
+ const rateCards = selectedPlan?.phases.at(-1)?.rateCards;
170
+ const { quotas, features } = categorizeRateCards(rateCards ?? []);
171
+ const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
172
+ const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
173
+ const createSubscriptionMutation = useMutation({
174
+ mutationFn: async () => {
175
+ if (!auth.profile?.email) throw new Error("No email found for user. Make sure your Authentication Provider exposes the email address.");
176
+ const signedRequest = await zudoku.signRequest(new Request(`https://api.zuploedge.com/v3/zudoku-metering/${environmentName}/subscriptions`, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({ planId })
180
+ }));
181
+ const response = await fetch(signedRequest);
182
+ const subscription = await response.json();
183
+ if (!response.ok) throw new Error(subscription.message);
184
+ return subscription.id;
185
+ },
186
+ onSuccess: (subscriptionId) => {
187
+ navigate(`/subscriptions/${subscriptionId}`);
188
+ },
189
+ onError: (error) => {
190
+ console.error("Error creating subscription:", error);
191
+ }
192
+ });
193
+ return /* @__PURE__ */ jsx("div", {
194
+ className: "w-full bg-muted min-h-screen flex items-center justify-center px-4 py-12 gap-4",
195
+ children: /* @__PURE__ */ jsxs("div", {
196
+ className: "max-w-2xl w-full",
197
+ children: [
198
+ /* @__PURE__ */ jsxs("div", {
199
+ className: "flex gap-2 text-muted-foreground text-sm items-center pt-4 pb-4",
200
+ children: [/* @__PURE__ */ jsx(ArrowLeftIcon, { className: "size-4" }), "Your payment is secured by Stripe"]
201
+ }),
202
+ " ",
203
+ createSubscriptionMutation.isError && /* @__PURE__ */ jsxs(Alert, {
204
+ className: "mb-4",
205
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: "Error" }), /* @__PURE__ */ jsx(AlertDescription, { children: createSubscriptionMutation.error.message })]
206
+ }),
207
+ /* @__PURE__ */ jsxs(Card, {
208
+ className: "p-8 w-full max-w-7xl",
209
+ children: [
210
+ /* @__PURE__ */ jsx("div", {
211
+ className: "flex justify-center mb-6",
212
+ children: /* @__PURE__ */ jsx("div", {
213
+ className: "rounded-full bg-primary/10 p-3",
214
+ children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-9 text-primary" })
215
+ })
216
+ }),
217
+ /* @__PURE__ */ jsxs("div", {
218
+ className: "text-center mb-8",
219
+ children: [/* @__PURE__ */ jsx("h1", {
220
+ className: "text-2xl font-bold text-card-foreground mb-3",
221
+ children: "Review you subscription"
222
+ }), /* @__PURE__ */ jsx("p", {
223
+ className: "text-muted-foreground text-base",
224
+ children: "Please confirm the details below before completing your purchase."
225
+ })]
226
+ }),
227
+ selectedPlan && /* @__PURE__ */ jsxs(Card, {
228
+ className: "bg-muted/50",
229
+ children: [/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsxs(CardTitle, {
230
+ className: "flex justify-between items-start",
231
+ children: [
232
+ /* @__PURE__ */ jsxs("div", {
233
+ className: "flex items-center gap-3",
234
+ children: [/* @__PURE__ */ jsx("div", {
235
+ className: "flex flex-col text-2xl font-bold bg-primary text-primary-foreground items-center justify-center rounded size-12",
236
+ children: selectedPlan.name.at(0)?.toUpperCase()
237
+ }), /* @__PURE__ */ jsxs("div", {
238
+ className: "flex flex-col",
239
+ children: [/* @__PURE__ */ jsx("span", {
240
+ className: "text-lg font-bold",
241
+ children: selectedPlan.name
242
+ }), /* @__PURE__ */ jsx("span", {
243
+ className: "text-sm font-normal text-muted-foreground",
244
+ children: selectedPlan.description || "Selected plan"
245
+ })]
246
+ })]
247
+ }),
248
+ price && price.monthly > 0 && /* @__PURE__ */ jsxs("div", {
249
+ className: "text-right",
250
+ children: [/* @__PURE__ */ jsxs("div", {
251
+ className: "text-2xl font-bold",
252
+ children: ["$", price.monthly.toLocaleString()]
253
+ }), billingCycle && /* @__PURE__ */ jsxs("div", {
254
+ className: "text-sm text-muted-foreground font-normal",
255
+ children: ["Billed ", formatBillingCycle(billingCycle)]
256
+ })]
257
+ }),
258
+ price && price.monthly === 0 && /* @__PURE__ */ jsx("div", {
259
+ className: "text-2xl text-muted-foreground font-bold",
260
+ children: "Free"
261
+ })
262
+ ]
263
+ }) }), /* @__PURE__ */ jsxs(CardContent, { children: [
264
+ /* @__PURE__ */ jsx(Separator, {}),
265
+ /* @__PURE__ */ jsx("div", {
266
+ className: "text-sm font-medium mb-3 mt-3",
267
+ children: "What's included:"
268
+ }),
269
+ /* @__PURE__ */ jsxs("div", {
270
+ className: "grid grid-cols-2 gap-2 text-muted-foreground",
271
+ children: [quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, {
272
+ quota,
273
+ className: "text-muted-foreground"
274
+ }, quota.key)), features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, {
275
+ feature,
276
+ className: "text-muted-foreground"
277
+ }, feature.key))]
278
+ })
279
+ ] })]
280
+ }),
281
+ /* @__PURE__ */ jsxs("div", {
282
+ className: "space-y-3 mt-4",
283
+ children: [/* @__PURE__ */ jsx(Button, {
284
+ className: "w-full",
285
+ onClick: () => createSubscriptionMutation.mutate(),
286
+ disabled: createSubscriptionMutation.isPending,
287
+ children: createSubscriptionMutation.isPending ? "Processing Payment..." : "Confirm & Subscribe"
288
+ }), /* @__PURE__ */ jsx(Button, {
289
+ variant: "outline",
290
+ className: "w-full",
291
+ asChild: true
292
+ })]
293
+ }),
294
+ /* @__PURE__ */ jsx("div", {
295
+ className: "mt-6 pt-6 border-t text-center",
296
+ children: /* @__PURE__ */ jsx("p", {
297
+ className: "text-xs text-muted-foreground",
298
+ children: "By confirming, you agree to our Terms of Service and Privacy Policy. You can cancel anytime."
299
+ })
300
+ })
301
+ ]
302
+ }),
303
+ /* @__PURE__ */ jsxs("div", {
304
+ className: "flex items-center gap-2 text-muted-foreground text-xs item-center justify-center pt-4",
305
+ children: [/* @__PURE__ */ jsx(LockIcon, { className: "size-3" }), "Your payment is secured by Stripe"]
306
+ })
307
+ ]
308
+ })
309
+ });
310
+ };
311
+ var CheckoutConfimPage_default = CheckoutConfirmPage;
312
+
313
+ //#endregion
314
+ //#region src/pages/CheckoutFailedPage.tsx
315
+ const CheckoutFailedPage = () => {
316
+ return /* @__PURE__ */ jsx("div", { children: "CheckoutFailedPage" });
317
+ };
318
+ var CheckoutFailedPage_default = CheckoutFailedPage;
319
+
320
+ //#endregion
321
+ //#region src/hooks/useUrlUtils.ts
322
+ const useUrlUtils = () => {
323
+ const basePath = useZudoku().options.basePath;
324
+ return { generateUrl: (path) => {
325
+ if (!window.location.origin) throw new Error("Only works in browser environment");
326
+ return joinUrl(window.location.origin, basePath, path);
327
+ } };
328
+ };
329
+
330
+ //#endregion
331
+ //#region src/pages/CheckoutPage.tsx
332
+ const CheckoutPage = ({ environmentName }) => {
333
+ const [search] = useSearchParams();
334
+ const zudoku = useZudoku();
335
+ const planId = search.get("plan");
336
+ const auth = useAuth();
337
+ const { generateUrl } = useUrlUtils();
338
+ const { data: _data } = useQuery({
339
+ queryKey: ["plan", planId],
340
+ queryFn: async () => {
341
+ if (!auth.profile?.email) throw new Error("No email found for user. Make sure your Authentication Provider exposes the email address.");
342
+ const request = await zudoku.signRequest(new Request(`https://api.zuploedge.com/v3/zudoku-metering/${environmentName}/stripe/checkout`, {
343
+ method: "POST",
344
+ headers: { "Content-Type": "application/json" },
345
+ body: JSON.stringify({
346
+ email: auth.profile?.email,
347
+ planId,
348
+ successURL: generateUrl(`/checkout-confirm`) + `?${planId ? `plan=${planId}` : ""}`,
349
+ cancelURL: generateUrl(`/checkout-failed`) + `?${planId ? `plan=${planId}` : ""}`
350
+ })
351
+ }));
352
+ console.log({
353
+ email: auth.profile?.email,
354
+ planId,
355
+ successURL: `${generateUrl(`/checkout-confirm`)}?${planId ? `plan=${planId}` : ""}`,
356
+ cancelURL: `${generateUrl(`/checkout-failed`)}?${planId ? `plan=${planId}` : ""}`
357
+ });
358
+ const checkoutRequest = await fetch(request).then((res) => res.json());
359
+ if (checkoutRequest.url) window.location.href = checkoutRequest.url;
360
+ return checkoutRequest;
361
+ }
362
+ });
363
+ return /* @__PURE__ */ jsx("div", {
364
+ className: "flex min-h-screen items-center justify-center bg-muted",
365
+ children: /* @__PURE__ */ jsxs("div", {
366
+ className: "flex max-w-md flex-col items-center space-y-6 text-center",
367
+ children: [
368
+ /* @__PURE__ */ jsx("div", {
369
+ className: "relative",
370
+ children: /* @__PURE__ */ jsx("div", {
371
+ className: "flex h-24 w-24 items-center justify-center rounded-full bg-foreground/10",
372
+ children: /* @__PURE__ */ jsx(ShieldIcon, { className: "w-12 h-12 text-foreground" })
373
+ })
374
+ }),
375
+ /* @__PURE__ */ jsxs("div", {
376
+ className: "space-y-2",
377
+ children: [
378
+ /* @__PURE__ */ jsx("h2", {
379
+ className: "text-2xl font-bold text-card-foreground",
380
+ children: "Establishing encrypted connection..."
381
+ }),
382
+ /* @__PURE__ */ jsx("p", {
383
+ className: "text-muted-foreground",
384
+ children: "Setting up your secure checkout experience"
385
+ }),
386
+ /* @__PURE__ */ jsx("p", {
387
+ className: "text-sm text-muted-foreground",
388
+ children: "Powered by Stripe for maximum security"
389
+ })
390
+ ]
391
+ }),
392
+ /* @__PURE__ */ jsxs("div", {
393
+ className: "flex space-x-2",
394
+ children: [
395
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-3 animate-pulse rounded-full bg-primary [animation-delay:-0.3s]" }),
396
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-3 animate-pulse rounded-full bg-primary [animation-delay:-0.15s]" }),
397
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-3 animate-pulse rounded-full bg-primary" })
398
+ ]
399
+ })
400
+ ]
401
+ })
402
+ });
403
+ };
404
+ var CheckoutPage_default = CheckoutPage;
405
+
406
+ //#endregion
407
+ //#region src/pages/PricingPage.tsx
408
+ const PricingCard = ({ plan, isPopular = false }) => {
409
+ const defaultPhase = plan.phases.at(-1);
410
+ if (!defaultPhase) return null;
411
+ const { quotas, features } = categorizeRateCards(defaultPhase.rateCards);
412
+ const price = getPriceFromPlan(plan);
413
+ const isFree = price.monthly === 0;
414
+ const isCustom = plan.key === "enterprise";
415
+ return /* @__PURE__ */ jsxs("div", {
416
+ className: cn("relative rounded-lg border p-6 flex flex-col min-w-72", isPopular && "border-primary border-2"),
417
+ children: [
418
+ isPopular && /* @__PURE__ */ jsx("div", {
419
+ className: "absolute top-0 -translate-y-1/2 left-1/2 -translate-x-1/2 whitespace-nowrap",
420
+ children: /* @__PURE__ */ jsx("span", {
421
+ className: "bg-primary text-primary-foreground text-xs font-semibold px-3 py-1 rounded-full uppercase",
422
+ children: "Most Popular"
423
+ })
424
+ }),
425
+ /* @__PURE__ */ jsxs("div", {
426
+ className: "mb-4 pb-4 border-b",
427
+ children: [
428
+ /* @__PURE__ */ jsx("h3", {
429
+ className: "text-base font-semibold text-muted-foreground mb-2",
430
+ children: plan.name
431
+ }),
432
+ /* @__PURE__ */ jsx("div", {
433
+ className: "flex items-baseline gap-1 flex-wrap",
434
+ children: isCustom ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
435
+ className: "text-3xl font-bold text-card-foreground",
436
+ children: "Custom"
437
+ }), /* @__PURE__ */ jsx("div", {
438
+ className: "text-sm text-muted-foreground mt-1",
439
+ children: "Contact Sales"
440
+ })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
441
+ className: "text-3xl font-bold text-card-foreground",
442
+ children: isFree ? "Free" : `$${price.monthly.toLocaleString()}`
443
+ }), !isFree && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
444
+ className: "text-muted-foreground text-sm",
445
+ children: "/mo"
446
+ }), /* @__PURE__ */ jsxs("div", {
447
+ className: "w-full text-sm text-muted-foreground mt-1",
448
+ children: [
449
+ "$",
450
+ price.yearly.toLocaleString(),
451
+ "/year"
452
+ ]
453
+ })] })] })
454
+ }),
455
+ isFree && /* @__PURE__ */ jsx("div", {
456
+ className: "text-sm text-muted-foreground mt-1",
457
+ children: "No CC required"
458
+ })
459
+ ]
460
+ }),
461
+ /* @__PURE__ */ jsxs("div", {
462
+ className: "space-y-4 mb-6 flex-grow",
463
+ children: [quotas.length > 0 && /* @__PURE__ */ jsx("div", {
464
+ className: "space-y-2",
465
+ children: quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, { quota }, quota.key))
466
+ }), features.length > 0 && /* @__PURE__ */ jsx("div", {
467
+ className: "space-y-2",
468
+ children: features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, { feature }, feature.key))
469
+ })]
470
+ }),
471
+ /* @__PURE__ */ jsx(Button, {
472
+ variant: isPopular ? "default" : "secondary",
473
+ asChild: true,
474
+ children: /* @__PURE__ */ jsx(Link, {
475
+ to: `/checkout?plan=${plan.id}`,
476
+ children: "Subscribe"
477
+ })
478
+ })
479
+ ]
480
+ });
481
+ };
482
+ const PricingPage = ({ environmentName }) => {
483
+ const { data: pricingTableData } = usePlans(environmentName);
484
+ const planOrder = [
485
+ "developer",
486
+ "startup",
487
+ "pro",
488
+ "business",
489
+ "enterprise"
490
+ ];
491
+ const sortedPlans = [...pricingTableData.items].sort((a, b) => {
492
+ return planOrder.indexOf(a.key) - planOrder.indexOf(b.key);
493
+ });
494
+ const getGridCols = (count) => {
495
+ if (count === 1) return "lg:grid-cols-1";
496
+ if (count === 2) return "lg:grid-cols-2";
497
+ if (count === 3) return "lg:grid-cols-3";
498
+ if (count === 4) return "lg:grid-cols-4";
499
+ return "lg:grid-cols-5";
500
+ };
501
+ return /* @__PURE__ */ jsxs("div", {
502
+ className: "w-full px-4 py-12",
503
+ children: [/* @__PURE__ */ jsxs("div", {
504
+ className: "text-center mb-12",
505
+ children: [/* @__PURE__ */ jsx(Heading, {
506
+ level: 1,
507
+ children: "Pricing"
508
+ }), /* @__PURE__ */ jsx("p", {
509
+ className: "text-lg text-gray-600 dark:text-gray-400",
510
+ children: "Global live music data, flexible plans for every scale"
511
+ })]
512
+ }), /* @__PURE__ */ jsx("div", {
513
+ className: "flex justify-center",
514
+ children: /* @__PURE__ */ jsx("div", {
515
+ className: cn("w-full md:w-auto grid grid-cols-1 md:grid-cols-2 gap-6 md:max-w-fit", getGridCols(sortedPlans.length)),
516
+ children: sortedPlans.map((plan) => /* @__PURE__ */ jsx(PricingCard, {
517
+ plan,
518
+ isPopular: plan.key === "pro"
519
+ }, plan.id))
520
+ })
521
+ })]
522
+ });
523
+ };
524
+ var PricingPage_default = PricingPage;
525
+
526
+ //#endregion
527
+ //#region src/hooks/useSubscriptions.ts
528
+ const useSubscriptions = (environmentName) => {
529
+ return useSuspenseQuery({
530
+ meta: { context: useZudoku() },
531
+ queryKey: [`/v3/zudoku-metering/${environmentName}/subscriptions`]
532
+ });
533
+ };
534
+
535
+ //#endregion
536
+ //#region src/ZuploMonetizationWrapper.tsx
537
+ const BASE_URL = "https://api.zuploedge.com";
538
+ const createMutationFn = (url, context, init) => {
539
+ return async () => {
540
+ const request = new Request(`${BASE_URL}${url}`, {
541
+ method: "POST",
542
+ ...init,
543
+ headers: {
544
+ "Content-Type": "application/json",
545
+ ...init?.headers
546
+ }
547
+ });
548
+ const response = await fetch(context ? await context.signRequest(request) : request);
549
+ if (!response.ok) {
550
+ const errorText = await response.text();
551
+ throw new Error(`Request failed: ${response.status} ${errorText}`);
552
+ }
553
+ return response.json();
554
+ };
555
+ };
556
+ const client = new QueryClient({ defaultOptions: {
557
+ queries: {
558
+ retry: false,
559
+ queryFn: async (q) => {
560
+ if (!Array.isArray(q.queryKey)) throw new Error("Query key must be an array");
561
+ if (q.queryKey.length === 0) throw new Error("Query key must be a non-empty array");
562
+ const url = q.queryKey[0];
563
+ if (!url || typeof url !== "string") throw new Error("URL is required");
564
+ const init = q.queryKey[1] ?? {};
565
+ const request = new Request(`${BASE_URL}${url}`, {
566
+ ...init,
567
+ headers: {
568
+ "Content-Type": "application/json",
569
+ ...init.headers
570
+ }
571
+ });
572
+ const response = await fetch(q.meta?.context ? await q.meta.context.signRequest(request) : request);
573
+ if (!response.ok) throw new Error("Failed to fetch request");
574
+ return response.json();
575
+ }
576
+ },
577
+ mutations: { retry: false }
578
+ } });
579
+ const ZuploMonetizationWrapper = () => {
580
+ return /* @__PURE__ */ jsx(QueryClientProvider, {
581
+ client,
582
+ children: /* @__PURE__ */ jsx(Outlet, {})
583
+ });
584
+ };
585
+ var ZuploMonetizationWrapper_default = ZuploMonetizationWrapper;
586
+
587
+ //#endregion
588
+ //#region src/pages/subscriptions/ApiKey.tsx
589
+ const ApiKey = ({ apiKey, createdAt, lastUsed, expiresAt, isActive = true, label, apiKeyId }) => {
590
+ const formatDate = (dateString) => {
591
+ if (!dateString) return "";
592
+ return new Date(dateString).toLocaleDateString("en-US", {
593
+ month: "short",
594
+ day: "numeric",
595
+ year: "numeric"
596
+ });
597
+ };
598
+ const getTimeAgo = (dateString) => {
599
+ if (!dateString) return "Never";
600
+ const date = new Date(dateString);
601
+ const now = /* @__PURE__ */ new Date();
602
+ const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1e3 * 60));
603
+ if (diffInMinutes < 1) return "Just now";
604
+ if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`;
605
+ const diffInHours = Math.floor(diffInMinutes / 60);
606
+ if (diffInHours < 24) return `${diffInHours} hours ago`;
607
+ const diffInDays = Math.floor(diffInHours / 24);
608
+ if (diffInDays === 1) return "1 day ago";
609
+ if (diffInDays < 30) return `${diffInDays} days ago`;
610
+ return formatDate(dateString);
611
+ };
612
+ const isExpiring = expiresAt && new Date(expiresAt) < new Date(Date.now() + 720 * 60 * 60 * 1e3);
613
+ const isExpired = expiresAt && new Date(expiresAt) < /* @__PURE__ */ new Date();
614
+ const handleDelete = () => {
615
+ console.log("Delete API Key", apiKeyId);
616
+ };
617
+ return /* @__PURE__ */ jsx("div", {
618
+ className: isExpiring && !isExpired ? "border-l-4 border-yellow-500 pl-4" : "",
619
+ children: /* @__PURE__ */ jsx(Card, {
620
+ className: isExpiring && !isExpired ? "border-yellow-200 bg-yellow-50" : "",
621
+ children: /* @__PURE__ */ jsx(CardContent, {
622
+ className: "p-6",
623
+ children: /* @__PURE__ */ jsxs("div", {
624
+ className: "space-y-4",
625
+ children: [
626
+ /* @__PURE__ */ jsx("div", {
627
+ className: "flex items-center justify-between",
628
+ children: /* @__PURE__ */ jsxs("div", {
629
+ className: "flex items-center gap-2",
630
+ children: [/* @__PURE__ */ jsx("span", {
631
+ className: "font-semibold text-base",
632
+ children: label || "API Key"
633
+ }), isActive ? /* @__PURE__ */ jsx("span", {
634
+ className: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800",
635
+ children: "Active"
636
+ }) : /* @__PURE__ */ jsx("span", {
637
+ className: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800",
638
+ children: "Expiring"
639
+ })]
640
+ })
641
+ }),
642
+ /* @__PURE__ */ jsxs("div", {
643
+ className: "flex items-center gap-1.5 text-sm text-muted-foreground",
644
+ children: [
645
+ /* @__PURE__ */ jsxs("div", {
646
+ className: "flex items-center gap-1.5",
647
+ children: [/* @__PURE__ */ jsx(ClockIcon, { className: "size-3.5" }), /* @__PURE__ */ jsxs("span", { children: ["Created ", formatDate(createdAt)] })]
648
+ }),
649
+ /* @__PURE__ */ jsx("span", { children: "•" }),
650
+ /* @__PURE__ */ jsxs("span", { children: ["Last used ", getTimeAgo(lastUsed)] }),
651
+ expiresAt,
652
+ expiresAt && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { children: "•" }), /* @__PURE__ */ jsxs("span", {
653
+ className: isExpired ? "text-red-700 font-medium" : isExpiring ? "text-yellow-700 font-medium" : "",
654
+ children: ["Expires ", formatDate(expiresAt)]
655
+ })] })
656
+ ]
657
+ }),
658
+ /* @__PURE__ */ jsxs("div", {
659
+ className: "flex items-center gap-2 rounded-md font-mono text-sm",
660
+ children: [/* @__PURE__ */ jsx(Secret, {
661
+ secret: apiKey,
662
+ status: isActive ? "active" : "expiring"
663
+ }), !isActive && /* @__PURE__ */ jsx("button", {
664
+ onClick: handleDelete,
665
+ className: "text-red-500 hover:text-red-700 p-1",
666
+ type: "button",
667
+ "aria-label": "Delete API key",
668
+ children: /* @__PURE__ */ jsx(Trash2Icon, { className: "size-4" })
669
+ })]
670
+ })
671
+ ]
672
+ })
673
+ })
674
+ })
675
+ });
676
+ };
677
+
678
+ //#endregion
679
+ //#region src/pages/subscriptions/ApiKeysList.tsx
680
+ const ApiKeysList = ({ apiKeys, deploymentName, consumerId }) => {
681
+ const queryClient = useQueryClient();
682
+ const context = useZudoku();
683
+ const rollKeyMutation = useMutation({
684
+ mutationFn: createMutationFn(`/v2/client/${deploymentName}/consumers/${consumerId}/roll-key`, context, {
685
+ method: "POST",
686
+ body: JSON.stringify({})
687
+ }),
688
+ onSuccess: () => {
689
+ queryClient.invalidateQueries({ queryKey: [`/${deploymentName}/consumers/${consumerId}`] });
690
+ }
691
+ });
692
+ if (!apiKeys || apiKeys.length === 0) return null;
693
+ const sortedKeys = [...apiKeys].sort((a, b) => {
694
+ const aExpired = new Date(a.expiresAt) < /* @__PURE__ */ new Date();
695
+ if (aExpired !== new Date(b.expiresAt) < /* @__PURE__ */ new Date()) return aExpired ? 1 : -1;
696
+ return new Date(b.createdOn).getTime() - new Date(a.createdOn).getTime();
697
+ });
698
+ const activeKey = sortedKeys.find((k) => !k.expiresAt);
699
+ const expiringKeys = sortedKeys.filter((k) => !k.expiresAt);
700
+ return /* @__PURE__ */ jsxs("div", {
701
+ className: "space-y-4",
702
+ children: [/* @__PURE__ */ jsxs("div", {
703
+ className: "flex items-center justify-between",
704
+ children: [/* @__PURE__ */ jsx("h3", {
705
+ className: "text-lg font-semibold",
706
+ children: "API Keys"
707
+ }), /* @__PURE__ */ jsxs(Button$1, {
708
+ onClick: () => rollKeyMutation.mutate(),
709
+ disabled: rollKeyMutation.isPending,
710
+ children: [/* @__PURE__ */ jsx(RefreshCwIcon, { className: `size-4 ${rollKeyMutation.isPending ? "animate-spin" : ""}` }), rollKeyMutation.isPending ? "Rolling..." : "Roll API Key"]
711
+ })]
712
+ }), /* @__PURE__ */ jsxs("div", {
713
+ className: "space-y-4",
714
+ children: [activeKey && /* @__PURE__ */ jsx(ApiKey, {
715
+ deploymentName,
716
+ consumerId,
717
+ apiKeyId: activeKey.id,
718
+ apiKey: activeKey.key,
719
+ createdAt: activeKey.createdOn,
720
+ lastUsed: activeKey.updatedOn,
721
+ expiresAt: activeKey.expiresAt,
722
+ isActive: true,
723
+ label: "Current Key"
724
+ }, activeKey.id), expiringKeys.map((apiKey) => /* @__PURE__ */ jsx(ApiKey, {
725
+ deploymentName,
726
+ consumerId,
727
+ apiKeyId: apiKey.id,
728
+ apiKey: apiKey.key,
729
+ createdAt: apiKey.createdOn,
730
+ lastUsed: apiKey.updatedOn,
731
+ expiresAt: apiKey.expiresAt,
732
+ isActive: false,
733
+ label: "Previous Key"
734
+ }, apiKey.id))]
735
+ })]
736
+ });
737
+ };
738
+
739
+ //#endregion
740
+ //#region src/pages/subscriptions/SubscriptionsList.tsx
741
+ const SubscriptionsList = ({ subscriptions, activeSubscriptionId }) => {
742
+ return /* @__PURE__ */ jsx("div", {
743
+ className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3",
744
+ children: subscriptions.map((subscription) => {
745
+ const isActive = activeSubscriptionId === subscription.id;
746
+ return /* @__PURE__ */ jsx(Link, {
747
+ to: `/subscriptions/${subscription.id}`,
748
+ children: /* @__PURE__ */ jsx(Item, {
749
+ size: "sm",
750
+ variant: "outline",
751
+ className: cn(isActive && "border-primary bg-primary/5 shadow-md"),
752
+ children: /* @__PURE__ */ jsx(ItemContent, { children: /* @__PURE__ */ jsx(ItemTitle, { children: subscription.name }) })
753
+ }, subscription.id)
754
+ }, subscription.id);
755
+ })
756
+ });
757
+ };
758
+
759
+ //#endregion
760
+ //#region src/pages/subscriptions/Usage.tsx
761
+ const isMeteredEntitlement = (entitlement) => {
762
+ return "balance" in entitlement;
763
+ };
764
+ const Usage = ({ subscriptionId, environmentName }) => {
765
+ const zudoku = useZudoku();
766
+ const { data: plans } = usePlans(environmentName);
767
+ const { data: usage } = useSuspenseQuery({
768
+ queryKey: [`/v3/zudoku-metering/${environmentName}/subscriptions/${subscriptionId}/usage`],
769
+ meta: { context: zudoku }
770
+ });
771
+ return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Heading, {
772
+ level: 3,
773
+ className: "mb-4",
774
+ children: "Usage"
775
+ }), /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, {
776
+ className: "p-6",
777
+ children: Object.entries(usage.entitlements).filter((entry) => isMeteredEntitlement(entry[1])).map(([key, metric], index) => /* @__PURE__ */ jsxs("div", { children: [index > 0 && /* @__PURE__ */ jsx("div", { className: "my-4 border-t" }), /* @__PURE__ */ jsxs("div", {
778
+ className: "space-y-2",
779
+ children: [
780
+ /* @__PURE__ */ jsxs("div", {
781
+ className: "flex items-center justify-between text-sm",
782
+ children: [/* @__PURE__ */ jsxs("div", {
783
+ className: "flex flex-col gap-2",
784
+ children: [/* @__PURE__ */ jsx("span", {
785
+ className: "text-base font-medium capitalize",
786
+ children: key
787
+ }), /* @__PURE__ */ jsxs("span", {
788
+ className: "text-muted-foreground",
789
+ children: [metric.usage.toLocaleString(), " used"]
790
+ })]
791
+ }), /* @__PURE__ */ jsxs("span", {
792
+ className: "text-muted-foreground",
793
+ children: [metric.balance.toLocaleString(), " limit"]
794
+ })]
795
+ }),
796
+ /* @__PURE__ */ jsx(Progress, {
797
+ value: metric.usage / metric.balance * 100,
798
+ className: "h-2"
799
+ }),
800
+ /* @__PURE__ */ jsxs("p", {
801
+ className: "text-sm text-muted-foreground",
802
+ children: [metric.balance - metric.usage, " calls remaining this month"]
803
+ })
804
+ ]
805
+ })] }, key))
806
+ }) })] });
807
+ };
808
+
809
+ //#endregion
810
+ //#region src/pages/SubscriptionsPage.tsx
811
+ const SubscriptionsPage = ({ environmentName }) => {
812
+ const { data } = useSubscriptions(environmentName);
813
+ const { subscriptionId } = useParams();
814
+ const subscriptions = data?.items ?? [];
815
+ const activeSubscription = useMemo(() => {
816
+ if (subscriptions.length === 0) return null;
817
+ if (subscriptionId) return subscriptions.find((s) => s.id === subscriptionId) ?? subscriptions[0];
818
+ return subscriptions[0];
819
+ }, [subscriptions, subscriptionId]);
820
+ return /* @__PURE__ */ jsx("div", {
821
+ className: "w-full py-12",
822
+ children: /* @__PURE__ */ jsxs("div", {
823
+ className: "max-w-4xl space-y-8",
824
+ children: [
825
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
826
+ className: "text-2xl font-bold text-foreground mb-2",
827
+ children: "My Subscriptions"
828
+ }), /* @__PURE__ */ jsx("p", {
829
+ className: "text-base text-muted-foreground",
830
+ children: "Manage your subscriptions, usage, and API keys"
831
+ })] }),
832
+ /* @__PURE__ */ jsx(SubscriptionsList, {
833
+ subscriptions,
834
+ activeSubscriptionId: activeSubscription?.id
835
+ }),
836
+ activeSubscription && /* @__PURE__ */ jsx(Usage, {
837
+ subscriptionId: activeSubscription?.id,
838
+ environmentName
839
+ }),
840
+ activeSubscription?.consumer?.apiKeys && /* @__PURE__ */ jsx(ApiKeysList, {
841
+ deploymentName: environmentName,
842
+ consumerId: activeSubscription.consumer.id,
843
+ apiKeys: activeSubscription.consumer.apiKeys
844
+ }),
845
+ subscriptions.length === 0 && /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, {
846
+ className: "p-12 text-center",
847
+ children: /* @__PURE__ */ jsx("p", {
848
+ className: "text-muted-foreground",
849
+ children: "No active subscriptions found."
850
+ })
851
+ }) })
852
+ ]
853
+ })
854
+ });
855
+ };
856
+ var SubscriptionsPage_default = SubscriptionsPage;
857
+
858
+ //#endregion
859
+ //#region src/ZuploMonetizationPlugin.tsx
860
+ const PRICING_PATH = "/pricing";
861
+ const enableMonetization = (config, options) => {
862
+ return {
863
+ ...config,
864
+ plugins: [...config.plugins ?? [], zuploMonetizationPlugin(options)],
865
+ slots: { "head-navigation-start": () => {
866
+ return /* @__PURE__ */ jsx(Link, {
867
+ to: PRICING_PATH,
868
+ children: "Pricing"
869
+ });
870
+ } }
871
+ };
872
+ };
873
+ const zuploMonetizationPlugin = (options) => {
874
+ return {
875
+ getIdentities: async (context) => {
876
+ return (await client.fetchQuery({
877
+ queryKey: [`/v3/zudoku-metering/${options.environmentName}/subscriptions`],
878
+ meta: { context }
879
+ })).items.flatMap((item) => item.consumer.apiKeys.map((apiKey) => ({
880
+ label: item.name,
881
+ id: apiKey.id,
882
+ authorizeRequest: async (request) => {
883
+ return new Request(request, { headers: { Authorization: `Bearer ${apiKey.key}` } });
884
+ },
885
+ authorizationFields: { headers: ["Authorization"] }
886
+ })));
887
+ },
888
+ getProfileMenuItems: () => [{
889
+ label: "My Subscriptions",
890
+ path: "/subscriptions",
891
+ icon: StarsIcon
892
+ }],
893
+ getRoutes: () => [{
894
+ Component: ZuploMonetizationWrapper_default,
895
+ handle: { layout: "none" },
896
+ children: [{
897
+ path: "/checkout",
898
+ element: /* @__PURE__ */ jsx(CheckoutPage_default, { environmentName: options.environmentName })
899
+ }, {
900
+ path: "/checkout-confirm",
901
+ element: /* @__PURE__ */ jsx(CheckoutConfimPage_default, { environmentName: options.environmentName })
902
+ }]
903
+ }, {
904
+ Component: ZuploMonetizationWrapper_default,
905
+ children: [
906
+ {
907
+ path: "/pricing",
908
+ element: /* @__PURE__ */ jsx(PricingPage_default, { environmentName: options.environmentName })
909
+ },
910
+ {
911
+ path: "/checkout-failed",
912
+ element: /* @__PURE__ */ jsx(CheckoutFailedPage_default, {})
913
+ },
914
+ {
915
+ path: "/subscriptions/:subscriptionId?",
916
+ element: /* @__PURE__ */ jsx(SubscriptionsPage_default, { environmentName: options.environmentName })
917
+ }
918
+ ]
919
+ }],
920
+ getProtectedRoutes: () => {
921
+ return [
922
+ "/checkout",
923
+ "/checkout-success",
924
+ "/checkout-failed"
925
+ ];
926
+ }
927
+ };
928
+ };
929
+
930
+ //#endregion
931
+ export { enableMonetization, zuploMonetizationPlugin };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@zuplo/zudoku-plugin-monetization",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "./dist/index.mjs",
6
+ "types": "./dist/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.mjs",
10
+ "types": "./dist/index.d.mts"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "tinyduration": "3.4.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/react": "19.2.7",
21
+ "@types/react-dom": "19.2.3",
22
+ "react": "19.2.3",
23
+ "react-dom": "19.2.3",
24
+ "tsdown": "0.20.0-beta.3",
25
+ "zudoku": "0.67.0"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=19.2.0",
29
+ "react-dom": ">=19.2.0",
30
+ "zudoku": ">=0.67.0"
31
+ },
32
+ "scripts": {
33
+ "build": "tsdown",
34
+ "dev": "tsdown --watch"
35
+ }
36
+ }