@zuplo/zudoku-plugin-monetization 0.0.43 → 0.0.45
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.mjs +600 -184
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { D as formatDurationAdjective, E as formatDuration, O as formatDurationInterval, T as formatPrice, _ as formatPlanPrice, a as planHasDefaultTaxBehavior, b as sameEntitlementSet, c as getPlanPriceSchedule, d as PlanEntitlements, f as PlanPhaseHeader, l as PlanPriceTag, o as subscriptionTaxLegendSentence, p as EntitlementList, r as isCustomPlan, t as PricingTable, u as PlanPriceSchedule, w as formatMinorCurrencyAmount, x as categorizeRateCards, y as comparePlanEntitlements } from "./PricingTable-WkG2n7V-.mjs";
|
|
2
2
|
import { cn, createPlugin, joinUrl, throwIfProblemJson } from "zudoku";
|
|
3
|
-
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
|
+
import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, BadgePercentIcon, CalendarIcon, CheckCheckIcon, CheckIcon, ChevronDownIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, HistoryIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
|
|
4
4
|
import { Link, Outlet, useLocation, useNavigate, useSearchParams } from "zudoku/router";
|
|
5
5
|
import { useAuth, useZudoku } from "zudoku/hooks";
|
|
6
6
|
import { QueryClient, QueryClientProvider, queryOptions, useMutation, useQuery, useQueryClient, useSuspenseQuery } from "zudoku/react-query";
|
|
@@ -9,6 +9,7 @@ import { createContext, use, useEffect, useMemo, useState } from "react";
|
|
|
9
9
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
10
10
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
|
|
11
11
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "zudoku/ui/Card";
|
|
12
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "zudoku/ui/Collapsible";
|
|
12
13
|
import { Separator } from "zudoku/ui/Separator";
|
|
13
14
|
import { Button as Button$1 } from "zudoku/ui/Button";
|
|
14
15
|
import { DismissibleAlert, DismissibleAlertAction } from "zudoku/ui/DismissibleAlert";
|
|
@@ -263,8 +264,13 @@ const formatBillingCycle = (duration) => {
|
|
|
263
264
|
* The price is derived from the plan's rate cards via {@link formatPlanPrice}
|
|
264
265
|
* and rendered in the plan's own billing cadence, so it stays correct for any
|
|
265
266
|
* cadence (e.g. `$2.99/hour`).
|
|
267
|
+
*
|
|
268
|
+
* Pass `collapsibleDetails` to hide the "What's included" section behind a
|
|
269
|
+
* collapsed-by-default toggle — used on the plan-change confirmation page where
|
|
270
|
+
* the current and new plan are stacked and the user opens each to compare. Pass
|
|
271
|
+
* `label` to render a small eyebrow above the name (e.g. "Current plan").
|
|
266
272
|
*/
|
|
267
|
-
const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName }) => {
|
|
273
|
+
const PlanSummaryCard = ({ plan, label, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName, collapsibleDetails = false }) => {
|
|
268
274
|
const priceLabel = formatPlanPrice(plan);
|
|
269
275
|
const schedule = getPlanPriceSchedule(plan);
|
|
270
276
|
const billingCycle = plan.billingCadence ? formatDuration(plan.billingCadence) : null;
|
|
@@ -287,13 +293,20 @@ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxIn
|
|
|
287
293
|
children: plan.name.at(0)?.toUpperCase()
|
|
288
294
|
}), /* @__PURE__ */ jsxs("div", {
|
|
289
295
|
className: "flex flex-col",
|
|
290
|
-
children: [
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
296
|
+
children: [
|
|
297
|
+
label && /* @__PURE__ */ jsx("span", {
|
|
298
|
+
className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
|
|
299
|
+
children: label
|
|
300
|
+
}),
|
|
301
|
+
/* @__PURE__ */ jsx("span", {
|
|
302
|
+
className: "text-lg font-bold",
|
|
303
|
+
children: plan.name
|
|
304
|
+
}),
|
|
305
|
+
/* @__PURE__ */ jsx("span", {
|
|
306
|
+
className: "text-sm font-normal text-muted-foreground",
|
|
307
|
+
children: plan.description || descriptionFallback
|
|
308
|
+
})
|
|
309
|
+
]
|
|
297
310
|
})]
|
|
298
311
|
}), !schedule && /* @__PURE__ */ jsxs("div", {
|
|
299
312
|
className: "text-right",
|
|
@@ -314,20 +327,28 @@ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxIn
|
|
|
314
327
|
className: "text-right",
|
|
315
328
|
children: [taxLine, billedLine]
|
|
316
329
|
})]
|
|
317
|
-
})] }), /* @__PURE__ */ jsxs(CardContent, { children: [
|
|
318
|
-
|
|
319
|
-
/* @__PURE__ */ jsx("
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
/* @__PURE__ */ jsx(PlanEntitlements, {
|
|
330
|
+
})] }), /* @__PURE__ */ jsxs(CardContent, { children: [/* @__PURE__ */ jsx(Separator, {}), collapsibleDetails ? /* @__PURE__ */ jsxs(Collapsible, { children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
|
|
331
|
+
className: "group flex w-full items-center justify-between text-sm font-medium mt-3",
|
|
332
|
+
children: ["Plan details", /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })]
|
|
333
|
+
}), /* @__PURE__ */ jsx(CollapsibleContent, {
|
|
334
|
+
className: "mt-3",
|
|
335
|
+
children: /* @__PURE__ */ jsx(PlanEntitlements, {
|
|
324
336
|
phases: plan.phases,
|
|
325
337
|
currency: plan.currency,
|
|
326
338
|
billingCadence: plan.billingCadence,
|
|
327
339
|
units,
|
|
328
340
|
itemClassName: entitlementsItemClassName
|
|
329
341
|
})
|
|
330
|
-
] })
|
|
342
|
+
})] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
|
|
343
|
+
className: "text-sm font-medium mb-3 mt-3",
|
|
344
|
+
children: "What's included:"
|
|
345
|
+
}), /* @__PURE__ */ jsx(PlanEntitlements, {
|
|
346
|
+
phases: plan.phases,
|
|
347
|
+
currency: plan.currency,
|
|
348
|
+
billingCadence: plan.billingCadence,
|
|
349
|
+
units,
|
|
350
|
+
itemClassName: entitlementsItemClassName
|
|
351
|
+
})] })] })]
|
|
331
352
|
});
|
|
332
353
|
};
|
|
333
354
|
//#endregion
|
|
@@ -802,76 +823,121 @@ const getSubscriptionPlanView = (subscription, options) => {
|
|
|
802
823
|
};
|
|
803
824
|
/** Whether a resolved view has any entitlements to render. */
|
|
804
825
|
const hasSubscriptionEntitlements = (view) => view.usingItems ? view.entitlements.quotas.length > 0 || view.entitlements.features.length > 0 : view.fallbackPhases.some((p) => p.rateCards?.some((rc) => rc.entitlementTemplate));
|
|
805
|
-
|
|
806
|
-
|
|
826
|
+
const phaseStatus = (phase, now) => {
|
|
827
|
+
if (phase.activeTo && new Date(phase.activeTo).getTime() < now) return "past";
|
|
828
|
+
if (new Date(phase.activeFrom).getTime() > now) return "future";
|
|
829
|
+
return "current";
|
|
830
|
+
};
|
|
807
831
|
/**
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
* plan's phases. Centralizes the "items, else fall back to plan phases" branch
|
|
812
|
-
* shared by the subscription details page and the Switch Plan baseline.
|
|
813
|
-
*
|
|
814
|
-
* Renders `null` when there's nothing to show, so callers can wrap it with
|
|
815
|
-
* {@link hasSubscriptionEntitlements} (for a section header / border) without
|
|
816
|
-
* leaving an empty container behind.
|
|
832
|
+
* Rate cards for one subscription phase: its own provisioned items (the
|
|
833
|
+
* authoritative quotas/fees), falling back to the catalog plan phase with the
|
|
834
|
+
* same `key` when a phase (typically a future one) hasn't been provisioned yet.
|
|
817
835
|
*/
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
836
|
+
const phaseRateCards = (plan, phase) => phase.items.length > 0 ? subscriptionItemsToRateCards(phase.items) : plan.phases?.find((p) => p.key === phase.key)?.rateCards ?? [];
|
|
837
|
+
const phasesByActiveFrom = (subscription) => [...subscription.phases].sort((a, b) => new Date(a.activeFrom).getTime() - new Date(b.activeFrom).getTime());
|
|
838
|
+
/**
|
|
839
|
+
* A clean ISO-8601 duration between two instants, preferring whole calendar
|
|
840
|
+
* units (years/months/weeks) over raw days so it formats the same way catalog
|
|
841
|
+
* plan durations do (e.g. `P1W` → "week", `P3M` → "3 months") rather than as
|
|
842
|
+
* "7 days" / "90 days". Returns `undefined` for a non-positive span.
|
|
843
|
+
*/
|
|
844
|
+
const isoDurationBetween = (from, to) => {
|
|
845
|
+
const a = new Date(from);
|
|
846
|
+
const b = new Date(to);
|
|
847
|
+
const ms = b.getTime() - a.getTime();
|
|
848
|
+
if (!(ms > 0)) return void 0;
|
|
849
|
+
let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
|
850
|
+
const anchor = new Date(a);
|
|
851
|
+
anchor.setMonth(a.getMonth() + months);
|
|
852
|
+
if (anchor.getTime() > b.getTime()) months -= 1;
|
|
853
|
+
const exact = new Date(a);
|
|
854
|
+
exact.setMonth(a.getMonth() + months);
|
|
855
|
+
if (months >= 1 && exact.getTime() === b.getTime()) return months % 12 === 0 ? `P${months / 12}Y` : `P${months}M`;
|
|
856
|
+
const days = Math.round(ms / 864e5);
|
|
857
|
+
if (days >= 7 && days % 7 === 0) return `P${days / 7}W`;
|
|
858
|
+
return `P${days}D`;
|
|
835
859
|
};
|
|
836
|
-
//#endregion
|
|
837
|
-
//#region src/pages/components/CurrentPlanBaseline.tsx
|
|
838
860
|
/**
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
* falling back to the plan's rate cards only when items aren't present — see
|
|
844
|
-
* {@link getSubscriptionPlanView}.
|
|
861
|
+
* The intended length of a subscription phase: the matching catalog plan
|
|
862
|
+
* phase's authored `duration` when available (the cleanest source), otherwise
|
|
863
|
+
* computed from the phase's own `activeFrom`/`activeTo`. `undefined` for an
|
|
864
|
+
* open-ended (final) phase — the price schedule labels that one "After that".
|
|
845
865
|
*/
|
|
846
|
-
const
|
|
866
|
+
const phaseDuration = (plan, phase) => {
|
|
867
|
+
const catalog = plan.phases?.find((p) => p.key === phase.key)?.duration;
|
|
868
|
+
if (catalog) return catalog;
|
|
869
|
+
if (!phase.activeTo) return void 0;
|
|
870
|
+
return isoDurationBetween(phase.activeFrom, phase.activeTo);
|
|
871
|
+
};
|
|
872
|
+
/**
|
|
873
|
+
* Resolve EVERY phase of a subscription (not just the active one) to a
|
|
874
|
+
* {@link SubscriptionPhaseView}, ordered by `activeFrom` and tagged
|
|
875
|
+
* past/current/future. Generalizes {@link getSubscriptionPlanView}: each phase's
|
|
876
|
+
* price and entitlements come from its own provisioned items (the authoritative
|
|
877
|
+
* source), falling back to the catalog plan phase with the same `key` when a
|
|
878
|
+
* phase (typically a future one) hasn't been provisioned yet. Powers the
|
|
879
|
+
* subscription details page's current + upcoming + previous phase timeline.
|
|
880
|
+
*/
|
|
881
|
+
const getSubscriptionPhaseViews = (subscription, options) => {
|
|
847
882
|
const plan = subscription.plan;
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
units
|
|
871
|
-
|
|
872
|
-
|
|
883
|
+
const currency = subscription.currency ?? plan.currency;
|
|
884
|
+
const billingCadence = subscription.billingCadence ?? plan.billingCadence;
|
|
885
|
+
const now = Date.now();
|
|
886
|
+
return phasesByActiveFrom(subscription).map((phase) => {
|
|
887
|
+
const rateCards = phaseRateCards(plan, phase);
|
|
888
|
+
return {
|
|
889
|
+
id: phase.id,
|
|
890
|
+
name: phase.name,
|
|
891
|
+
status: phaseStatus(phase, now),
|
|
892
|
+
activeFrom: phase.activeFrom,
|
|
893
|
+
activeTo: phase.activeTo,
|
|
894
|
+
priceLabel: formatPlanPrice({
|
|
895
|
+
...plan,
|
|
896
|
+
billingCadence,
|
|
897
|
+
phases: [{
|
|
898
|
+
key: phase.key,
|
|
899
|
+
name: phase.name,
|
|
900
|
+
rateCards
|
|
901
|
+
}]
|
|
902
|
+
}),
|
|
903
|
+
entitlements: categorizeRateCards(rateCards, {
|
|
904
|
+
currency,
|
|
905
|
+
units: options?.units,
|
|
906
|
+
planBillingCadence: billingCadence
|
|
907
|
+
}),
|
|
908
|
+
billingCadence,
|
|
909
|
+
currency
|
|
910
|
+
};
|
|
873
911
|
});
|
|
874
912
|
};
|
|
913
|
+
/**
|
|
914
|
+
* Build a catalog-shaped {@link Plan} from a subscription's *own* current and
|
|
915
|
+
* future phases (past phases dropped), so the plan-change confirmation page can
|
|
916
|
+
* render the current subscription with the exact same pricing-table card
|
|
917
|
+
* ({@link formatPlanPrice} / `getPlanPriceSchedule` / `PlanEntitlements`) used
|
|
918
|
+
* for the new plan. Each phase's rate cards come from its provisioned items
|
|
919
|
+
* (the authoritative source) — the embedded `subscription.plan` snapshot is
|
|
920
|
+
* unreliable here (it can arrive with `phases: []`), so we never read its
|
|
921
|
+
* phases for pricing. Each phase's `duration` is carried so the price schedule
|
|
922
|
+
* labels read "First 3 months" / "After that" exactly like the new plan, rather
|
|
923
|
+
* than falling back to the phase name (see {@link phaseDuration}).
|
|
924
|
+
*/
|
|
925
|
+
const subscriptionToCurrentPlan = (subscription) => {
|
|
926
|
+
const plan = subscription.plan;
|
|
927
|
+
const now = Date.now();
|
|
928
|
+
const phases = phasesByActiveFrom(subscription).filter((p) => !(p.activeTo && new Date(p.activeTo).getTime() < now)).map((phase) => ({
|
|
929
|
+
key: phase.key,
|
|
930
|
+
name: phase.name,
|
|
931
|
+
duration: phaseDuration(plan, phase),
|
|
932
|
+
rateCards: phaseRateCards(plan, phase)
|
|
933
|
+
}));
|
|
934
|
+
return {
|
|
935
|
+
...plan,
|
|
936
|
+
currency: subscription.currency ?? plan.currency,
|
|
937
|
+
billingCadence: subscription.billingCadence ?? plan.billingCadence,
|
|
938
|
+
phases
|
|
939
|
+
};
|
|
940
|
+
};
|
|
875
941
|
//#endregion
|
|
876
942
|
//#region src/pages/SubscriptionChangeConfirmPage.tsx
|
|
877
943
|
const SubscriptionChangeConfirmPage = () => {
|
|
@@ -911,20 +977,27 @@ const SubscriptionChangeConfirmPage = () => {
|
|
|
911
977
|
children: /* @__PURE__ */ jsxs("div", {
|
|
912
978
|
className: "space-y-3",
|
|
913
979
|
children: [
|
|
914
|
-
currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(
|
|
915
|
-
|
|
916
|
-
|
|
980
|
+
currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(PlanSummaryCard, {
|
|
981
|
+
plan: subscriptionToCurrentPlan(currentSubscription),
|
|
982
|
+
label: "Current plan",
|
|
983
|
+
descriptionFallback: "",
|
|
984
|
+
taxLabel: "",
|
|
985
|
+
taxInclusive: false,
|
|
986
|
+
units: pricing?.units,
|
|
987
|
+
collapsibleDetails: true
|
|
917
988
|
}), /* @__PURE__ */ jsxs("div", {
|
|
918
989
|
className: "flex items-center justify-center gap-1.5 text-sm text-muted-foreground",
|
|
919
990
|
children: [/* @__PURE__ */ jsx(ArrowDownIcon, { className: "size-4" }), " Changing to"]
|
|
920
991
|
})] }),
|
|
921
992
|
selectedPlan && /* @__PURE__ */ jsx(PlanSummaryCard, {
|
|
922
993
|
plan: selectedPlan,
|
|
923
|
-
|
|
994
|
+
label: "New plan",
|
|
995
|
+
descriptionFallback: "",
|
|
924
996
|
taxAmount,
|
|
925
997
|
taxLabel,
|
|
926
998
|
taxInclusive,
|
|
927
|
-
units: pricing?.units
|
|
999
|
+
units: pricing?.units,
|
|
1000
|
+
collapsibleDetails: true
|
|
928
1001
|
}),
|
|
929
1002
|
/* @__PURE__ */ jsxs("div", {
|
|
930
1003
|
className: "rounded-lg bg-muted/50 p-3 text-sm space-y-1",
|
|
@@ -961,6 +1034,24 @@ const useSubscriptions = () => {
|
|
|
961
1034
|
});
|
|
962
1035
|
};
|
|
963
1036
|
//#endregion
|
|
1037
|
+
//#region src/hooks/usePendingCredits.ts
|
|
1038
|
+
/**
|
|
1039
|
+
* Fetch the operator-applied usage credits for a subscription, via the Zuplo
|
|
1040
|
+
* metering `.../pending-credits` endpoint. Failures are swallowed (`retry: false`,
|
|
1041
|
+
* `throwOnError: false`) so the usage page never breaks when credits are
|
|
1042
|
+
* unavailable — the credit banner simply isn't shown.
|
|
1043
|
+
*/
|
|
1044
|
+
const usePendingCredits = (deploymentName, subscriptionId) => {
|
|
1045
|
+
const zudoku = useZudoku();
|
|
1046
|
+
return useQuery({
|
|
1047
|
+
queryKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions/${subscriptionId}/pending-credits`],
|
|
1048
|
+
meta: { context: zudoku },
|
|
1049
|
+
refetchOnWindowFocus: true,
|
|
1050
|
+
retry: false,
|
|
1051
|
+
throwOnError: false
|
|
1052
|
+
});
|
|
1053
|
+
};
|
|
1054
|
+
//#endregion
|
|
964
1055
|
//#region src/pages/subscriptions/ConfirmDeleteKeyAlert.tsx
|
|
965
1056
|
const ConfirmDeleteKeyAlert = ({ children, onDelete }) => {
|
|
966
1057
|
return /* @__PURE__ */ jsxs(AlertDialog, { children: [/* @__PURE__ */ jsx(AlertDialogTrigger, {
|
|
@@ -1446,6 +1537,76 @@ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionI
|
|
|
1446
1537
|
});
|
|
1447
1538
|
};
|
|
1448
1539
|
//#endregion
|
|
1540
|
+
//#region src/pages/components/SubscriptionEntitlements.tsx
|
|
1541
|
+
/**
|
|
1542
|
+
* Render a subscription's entitlements from a resolved
|
|
1543
|
+
* {@link SubscriptionPlanView}: the active phase's *provisioned* items when
|
|
1544
|
+
* present (the real included quotas + per-unit prices), otherwise the catalog
|
|
1545
|
+
* plan's phases. Centralizes the "items, else fall back to plan phases" branch
|
|
1546
|
+
* shared by the subscription details page and the Switch Plan baseline.
|
|
1547
|
+
*
|
|
1548
|
+
* Renders `null` when there's nothing to show, so callers can wrap it with
|
|
1549
|
+
* {@link hasSubscriptionEntitlements} (for a section header / border) without
|
|
1550
|
+
* leaving an empty container behind.
|
|
1551
|
+
*/
|
|
1552
|
+
const SubscriptionEntitlements = ({ view, currency, billingCadence, units, itemClassName }) => {
|
|
1553
|
+
if (view.usingItems) {
|
|
1554
|
+
const { quotas, features } = view.entitlements;
|
|
1555
|
+
return /* @__PURE__ */ jsx(EntitlementList, {
|
|
1556
|
+
quotas,
|
|
1557
|
+
features,
|
|
1558
|
+
itemClassName
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
if (view.fallbackPhases.length === 0) return null;
|
|
1562
|
+
return /* @__PURE__ */ jsx(PlanEntitlements, {
|
|
1563
|
+
phases: view.fallbackPhases,
|
|
1564
|
+
currency,
|
|
1565
|
+
billingCadence,
|
|
1566
|
+
units,
|
|
1567
|
+
itemClassName
|
|
1568
|
+
});
|
|
1569
|
+
};
|
|
1570
|
+
//#endregion
|
|
1571
|
+
//#region src/pages/components/CurrentPlanBaseline.tsx
|
|
1572
|
+
/**
|
|
1573
|
+
* Baseline shown at the top of the Switch Plan modal: the current plan's price
|
|
1574
|
+
* and what it actually includes, so users have a concrete reference to compare
|
|
1575
|
+
* targets against. Both the price and the entitlements come from the
|
|
1576
|
+
* subscription's *provisioned* items (real included quotas + recurring fees),
|
|
1577
|
+
* falling back to the plan's rate cards only when items aren't present — see
|
|
1578
|
+
* {@link getSubscriptionPlanView}.
|
|
1579
|
+
*/
|
|
1580
|
+
const CurrentPlanBaseline = ({ subscription, units }) => {
|
|
1581
|
+
const plan = subscription.plan;
|
|
1582
|
+
const view = getSubscriptionPlanView(subscription, { units });
|
|
1583
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1584
|
+
className: "border rounded-lg p-4",
|
|
1585
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
1586
|
+
className: "flex items-start justify-between gap-3 flex-wrap",
|
|
1587
|
+
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
|
|
1588
|
+
className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
|
|
1589
|
+
children: "Current Plan"
|
|
1590
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1591
|
+
className: "text-lg font-bold text-foreground",
|
|
1592
|
+
children: plan.name
|
|
1593
|
+
})] }), /* @__PURE__ */ jsx(PlanPriceTag, {
|
|
1594
|
+
label: view.priceLabel,
|
|
1595
|
+
currency: view.currency,
|
|
1596
|
+
billingCadence: view.billingCadence
|
|
1597
|
+
})]
|
|
1598
|
+
}), hasSubscriptionEntitlements(view) && /* @__PURE__ */ jsx("div", {
|
|
1599
|
+
className: "mt-3 pt-3 border-t",
|
|
1600
|
+
children: /* @__PURE__ */ jsx(SubscriptionEntitlements, {
|
|
1601
|
+
view,
|
|
1602
|
+
currency: view.currency,
|
|
1603
|
+
billingCadence: view.billingCadence,
|
|
1604
|
+
units
|
|
1605
|
+
})
|
|
1606
|
+
})]
|
|
1607
|
+
});
|
|
1608
|
+
};
|
|
1609
|
+
//#endregion
|
|
1449
1610
|
//#region src/pages/components/PlanChangeCard.tsx
|
|
1450
1611
|
const MODE_LABEL = {
|
|
1451
1612
|
upgrade: "Upgrade",
|
|
@@ -1924,17 +2085,90 @@ const ManageSubscription = ({ subscription, planName }) => {
|
|
|
1924
2085
|
] });
|
|
1925
2086
|
};
|
|
1926
2087
|
//#endregion
|
|
2088
|
+
//#region src/pages/components/SubscriptionPhases.tsx
|
|
2089
|
+
const phaseDateLabel = (view) => {
|
|
2090
|
+
if (view.status === "current") return "Current phase";
|
|
2091
|
+
if (view.status === "future") return `Starts ${formatDateTime(view.activeFrom)}`;
|
|
2092
|
+
return view.activeTo ? `${formatDateTime(view.activeFrom)} – ${formatDateTime(view.activeTo)}` : formatDateTime(view.activeFrom);
|
|
2093
|
+
};
|
|
2094
|
+
/**
|
|
2095
|
+
* One phase of a subscription: a header with the phase name, its timing
|
|
2096
|
+
* sub-label, and the phase's own price, followed by that phase's included
|
|
2097
|
+
* quotas/features. The header always renders (even when the phase has no
|
|
2098
|
+
* entitlements) so the price/timing is never hidden.
|
|
2099
|
+
*/
|
|
2100
|
+
const SubscriptionPhaseSection = ({ view }) => {
|
|
2101
|
+
const dateLabel = phaseDateLabel(view);
|
|
2102
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2103
|
+
className: "space-y-2",
|
|
2104
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2105
|
+
className: "flex items-baseline justify-between gap-3",
|
|
2106
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2107
|
+
className: "text-sm font-medium text-card-foreground",
|
|
2108
|
+
children: [view.name, dateLabel && /* @__PURE__ */ jsxs("span", {
|
|
2109
|
+
className: "text-muted-foreground font-normal",
|
|
2110
|
+
children: [
|
|
2111
|
+
" ",
|
|
2112
|
+
"· ",
|
|
2113
|
+
dateLabel
|
|
2114
|
+
]
|
|
2115
|
+
})]
|
|
2116
|
+
}), /* @__PURE__ */ jsx(PlanPriceTag, {
|
|
2117
|
+
label: view.priceLabel,
|
|
2118
|
+
currency: view.currency,
|
|
2119
|
+
billingCadence: view.billingCadence
|
|
2120
|
+
})]
|
|
2121
|
+
}), /* @__PURE__ */ jsx(EntitlementList, {
|
|
2122
|
+
quotas: view.entitlements.quotas,
|
|
2123
|
+
features: view.entitlements.features
|
|
2124
|
+
})]
|
|
2125
|
+
});
|
|
2126
|
+
};
|
|
2127
|
+
/**
|
|
2128
|
+
* The subscription's future phases (each with its start date, price, and
|
|
2129
|
+
* entitlements), shown after the current phase on the details page and on the
|
|
2130
|
+
* Switch Plan baseline. Renders nothing when there are no upcoming phases.
|
|
2131
|
+
*/
|
|
2132
|
+
const UpcomingPhases = ({ subscription, units }) => {
|
|
2133
|
+
const future = getSubscriptionPhaseViews(subscription, { units }).filter((v) => v.status === "future");
|
|
2134
|
+
if (future.length === 0) return null;
|
|
2135
|
+
return /* @__PURE__ */ jsx("div", {
|
|
2136
|
+
className: "space-y-4",
|
|
2137
|
+
children: future.map((view) => /* @__PURE__ */ jsx(SubscriptionPhaseSection, { view }, view.id))
|
|
2138
|
+
});
|
|
2139
|
+
};
|
|
2140
|
+
/**
|
|
2141
|
+
* The subscription's already-ended phases, collapsed by default, each labelled
|
|
2142
|
+
* with the exact dates it ran. Lets users review what they previously paid for
|
|
2143
|
+
* without cluttering the page. Renders nothing when there are no past phases.
|
|
2144
|
+
*/
|
|
2145
|
+
const PreviousPhases = ({ subscription, units }) => {
|
|
2146
|
+
const past = getSubscriptionPhaseViews(subscription, { units }).filter((v) => v.status === "past");
|
|
2147
|
+
if (past.length === 0) return null;
|
|
2148
|
+
return /* @__PURE__ */ jsxs(Collapsible, { children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
|
|
2149
|
+
className: "group flex w-full items-center justify-between text-sm font-medium",
|
|
2150
|
+
children: ["Previous phases", /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })]
|
|
2151
|
+
}), /* @__PURE__ */ jsx(CollapsibleContent, {
|
|
2152
|
+
className: "mt-3 space-y-4",
|
|
2153
|
+
children: past.map((view) => /* @__PURE__ */ jsx(SubscriptionPhaseSection, { view }, view.id))
|
|
2154
|
+
})] });
|
|
2155
|
+
};
|
|
2156
|
+
//#endregion
|
|
1927
2157
|
//#region src/pages/subscriptions/SubscriptionPlanDetails.tsx
|
|
1928
2158
|
const detailLabelClassName = "text-sm font-semibold tracking-wide mb-1";
|
|
1929
2159
|
const sectionLabelClassName = "text-base font-semibold tracking-wide mb-3 mt-2";
|
|
1930
2160
|
const formatDateTimeRange = (from, to) => `${formatDateTime(from)} – ${formatDateTime(to)}`;
|
|
1931
2161
|
const SubscriptionPlanDetails = ({ subscription }) => {
|
|
1932
2162
|
const { pricing } = useMonetizationConfig();
|
|
2163
|
+
const hasEnded = !!subscription.activeTo && new Date(subscription.activeTo).getTime() < Date.now();
|
|
1933
2164
|
const plan = subscription.plan;
|
|
1934
2165
|
const view = getSubscriptionPlanView(subscription, { units: pricing?.units });
|
|
1935
2166
|
const { priceLabel } = view;
|
|
1936
2167
|
const taxLegendSentence = planHasDefaultTaxBehavior(plan) ? subscriptionTaxLegendSentence(plan.defaultTaxConfig?.behavior ?? "") : void 0;
|
|
1937
2168
|
const hasEntitlements = hasSubscriptionEntitlements(view);
|
|
2169
|
+
const phaseViews = getSubscriptionPhaseViews(subscription, { units: pricing?.units });
|
|
2170
|
+
const hasUpcomingPhases = phaseViews.some((p) => p.status === "future");
|
|
2171
|
+
const hasPreviousPhases = phaseViews.some((p) => p.status === "past");
|
|
1938
2172
|
return /* @__PURE__ */ jsxs("div", {
|
|
1939
2173
|
className: "space-y-4",
|
|
1940
2174
|
children: [/* @__PURE__ */ jsx(Heading, {
|
|
@@ -1945,104 +2179,261 @@ const SubscriptionPlanDetails = ({ subscription }) => {
|
|
|
1945
2179
|
children: plan.name
|
|
1946
2180
|
}), plan.description ? /* @__PURE__ */ jsx(CardDescription, { children: plan.description }) : null] }), /* @__PURE__ */ jsxs(CardContent, {
|
|
1947
2181
|
className: "space-y-6",
|
|
1948
|
-
children: [
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
className:
|
|
1992
|
-
children: "
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2182
|
+
children: [
|
|
2183
|
+
/* @__PURE__ */ jsxs("dl", {
|
|
2184
|
+
className: "grid gap-4 sm:grid-cols-2 text-sm",
|
|
2185
|
+
children: [
|
|
2186
|
+
/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
|
|
2187
|
+
className: detailLabelClassName,
|
|
2188
|
+
children: "Subscription ID"
|
|
2189
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
2190
|
+
className: "text-foreground font-mono text-xs break-all",
|
|
2191
|
+
children: subscription.id
|
|
2192
|
+
})] }),
|
|
2193
|
+
/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
|
|
2194
|
+
className: detailLabelClassName,
|
|
2195
|
+
children: "Active since"
|
|
2196
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
2197
|
+
className: "text-foreground",
|
|
2198
|
+
children: formatDateTime(subscription.activeFrom)
|
|
2199
|
+
})] }),
|
|
2200
|
+
/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
|
|
2201
|
+
className: detailLabelClassName,
|
|
2202
|
+
children: "Price"
|
|
2203
|
+
}), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
|
|
2204
|
+
className: "flex flex-wrap items-baseline gap-1",
|
|
2205
|
+
children: /* @__PURE__ */ jsx(PlanPriceTag, {
|
|
2206
|
+
label: priceLabel,
|
|
2207
|
+
currency: view.currency,
|
|
2208
|
+
billingCadence: view.billingCadence,
|
|
2209
|
+
description: true
|
|
2210
|
+
})
|
|
2211
|
+
}), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
|
|
2212
|
+
className: "text-xs text-muted-foreground mt-1",
|
|
2213
|
+
children: taxLegendSentence
|
|
2214
|
+
}) : null] })] }),
|
|
2215
|
+
/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
|
|
2216
|
+
className: detailLabelClassName,
|
|
2217
|
+
children: hasEnded ? "Ended" : "Current period"
|
|
2218
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
2219
|
+
className: "text-foreground",
|
|
2220
|
+
children: hasEnded ? formatDateTime(subscription.activeTo ?? "") : subscription.alignment?.currentAlignedBillingPeriod ? formatDateTimeRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : "—"
|
|
2221
|
+
})] })
|
|
2222
|
+
]
|
|
2223
|
+
}),
|
|
2224
|
+
hasEntitlements ? /* @__PURE__ */ jsxs("div", {
|
|
2225
|
+
className: "space-y-2 pt-2 border-t border-border",
|
|
2226
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
2227
|
+
className: sectionLabelClassName,
|
|
2228
|
+
children: "What's included"
|
|
2229
|
+
}), /* @__PURE__ */ jsx(SubscriptionEntitlements, {
|
|
2230
|
+
view,
|
|
2231
|
+
currency: view.currency,
|
|
2232
|
+
billingCadence: view.billingCadence,
|
|
2233
|
+
units: pricing?.units
|
|
2234
|
+
})]
|
|
2235
|
+
}) : null,
|
|
2236
|
+
hasUpcomingPhases ? /* @__PURE__ */ jsxs("div", {
|
|
2237
|
+
className: "space-y-3 pt-2 border-t border-border",
|
|
2238
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
2239
|
+
className: sectionLabelClassName,
|
|
2240
|
+
children: "Upcoming phases"
|
|
2241
|
+
}), /* @__PURE__ */ jsx(UpcomingPhases, {
|
|
2242
|
+
subscription,
|
|
2243
|
+
units: pricing?.units
|
|
2244
|
+
})]
|
|
2245
|
+
}) : null,
|
|
2246
|
+
hasPreviousPhases ? /* @__PURE__ */ jsx("div", {
|
|
2247
|
+
className: "pt-2 border-t border-border",
|
|
2248
|
+
children: /* @__PURE__ */ jsx(PreviousPhases, {
|
|
2249
|
+
subscription,
|
|
2250
|
+
units: pricing?.units
|
|
2251
|
+
})
|
|
2252
|
+
}) : null
|
|
2253
|
+
]
|
|
2000
2254
|
})] })]
|
|
2001
2255
|
});
|
|
2002
2256
|
};
|
|
2003
2257
|
//#endregion
|
|
2258
|
+
//#region src/utils/priceIncludedUnits.ts
|
|
2259
|
+
const partAmount = (part) => {
|
|
2260
|
+
if (!part || part.amount === void 0) return 0;
|
|
2261
|
+
const amount = parseFloat(part.amount);
|
|
2262
|
+
return Number.isFinite(amount) ? amount : Number.POSITIVE_INFINITY;
|
|
2263
|
+
};
|
|
2264
|
+
const priceIncludedUnits = (price) => {
|
|
2265
|
+
if (!price) return 0;
|
|
2266
|
+
if (price.type === "flat" || price.type === "package" || price.type === "dynamic") return 0;
|
|
2267
|
+
if (price.tiers?.length) {
|
|
2268
|
+
if ((price.mode ?? "graduated") !== "graduated") return 0;
|
|
2269
|
+
const sorted = [...price.tiers].sort((a, b) => {
|
|
2270
|
+
if (a.upToAmount === void 0) return 1;
|
|
2271
|
+
if (b.upToAmount === void 0) return -1;
|
|
2272
|
+
return Number(a.upToAmount) - Number(b.upToAmount);
|
|
2273
|
+
});
|
|
2274
|
+
let free = 0;
|
|
2275
|
+
for (const [index, tier] of sorted.entries()) {
|
|
2276
|
+
if (partAmount(tier.unitPrice) > 0) break;
|
|
2277
|
+
if (index > 0 && partAmount(tier.flatPrice) > 0) break;
|
|
2278
|
+
if (tier.upToAmount === void 0) return Number.POSITIVE_INFINITY;
|
|
2279
|
+
const bound = Number(tier.upToAmount);
|
|
2280
|
+
if (!Number.isFinite(bound) || bound <= free) break;
|
|
2281
|
+
free = bound;
|
|
2282
|
+
}
|
|
2283
|
+
return free;
|
|
2284
|
+
}
|
|
2285
|
+
if (price.type === "unit") {
|
|
2286
|
+
const amount = parseFloat(price.amount ?? "");
|
|
2287
|
+
return Number.isFinite(amount) && amount === 0 ? Number.POSITIVE_INFINITY : 0;
|
|
2288
|
+
}
|
|
2289
|
+
return 0;
|
|
2290
|
+
};
|
|
2291
|
+
//#endregion
|
|
2292
|
+
//#region src/pages/subscriptions/deriveUsageView.ts
|
|
2293
|
+
const formatAmount = (amount) => {
|
|
2294
|
+
const value = parseFloat(amount ?? "");
|
|
2295
|
+
return Number.isFinite(value) ? `$${value.toFixed(2)}` : void 0;
|
|
2296
|
+
};
|
|
2297
|
+
const pluralizeUnit = (unitName) => unitName.endsWith("s") ? unitName : `${unitName}s`;
|
|
2298
|
+
/** The label for what additional usage costs, when the shape has one number. */
|
|
2299
|
+
const rateLabelFor = (price, unitName) => {
|
|
2300
|
+
if (!price) return void 0;
|
|
2301
|
+
if (price.type === "unit") {
|
|
2302
|
+
const amount = formatAmount(price.amount);
|
|
2303
|
+
return amount ? `${amount}/${unitName}` : void 0;
|
|
2304
|
+
}
|
|
2305
|
+
if (price.type === "tiered") {
|
|
2306
|
+
const amount = formatAmount((price.tiers?.find((t) => !t.upToAmount) ?? price.tiers?.at(-1))?.unitPrice?.amount);
|
|
2307
|
+
return amount ? `${amount}/${unitName}` : void 0;
|
|
2308
|
+
}
|
|
2309
|
+
if (price.type === "package") {
|
|
2310
|
+
const amount = formatAmount(price.amount);
|
|
2311
|
+
if (!amount) return void 0;
|
|
2312
|
+
const size = parseFloat(price.quantityPerUnit ?? "");
|
|
2313
|
+
return Number.isFinite(size) && size > 0 ? `${amount} per ${size.toLocaleString()} ${pluralizeUnit(unitName)}` : amount;
|
|
2314
|
+
}
|
|
2315
|
+
};
|
|
2316
|
+
const NO_CAP = "There is no usage cap.";
|
|
2317
|
+
const deriveUsageView = (meter, item, unitName = "unit") => {
|
|
2318
|
+
const quota = meter.balance + meter.usage - meter.overage;
|
|
2319
|
+
const isSoftLimit = item?.included?.entitlement?.isSoftLimit ?? true;
|
|
2320
|
+
const rateLabel = rateLabelFor(item?.price, unitName);
|
|
2321
|
+
if (!isSoftLimit) return {
|
|
2322
|
+
kind: "capped",
|
|
2323
|
+
usage: meter.usage,
|
|
2324
|
+
quota,
|
|
2325
|
+
remaining: meter.balance,
|
|
2326
|
+
atLimit: meter.usage >= quota,
|
|
2327
|
+
rateLabel
|
|
2328
|
+
};
|
|
2329
|
+
if (!item) return {
|
|
2330
|
+
kind: "meteredGeneric",
|
|
2331
|
+
usage: meter.usage,
|
|
2332
|
+
quota: quota > 0 ? quota : void 0,
|
|
2333
|
+
caption: "Usage is billed per your plan's pricing."
|
|
2334
|
+
};
|
|
2335
|
+
if (!item.price || item.price.type === "flat") return {
|
|
2336
|
+
kind: "meteredGeneric",
|
|
2337
|
+
usage: meter.usage,
|
|
2338
|
+
quota: quota > 0 ? quota : void 0,
|
|
2339
|
+
caption: `Usage doesn't change your bill. ${NO_CAP}`
|
|
2340
|
+
};
|
|
2341
|
+
const isGraduated = item.price.type === "tiered" && (item.price.mode ?? "graduated") === "graduated";
|
|
2342
|
+
const derivable = item.price.type === "unit" || isGraduated;
|
|
2343
|
+
const freeUnits = priceIncludedUnits(item.price);
|
|
2344
|
+
if (derivable) {
|
|
2345
|
+
if (freeUnits === 0) return {
|
|
2346
|
+
kind: "payAsYouGo",
|
|
2347
|
+
usage: meter.usage,
|
|
2348
|
+
caption: `Pay as you go — every ${unitName} is billed; there is no usage cap.`,
|
|
2349
|
+
rateLabel
|
|
2350
|
+
};
|
|
2351
|
+
if (freeUnits === Number.POSITIVE_INFINITY) return {
|
|
2352
|
+
kind: "meteredGeneric",
|
|
2353
|
+
usage: meter.usage,
|
|
2354
|
+
quota: quota > 0 ? quota : void 0,
|
|
2355
|
+
caption: `Included with your plan. ${NO_CAP}`,
|
|
2356
|
+
rateLabel
|
|
2357
|
+
};
|
|
2358
|
+
if (quota > 0) return {
|
|
2359
|
+
kind: "included",
|
|
2360
|
+
usage: meter.usage,
|
|
2361
|
+
included: quota,
|
|
2362
|
+
remaining: meter.balance,
|
|
2363
|
+
overage: meter.overage,
|
|
2364
|
+
rateLabel
|
|
2365
|
+
};
|
|
2366
|
+
return {
|
|
2367
|
+
kind: "meteredGeneric",
|
|
2368
|
+
usage: meter.usage,
|
|
2369
|
+
caption: `The first ${freeUnits.toLocaleString()} ${pluralizeUnit(unitName)} are included; additional usage is billed. ${NO_CAP}`,
|
|
2370
|
+
rateLabel
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
return {
|
|
2374
|
+
kind: "meteredGeneric",
|
|
2375
|
+
usage: meter.usage,
|
|
2376
|
+
quota: quota > 0 ? quota : void 0,
|
|
2377
|
+
caption: `Usage is billed per your plan's pricing. ${NO_CAP}`,
|
|
2378
|
+
rateLabel
|
|
2379
|
+
};
|
|
2380
|
+
};
|
|
2381
|
+
//#endregion
|
|
2004
2382
|
//#region src/pages/subscriptions/Usage.tsx
|
|
2005
2383
|
const isMeteredEntitlement = (entitlement) => {
|
|
2006
2384
|
return "balance" in entitlement;
|
|
2007
2385
|
};
|
|
2008
|
-
const UsageItem = ({ meter, item, subscription, featureKey }) => {
|
|
2386
|
+
const UsageItem = ({ meter, item, subscription, featureKey, pendingCredit }) => {
|
|
2387
|
+
const { pricing } = useMonetizationConfig();
|
|
2009
2388
|
const cadence = item?.billingCadence ?? subscription?.billingCadence;
|
|
2010
2389
|
const billingPeriod = cadence ? formatDurationAdjective(cadence) : "monthly";
|
|
2011
|
-
const
|
|
2012
|
-
const
|
|
2013
|
-
const
|
|
2014
|
-
const
|
|
2015
|
-
|
|
2016
|
-
|
|
2390
|
+
const view = deriveUsageView(meter, item, pricing?.units?.[item?.key ?? ""] ?? pricing?.units?.[featureKey] ?? "unit");
|
|
2391
|
+
const atHardLimit = view.kind === "capped" && view.atLimit;
|
|
2392
|
+
const overIncluded = view.kind === "included" && view.overage > 0;
|
|
2393
|
+
const upgradeAction = (variant) => subscription && /* @__PURE__ */ jsx(AlertAction, { children: /* @__PURE__ */ jsx(SwitchPlanModal, {
|
|
2394
|
+
subscription,
|
|
2395
|
+
children: /* @__PURE__ */ jsxs(Button, {
|
|
2396
|
+
variant,
|
|
2397
|
+
size: "xs",
|
|
2398
|
+
children: [/* @__PURE__ */ jsx(ArrowUpIcon, {}), "Upgrade"]
|
|
2399
|
+
})
|
|
2400
|
+
}) });
|
|
2017
2401
|
return /* @__PURE__ */ jsxs(Card, {
|
|
2018
|
-
className: cn(
|
|
2402
|
+
className: cn(atHardLimit && "border-destructive bg-destructive/5"),
|
|
2019
2403
|
children: [/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
2020
|
-
|
|
2021
|
-
variant: "destructive",
|
|
2404
|
+
pendingCredit && /* @__PURE__ */ jsxs(Alert, {
|
|
2022
2405
|
className: "mb-4",
|
|
2023
2406
|
children: [
|
|
2024
|
-
/* @__PURE__ */ jsx(
|
|
2407
|
+
/* @__PURE__ */ jsx(BadgePercentIcon, { className: "size-4 text-green-600 shrink-0" }),
|
|
2408
|
+
/* @__PURE__ */ jsx(AlertTitle, { children: "Usage credit applied" }),
|
|
2409
|
+
/* @__PURE__ */ jsxs(AlertDescription, { children: [
|
|
2410
|
+
"A credit of ",
|
|
2411
|
+
pendingCredit.units.toLocaleString(),
|
|
2412
|
+
" ",
|
|
2413
|
+
pendingCredit.units === 1 ? "unit" : "units",
|
|
2414
|
+
" applies to this billing period and will be deducted from your next invoice automatically."
|
|
2415
|
+
] })
|
|
2416
|
+
]
|
|
2417
|
+
}),
|
|
2418
|
+
overIncluded && /* @__PURE__ */ jsxs(Alert, {
|
|
2419
|
+
variant: "warning",
|
|
2420
|
+
className: "mb-4",
|
|
2421
|
+
children: [
|
|
2422
|
+
/* @__PURE__ */ jsx(AlertTriangleIcon, { className: "size-4 shrink-0" }),
|
|
2025
2423
|
/* @__PURE__ */ jsxs(AlertTitle, { children: [
|
|
2026
|
-
"You've
|
|
2424
|
+
"You've used your included ",
|
|
2027
2425
|
billingPeriod,
|
|
2028
|
-
"
|
|
2426
|
+
" usage"
|
|
2029
2427
|
] }),
|
|
2030
2428
|
/* @__PURE__ */ jsxs(AlertDescription, { children: [
|
|
2031
|
-
"Additional usage is
|
|
2032
|
-
|
|
2033
|
-
". Upgrade to a higher plan for more usage."
|
|
2429
|
+
"Additional usage is billed",
|
|
2430
|
+
view.rateLabel ? ` at ${view.rateLabel}` : "",
|
|
2431
|
+
". Upgrade to a higher plan for more included usage."
|
|
2034
2432
|
] }),
|
|
2035
|
-
|
|
2036
|
-
subscription,
|
|
2037
|
-
children: /* @__PURE__ */ jsxs(Button, {
|
|
2038
|
-
variant: "destructive",
|
|
2039
|
-
size: "xs",
|
|
2040
|
-
children: [/* @__PURE__ */ jsx(ArrowUpIcon, {}), "Upgrade"]
|
|
2041
|
-
})
|
|
2042
|
-
}) })
|
|
2433
|
+
upgradeAction("outline")
|
|
2043
2434
|
]
|
|
2044
2435
|
}),
|
|
2045
|
-
|
|
2436
|
+
atHardLimit && /* @__PURE__ */ jsxs(Alert, {
|
|
2046
2437
|
variant: "destructive",
|
|
2047
2438
|
className: "mb-4",
|
|
2048
2439
|
children: [
|
|
@@ -2053,58 +2444,79 @@ const UsageItem = ({ meter, item, subscription, featureKey }) => {
|
|
|
2053
2444
|
" limit"
|
|
2054
2445
|
] }),
|
|
2055
2446
|
/* @__PURE__ */ jsx(AlertDescription, { children: "Requests beyond your quota are blocked. Upgrade to a higher plan for more usage." }),
|
|
2056
|
-
|
|
2057
|
-
subscription,
|
|
2058
|
-
children: /* @__PURE__ */ jsxs(Button, {
|
|
2059
|
-
variant: "destructive",
|
|
2060
|
-
size: "xs",
|
|
2061
|
-
children: [/* @__PURE__ */ jsx(ArrowUpIcon, {}), "Upgrade"]
|
|
2062
|
-
})
|
|
2063
|
-
}) })
|
|
2447
|
+
upgradeAction("destructive")
|
|
2064
2448
|
]
|
|
2065
2449
|
}),
|
|
2066
2450
|
/* @__PURE__ */ jsx(CardTitle, { children: item?.name ?? featureKey })
|
|
2067
|
-
] }), /* @__PURE__ */
|
|
2451
|
+
] }), /* @__PURE__ */ jsx(CardContent, {
|
|
2068
2452
|
className: "space-y-2",
|
|
2069
|
-
children: [
|
|
2453
|
+
children: view.kind === "capped" || view.kind === "included" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2070
2454
|
/* @__PURE__ */ jsxs("div", {
|
|
2071
2455
|
className: "flex items-center justify-between text-sm",
|
|
2072
2456
|
children: [/* @__PURE__ */ jsx("div", {
|
|
2073
2457
|
className: "flex flex-col gap-2 mb-2",
|
|
2074
2458
|
children: /* @__PURE__ */ jsxs("span", {
|
|
2075
|
-
className: cn(
|
|
2459
|
+
className: cn(atHardLimit && "text-red-600 font-medium"),
|
|
2076
2460
|
children: [
|
|
2077
|
-
|
|
2461
|
+
view.usage.toLocaleString(),
|
|
2078
2462
|
" used",
|
|
2079
|
-
|
|
2463
|
+
view.kind === "included" && view.overage > 0 && /* @__PURE__ */ jsxs("span", {
|
|
2080
2464
|
className: "ml-1 text-xs",
|
|
2081
2465
|
children: [
|
|
2082
2466
|
"(+",
|
|
2083
|
-
|
|
2467
|
+
view.overage.toLocaleString(),
|
|
2084
2468
|
" overage)"
|
|
2085
2469
|
]
|
|
2086
2470
|
})
|
|
2087
2471
|
]
|
|
2088
2472
|
})
|
|
2089
|
-
}), /* @__PURE__ */
|
|
2473
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2090
2474
|
className: "text-foreground font-medium",
|
|
2091
|
-
children:
|
|
2475
|
+
children: view.kind === "capped" ? `${view.quota.toLocaleString()} limit` : `${view.included.toLocaleString()} included`
|
|
2092
2476
|
})]
|
|
2093
2477
|
}),
|
|
2094
2478
|
/* @__PURE__ */ jsx(Progress, {
|
|
2095
|
-
value: Math.min(100,
|
|
2096
|
-
className: cn("mb-3 h-2",
|
|
2479
|
+
value: Math.min(100, (view.kind === "capped" ? view.quota : view.included) > 0 ? view.usage / (view.kind === "capped" ? view.quota : view.included) * 100 : 100),
|
|
2480
|
+
className: cn("mb-3 h-2", atHardLimit && "bg-destructive")
|
|
2097
2481
|
}),
|
|
2098
2482
|
/* @__PURE__ */ jsxs("p", {
|
|
2099
2483
|
className: "text-xs text-muted-foreground",
|
|
2100
|
-
children: [
|
|
2484
|
+
children: [
|
|
2485
|
+
view.remaining.toLocaleString(),
|
|
2486
|
+
view.kind === "included" ? " included" : "",
|
|
2487
|
+
" remaining this billing period"
|
|
2488
|
+
]
|
|
2101
2489
|
})
|
|
2102
|
-
]
|
|
2490
|
+
] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
|
|
2491
|
+
className: "flex items-center justify-between text-sm",
|
|
2492
|
+
children: [/* @__PURE__ */ jsxs("span", { children: [view.usage.toLocaleString(), " used this billing period"] }), view.kind === "meteredGeneric" && view.quota !== void 0 ? /* @__PURE__ */ jsxs("span", {
|
|
2493
|
+
className: "text-foreground font-medium",
|
|
2494
|
+
children: [view.quota.toLocaleString(), " quota"]
|
|
2495
|
+
}) : view.rateLabel && /* @__PURE__ */ jsx("span", {
|
|
2496
|
+
className: "text-muted-foreground",
|
|
2497
|
+
children: view.rateLabel
|
|
2498
|
+
})]
|
|
2499
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
2500
|
+
className: "text-xs text-muted-foreground",
|
|
2501
|
+
children: view.caption
|
|
2502
|
+
})] })
|
|
2103
2503
|
})]
|
|
2104
2504
|
});
|
|
2105
2505
|
};
|
|
2106
|
-
const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPayment }) => {
|
|
2506
|
+
const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPayment, pendingCredits }) => {
|
|
2107
2507
|
const hasUsage = Object.values(usage.entitlements).some((value) => isMeteredEntitlement(value));
|
|
2508
|
+
if (!!subscription?.activeTo && new Date(subscription.activeTo).getTime() < Date.now()) return /* @__PURE__ */ jsxs("div", {
|
|
2509
|
+
className: "space-y-4",
|
|
2510
|
+
children: [/* @__PURE__ */ jsx(Heading, {
|
|
2511
|
+
level: 3,
|
|
2512
|
+
children: "Usage"
|
|
2513
|
+
}), /* @__PURE__ */ jsxs(Alert, { children: [
|
|
2514
|
+
/* @__PURE__ */ jsx(HistoryIcon, { className: "size-4 shrink-0" }),
|
|
2515
|
+
/* @__PURE__ */ jsx(AlertTitle, { children: "This subscription has ended" }),
|
|
2516
|
+
/* @__PURE__ */ jsx(AlertDescription, { children: "Usage history isn't available yet." })
|
|
2517
|
+
] })]
|
|
2518
|
+
});
|
|
2519
|
+
const creditByFeature = new Map((pendingCredits ?? []).map((credit) => [credit.featureKey, credit]));
|
|
2108
2520
|
return /* @__PURE__ */ jsxs("div", {
|
|
2109
2521
|
className: "space-y-4",
|
|
2110
2522
|
children: [
|
|
@@ -2143,7 +2555,8 @@ const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPa
|
|
|
2143
2555
|
featureKey: key,
|
|
2144
2556
|
meter: { ...value },
|
|
2145
2557
|
subscription,
|
|
2146
|
-
item: currentItems?.find((item) => item.featureKey === key)
|
|
2558
|
+
item: currentItems?.find((item) => item.featureKey === key),
|
|
2559
|
+
pendingCredit: creditByFeature.get(key)
|
|
2147
2560
|
}, key) : []) : !isFetching && !subscription?.annotations?.["subscription.previous.id"] ? /* @__PURE__ */ jsxs(Alert, {
|
|
2148
2561
|
variant: "warning",
|
|
2149
2562
|
children: [
|
|
@@ -2166,6 +2579,7 @@ const ActiveSubscription = ({ subscription, deploymentName }) => {
|
|
|
2166
2579
|
refetchOnWindowFocus: true,
|
|
2167
2580
|
meta: { context: zudoku }
|
|
2168
2581
|
});
|
|
2582
|
+
const pendingCreditsQuery = usePendingCredits(deploymentName, subscription.id);
|
|
2169
2583
|
const isPendingFirstPayment = usageQuery.data.paymentStatus.isFirstPayment === true && usageQuery.data.paymentStatus.status !== "paid" && usageQuery.data.paymentStatus.status !== "not_required";
|
|
2170
2584
|
const activePhase = getActivePhase(subscription);
|
|
2171
2585
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
@@ -2184,7 +2598,8 @@ const ActiveSubscription = ({ subscription, deploymentName }) => {
|
|
|
2184
2598
|
usage: usageQuery.data,
|
|
2185
2599
|
isFetching: usageQuery.isFetching,
|
|
2186
2600
|
subscription,
|
|
2187
|
-
isPendingFirstPayment
|
|
2601
|
+
isPendingFirstPayment,
|
|
2602
|
+
pendingCredits: pendingCreditsQuery.data?.pendingCredits
|
|
2188
2603
|
}),
|
|
2189
2604
|
subscription?.consumer?.apiKeys && /* @__PURE__ */ jsx(ApiKeysList, {
|
|
2190
2605
|
isPendingFirstPayment,
|
|
@@ -2331,6 +2746,7 @@ const zuploMonetizationPlugin = createPlugin((options = {}) => ({
|
|
|
2331
2746
|
if (context.getAuthState().isAuthenticated) queryClient.prefetchQuery(subscriptionsQuery(context));
|
|
2332
2747
|
},
|
|
2333
2748
|
getIdentities: async (context) => {
|
|
2749
|
+
if (!context.getAuthState().isAuthenticated) return [];
|
|
2334
2750
|
return (await queryClient.fetchQuery(subscriptionsQuery(context))).items.flatMap((sub) => sub.status !== "active" ? [] : sub.consumer.apiKeys.flatMap((apiKey) => apiKey.expiresOn && new Date(apiKey.expiresOn) < /* @__PURE__ */ new Date() ? [] : {
|
|
2335
2751
|
label: `${sub.name} (****${apiKey.key.slice(-5)})`,
|
|
2336
2752
|
id: apiKey.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zuplo/zudoku-plugin-monetization",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.45",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/zuplo/zudoku",
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
"@testing-library/react": "16.3.2",
|
|
34
34
|
"@types/react": "19.2.14",
|
|
35
35
|
"@types/react-dom": "19.2.3",
|
|
36
|
-
"happy-dom": "20.
|
|
36
|
+
"happy-dom": "20.10.2",
|
|
37
37
|
"react": "19.2.5",
|
|
38
38
|
"react-dom": "19.2.5",
|
|
39
39
|
"tsdown": "0.22.0",
|
|
40
|
-
"zudoku": "0.
|
|
40
|
+
"zudoku": "0.81.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"react": ">=19.2.0",
|