@zuplo/zudoku-plugin-monetization 0.0.25 → 0.0.27

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 CHANGED
@@ -1,14 +1,17 @@
1
1
  import * as zudoku from "zudoku";
2
+ import "react";
2
3
 
3
- //#region src/ZuploMonetizationPlugin.d.ts
4
- type ZudokuMonetizationPluginOptions = {
4
+ //#region src/MonetizationContext.d.ts
5
+ interface MonetizationConfig {
5
6
  pricing?: {
6
7
  subtitle?: string;
7
8
  title?: string;
8
- units?: Record<string, string>;
9
9
  showYearlyPrice?: boolean;
10
+ units?: Record<string, string>;
10
11
  };
11
- };
12
- declare const zuploMonetizationPlugin: (options?: ZudokuMonetizationPluginOptions | undefined) => zudoku.ZudokuPlugin;
12
+ }
13
+ //#endregion
14
+ //#region src/ZuploMonetizationPlugin.d.ts
15
+ declare const zuploMonetizationPlugin: (options?: MonetizationConfig | undefined) => zudoku.ZudokuPlugin;
13
16
  //#endregion
14
17
  export { zuploMonetizationPlugin };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cn, createPlugin, joinUrl, throwIfProblemJson } from "zudoku";
2
- import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, CalendarIcon, CheckCheckIcon, CheckIcon, CircleAlert, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
2
+ import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, CalendarIcon, CheckCheckIcon, CheckIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
3
3
  import { Button, ClientOnly, Head, Heading, Link, Slot } from "zudoku/components";
4
4
  import { useAuth, useZudoku } from "zudoku/hooks";
5
5
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, useSuspenseQuery } from "zudoku/react-query";
@@ -8,9 +8,9 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "zudoku/ui/Aler
8
8
  import { Card, CardContent, CardHeader, CardTitle } from "zudoku/ui/Card";
9
9
  import { Separator } from "zudoku/ui/Separator";
10
10
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
+ import { createContext, use, useEffect, useMemo, useState } from "react";
11
12
  import { parse } from "tinyduration";
12
13
  import { Button as Button$1 } from "zudoku/ui/Button";
13
- import { useEffect, useMemo, useState } from "react";
14
14
  import { DismissibleAlert, DismissibleAlertAction } from "zudoku/ui/DismissibleAlert";
15
15
  import { ActionButton } from "zudoku/ui/ActionButton";
16
16
  import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "zudoku/ui/Item";
@@ -19,7 +19,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
19
19
  import { Frame, FrameFooter, FramePanel } from "zudoku/ui/Frame";
20
20
  import { Secret } from "zudoku/ui/Secret";
21
21
  import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "zudoku/ui/AlertDialog";
22
- import { Tooltip, TooltipContent, TooltipTrigger } from "zudoku/ui/Tooltip";
23
22
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "zudoku/ui/Dialog";
24
23
  import { Input } from "zudoku/ui/Input";
25
24
  import { Progress } from "zudoku/ui/Progress";
@@ -80,15 +79,9 @@ const useDeploymentName = () => {
80
79
  };
81
80
 
82
81
  //#endregion
83
- //#region src/hooks/usePlans.ts
84
- const usePlans = () => {
85
- const zudoku = useZudoku();
86
- const auth = useAuth();
87
- return useSuspenseQuery({
88
- queryKey: [`/v3/zudoku-metering/${useDeploymentName()}/pricing-page`],
89
- meta: { context: auth.isAuthenticated ? zudoku : void 0 }
90
- });
91
- };
82
+ //#region src/MonetizationContext.tsx
83
+ const MonetizationContext = createContext({});
84
+ const useMonetizationConfig = () => use(MonetizationContext);
92
85
 
93
86
  //#endregion
94
87
  //#region src/utils/formatDuration.ts
@@ -124,6 +117,24 @@ const formatDurationInterval = (iso) => {
124
117
  return iso;
125
118
  }
126
119
  };
120
+ /**
121
+ * Returns an adjective form suitable for possessive context
122
+ * e.g. "your monthly quota", "your weekly limit".
123
+ * Falls back to "billing period" for multi-unit cadences
124
+ * where "every 3 months" would be grammatically awkward.
125
+ */
126
+ const formatDurationAdjective = (iso) => {
127
+ try {
128
+ const d = parse(iso);
129
+ if (d.years === 1) return "yearly";
130
+ if (d.months === 1) return "monthly";
131
+ if (d.weeks === 1) return "weekly";
132
+ if (d.days === 1) return "daily";
133
+ return "billing period";
134
+ } catch {
135
+ return "billing period";
136
+ }
137
+ };
127
138
 
128
139
  //#endregion
129
140
  //#region src/utils/formatPrice.ts
@@ -137,7 +148,8 @@ const formatPrice = (amount, currency) => new Intl.NumberFormat("en-US", {
137
148
 
138
149
  //#endregion
139
150
  //#region src/utils/categorizeRateCards.ts
140
- const categorizeRateCards = (rateCards, currency, units) => {
151
+ const categorizeRateCards = (rateCards, options) => {
152
+ const { currency, units, planBillingCadence } = options ?? {};
141
153
  const quotas = [];
142
154
  const features = [];
143
155
  for (const rc of rateCards) {
@@ -157,7 +169,7 @@ const categorizeRateCards = (rateCards, currency, units) => {
157
169
  key: rc.featureKey ?? rc.key,
158
170
  name: rc.name,
159
171
  limit: et.issueAfterReset,
160
- period: et.usagePeriod ? formatDuration(et.usagePeriod) : "Month",
172
+ period: rc.billingCadence ? formatDuration(rc.billingCadence) : planBillingCadence ? formatDuration(planBillingCadence) : "month",
161
173
  overagePrice
162
174
  });
163
175
  } else if (et.type === "boolean") features.push({
@@ -267,26 +279,45 @@ const queryClient = new QueryClient({ defaultOptions: {
267
279
  }
268
280
  }
269
281
  } });
270
- const ZuploMonetizationWrapper = () => {
271
- return /* @__PURE__ */ jsx(QueryClientProvider, {
272
- client: queryClient,
282
+ const ZuploMonetizationWrapper = ({ options = {} }) => /* @__PURE__ */ jsx(QueryClientProvider, {
283
+ client: queryClient,
284
+ children: /* @__PURE__ */ jsx(MonetizationContext, {
285
+ value: options,
273
286
  children: /* @__PURE__ */ jsx(ClientOnly, { children: /* @__PURE__ */ jsx(Outlet, {}) })
274
- });
275
- };
287
+ })
288
+ });
276
289
 
277
290
  //#endregion
278
291
  //#region src/pages/CheckoutConfirmPage.tsx
292
+ const getPlanFromPurchaseDetails = (response) => {
293
+ return "plan" in response ? response.plan : response;
294
+ };
295
+ const getTaxAmountFromPurchaseDetails = (response) => {
296
+ const taxAmount = response?.tax?.amount;
297
+ const numericAmount = typeof taxAmount === "number" ? taxAmount : Number.parseFloat(taxAmount ?? "");
298
+ if (!Number.isFinite(numericAmount)) return;
299
+ return numericAmount;
300
+ };
279
301
  const CheckoutConfirmPage = () => {
280
302
  const [search] = useSearchParams();
281
303
  const planId = search.get("planId");
282
304
  const zudoku = useZudoku();
283
305
  const deploymentName = useDeploymentName();
284
306
  const navigate = useNavigate();
285
- const { data: plans } = usePlans();
286
- const selectedPlan = plans?.items?.find((plan) => plan.id === planId);
307
+ const { pricing } = useMonetizationConfig();
287
308
  if (!planId) throw new Error("Parameter `planId` missing");
309
+ const purchaseDetails = useSuspenseQuery({
310
+ queryKey: [`/v3/zudoku-metering/${deploymentName}/plans/${planId}/purchase-details`],
311
+ meta: { context: zudoku }
312
+ });
313
+ const selectedPlan = getPlanFromPurchaseDetails(purchaseDetails.data);
314
+ const taxAmount = getTaxAmountFromPurchaseDetails(purchaseDetails.data);
288
315
  const rateCards = selectedPlan?.phases.at(-1)?.rateCards;
289
- const { quotas, features } = categorizeRateCards(rateCards ?? [], selectedPlan?.currency);
316
+ const { quotas, features } = categorizeRateCards(rateCards ?? [], {
317
+ currency: selectedPlan?.currency,
318
+ units: pricing?.units,
319
+ planBillingCadence: selectedPlan?.billingCadence
320
+ });
290
321
  const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
291
322
  const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
292
323
  const createSubscriptionMutation = useMutation({
@@ -356,13 +387,24 @@ const CheckoutConfirmPage = () => {
356
387
  }),
357
388
  price && price.monthly > 0 && /* @__PURE__ */ jsxs("div", {
358
389
  className: "text-right",
359
- children: [/* @__PURE__ */ jsx("div", {
360
- className: "text-2xl font-bold",
361
- children: formatPrice(price.monthly, selectedPlan?.currency)
362
- }), billingCycle && /* @__PURE__ */ jsxs("div", {
363
- className: "text-sm text-muted-foreground font-normal",
364
- children: ["Billed ", formatBillingCycle(billingCycle)]
365
- })]
390
+ children: [
391
+ /* @__PURE__ */ jsx("div", {
392
+ className: "text-2xl font-bold",
393
+ children: formatPrice(price.monthly, selectedPlan?.currency)
394
+ }),
395
+ billingCycle && /* @__PURE__ */ jsxs("div", {
396
+ className: "text-sm text-muted-foreground font-normal",
397
+ children: ["Billed ", formatBillingCycle(billingCycle)]
398
+ }),
399
+ taxAmount != null && /* @__PURE__ */ jsxs("div", {
400
+ className: "text-xs text-muted-foreground font-normal mt-1",
401
+ children: [
402
+ "+ ",
403
+ formatPrice(taxAmount, selectedPlan?.currency),
404
+ " VAT"
405
+ ]
406
+ })
407
+ ]
366
408
  }),
367
409
  price && price.monthly === 0 && /* @__PURE__ */ jsx("div", {
368
410
  className: "text-2xl text-muted-foreground font-bold",
@@ -392,7 +434,7 @@ const CheckoutConfirmPage = () => {
392
434
  children: [/* @__PURE__ */ jsx(Button, {
393
435
  className: "w-full",
394
436
  onClick: () => createSubscriptionMutation.mutate(),
395
- disabled: createSubscriptionMutation.isPending,
437
+ disabled: createSubscriptionMutation.isPending || !selectedPlan,
396
438
  children: createSubscriptionMutation.isPending ? "Processing Payment..." : "Confirm & Subscribe"
397
439
  }), /* @__PURE__ */ jsx(Button, {
398
440
  variant: "ghost",
@@ -576,13 +618,27 @@ const ManagePaymentPage = () => {
576
618
  });
577
619
  };
578
620
 
621
+ //#endregion
622
+ //#region src/hooks/usePlans.ts
623
+ const usePlans = () => {
624
+ const zudoku = useZudoku();
625
+ const auth = useAuth();
626
+ return useSuspenseQuery({
627
+ queryKey: [`/v3/zudoku-metering/${useDeploymentName()}/pricing-page`],
628
+ meta: { context: auth.isAuthenticated ? zudoku : void 0 }
629
+ });
630
+ };
631
+
579
632
  //#endregion
580
633
  //#region src/pages/pricing/PricingCard.tsx
581
- const PhaseSection = ({ phase, currency, showName, excludeKeys, units }) => {
582
- const { quotas, features } = categorizeRateCards(phase.rateCards, currency, units);
583
- const filteredQuotas = quotas.filter((q) => !excludeKeys.has(q.key));
584
- const filteredFeatures = features.filter((f) => !excludeKeys.has(f.key));
585
- if (filteredQuotas.length === 0 && filteredFeatures.length === 0) return null;
634
+ const PhaseSection = ({ phase, currency, showName, billingCadence }) => {
635
+ const { pricing } = useMonetizationConfig();
636
+ const { quotas, features } = categorizeRateCards(phase.rateCards, {
637
+ currency,
638
+ units: pricing?.units,
639
+ planBillingCadence: billingCadence
640
+ });
641
+ if (quotas.length === 0 && features.length === 0) return null;
586
642
  return /* @__PURE__ */ jsxs("div", {
587
643
  className: "space-y-2",
588
644
  children: [
@@ -597,17 +653,19 @@ const PhaseSection = ({ phase, currency, showName, excludeKeys, units }) => {
597
653
  ]
598
654
  })]
599
655
  }),
600
- filteredQuotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, { quota }, quota.key)),
601
- filteredFeatures.map((feature) => /* @__PURE__ */ jsx(FeatureItem, { feature }, feature.key))
656
+ quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, { quota }, quota.key)),
657
+ features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, { feature }, feature.key))
602
658
  ]
603
659
  });
604
660
  };
605
- const PricingCard = ({ plan, isPopular = false, isSubscribed = false, showYearlyPrice = true, units }) => {
661
+ const PricingCard = ({ plan, isPopular = false, isSubscribed = false }) => {
662
+ const { pricing } = useMonetizationConfig();
606
663
  if (plan.phases.length === 0) return null;
607
664
  const price = getPriceFromPlan(plan);
608
665
  const isFree = price.monthly === 0;
609
666
  const isCustom = plan.metadata?.isCustom === true;
610
667
  const hasMultiplePhases = plan.phases.length > 1;
668
+ const billingInterval = formatDuration(plan.billingCadence);
611
669
  return /* @__PURE__ */ jsxs("div", {
612
670
  className: cn("relative rounded-lg border p-6 flex flex-col", isPopular && "border-primary border-2"),
613
671
  children: [
@@ -636,10 +694,10 @@ const PricingCard = ({ plan, isPopular = false, isSubscribed = false, showYearly
636
694
  })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
637
695
  className: "text-3xl font-bold text-card-foreground",
638
696
  children: isFree ? "Free" : formatPrice(price.monthly, plan.currency)
639
- }), !isFree && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
697
+ }), !isFree && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
640
698
  className: "text-muted-foreground text-sm",
641
- children: "/mo"
642
- }), showYearlyPrice && /* @__PURE__ */ jsxs("div", {
699
+ children: ["/", billingInterval]
700
+ }), pricing?.showYearlyPrice !== false && price.yearly > 0 && /* @__PURE__ */ jsxs("div", {
643
701
  className: "w-full text-sm text-muted-foreground mt-1",
644
702
  children: [formatPrice(price.yearly, plan.currency), "/year"]
645
703
  })] })] })
@@ -652,16 +710,12 @@ const PricingCard = ({ plan, isPopular = false, isSubscribed = false, showYearly
652
710
  }),
653
711
  /* @__PURE__ */ jsx("div", {
654
712
  className: "space-y-4 mb-6 grow",
655
- children: plan.phases.map((phase, index) => {
656
- const laterKeys = new Set(plan.phases.slice(index + 1).flatMap((p) => p.rateCards.map((rc) => rc.featureKey ?? rc.key)));
657
- return /* @__PURE__ */ jsx(PhaseSection, {
658
- phase,
659
- currency: plan.currency,
660
- showName: hasMultiplePhases,
661
- excludeKeys: laterKeys,
662
- units
663
- }, phase.key);
664
- })
713
+ children: plan.phases.map((phase) => /* @__PURE__ */ jsx(PhaseSection, {
714
+ phase,
715
+ currency: plan.currency,
716
+ showName: hasMultiplePhases,
717
+ billingCadence: plan.billingCadence
718
+ }, phase.key))
665
719
  }),
666
720
  isSubscribed ? /* @__PURE__ */ jsx(Button, {
667
721
  variant: isPopular ? "default" : "secondary",
@@ -684,7 +738,8 @@ const PricingCard = ({ plan, isPopular = false, isSubscribed = false, showYearly
684
738
 
685
739
  //#endregion
686
740
  //#region src/pages/PricingPage.tsx
687
- const PricingPage = ({ subtitle = "See our pricing options and choose the one that best suits your needs.", title = "Pricing", units, showYearlyPrice = true }) => {
741
+ const PricingPage = () => {
742
+ const { pricing } = useMonetizationConfig();
688
743
  const zudoku = useZudoku();
689
744
  const deploymentName = useDeploymentName();
690
745
  const auth = useAuth();
@@ -697,28 +752,26 @@ const PricingPage = ({ subtitle = "See our pricing options and choose the one th
697
752
  return /* @__PURE__ */ jsxs("div", {
698
753
  className: "w-full px-4 pt-(--padding-content-top) pb-(--padding-content-bottom)",
699
754
  children: [
700
- /* @__PURE__ */ jsxs(Head, { children: [/* @__PURE__ */ jsx("title", { children: title }), /* @__PURE__ */ jsx("meta", {
755
+ /* @__PURE__ */ jsxs(Head, { children: [/* @__PURE__ */ jsx("title", { children: pricing?.title ?? "Pricing" }), /* @__PURE__ */ jsx("meta", {
701
756
  name: "description",
702
- content: subtitle
757
+ content: pricing?.subtitle ?? "See our pricing options and choose the one that best suits your needs."
703
758
  })] }),
704
759
  /* @__PURE__ */ jsxs("div", {
705
760
  className: "text-center space-y-4 mb-12",
706
761
  children: [/* @__PURE__ */ jsx(Heading, {
707
762
  level: 1,
708
763
  "data-testid": "title",
709
- children: title
764
+ children: pricing?.title ?? "Pricing"
710
765
  }), /* @__PURE__ */ jsx("p", {
711
766
  className: "text-muted-foreground",
712
767
  "data-testid": "subtitle",
713
- children: subtitle
768
+ children: pricing?.subtitle ?? "See our pricing options and choose the one that best suits your needs."
714
769
  })]
715
770
  }),
716
771
  /* @__PURE__ */ jsx("div", {
717
772
  className: "w-full grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(300px,max-content))] justify-center gap-6",
718
773
  children: pricingTable.items.map((plan) => /* @__PURE__ */ jsx(PricingCard, {
719
774
  plan,
720
- units,
721
- showYearlyPrice,
722
775
  isPopular: plan.metadata?.zuplo_most_popular === "true",
723
776
  isSubscribed: subscriptions.items.some((subscription) => ["active", "canceled"].includes(subscription.status))
724
777
  }, plan.id))
@@ -738,11 +791,16 @@ const SubscriptionChangeConfirmPage = () => {
738
791
  const deploymentName = useDeploymentName();
739
792
  const navigate = useNavigate();
740
793
  const { data: plans } = usePlans();
794
+ const { pricing } = useMonetizationConfig();
741
795
  const selectedPlan = plans?.items?.find((plan) => plan.id === planId);
742
796
  if (!planId) throw new Error("Parameter `planId` missing");
743
797
  if (!subscriptionId) throw new Error("Parameter `subscriptionId` missing");
744
798
  const rateCards = selectedPlan?.phases.at(-1)?.rateCards;
745
- const { quotas, features } = categorizeRateCards(rateCards ?? [], selectedPlan?.currency);
799
+ const { quotas, features } = categorizeRateCards(rateCards ?? [], {
800
+ currency: selectedPlan?.currency,
801
+ units: pricing?.units,
802
+ planBillingCadence: selectedPlan?.billingCadence
803
+ });
746
804
  const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
747
805
  const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
748
806
  const changeMutation = useMutation({
@@ -1216,15 +1274,24 @@ const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId
1216
1274
  children: [
1217
1275
  /* @__PURE__ */ jsx(CalendarIcon, { className: "size-4" }),
1218
1276
  /* @__PURE__ */ jsx(AlertTitle, { children: "Your plan will be canceled at the end of your billing cycle." }),
1219
- /* @__PURE__ */ jsxs(AlertDescription, { children: ["You'll retain access until ", formatDate$1(billingPeriodEnd)] })
1277
+ /* @__PURE__ */ jsxs(AlertDescription, { children: [
1278
+ "You'll retain access until ",
1279
+ formatDate$1(billingPeriodEnd),
1280
+ ". After your billing period ends, this plan will not renew and you would need to subscribe again to continue."
1281
+ ] })
1220
1282
  ]
1221
1283
  }),
1222
1284
  /* @__PURE__ */ jsxs(Alert, {
1223
- variant: "destructive",
1285
+ variant: "info",
1224
1286
  children: [
1225
- /* @__PURE__ */ jsx(CircleAlert, { className: "size-4" }),
1226
- /* @__PURE__ */ jsx(AlertTitle, { children: "This action cannot be undone" }),
1227
- /* @__PURE__ */ jsx(AlertDescription, { children: "Once cancelled, you will not be able to recover this plan or its associated settings. You would need to subscribe again." })
1287
+ /* @__PURE__ */ jsx(InfoIcon, { className: "size-4" }),
1288
+ /* @__PURE__ */ jsx(AlertTitle, { children: "You can still resume before then" }),
1289
+ /* @__PURE__ */ jsxs(AlertDescription, { children: [
1290
+ "If you change your mind you have until",
1291
+ " ",
1292
+ formatDate$1(billingPeriodEnd),
1293
+ " to remove this cancellation from Manage subscription."
1294
+ ] })
1228
1295
  ]
1229
1296
  }),
1230
1297
  /* @__PURE__ */ jsxs("div", {
@@ -1277,17 +1344,107 @@ const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId
1277
1344
  });
1278
1345
  };
1279
1346
 
1347
+ //#endregion
1348
+ //#region src/pages/subscriptions/RestoreSubscriptionDialog.tsx
1349
+ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId, billingPeriodEnd }) => {
1350
+ const deploymentName = useDeploymentName();
1351
+ const context = useZudoku();
1352
+ const queryClient = useQueryClient();
1353
+ const restoreSubscriptionMutation = useMutation({
1354
+ mutationKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions/${subscriptionId}/restore`],
1355
+ meta: {
1356
+ context,
1357
+ request: { method: "POST" }
1358
+ },
1359
+ onSuccess: async () => {
1360
+ await queryClient.invalidateQueries();
1361
+ onOpenChange(false);
1362
+ }
1363
+ });
1364
+ useEffect(() => {
1365
+ if (open) restoreSubscriptionMutation.reset();
1366
+ }, [open, restoreSubscriptionMutation]);
1367
+ const handleOpenChange = (nextOpen) => {
1368
+ if (!nextOpen) restoreSubscriptionMutation.reset();
1369
+ onOpenChange(nextOpen);
1370
+ };
1371
+ return /* @__PURE__ */ jsx(Dialog, {
1372
+ open,
1373
+ onOpenChange: handleOpenChange,
1374
+ children: /* @__PURE__ */ jsxs(DialogContent, {
1375
+ className: "sm:max-w-md",
1376
+ children: [
1377
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [/* @__PURE__ */ jsx(DialogTitle, { children: "Resume subscription" }), /* @__PURE__ */ jsxs(DialogDescription, { children: [
1378
+ "You scheduled ",
1379
+ /* @__PURE__ */ jsx("span", {
1380
+ className: "font-medium",
1381
+ children: planName
1382
+ }),
1383
+ " to end. You can still change your mind before the current billing period ends."
1384
+ ] })] }),
1385
+ /* @__PURE__ */ jsxs("div", {
1386
+ className: "space-y-4 mt-4",
1387
+ children: [/* @__PURE__ */ jsxs(Alert, {
1388
+ variant: "info",
1389
+ children: [
1390
+ /* @__PURE__ */ jsx(CalendarIcon, { className: "size-4" }),
1391
+ /* @__PURE__ */ jsx(AlertTitle, { children: "What happens if you resume" }),
1392
+ /* @__PURE__ */ jsxs(AlertDescription, {
1393
+ className: "space-y-2",
1394
+ children: [/* @__PURE__ */ jsxs("p", { children: [
1395
+ "Your access stays in place until ",
1396
+ formatDate$1(billingPeriodEnd),
1397
+ " ",
1398
+ "either way."
1399
+ ] }), /* @__PURE__ */ jsx("p", { children: "Confirming will remove the pending cancellation. Your subscription will remain active and continue to renew on your normal billing schedule, and charges will apply as usual." })]
1400
+ })
1401
+ ]
1402
+ }), restoreSubscriptionMutation.isError && /* @__PURE__ */ jsxs(Alert, {
1403
+ variant: "destructive",
1404
+ children: [
1405
+ /* @__PURE__ */ jsx(CircleSlashIcon, { className: "size-4" }),
1406
+ /* @__PURE__ */ jsx(AlertTitle, { children: "Could not resume subscription" }),
1407
+ /* @__PURE__ */ jsx(AlertDescription, { children: restoreSubscriptionMutation.error.message })
1408
+ ]
1409
+ })]
1410
+ }),
1411
+ /* @__PURE__ */ jsxs("div", {
1412
+ className: "flex flex-col gap-2",
1413
+ children: [/* @__PURE__ */ jsx(ActionButton, {
1414
+ disabled: restoreSubscriptionMutation.isPending,
1415
+ isPending: restoreSubscriptionMutation.isPending || restoreSubscriptionMutation.isSuccess,
1416
+ onClick: () => restoreSubscriptionMutation.mutate(),
1417
+ children: "Resume subscription"
1418
+ }), /* @__PURE__ */ jsx(Button$1, {
1419
+ variant: "ghost",
1420
+ onClick: () => handleOpenChange(false),
1421
+ children: "Keep cancellation"
1422
+ })]
1423
+ })
1424
+ ]
1425
+ })
1426
+ });
1427
+ };
1428
+
1280
1429
  //#endregion
1281
1430
  //#region src/pages/subscriptions/SwitchPlanModal.tsx
1282
- const comparePlans = (currentPlan, targetPlan, currentIndex, targetIndex) => {
1431
+ const comparePlans = (currentPlan, targetPlan, currentIndex, targetIndex, units) => {
1283
1432
  const isUpgrade = targetIndex > currentIndex;
1284
1433
  const currentPhase = currentPlan?.phases.at(-1);
1285
1434
  const targetPhase = targetPlan.phases.at(-1);
1286
- const { quotas: currentQuotas, features: currentFeatures } = currentPhase ? categorizeRateCards(currentPhase.rateCards, currentPlan?.currency) : {
1435
+ const { quotas: currentQuotas, features: currentFeatures } = currentPhase ? categorizeRateCards(currentPhase.rateCards, {
1436
+ currency: currentPlan?.currency,
1437
+ units,
1438
+ planBillingCadence: currentPlan?.billingCadence
1439
+ }) : {
1287
1440
  quotas: [],
1288
1441
  features: []
1289
1442
  };
1290
- const { quotas: targetQuotas, features: targetFeatures } = targetPhase ? categorizeRateCards(targetPhase.rateCards, targetPlan.currency) : {
1443
+ const { quotas: targetQuotas, features: targetFeatures } = targetPhase ? categorizeRateCards(targetPhase.rateCards, {
1444
+ currency: targetPlan.currency,
1445
+ units,
1446
+ planBillingCadence: targetPlan.billingCadence
1447
+ }) : {
1291
1448
  quotas: [],
1292
1449
  features: []
1293
1450
  };
@@ -1394,7 +1551,11 @@ const PlanComparisonItem = ({ comparison, subscriptionId, mode, onRequestChange
1394
1551
  children: "Free"
1395
1552
  }) : /* @__PURE__ */ jsxs("span", {
1396
1553
  className: "text-primary font-medium text-lg",
1397
- children: [formatPrice(displayPrice, comparison.plan.currency), "/ mo"]
1554
+ children: [
1555
+ formatPrice(displayPrice, comparison.plan.currency),
1556
+ "/",
1557
+ formatDuration(comparison.plan.billingCadence)
1558
+ ]
1398
1559
  })]
1399
1560
  }), isCustom ? /* @__PURE__ */ jsx(Button$1, {
1400
1561
  variant: "default",
@@ -1558,6 +1719,7 @@ const SwitchPlanModal = ({ subscription, children }) => {
1558
1719
  const [open, setOpen] = useState(false);
1559
1720
  const { data: plansData } = usePlans();
1560
1721
  const [switchTo, setSwitchTo] = useState(null);
1722
+ const { pricing } = useMonetizationConfig();
1561
1723
  const currentPlan = plansData?.items.find((p) => p.id === subscription.plan.id);
1562
1724
  const { upgrades, downgrades, privatePlans } = useMemo(() => {
1563
1725
  if (!plansData?.items || !currentPlan) return {
@@ -1568,14 +1730,18 @@ const SwitchPlanModal = ({ subscription, children }) => {
1568
1730
  const isPrivatePlan = (plan) => plan.metadata?.zuplo_private_plan === "true";
1569
1731
  const currentIndex = plansData.items.findIndex((p) => p.id === currentPlan.id);
1570
1732
  const allComparisons = plansData.items.filter((p) => p.id !== currentPlan.id).map((plan) => {
1571
- return comparePlans(currentPlan, plan, currentIndex, plansData.items.indexOf(plan));
1733
+ return comparePlans(currentPlan, plan, currentIndex, plansData.items.indexOf(plan), pricing?.units);
1572
1734
  });
1573
1735
  return {
1574
1736
  upgrades: allComparisons.filter((c) => c.isUpgrade && !isPrivatePlan(c.plan)),
1575
1737
  downgrades: allComparisons.filter((c) => !c.isUpgrade && !isPrivatePlan(c.plan)),
1576
1738
  privatePlans: allComparisons.filter((c) => isPrivatePlan(c.plan))
1577
1739
  };
1578
- }, [plansData?.items, currentPlan]);
1740
+ }, [
1741
+ plansData?.items,
1742
+ currentPlan,
1743
+ pricing?.units
1744
+ ]);
1579
1745
  return /* @__PURE__ */ jsxs(Fragment, { children: [switchTo !== null && /* @__PURE__ */ jsx(ConfirmSwitchAlert, {
1580
1746
  switchTo,
1581
1747
  onRequestClose: () => setSwitchTo(null)
@@ -1680,81 +1846,92 @@ const SwitchPlanModal = ({ subscription, children }) => {
1680
1846
  //#region src/pages/subscriptions/ManageSubscription.tsx
1681
1847
  const ManageSubscription = ({ subscription, planName }) => {
1682
1848
  const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
1683
- return /* @__PURE__ */ jsxs(Card, { children: [/* @__PURE__ */ jsx(CancelSubscriptionDialog, {
1684
- open: cancelDialogOpen,
1685
- onOpenChange: setCancelDialogOpen,
1686
- planName,
1687
- subscriptionId: subscription.id,
1688
- billingPeriodEnd: subscription.alignment.currentAlignedBillingPeriod.to
1689
- }), /* @__PURE__ */ jsx(CardContent, {
1690
- className: "p-6",
1691
- children: /* @__PURE__ */ jsxs("div", {
1692
- className: "flex gap-4",
1693
- children: [/* @__PURE__ */ jsx("div", {
1694
- className: "flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 shrink-0",
1695
- children: /* @__PURE__ */ jsx(Settings, { className: "w-6 h-6 text-primary" })
1696
- }), /* @__PURE__ */ jsxs("div", {
1697
- className: "flex-1",
1698
- id: "manage",
1699
- children: [
1700
- /* @__PURE__ */ jsx("h2", {
1701
- className: "text-lg font-semibold text-foreground mb-1",
1702
- children: "Manage Subscription"
1703
- }),
1704
- /* @__PURE__ */ jsx("p", {
1705
- className: "text-sm text-muted-foreground mb-4",
1706
- children: "Switch to a different plan or cancel your current subscription."
1707
- }),
1708
- /* @__PURE__ */ jsxs("div", {
1709
- className: "flex flex-wrap gap-3",
1710
- children: [
1711
- subscription.status === "canceled" && /* @__PURE__ */ jsx(Button$1, {
1712
- variant: "outline",
1713
- size: "sm",
1714
- asChild: true,
1715
- children: /* @__PURE__ */ jsxs(Link, {
1716
- to: "/pricing",
1717
- children: [/* @__PURE__ */ jsx(RefreshCcw, { className: "w-4 h-4 mr-2" }), "New subscription"]
1718
- })
1719
- }),
1720
- subscription.status === "active" && /* @__PURE__ */ jsx(SwitchPlanModal, { subscription }),
1721
- /* @__PURE__ */ jsxs(Tooltip, {
1722
- delayDuration: 0,
1723
- children: [/* @__PURE__ */ jsx(TooltipTrigger, {
1849
+ const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
1850
+ const billingPeriodEnd = subscription.alignment.currentAlignedBillingPeriod.to;
1851
+ const canResumeCanceledSubscription = subscription.status === "canceled" && new Date(billingPeriodEnd) > /* @__PURE__ */ new Date();
1852
+ return /* @__PURE__ */ jsxs(Card, { children: [
1853
+ /* @__PURE__ */ jsx(CancelSubscriptionDialog, {
1854
+ open: cancelDialogOpen,
1855
+ onOpenChange: setCancelDialogOpen,
1856
+ planName,
1857
+ subscriptionId: subscription.id,
1858
+ billingPeriodEnd
1859
+ }),
1860
+ /* @__PURE__ */ jsx(RestoreSubscriptionDialog, {
1861
+ open: restoreDialogOpen,
1862
+ onOpenChange: setRestoreDialogOpen,
1863
+ planName,
1864
+ subscriptionId: subscription.id,
1865
+ billingPeriodEnd
1866
+ }),
1867
+ /* @__PURE__ */ jsx(CardContent, {
1868
+ className: "p-6",
1869
+ children: /* @__PURE__ */ jsxs("div", {
1870
+ className: "flex gap-4",
1871
+ children: [/* @__PURE__ */ jsx("div", {
1872
+ className: "flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 shrink-0",
1873
+ children: /* @__PURE__ */ jsx(Settings, { className: "w-6 h-6 text-primary" })
1874
+ }), /* @__PURE__ */ jsxs("div", {
1875
+ className: "flex-1",
1876
+ id: "manage",
1877
+ children: [
1878
+ /* @__PURE__ */ jsx("h2", {
1879
+ className: "text-lg font-semibold text-foreground mb-1",
1880
+ children: "Manage Subscription"
1881
+ }),
1882
+ /* @__PURE__ */ jsx("p", {
1883
+ className: "text-sm text-muted-foreground mb-4",
1884
+ children: "Switch to a different plan or cancel your current subscription."
1885
+ }),
1886
+ /* @__PURE__ */ jsxs("div", {
1887
+ className: "flex flex-wrap gap-3",
1888
+ children: [
1889
+ subscription.status === "canceled" && /* @__PURE__ */ jsx(Button$1, {
1890
+ variant: "outline",
1891
+ size: "sm",
1724
1892
  asChild: true,
1725
- children: /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button$1, {
1726
- variant: "outline",
1727
- size: "sm",
1728
- onClick: () => setCancelDialogOpen(true),
1729
- title: "You can only cancel your subscription if it is not active.",
1730
- disabled: subscription.status !== "active",
1731
- children: "Cancel subscription"
1732
- }) })
1733
- }), subscription.status === "canceled" && /* @__PURE__ */ jsx(TooltipContent, { children: "Your subscription is already cancelled." })]
1734
- }),
1735
- /* @__PURE__ */ jsx(Button$1, {
1736
- asChild: true,
1737
- size: "sm",
1738
- variant: "secondary",
1739
- children: /* @__PURE__ */ jsx(Link, {
1740
- to: "/manage-payment",
1741
- children: /* @__PURE__ */ jsxs("div", {
1742
- className: "flex items-center gap-2",
1743
- children: [/* @__PURE__ */ jsx(CreditCardIcon, {}), "Manage payment details"]
1893
+ children: /* @__PURE__ */ jsxs(Link, {
1894
+ to: "/pricing",
1895
+ children: [/* @__PURE__ */ jsx(RefreshCcw, { className: "w-4 h-4 mr-2" }), "New subscription"]
1896
+ })
1897
+ }),
1898
+ subscription.status === "active" && /* @__PURE__ */ jsx(SwitchPlanModal, { subscription }),
1899
+ subscription.status === "active" && /* @__PURE__ */ jsx(Button$1, {
1900
+ variant: "outline",
1901
+ size: "sm",
1902
+ onClick: () => setCancelDialogOpen(true),
1903
+ children: "Cancel subscription"
1904
+ }),
1905
+ canResumeCanceledSubscription && /* @__PURE__ */ jsx(Button$1, {
1906
+ variant: "outline",
1907
+ size: "sm",
1908
+ onClick: () => setRestoreDialogOpen(true),
1909
+ children: "Resume subscription"
1910
+ }),
1911
+ /* @__PURE__ */ jsx(Button$1, {
1912
+ asChild: true,
1913
+ size: "sm",
1914
+ variant: "secondary",
1915
+ children: /* @__PURE__ */ jsx(Link, {
1916
+ to: "/manage-payment",
1917
+ children: /* @__PURE__ */ jsxs("div", {
1918
+ className: "flex items-center gap-2",
1919
+ children: [/* @__PURE__ */ jsx(CreditCardIcon, {}), "Manage payment details"]
1920
+ })
1744
1921
  })
1745
1922
  })
1746
- })
1747
- ]
1748
- }),
1749
- /* @__PURE__ */ jsx(Separator, { className: "my-4" }),
1750
- /* @__PURE__ */ jsx("span", {
1751
- className: "text-sm text-muted-foreground",
1752
- children: "Your payment is securely managed by Stripe."
1753
- })
1754
- ]
1755
- })]
1923
+ ]
1924
+ }),
1925
+ /* @__PURE__ */ jsx(Separator, { className: "my-4" }),
1926
+ /* @__PURE__ */ jsx("span", {
1927
+ className: "text-sm text-muted-foreground",
1928
+ children: "Your payment is securely managed by Stripe."
1929
+ })
1930
+ ]
1931
+ })]
1932
+ })
1756
1933
  })
1757
- })] });
1934
+ ] });
1758
1935
  };
1759
1936
 
1760
1937
  //#endregion
@@ -1763,6 +1940,8 @@ const isMeteredEntitlement = (entitlement) => {
1763
1940
  return "balance" in entitlement;
1764
1941
  };
1765
1942
  const UsageItem = ({ meter, item, subscription }) => {
1943
+ const cadence = item?.billingCadence ?? subscription?.billingCadence;
1944
+ const billingPeriod = cadence ? formatDurationAdjective(cadence) : "monthly";
1766
1945
  const isSoftLimit = item?.included?.entitlement?.isSoftLimit ?? true;
1767
1946
  const rate = (item?.price?.tiers?.find((t) => !t.upToAmount) ?? item?.price?.tiers?.at(-1))?.unitPrice?.amount;
1768
1947
  const hasOverage = meter.overage > 0;
@@ -1777,7 +1956,11 @@ const UsageItem = ({ meter, item, subscription }) => {
1777
1956
  className: "mb-4",
1778
1957
  children: [
1779
1958
  /* @__PURE__ */ jsx(AlertTriangleIcon, { className: "size-4 text-red-600 shrink-0" }),
1780
- /* @__PURE__ */ jsx(AlertTitle, { children: "You've exceeded your monthly quota" }),
1959
+ /* @__PURE__ */ jsxs(AlertTitle, { children: [
1960
+ "You've exceeded your ",
1961
+ billingPeriod,
1962
+ " quota"
1963
+ ] }),
1781
1964
  /* @__PURE__ */ jsxs(AlertDescription, { children: [
1782
1965
  "Additional usage is being charged at the overage rate",
1783
1966
  rate ? ` ($${Number(rate).toFixed(2)}/call)` : "",
@@ -1798,7 +1981,11 @@ const UsageItem = ({ meter, item, subscription }) => {
1798
1981
  className: "mb-4",
1799
1982
  children: [
1800
1983
  /* @__PURE__ */ jsx(AlertTriangleIcon, { className: "size-4 text-red-600 shrink-0" }),
1801
- /* @__PURE__ */ jsx(AlertTitle, { children: "You've reached your monthly limit" }),
1984
+ /* @__PURE__ */ jsxs(AlertTitle, { children: [
1985
+ "You've reached your ",
1986
+ billingPeriod,
1987
+ " limit"
1988
+ ] }),
1802
1989
  /* @__PURE__ */ jsx(AlertDescription, { children: "Requests beyond your quota are blocked. Upgrade to a higher plan for more usage." }),
1803
1990
  subscription && /* @__PURE__ */ jsx(AlertAction, { children: /* @__PURE__ */ jsx(SwitchPlanModal, {
1804
1991
  subscription,
@@ -1848,7 +2035,7 @@ const UsageItem = ({ meter, item, subscription }) => {
1848
2035
  }),
1849
2036
  /* @__PURE__ */ jsxs("p", {
1850
2037
  className: "text-xs text-muted-foreground",
1851
- children: [meter.balance.toLocaleString(), " remaining this month"]
2038
+ children: [meter.balance.toLocaleString(), " remaining this billing period"]
1852
2039
  })
1853
2040
  ]
1854
2041
  })]
@@ -2071,7 +2258,7 @@ const SubscriptionsPage = () => {
2071
2258
  //#endregion
2072
2259
  //#region src/ZuploMonetizationPlugin.tsx
2073
2260
  const PRICING_PATH = "/pricing";
2074
- const zuploMonetizationPlugin = createPlugin((options) => ({
2261
+ const zuploMonetizationPlugin = createPlugin((options = {}) => ({
2075
2262
  transformConfig: ({ config, merge }) => merge({
2076
2263
  apiKeys: { enabled: false },
2077
2264
  header: { navigation: [...config.header?.navigation ?? [], {
@@ -2107,7 +2294,7 @@ const zuploMonetizationPlugin = createPlugin((options) => ({
2107
2294
  }],
2108
2295
  getRoutes: () => {
2109
2296
  return [{
2110
- Component: ZuploMonetizationWrapper,
2297
+ element: /* @__PURE__ */ jsx(ZuploMonetizationWrapper, { options }),
2111
2298
  handle: { layout: "none" },
2112
2299
  children: [
2113
2300
  {
@@ -2129,12 +2316,7 @@ const zuploMonetizationPlugin = createPlugin((options) => ({
2129
2316
  {
2130
2317
  path: PRICING_PATH,
2131
2318
  handle: { layout: "default" },
2132
- element: /* @__PURE__ */ jsx(PricingPage, {
2133
- subtitle: options?.pricing?.subtitle,
2134
- title: options?.pricing?.title,
2135
- units: options?.pricing?.units,
2136
- showYearlyPrice: options?.pricing?.showYearlyPrice
2137
- })
2319
+ element: /* @__PURE__ */ jsx(PricingPage, {})
2138
2320
  },
2139
2321
  {
2140
2322
  handle: { layout: "default" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zuplo/zudoku-plugin-monetization",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zuplo/zudoku",
@@ -31,7 +31,7 @@
31
31
  "react": "19.2.4",
32
32
  "react-dom": "19.2.4",
33
33
  "tsdown": "0.20.3",
34
- "zudoku": "0.71.10"
34
+ "zudoku": "0.72.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": ">=19.2.0",