@lukoweb/apitogo-plugin-dev-portal-billing 0.2.52 → 0.2.54

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.
Files changed (23) hide show
  1. package/dist/{AccountPlansPage-CeUizdWk.mjs → AccountPlansPage-0PYGy1aV.mjs} +6 -66
  2. package/dist/{CheckoutPage-CwSElFUk.mjs → CheckoutPage-Bpa128i2.mjs} +26 -14
  3. package/dist/{ContactSalesPage-q7Dxxtx6.mjs → ContactSalesPage-BslD3Yk4.mjs} +3 -10
  4. package/dist/{CredentialsPage-B2jPo6Go.mjs → CredentialsPage-C4qpBVrh.mjs} +27 -18
  5. package/dist/{PricingCard-CKdq1oWn.mjs → PricingCard-YJIEwydG.mjs} +1 -1
  6. package/dist/{PricingPage-DnQQfy9L.mjs → PricingPage-DIhoFOd9.mjs} +4 -4
  7. package/dist/SubscriptionsPage-D7hsYtTB.mjs +268 -0
  8. package/dist/{UsagePage-BF4QgrTc.mjs → UsagePage-Di9kQ_3Z.mjs} +60 -18
  9. package/dist/{account-links-D_vS0GJn.mjs → account-links-9h91uCGj.mjs} +1 -1
  10. package/dist/index.mjs +19 -19
  11. package/dist/{key-plan-note-DTWeFhn2.mjs → page-state-BB-72pbw.mjs} +22 -2
  12. package/dist/paths-DaVyTIDJ.mjs +14 -0
  13. package/dist/problem-details-n20-dy3w.mjs +18 -0
  14. package/dist/{useApiKeys-CVgXfUop.mjs → useApiKeys-CEwMzqju.mjs} +2 -8
  15. package/dist/{useMySubscriptions-BtxAXA1m.mjs → useMySubscriptions-DuVq2tXL.mjs} +1 -1
  16. package/package.json +3 -3
  17. package/dist/SubscriptionsPage-msO31jv-.mjs +0 -223
  18. package/dist/paths-ujy9wjVK.mjs +0 -12
  19. /package/dist/{billing-state-DbU5Lpoz.mjs → billing-state-Dgcc3_pT.mjs} +0 -0
  20. /package/dist/{demo-session-rN2Gj4lM.mjs → demo-session-yK9qEqV-.mjs} +0 -0
  21. /package/dist/{pricing-config-02m4dyFf.mjs → pricing-config-C44AIqql.mjs} +0 -0
  22. /package/dist/{usePlans-DPGFWcVo.mjs → usePlans-BF7Hy2qJ.mjs} +0 -0
  23. /package/dist/{useUsage--5sZub3M.mjs → useUsage-CJ9VIBc7.mjs} +0 -0
@@ -1,8 +1,9 @@
1
1
  import { f as parseManifestPlans, n as useDevPortalPreviewMode, r as useIsDemoSession, s as isActiveSubscription, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
2
- import { t as PricingCard } from "./PricingCard-CKdq1oWn.mjs";
2
+ import { t as PricingCard } from "./PricingCard-YJIEwydG.mjs";
3
3
  import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
4
- import { t as useMySubscriptions } from "./useMySubscriptions-BtxAXA1m.mjs";
5
- import { t as usePlans } from "./usePlans-DPGFWcVo.mjs";
4
+ import { n as readProblemDetail } from "./problem-details-n20-dy3w.mjs";
5
+ import { t as useMySubscriptions } from "./useMySubscriptions-DuVq2tXL.mjs";
6
+ import { t as usePlans } from "./usePlans-BF7Hy2qJ.mjs";
6
7
  import { Head } from "@lukoweb/apitogo/components";
7
8
  import { CreditCardIcon } from "@lukoweb/apitogo/icons";
8
9
  import { useSearchParams } from "@lukoweb/apitogo/router";
@@ -43,10 +44,7 @@ function useCancelSubscription() {
43
44
  credentials: "include",
44
45
  body: JSON.stringify({ planSku })
45
46
  });
46
- if (!response.ok) {
47
- const detail = await response.text();
48
- throw new Error(detail || "Failed to cancel subscription.");
49
- }
47
+ if (!response.ok) throw new Error(await readProblemDetail(response) ?? "Failed to cancel subscription.");
50
48
  return await response.json();
51
49
  },
52
50
  onSuccess: async () => {
@@ -55,42 +53,6 @@ function useCancelSubscription() {
55
53
  });
56
54
  }
57
55
 
58
- //#endregion
59
- //#region src/hooks/useChangePlan.ts
60
- function useChangePlan() {
61
- const apiBaseUrl = useDevPortalApiBaseUrl();
62
- const queryClient = useQueryClient();
63
- return useMutation({
64
- mutationFn: async ({ fromPlanSku, toPlanSku }) => {
65
- if (!apiBaseUrl) throw new Error("Dev portal API is not configured.");
66
- const response = await fetch(`${apiBaseUrl}/api/v1/subscriptions/change`, {
67
- method: "POST",
68
- headers: {
69
- Accept: "application/json",
70
- "Content-Type": "application/json"
71
- },
72
- credentials: "include",
73
- body: JSON.stringify({
74
- fromPlanSku,
75
- toPlanSku
76
- })
77
- });
78
- if (!response.ok) {
79
- const detail = await response.text();
80
- throw new Error(detail || "Failed to change plan.");
81
- }
82
- return await response.json();
83
- },
84
- onSuccess: async (result) => {
85
- if (result.checkoutUrl) {
86
- window.location.assign(result.checkoutUrl);
87
- return;
88
- }
89
- await queryClient.invalidateQueries({ queryKey: ["dev-portal-me", apiBaseUrl] });
90
- }
91
- });
92
- }
93
-
94
56
  //#endregion
95
57
  //#region src/pages/AccountPlansPage.tsx
96
58
  var AccountPlansPage_exports = /* @__PURE__ */ __exportAll({ default: () => AccountPlansPage });
@@ -106,24 +68,8 @@ const AccountPlansPage = () => {
106
68
  const isError = isPreview ? false : livePlans.isError;
107
69
  const { data: me } = useMySubscriptions();
108
70
  const cancelMutation = useCancelSubscription();
109
- const changeMutation = useChangePlan();
110
71
  const [cancelingSku, setCancelingSku] = useState(null);
111
- const [switchingSku, setSwitchingSku] = useState(null);
112
72
  const subscriptionBySku = new Map((me?.subscriptions ?? []).map((sub) => [sub.planSku, sub]));
113
- const activePlanSkus = (me?.subscriptions ?? []).filter((sub) => isActiveSubscription(sub.status)).map((sub) => sub.planSku);
114
- const currentPlanSku = activePlanSkus.length === 1 ? activePlanSkus[0] : null;
115
- const handleSwitch = async (toPlanSku) => {
116
- if (!currentPlanSku) return;
117
- setSwitchingSku(toPlanSku);
118
- try {
119
- await changeMutation.mutateAsync({
120
- fromPlanSku: currentPlanSku,
121
- toPlanSku
122
- });
123
- } finally {
124
- setSwitchingSku(null);
125
- }
126
- };
127
73
  const handleUnsubscribe = async (planSku) => {
128
74
  setCancelingSku(planSku);
129
75
  try {
@@ -150,7 +96,7 @@ const AccountPlansPage = () => {
150
96
  }),
151
97
  /* @__PURE__ */ jsx("p", {
152
98
  className: "max-w-2xl text-muted-foreground",
153
- children: "Compare plans, manage your subscriptions, and change tiers."
99
+ children: "Compare plans and manage your subscriptions. You can hold several plans at once, and each has its own API keys and limits."
154
100
  }),
155
101
  checkoutCancelled ? /* @__PURE__ */ jsx("p", {
156
102
  className: "max-w-2xl text-sm text-muted-foreground",
@@ -167,10 +113,6 @@ const AccountPlansPage = () => {
167
113
  className: "mb-6 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive",
168
114
  children: cancelMutation.error instanceof Error ? cancelMutation.error.message : "Failed to cancel subscription."
169
115
  }) : null,
170
- changeMutation.isError ? /* @__PURE__ */ jsx("div", {
171
- className: "mb-6 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive",
172
- children: changeMutation.error instanceof Error ? changeMutation.error.message : "Failed to change plan."
173
- }) : null,
174
116
  isLoading ? /* @__PURE__ */ jsx("p", {
175
117
  className: "text-muted-foreground",
176
118
  children: "Loading plans…"
@@ -190,8 +132,6 @@ const AccountPlansPage = () => {
190
132
  subscriptionStatus: subscription?.status,
191
133
  isSubscribed,
192
134
  onUnsubscribe: isPreview || isDemoSession ? void 0 : handleUnsubscribe,
193
- onSwitch: currentPlanSku && !isPreview && !isDemoSession ? handleSwitch : void 0,
194
- isSwitching: switchingSku === plan.sku,
195
135
  isCanceling: cancelingSku === plan.sku,
196
136
  cancelDisabled: isPreview || isDemoSession
197
137
  }, plan.sku);
@@ -1,6 +1,7 @@
1
- import { o as PRICING_PATH, t as ACCOUNT_PLANS_PATH } from "./paths-ujy9wjVK.mjs";
1
+ import { n as ALREADY_SUBSCRIBED_PARAM, s as PRICING_PATH, t as ACCOUNT_PLANS_PATH } from "./paths-DaVyTIDJ.mjs";
2
2
  import { n as useDevPortalPreviewMode, r as useIsDemoSession, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
3
- import { t as FREE_CHECKOUT_MARKER } from "./billing-state-DbU5Lpoz.mjs";
3
+ import { n as readProblemDetail, t as isAlreadySubscribedMessage } from "./problem-details-n20-dy3w.mjs";
4
+ import { t as FREE_CHECKOUT_MARKER } from "./billing-state-Dgcc3_pT.mjs";
4
5
  import { ShieldIcon } from "@lukoweb/apitogo/icons";
5
6
  import { Link, useNavigate, useSearchParams } from "@lukoweb/apitogo/router";
6
7
  import { useEffect } from "react";
@@ -81,14 +82,19 @@ const CheckoutPage = () => {
81
82
  credentials: "include",
82
83
  body: JSON.stringify({ planSku })
83
84
  });
84
- if (!response.ok) {
85
- const detail = await response.text();
86
- throw new Error(detail || "Checkout failed.");
87
- }
85
+ if (!response.ok) throw new Error(await readProblemDetail(response) ?? "Checkout failed.");
88
86
  return await response.json();
89
87
  }
90
88
  });
91
89
  const freeProvisioned = checkoutQuery.data?.checkoutUrl == null && checkoutQuery.data?.entitlementProvisioned === true;
90
+ const alreadySubscribed = checkoutQuery.isError && isAlreadySubscribedMessage(checkoutQuery.error.message);
91
+ useEffect(() => {
92
+ if (alreadySubscribed) navigate(`/account/subscriptions?${ALREADY_SUBSCRIBED_PARAM}=${encodeURIComponent(planSku)}`, { replace: true });
93
+ }, [
94
+ alreadySubscribed,
95
+ navigate,
96
+ planSku
97
+ ]);
92
98
  useEffect(() => {
93
99
  if (freeProvisioned) navigate(`/account/subscriptions?checkout=success&${FREE_CHECKOUT_MARKER}`, { replace: true });
94
100
  }, [freeProvisioned, navigate]);
@@ -146,13 +152,13 @@ const CheckoutPage = () => {
146
152
  })
147
153
  ]
148
154
  });
149
- if (freeProvisioned) return null;
150
- return /* @__PURE__ */ jsx(RedirectPage, {
151
- icon: ShieldIcon,
152
- title: "Establishing secure checkout…",
153
- description: "Redirecting you to complete your subscription.",
154
- url: checkoutQuery.data?.checkoutUrl ?? void 0,
155
- children: checkoutQuery.isError ? /* @__PURE__ */ jsxs(Alert, {
155
+ if (freeProvisioned || alreadySubscribed) return null;
156
+ if (checkoutQuery.isError) return /* @__PURE__ */ jsxs("div", {
157
+ className: "mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-4 text-center",
158
+ children: [/* @__PURE__ */ jsx("h1", {
159
+ className: "text-2xl font-semibold tracking-tight",
160
+ children: "Checkout could not start"
161
+ }), /* @__PURE__ */ jsxs(Alert, {
156
162
  variant: "destructive",
157
163
  children: [/* @__PURE__ */ jsx(AlertDescription, {
158
164
  className: "first-letter:uppercase",
@@ -166,7 +172,13 @@ const CheckoutPage = () => {
166
172
  children: "Back to plans"
167
173
  })
168
174
  }) })]
169
- }) : null
175
+ })]
176
+ });
177
+ return /* @__PURE__ */ jsx(RedirectPage, {
178
+ icon: ShieldIcon,
179
+ title: "Establishing secure checkout…",
180
+ description: "Redirecting you to complete your subscription.",
181
+ url: checkoutQuery.data?.checkoutUrl ?? void 0
170
182
  });
171
183
  };
172
184
 
@@ -1,7 +1,8 @@
1
- import { o as PRICING_PATH } from "./paths-ujy9wjVK.mjs";
1
+ import { s as PRICING_PATH } from "./paths-DaVyTIDJ.mjs";
2
2
  import { l as isPlanCustomPriced, n as useDevPortalPreviewMode, r as useIsDemoSession, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
3
3
  import "./DevPortalBillingContext-CR83s4LO.mjs";
4
- import { t as usePlans } from "./usePlans-DPGFWcVo.mjs";
4
+ import { n as readProblemDetail } from "./problem-details-n20-dy3w.mjs";
5
+ import { t as usePlans } from "./usePlans-BF7Hy2qJ.mjs";
5
6
  import { Link, useSearchParams } from "@lukoweb/apitogo/router";
6
7
  import { useState } from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -32,14 +33,6 @@ function useSubmitPlanInquiry() {
32
33
  if (!response.ok) throw new Error(await readProblemDetail(response) ?? "Your message could not be sent. Please try again later.");
33
34
  } });
34
35
  }
35
- async function readProblemDetail(response) {
36
- try {
37
- const body = await response.json();
38
- return (body.errors ? Object.values(body.errors).flat()[0] : void 0) ?? body.detail ?? body.title ?? null;
39
- } catch {
40
- return null;
41
- }
42
- }
43
36
 
44
37
  //#endregion
45
38
  //#region src/pages/ContactSalesPage.tsx
@@ -1,10 +1,10 @@
1
- import { t as ACCOUNT_PLANS_PATH } from "./paths-ujy9wjVK.mjs";
1
+ import { t as ACCOUNT_PLANS_PATH } from "./paths-DaVyTIDJ.mjs";
2
2
  import { n as useDevPortalPreviewMode, r as useIsDemoSession, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
3
3
  import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
4
- import { r as createDemoCredentials, t as DEMO_GATEWAY_KEY_HEADER } from "./demo-session-rN2Gj4lM.mjs";
5
- import { n as useApiKeys } from "./useApiKeys-CVgXfUop.mjs";
6
- import { n as apiReferencePath, r as ApiKeysPanel } from "./account-links-D_vS0GJn.mjs";
7
- import { n as ChoosePlanNotice, t as keyPlanNote } from "./key-plan-note-DTWeFhn2.mjs";
4
+ import { r as createDemoCredentials, t as DEMO_GATEWAY_KEY_HEADER } from "./demo-session-yK9qEqV-.mjs";
5
+ import { n as useApiKeys } from "./useApiKeys-CEwMzqju.mjs";
6
+ import { n as apiReferencePath, r as ApiKeysPanel } from "./account-links-9h91uCGj.mjs";
7
+ import { i as ChoosePlanNotice, r as keyPlanNote, t as apiKeysPageState } from "./page-state-BB-72pbw.mjs";
8
8
  import { Head } from "@lukoweb/apitogo/components";
9
9
  import { KeyRoundIcon } from "@lukoweb/apitogo/icons";
10
10
  import { Link as Link$1 } from "@lukoweb/apitogo/router";
@@ -57,18 +57,23 @@ const CredentialsPage = () => {
57
57
  const auth = useAuth();
58
58
  const isPreview = useDevPortalPreviewMode();
59
59
  const isDemoSession = useIsDemoSession();
60
- const { data: credentials, isLoading, error } = useCredentials();
61
- const { data: apiKeys, isLoading: keysLoading, error: keysError } = useApiKeys();
60
+ const { data: credentials } = useCredentials();
61
+ const { data: apiKeys, error: keysError } = useApiKeys();
62
62
  const { gatewayBaseUrl: configuredGatewayBaseUrl, links, keyHeader: configuredKeyHeader } = useDevPortalBillingConfig();
63
63
  const apiReferenceLink = apiReferencePath(links);
64
64
  const gatewayBaseUrl = credentials?.gatewayBaseUrl?.trim() || apiKeys?.gatewayBaseUrl?.trim() || configuredGatewayBaseUrl?.trim() || zudokuConfig.__meta?.gatewayBaseUrl?.trim() || null;
65
65
  const header = configuredKeyHeader?.trim() || apiKeys?.header || DEMO_GATEWAY_KEY_HEADER;
66
66
  const plans = apiKeys?.plans ?? [];
67
67
  const planNote = isDemoSession ? null : keyPlanNote(plans);
68
+ const pageState = apiKeysPageState({
69
+ data: apiKeys,
70
+ error: keysError
71
+ });
72
+ const showsLiveData = auth.isAuthenticated && (isDemoSession || !isPreview);
68
73
  return /* @__PURE__ */ jsxs("div", {
69
74
  className: "mx-auto max-w-screen-lg px-4 pb-16 pt-(--padding-content-top) lg:px-8",
70
75
  children: [
71
- /* @__PURE__ */ jsx(Head, { children: /* @__PURE__ */ jsx("title", { children: "API credentials" }) }),
76
+ /* @__PURE__ */ jsx(Head, { children: /* @__PURE__ */ jsx("title", { children: "API keys" }) }),
72
77
  /* @__PURE__ */ jsxs("div", {
73
78
  className: "mb-8 flex items-start gap-4",
74
79
  children: [/* @__PURE__ */ jsx("div", {
@@ -78,7 +83,7 @@ const CredentialsPage = () => {
78
83
  className: "space-y-1",
79
84
  children: [/* @__PURE__ */ jsx("h1", {
80
85
  className: "text-3xl font-semibold tracking-tight",
81
- children: "API credentials"
86
+ children: "API keys"
82
87
  }), /* @__PURE__ */ jsx("p", {
83
88
  className: "text-muted-foreground",
84
89
  children: "Create API keys for each subscribed plan and use them to authenticate requests to the API gateway."
@@ -93,7 +98,7 @@ const CredentialsPage = () => {
93
98
  className: "rounded-xl border border-dashed p-8 text-center",
94
99
  children: /* @__PURE__ */ jsx("p", {
95
100
  className: "text-muted-foreground",
96
- children: "API credentials are available after the developer portal is published."
101
+ children: "API keys are available after the developer portal is published."
97
102
  })
98
103
  }) : null,
99
104
  !isPreview && !isDemoSession && !auth.isAuthenticated ? /* @__PURE__ */ jsxs("div", {
@@ -110,15 +115,18 @@ const CredentialsPage = () => {
110
115
  })
111
116
  })]
112
117
  }) : null,
113
- !isPreview && !isDemoSession && auth.isAuthenticated && (isLoading || keysLoading) ? /* @__PURE__ */ jsx("p", {
114
- className: "text-muted-foreground",
115
- children: "Loading credentials…"
118
+ showsLiveData && pageState === "loading" ? /* @__PURE__ */ jsx("div", {
119
+ className: "space-y-6",
120
+ role: "status",
121
+ "aria-busy": "true",
122
+ "aria-label": "Loading API keys",
123
+ children: [0, 1].map((i) => /* @__PURE__ */ jsx("div", { className: "h-40 animate-pulse rounded-xl border bg-muted/40" }, i))
116
124
  }) : null,
117
- !isPreview && !isDemoSession && auth.isAuthenticated && (error || keysError) ? error instanceof Error && error.message === "NO_ENTITLEMENT" || keysError instanceof Error && keysError.message === "NO_ENTITLEMENT" ? /* @__PURE__ */ jsx(ChoosePlanNotice, {}) : /* @__PURE__ */ jsxs("div", {
125
+ showsLiveData && pageState === "failed" ? /* @__PURE__ */ jsxs("div", {
118
126
  className: "rounded-xl border border-dashed p-8 text-center",
119
127
  children: [/* @__PURE__ */ jsx("p", {
120
128
  className: "text-muted-foreground",
121
- children: "Unable to load API credentials. Try again in a moment."
129
+ children: "Unable to load your API keys. Try again in a moment."
122
130
  }), /* @__PURE__ */ jsx(Button$1, {
123
131
  className: "mt-4",
124
132
  asChild: true,
@@ -128,7 +136,8 @@ const CredentialsPage = () => {
128
136
  })
129
137
  })]
130
138
  }) : null,
131
- (isDemoSession || !isPreview) && auth.isAuthenticated && !error && !keysError && !isLoading && !keysLoading ? /* @__PURE__ */ jsxs("div", {
139
+ showsLiveData && pageState === "empty" ? /* @__PURE__ */ jsx(ChoosePlanNotice, {}) : null,
140
+ showsLiveData && pageState === "ready" ? /* @__PURE__ */ jsxs("div", {
132
141
  className: "space-y-6",
133
142
  children: [
134
143
  planNote ? /* @__PURE__ */ jsx("div", {
@@ -136,7 +145,7 @@ const CredentialsPage = () => {
136
145
  className: "rounded-xl border border-primary/30 bg-primary/5 p-4 text-sm",
137
146
  children: planNote.text
138
147
  }) : null,
139
- plans.length === 0 ? /* @__PURE__ */ jsx(ChoosePlanNotice, {}) : plans.map((plan) => /* @__PURE__ */ jsx(ApiKeysPanel, {
148
+ plans.map((plan) => /* @__PURE__ */ jsx(ApiKeysPanel, {
140
149
  plan,
141
150
  header,
142
151
  readOnly: isDemoSession
@@ -169,7 +178,7 @@ const CredentialsPage = () => {
169
178
  asChild: true,
170
179
  children: /* @__PURE__ */ jsx(Link$1, {
171
180
  to: "/account/subscriptions",
172
- children: "My subscriptions"
181
+ children: "Billing"
173
182
  })
174
183
  }), apiReferenceLink ? /* @__PURE__ */ jsx(Button$1, {
175
184
  variant: "outline",
@@ -1,4 +1,4 @@
1
- import { n as CHECKOUT_PATH, r as CONTACT_SALES_PATH } from "./paths-ujy9wjVK.mjs";
1
+ import { i as CONTACT_SALES_PATH, r as CHECKOUT_PATH } from "./paths-DaVyTIDJ.mjs";
2
2
  import { d as listPlanFeatureLines, i as formatPlanDisplayPrice, l as isPlanCustomPriced, o as formatPlanPriceUnit, s as isActiveSubscription, u as isPlanFree } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
3
3
  import { Button } from "@lukoweb/apitogo/components";
4
4
  import { Link as Link$1 } from "@lukoweb/apitogo/router";
@@ -1,9 +1,9 @@
1
1
  import { f as parseManifestPlans, h as pickFeaturedPlanSku, n as useDevPortalPreviewMode } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
2
- import { t as PricingCard } from "./PricingCard-CKdq1oWn.mjs";
2
+ import { t as PricingCard } from "./PricingCard-YJIEwydG.mjs";
3
3
  import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
4
- import { t as usePlans } from "./usePlans-DPGFWcVo.mjs";
5
- import { t as isPreviewPricingEnabled } from "./pricing-config-02m4dyFf.mjs";
6
- import { a as isPricingLoading, n as buildCompareRows } from "./billing-state-DbU5Lpoz.mjs";
4
+ import { t as usePlans } from "./usePlans-BF7Hy2qJ.mjs";
5
+ import { t as isPreviewPricingEnabled } from "./pricing-config-C44AIqql.mjs";
6
+ import { a as isPricingLoading, n as buildCompareRows } from "./billing-state-Dgcc3_pT.mjs";
7
7
  import { Head, Heading } from "@lukoweb/apitogo/components";
8
8
  import { Navigate } from "@lukoweb/apitogo/router";
9
9
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -0,0 +1,268 @@
1
+ import { a as CREDENTIALS_PATH, n as ALREADY_SUBSCRIBED_PARAM, t as ACCOUNT_PLANS_PATH } from "./paths-DaVyTIDJ.mjs";
2
+ import { a as formatPlanPrice, p as parseMonthlyQuotaCalls, s as isActiveSubscription, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
3
+ import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
4
+ import { t as useMySubscriptions } from "./useMySubscriptions-DuVq2tXL.mjs";
5
+ import { t as usePlans } from "./usePlans-BF7Hy2qJ.mjs";
6
+ import { n as useApiKeys } from "./useApiKeys-CEwMzqju.mjs";
7
+ import { t as useUsage } from "./useUsage-CJ9VIBc7.mjs";
8
+ import { i as hasPaymentProvider, r as checkoutConfirmingText } from "./billing-state-Dgcc3_pT.mjs";
9
+ import { i as ChoosePlanNotice, n as billingPageState, r as keyPlanNote } from "./page-state-BB-72pbw.mjs";
10
+ import { Head, Spinner } from "@lukoweb/apitogo/components";
11
+ import { Link as Link$1, useSearchParams } from "@lukoweb/apitogo/router";
12
+ import { useEffect } from "react";
13
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
+ import { useMutation } from "@lukoweb/apitogo/react-query";
15
+ import { useAuth } from "@lukoweb/apitogo/hooks";
16
+ import { Button as Button$1 } from "@lukoweb/apitogo/ui/Button.js";
17
+
18
+ //#region src/hooks/useBillingPortal.ts
19
+ function useBillingPortal() {
20
+ const apiBaseUrl = useDevPortalApiBaseUrl();
21
+ return useMutation({ mutationFn: async (returnUrl) => {
22
+ if (!apiBaseUrl) throw new Error("Dev portal API is not configured.");
23
+ const response = await fetch(`${apiBaseUrl}/api/v1/subscriptions/billing-portal`, {
24
+ method: "POST",
25
+ headers: {
26
+ Accept: "application/json",
27
+ "Content-Type": "application/json"
28
+ },
29
+ credentials: "include",
30
+ body: JSON.stringify({ returnUrl: returnUrl ?? `${window.location.origin}/account/subscriptions` })
31
+ });
32
+ if (!response.ok) throw new Error("Unable to open billing portal.");
33
+ const body = await response.json();
34
+ if (!body.url) throw new Error("Billing portal URL was not returned.");
35
+ window.location.assign(body.url);
36
+ } });
37
+ }
38
+
39
+ //#endregion
40
+ //#region src/pages/SubscriptionsPage.tsx
41
+ /** How an earlier plan ended, in words a customer reads. */
42
+ function subscriptionStatusLabel(status) {
43
+ switch (status.toLowerCase().replace(/_/g, "")) {
44
+ case "canceled": return "Ended";
45
+ case "pastdue": return "Payment due";
46
+ case "incomplete": return "Not started";
47
+ default: return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
48
+ }
49
+ }
50
+ const SubscriptionsPage = () => {
51
+ const { billing } = useDevPortalBillingConfig();
52
+ const auth = useAuth();
53
+ const [searchParams] = useSearchParams();
54
+ const checkoutSuccess = searchParams.get("checkout") === "success";
55
+ const alreadySubscribedSku = searchParams.get(ALREADY_SUBSCRIBED_PARAM);
56
+ const checkoutPaid = searchParams.get("paid") !== "0";
57
+ const { data: me, error: meError, refetch } = useMySubscriptions();
58
+ const billingState = billingPageState({
59
+ data: me,
60
+ error: meError
61
+ });
62
+ const isLoading = billingState === "loading";
63
+ const { data: catalog } = usePlans();
64
+ const billingPortal = useBillingPortal();
65
+ const { data: usage } = useUsage("30d");
66
+ const { data: apiKeys } = useApiKeys();
67
+ const planNote = keyPlanNote(apiKeys?.plans ?? []);
68
+ const planBySku = new Map((catalog?.plans ?? []).map((plan) => [plan.sku, plan]));
69
+ const subscriptions = me?.subscriptions ?? [];
70
+ const activeSubscriptions = subscriptions.filter((sub) => isActiveSubscription(sub.status));
71
+ const earlierSubscriptions = subscriptions.filter((sub) => !isActiveSubscription(sub.status));
72
+ const hasActivePlan = activeSubscriptions.length > 0;
73
+ const singlePlan = activeSubscriptions.length === 1 ? planBySku.get(activeSubscriptions[0].planSku) : void 0;
74
+ const confirmingPayment = checkoutSuccess && !isLoading && !hasActivePlan;
75
+ useEffect(() => {
76
+ if (!confirmingPayment) return;
77
+ let attempts = 0;
78
+ const timer = setInterval(() => {
79
+ attempts += 1;
80
+ refetch();
81
+ if (attempts >= 30) clearInterval(timer);
82
+ }, 2e3);
83
+ return () => clearInterval(timer);
84
+ }, [confirmingPayment, refetch]);
85
+ const monthlyQuota = singlePlan ? parseMonthlyQuotaCalls(singlePlan.limitsJson) : null;
86
+ const usedRequests = usage?.requestCount ?? 0;
87
+ const quotaPct = monthlyQuota && monthlyQuota > 0 ? Math.min(100, Math.round(usedRequests / monthlyQuota * 100)) : null;
88
+ return /* @__PURE__ */ jsxs("div", {
89
+ className: "px-8 py-7",
90
+ children: [
91
+ /* @__PURE__ */ jsx(Head, { children: /* @__PURE__ */ jsx("title", { children: "Billing" }) }),
92
+ /* @__PURE__ */ jsx("h1", {
93
+ className: "mb-2 text-[1.6rem] font-bold tracking-[-0.01em]",
94
+ children: "Billing"
95
+ }),
96
+ /* @__PURE__ */ jsx("p", {
97
+ className: "mb-8 text-muted-foreground",
98
+ children: "Manage your plans, payment method, and invoices."
99
+ }),
100
+ alreadySubscribedSku ? /* @__PURE__ */ jsxs("div", {
101
+ role: "status",
102
+ className: "mb-6 rounded-xl border border-primary/30 bg-primary/5 p-4 text-sm",
103
+ children: [
104
+ "You're already on the",
105
+ " ",
106
+ planBySku.get(alreadySubscribedSku)?.displayName ?? alreadySubscribedSku,
107
+ " ",
108
+ "plan. Go to",
109
+ " ",
110
+ /* @__PURE__ */ jsx(Link$1, {
111
+ to: CREDENTIALS_PATH,
112
+ className: "font-medium underline",
113
+ children: "API keys"
114
+ }),
115
+ " ",
116
+ "to create a key."
117
+ ]
118
+ }) : null,
119
+ confirmingPayment ? /* @__PURE__ */ jsx("div", {
120
+ className: "mb-6 rounded-xl border border-border bg-card p-4 text-sm",
121
+ children: checkoutConfirmingText(checkoutPaid)
122
+ }) : null,
123
+ checkoutSuccess && hasActivePlan ? /* @__PURE__ */ jsx("div", {
124
+ className: "mb-6 rounded-xl border border-green-500/30 bg-green-500/10 p-4 text-sm",
125
+ children: planNote ? /* @__PURE__ */ jsxs(Fragment, { children: [
126
+ planNote.text,
127
+ " ",
128
+ /* @__PURE__ */ jsx(Link$1, {
129
+ to: CREDENTIALS_PATH,
130
+ className: "font-medium underline",
131
+ children: "Create the key"
132
+ }),
133
+ "."
134
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
135
+ "Your subscription is active.",
136
+ " ",
137
+ /* @__PURE__ */ jsx(Link$1, {
138
+ to: CREDENTIALS_PATH,
139
+ className: "font-medium underline",
140
+ children: "View your API keys"
141
+ }),
142
+ "."
143
+ ] })
144
+ }) : null,
145
+ isLoading ? /* @__PURE__ */ jsx("div", {
146
+ className: "mb-8 h-40 animate-pulse rounded-xl border bg-muted/40",
147
+ role: "status",
148
+ "aria-busy": "true",
149
+ "aria-label": "Loading billing details"
150
+ }) : null,
151
+ billingState === "failed" ? /* @__PURE__ */ jsx("p", {
152
+ className: "mb-8 text-muted-foreground",
153
+ children: "Unable to load your plans. Try again in a moment."
154
+ }) : null,
155
+ !isLoading && hasActivePlan ? /* @__PURE__ */ jsxs("section", {
156
+ className: "mb-8",
157
+ children: [/* @__PURE__ */ jsx("p", {
158
+ className: "mb-3 text-xs font-medium uppercase tracking-[0.05em] text-muted-foreground",
159
+ children: activeSubscriptions.length > 1 ? "Your plans" : "Current plan"
160
+ }), /* @__PURE__ */ jsx("div", {
161
+ className: "space-y-4",
162
+ children: activeSubscriptions.map((subscription) => {
163
+ const plan = planBySku.get(subscription.planSku);
164
+ return /* @__PURE__ */ jsxs("article", {
165
+ className: "rounded-xl border border-border bg-card p-6",
166
+ "data-testid": `active-plan-${subscription.planSku}`,
167
+ children: [
168
+ /* @__PURE__ */ jsx("h2", {
169
+ className: "text-2xl font-bold",
170
+ children: plan?.displayName ?? subscription.planSku
171
+ }),
172
+ plan ? /* @__PURE__ */ jsx("p", {
173
+ className: "mt-1 text-sm text-muted-foreground",
174
+ children: formatPlanPrice(plan)
175
+ }) : /* @__PURE__ */ jsx("p", {
176
+ className: "mt-1 text-sm text-muted-foreground",
177
+ children: "This plan is no longer offered. Your access continues until you cancel it."
178
+ }),
179
+ subscription.currentPeriodEnd ? /* @__PURE__ */ jsxs("p", {
180
+ className: "mt-3 text-sm text-muted-foreground",
181
+ children: [
182
+ "Renews",
183
+ " ",
184
+ new Date(subscription.currentPeriodEnd).toLocaleDateString()
185
+ ]
186
+ }) : null,
187
+ singlePlan && quotaPct != null && monthlyQuota != null ? /* @__PURE__ */ jsxs("div", {
188
+ className: "mt-5",
189
+ children: [/* @__PURE__ */ jsxs("div", {
190
+ className: "mb-1.5 flex justify-between text-xs text-muted-foreground",
191
+ children: [/* @__PURE__ */ jsx("span", { children: "Monthly requests" }), /* @__PURE__ */ jsxs("span", { children: [
192
+ usedRequests.toLocaleString(),
193
+ " /",
194
+ " ",
195
+ monthlyQuota.toLocaleString()
196
+ ] })]
197
+ }), /* @__PURE__ */ jsx("div", {
198
+ className: "h-2 rounded-full bg-muted",
199
+ children: /* @__PURE__ */ jsx("div", {
200
+ className: "h-full rounded-full bg-primary transition-[width]",
201
+ style: { width: `${quotaPct}%` }
202
+ })
203
+ })]
204
+ }) : null
205
+ ]
206
+ }, subscription.planSku);
207
+ })
208
+ })]
209
+ }) : null,
210
+ billingState === "empty" ? /* @__PURE__ */ jsx(ChoosePlanNotice, {
211
+ className: "mb-8 rounded-xl border border-dashed p-8 text-center",
212
+ body: "You do not have a plan yet. Choose one to start using the API; your billing details appear here afterwards."
213
+ }) : null,
214
+ !isLoading && earlierSubscriptions.length > 0 ? /* @__PURE__ */ jsxs("section", {
215
+ className: "mb-8",
216
+ children: [/* @__PURE__ */ jsx("p", {
217
+ className: "mb-2 text-xs font-medium uppercase tracking-[0.05em] text-muted-foreground",
218
+ children: "Earlier plans"
219
+ }), /* @__PURE__ */ jsx("ul", {
220
+ className: "divide-y rounded-xl border bg-card text-sm",
221
+ children: earlierSubscriptions.map((subscription) => /* @__PURE__ */ jsxs("li", {
222
+ className: "flex items-center justify-between gap-4 px-5 py-3",
223
+ children: [/* @__PURE__ */ jsx("span", { children: planBySku.get(subscription.planSku)?.displayName ?? subscription.planSku }), /* @__PURE__ */ jsx("span", {
224
+ className: "text-xs text-muted-foreground",
225
+ children: subscriptionStatusLabel(subscription.status)
226
+ })]
227
+ }, `${subscription.planSku}-${subscription.status}`))
228
+ })]
229
+ }) : null,
230
+ /* @__PURE__ */ jsxs("div", {
231
+ className: "flex flex-wrap gap-3",
232
+ children: [
233
+ /* @__PURE__ */ jsx(Button$1, {
234
+ variant: "outline",
235
+ asChild: true,
236
+ children: /* @__PURE__ */ jsx(Link$1, {
237
+ to: ACCOUNT_PLANS_PATH,
238
+ children: "Manage plans"
239
+ })
240
+ }),
241
+ auth.isAuthenticated && hasPaymentProvider(billing) ? /* @__PURE__ */ jsx(Button$1, {
242
+ variant: "default",
243
+ disabled: billingPortal.isPending,
244
+ onClick: () => {
245
+ billingPortal.mutate();
246
+ },
247
+ children: billingPortal.isPending ? /* @__PURE__ */ jsx(Spinner, {}) : "Manage payment & invoices"
248
+ }) : null,
249
+ auth.isAuthenticated ? /* @__PURE__ */ jsx(Button$1, {
250
+ variant: "secondary",
251
+ asChild: true,
252
+ children: /* @__PURE__ */ jsx(Link$1, {
253
+ to: CREDENTIALS_PATH,
254
+ children: "API keys"
255
+ })
256
+ }) : null
257
+ ]
258
+ }),
259
+ billingPortal.isError ? /* @__PURE__ */ jsx("p", {
260
+ className: "mt-4 text-sm text-destructive",
261
+ children: billingPortal.error instanceof Error ? billingPortal.error.message : "Unable to open billing portal."
262
+ }) : null
263
+ ]
264
+ });
265
+ };
266
+
267
+ //#endregion
268
+ export { SubscriptionsPage as default, subscriptionStatusLabel };
@@ -1,8 +1,8 @@
1
1
  import { m as parseQuotaPeriodDays, p as parseMonthlyQuotaCalls, s as isActiveSubscription } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
2
2
  import "./DevPortalBillingContext-CR83s4LO.mjs";
3
- import { t as useMySubscriptions } from "./useMySubscriptions-BtxAXA1m.mjs";
4
- import { t as usePlans } from "./usePlans-DPGFWcVo.mjs";
5
- import { t as useUsage } from "./useUsage--5sZub3M.mjs";
3
+ import { t as useMySubscriptions } from "./useMySubscriptions-DuVq2tXL.mjs";
4
+ import { t as usePlans } from "./usePlans-BF7Hy2qJ.mjs";
5
+ import { n as useUsageRecent, t as useUsage } from "./useUsage-CJ9VIBc7.mjs";
6
6
  import { Head } from "@lukoweb/apitogo/components";
7
7
  import { Link as Link$1 } from "@lukoweb/apitogo/router";
8
8
  import { useState } from "react";
@@ -35,6 +35,27 @@ function quotaSummary(used, limit, periodDays, periodEnd) {
35
35
  };
36
36
  }
37
37
 
38
+ //#endregion
39
+ //#region src/utils/usage-display.ts
40
+ /**
41
+ * How the usage page shows its numbers (API-126).
42
+ */
43
+ /** "GET /p<project id>/hello" becomes "GET /hello": the shared gateway's project prefix means nothing to an API user. */
44
+ function endpointLabel(label) {
45
+ return label.replace(/(^|\s)\/p[0-9a-f]{32}(\/|$)/i, "$1/");
46
+ }
47
+ /** Calls the gateway refused because the plan's rate limit or quota was reached (counted as "429"). */
48
+ function blockedCount(byStatus) {
49
+ return byStatus.filter((item) => item.label === "429").reduce((sum, item) => sum + item.count, 0);
50
+ }
51
+ /** The middle latency of the given calls, or null without any. A slow outlier moves it less than an average. */
52
+ function medianLatency(latencies) {
53
+ const values = latencies.filter((value) => typeof value === "number").sort((a, b) => a - b);
54
+ if (values.length === 0) return null;
55
+ const middle = Math.floor(values.length / 2);
56
+ return values.length % 2 === 1 ? values[middle] : Math.round((values[middle - 1] + values[middle]) / 2);
57
+ }
58
+
38
59
  //#endregion
39
60
  //#region src/pages/UsagePage.tsx
40
61
  const RANGES = [
@@ -65,6 +86,9 @@ const UsagePage = () => {
65
86
  const auth = useAuth();
66
87
  const [range, setRange] = useState("30d");
67
88
  const { data, isLoading, isError } = useUsage(range);
89
+ const { data: recent } = useUsageRecent(50);
90
+ const median = medianLatency((recent?.items ?? []).map((item) => item.latencyMs));
91
+ const blocked = data ? blockedCount(data.byStatus) : 0;
68
92
  const { data: quotaUsage } = useUsage("30d");
69
93
  const { data: mine } = useMySubscriptions();
70
94
  const { data: catalog } = usePlans();
@@ -129,23 +153,41 @@ const UsagePage = () => {
129
153
  children: [
130
154
  /* @__PURE__ */ jsxs("div", {
131
155
  className: "rounded-xl border border-border bg-card p-4",
132
- children: [/* @__PURE__ */ jsx("div", {
133
- className: "text-xs uppercase tracking-[0.05em] text-muted-foreground",
134
- children: "Requests"
135
- }), /* @__PURE__ */ jsx("div", {
136
- className: "mt-1.5 text-2xl font-bold",
137
- children: data ? formatNumber(data.requestCount) : "—"
138
- })]
156
+ children: [
157
+ /* @__PURE__ */ jsx("div", {
158
+ className: "text-xs uppercase tracking-[0.05em] text-muted-foreground",
159
+ children: "Requests"
160
+ }),
161
+ /* @__PURE__ */ jsx("div", {
162
+ className: "mt-1.5 text-2xl font-bold",
163
+ children: data ? formatNumber(data.requestCount) : "—"
164
+ }),
165
+ blocked > 0 ? /* @__PURE__ */ jsxs("div", {
166
+ className: "mt-1 text-xs text-muted-foreground",
167
+ children: ["Blocked by rate limit: ", formatNumber(blocked)]
168
+ }) : null
169
+ ]
139
170
  }),
140
171
  /* @__PURE__ */ jsxs("div", {
141
172
  className: "rounded-xl border border-border bg-card p-4",
142
- children: [/* @__PURE__ */ jsx("div", {
143
- className: "text-xs uppercase tracking-[0.05em] text-muted-foreground",
144
- children: "Avg latency"
145
- }), /* @__PURE__ */ jsx("div", {
146
- className: "mt-1.5 text-2xl font-bold",
147
- children: data?.avgLatencyMs != null ? `${data.avgLatencyMs} ms` : "—"
148
- })]
173
+ children: [
174
+ /* @__PURE__ */ jsx("div", {
175
+ className: "text-xs uppercase tracking-[0.05em] text-muted-foreground",
176
+ children: median != null ? "Median latency" : "Avg latency"
177
+ }),
178
+ /* @__PURE__ */ jsx("div", {
179
+ className: "mt-1.5 text-2xl font-bold",
180
+ children: median != null ? `${median} ms` : data?.avgLatencyMs != null ? `${data.avgLatencyMs} ms` : "—"
181
+ }),
182
+ median != null ? /* @__PURE__ */ jsxs("div", {
183
+ className: "mt-1 text-xs text-muted-foreground",
184
+ children: [
185
+ "Of the latest ",
186
+ recent?.items.length ?? 0,
187
+ " calls"
188
+ ]
189
+ }) : null
190
+ ]
149
191
  }),
150
192
  /* @__PURE__ */ jsxs("div", {
151
193
  className: "rounded-xl border border-border bg-card p-4",
@@ -222,7 +264,7 @@ const UsagePage = () => {
222
264
  className: "mb-1 flex justify-between text-[13px]",
223
265
  children: [/* @__PURE__ */ jsx("code", {
224
266
  className: "font-mono text-foreground",
225
- children: item.path
267
+ children: endpointLabel(item.path)
226
268
  }), /* @__PURE__ */ jsxs("span", {
227
269
  className: "text-muted-foreground",
228
270
  children: [pct, "%"]
@@ -1,4 +1,4 @@
1
- import { i as useRevokeApiKey, r as useCreateApiKey, t as API_KEY_CAP_REACHED } from "./useApiKeys-CVgXfUop.mjs";
1
+ import { i as useRevokeApiKey, r as useCreateApiKey, t as API_KEY_CAP_REACHED } from "./useApiKeys-CEwMzqju.mjs";
2
2
  import { Spinner } from "@lukoweb/apitogo/components";
3
3
  import { KeyRoundIcon, TrashIcon } from "@lukoweb/apitogo/icons";
4
4
  import { useState } from "react";
package/dist/index.mjs CHANGED
@@ -1,15 +1,15 @@
1
- import { t as AccountPlansPage } from "./AccountPlansPage-CeUizdWk.mjs";
2
- import { a as LOGIN_NAV_PATH, c as USAGE_PATH, i as CREDENTIALS_PATH, n as CHECKOUT_PATH, o as PRICING_PATH, r as CONTACT_SALES_PATH, s as SUBSCRIPTIONS_PATH, t as ACCOUNT_PLANS_PATH } from "./paths-ujy9wjVK.mjs";
1
+ import { t as AccountPlansPage } from "./AccountPlansPage-0PYGy1aV.mjs";
2
+ import { a as CREDENTIALS_PATH, c as SUBSCRIPTIONS_PATH, i as CONTACT_SALES_PATH, l as USAGE_PATH, o as LOGIN_NAV_PATH, r as CHECKOUT_PATH, s as PRICING_PATH, t as ACCOUNT_PLANS_PATH } from "./paths-DaVyTIDJ.mjs";
3
3
  import { d as listPlanFeatureLines, f as parseManifestPlans, h as pickFeaturedPlanSku, i as formatPlanDisplayPrice, o as formatPlanPriceUnit, s as isActiveSubscription, v as resolveDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
4
- import "./PricingCard-CKdq1oWn.mjs";
4
+ import "./PricingCard-YJIEwydG.mjs";
5
5
  import { n as useDevPortalBillingConfig, t as DevPortalBillingProvider } from "./DevPortalBillingContext-CR83s4LO.mjs";
6
- import { t as DEMO_GATEWAY_KEY_HEADER } from "./demo-session-rN2Gj4lM.mjs";
7
- import { t as useMySubscriptions } from "./useMySubscriptions-BtxAXA1m.mjs";
8
- import { n as startPlansPrefetch, t as usePlans } from "./usePlans-DPGFWcVo.mjs";
9
- import { a as playgroundKeyIdentities, i as useRevokeApiKey, n as useApiKeys, r as useCreateApiKey, t as API_KEY_CAP_REACHED } from "./useApiKeys-CVgXfUop.mjs";
10
- import { n as useUsageRecent, t as useUsage } from "./useUsage--5sZub3M.mjs";
11
- import { r as ApiKeysPanel, t as accountLayoutProps } from "./account-links-D_vS0GJn.mjs";
12
- import { t as isPreviewPricingEnabled } from "./pricing-config-02m4dyFf.mjs";
6
+ import { t as DEMO_GATEWAY_KEY_HEADER } from "./demo-session-yK9qEqV-.mjs";
7
+ import { t as useMySubscriptions } from "./useMySubscriptions-DuVq2tXL.mjs";
8
+ import { n as startPlansPrefetch, t as usePlans } from "./usePlans-BF7Hy2qJ.mjs";
9
+ import { a as playgroundKeyIdentities, i as useRevokeApiKey, n as useApiKeys, r as useCreateApiKey, t as API_KEY_CAP_REACHED } from "./useApiKeys-CEwMzqju.mjs";
10
+ import { n as useUsageRecent, t as useUsage } from "./useUsage-CJ9VIBc7.mjs";
11
+ import { r as ApiKeysPanel, t as accountLayoutProps } from "./account-links-9h91uCGj.mjs";
12
+ import { t as isPreviewPricingEnabled } from "./pricing-config-C44AIqql.mjs";
13
13
  import { Head, Link } from "@lukoweb/apitogo/components";
14
14
  import { ArrowRightIcon, KeyRoundIcon, StarsIcon } from "@lukoweb/apitogo/icons";
15
15
  import { Outlet } from "@lukoweb/apitogo/router";
@@ -366,13 +366,13 @@ async function getSignedInIdentities(apiBaseUrl, fetchImpl = fetch) {
366
366
 
367
367
  //#endregion
368
368
  //#region src/DevPortalBillingPlugin.tsx
369
- const AccountPlansPage$1 = lazy(() => import("./AccountPlansPage-CeUizdWk.mjs").then((n) => n.n));
370
- const CheckoutPage = lazy(() => import("./CheckoutPage-CwSElFUk.mjs"));
371
- const ContactSalesPage = lazy(() => import("./ContactSalesPage-q7Dxxtx6.mjs"));
372
- const CredentialsPage = lazy(() => import("./CredentialsPage-B2jPo6Go.mjs"));
373
- const PricingPage = lazy(() => import("./PricingPage-DnQQfy9L.mjs"));
374
- const SubscriptionsPage = lazy(() => import("./SubscriptionsPage-msO31jv-.mjs"));
375
- const UsagePage = lazy(() => import("./UsagePage-BF4QgrTc.mjs"));
369
+ const AccountPlansPage$1 = lazy(() => import("./AccountPlansPage-0PYGy1aV.mjs").then((n) => n.n));
370
+ const CheckoutPage = lazy(() => import("./CheckoutPage-Bpa128i2.mjs"));
371
+ const ContactSalesPage = lazy(() => import("./ContactSalesPage-BslD3Yk4.mjs"));
372
+ const CredentialsPage = lazy(() => import("./CredentialsPage-C4qpBVrh.mjs"));
373
+ const PricingPage = lazy(() => import("./PricingPage-DIhoFOd9.mjs"));
374
+ const SubscriptionsPage = lazy(() => import("./SubscriptionsPage-D7hsYtTB.mjs"));
375
+ const UsagePage = lazy(() => import("./UsagePage-Di9kQ_3Z.mjs"));
376
376
  function resolvePricingEnabled(config) {
377
377
  if (config.__meta?.pricingEnabled !== void 0) return config.__meta.pricingEnabled;
378
378
  return isPreviewPricingEnabled();
@@ -399,13 +399,13 @@ const devPortalBillingPlugin = createPlugin((options = {}) => {
399
399
  },
400
400
  getIdentities: async () => [...playgroundKeyIdentities(), ...await getSignedInIdentities(resolveDevPortalApiBaseUrl(options.apiBaseUrl))],
401
401
  getProfileMenuItems: () => [{
402
- label: "My Subscriptions",
402
+ label: "Billing",
403
403
  path: SUBSCRIPTIONS_PATH,
404
404
  icon: StarsIcon,
405
405
  category: "middle",
406
406
  weight: 25
407
407
  }, {
408
- label: "API credentials",
408
+ label: "API keys",
409
409
  path: CREDENTIALS_PATH,
410
410
  icon: KeyRoundIcon,
411
411
  category: "middle",
@@ -1,4 +1,4 @@
1
- import { t as ACCOUNT_PLANS_PATH } from "./paths-ujy9wjVK.mjs";
1
+ import { t as ACCOUNT_PLANS_PATH } from "./paths-DaVyTIDJ.mjs";
2
2
  import { Link } from "@lukoweb/apitogo/router";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  import { Button } from "@lukoweb/apitogo/ui/Button.js";
@@ -50,4 +50,24 @@ function keyPlanNote(plans) {
50
50
  }
51
51
 
52
52
  //#endregion
53
- export { ChoosePlanNotice as n, keyPlanNote as t };
53
+ //#region src/utils/page-state.ts
54
+ /** True for the error the keys endpoint gives a user without any plan (HTTP 404). */
55
+ const isNoEntitlement = (error) => error instanceof Error && error.message === "NO_ENTITLEMENT";
56
+ /**
57
+ * The API keys page. The key list is what the page shows, so it decides: the credentials request only adds the gateway
58
+ * address, which the key list also carries, and its quick 404 right after sign-up must not show "no plan" while the
59
+ * keys are still loading.
60
+ */
61
+ function apiKeysPageState(keys) {
62
+ if (keys.data) return keys.data.plans.length > 0 ? "ready" : "empty";
63
+ if (keys.error) return isNoEntitlement(keys.error) ? "empty" : "failed";
64
+ return "loading";
65
+ }
66
+ /** The Billing page: subscriptions decide; until they arrive the page is loading. */
67
+ function billingPageState(me) {
68
+ if (me.data) return (me.data.subscriptions ?? []).length > 0 ? "ready" : "empty";
69
+ return me.error ? "failed" : "loading";
70
+ }
71
+
72
+ //#endregion
73
+ export { ChoosePlanNotice as i, billingPageState as n, keyPlanNote as r, apiKeysPageState as t };
@@ -0,0 +1,14 @@
1
+ //#region src/paths.ts
2
+ const PRICING_PATH = "/pricing";
3
+ const ACCOUNT_PLANS_PATH = "/account/plans";
4
+ const CHECKOUT_PATH = "/checkout";
5
+ const CONTACT_SALES_PATH = "/contact-sales";
6
+ const SUBSCRIPTIONS_PATH = "/account/subscriptions";
7
+ const CREDENTIALS_PATH = "/account/credentials";
8
+ const USAGE_PATH = "/account/usage";
9
+ const LOGIN_NAV_PATH = "/signin";
10
+ /** Billing opens with this query parameter, naming the plan the user tried to buy again (API-123). */
11
+ const ALREADY_SUBSCRIBED_PARAM = "alreadySubscribed";
12
+
13
+ //#endregion
14
+ export { CREDENTIALS_PATH as a, SUBSCRIPTIONS_PATH as c, CONTACT_SALES_PATH as i, USAGE_PATH as l, ALREADY_SUBSCRIBED_PARAM as n, LOGIN_NAV_PATH as o, CHECKOUT_PATH as r, PRICING_PATH as s, ACCOUNT_PLANS_PATH as t };
@@ -0,0 +1,18 @@
1
+ //#region src/utils/problem-details.ts
2
+ /**
3
+ * The sentence an RFC 7807 Problem Details response carries for a person: the first field error, else `detail`, else
4
+ * `title`. Pages show this, never the raw JSON (API-123). Null when the body is not Problem Details.
5
+ */
6
+ async function readProblemDetail(response) {
7
+ try {
8
+ const body = await response.json();
9
+ return (body.errors ? Object.values(body.errors).flat()[0] : void 0)?.trim() || body.detail?.trim() || body.title?.trim() || null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ /** The plans service's answer when a user starts checkout for a plan they already hold. */
15
+ const isAlreadySubscribedMessage = (message) => /already have an active subscription/i.test(message);
16
+
17
+ //#endregion
18
+ export { readProblemDetail as n, isAlreadySubscribedMessage as t };
@@ -1,6 +1,7 @@
1
1
  import { n as useDevPortalPreviewMode, r as useIsDemoSession, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
2
2
  import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
3
- import { n as createDemoApiKeys, t as DEMO_GATEWAY_KEY_HEADER } from "./demo-session-rN2Gj4lM.mjs";
3
+ import { n as readProblemDetail } from "./problem-details-n20-dy3w.mjs";
4
+ import { n as createDemoApiKeys, t as DEMO_GATEWAY_KEY_HEADER } from "./demo-session-yK9qEqV-.mjs";
4
5
  import { useMutation, useQuery, useQueryClient } from "@lukoweb/apitogo/react-query";
5
6
  import { useAuth } from "@lukoweb/apitogo/hooks";
6
7
 
@@ -154,13 +155,6 @@ function useRevokeApiKey() {
154
155
  }
155
156
  });
156
157
  }
157
- async function readProblemDetail(response) {
158
- try {
159
- return (await response.json()).detail?.trim() || null;
160
- } catch {
161
- return null;
162
- }
163
- }
164
158
 
165
159
  //#endregion
166
160
  export { playgroundKeyIdentities as a, useRevokeApiKey as i, useApiKeys as n, useCreateApiKey as r, API_KEY_CAP_REACHED as t };
@@ -1,6 +1,6 @@
1
1
  import { n as useDevPortalPreviewMode, r as useIsDemoSession, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
2
2
  import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
3
- import { i as createDemoMeResponse } from "./demo-session-rN2Gj4lM.mjs";
3
+ import { i as createDemoMeResponse } from "./demo-session-yK9qEqV-.mjs";
4
4
  import { useQuery } from "@lukoweb/apitogo/react-query";
5
5
  import { useAuth } from "@lukoweb/apitogo/hooks";
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo-plugin-dev-portal-billing",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zuplo/zudoku",
@@ -46,8 +46,8 @@
46
46
  "react": "19.2.4",
47
47
  "react-dom": "19.2.4",
48
48
  "tsdown": "0.20.3",
49
- "@lukoweb/apitogo": "0.2.52",
50
- "@lukoweb/apitogo-module-user-panel": "0.2.52"
49
+ "@lukoweb/apitogo": "0.2.54",
50
+ "@lukoweb/apitogo-module-user-panel": "0.2.54"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": ">=19.2.0",
@@ -1,223 +0,0 @@
1
- import { i as CREDENTIALS_PATH, t as ACCOUNT_PLANS_PATH } from "./paths-ujy9wjVK.mjs";
2
- import { a as formatPlanPrice, p as parseMonthlyQuotaCalls, s as isActiveSubscription, t as useDevPortalApiBaseUrl } from "./useDevPortalApiBaseUrl-BstWzITX.mjs";
3
- import { n as useDevPortalBillingConfig } from "./DevPortalBillingContext-CR83s4LO.mjs";
4
- import { t as useMySubscriptions } from "./useMySubscriptions-BtxAXA1m.mjs";
5
- import { t as usePlans } from "./usePlans-DPGFWcVo.mjs";
6
- import { n as useApiKeys } from "./useApiKeys-CVgXfUop.mjs";
7
- import { t as useUsage } from "./useUsage--5sZub3M.mjs";
8
- import { i as hasPaymentProvider, r as checkoutConfirmingText } from "./billing-state-DbU5Lpoz.mjs";
9
- import { n as ChoosePlanNotice, t as keyPlanNote } from "./key-plan-note-DTWeFhn2.mjs";
10
- import { Head, Spinner } from "@lukoweb/apitogo/components";
11
- import { Link as Link$1, useSearchParams } from "@lukoweb/apitogo/router";
12
- import { useEffect } from "react";
13
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
- import { useMutation } from "@lukoweb/apitogo/react-query";
15
- import { useAuth } from "@lukoweb/apitogo/hooks";
16
- import { Button as Button$1 } from "@lukoweb/apitogo/ui/Button.js";
17
-
18
- //#region src/hooks/useBillingPortal.ts
19
- function useBillingPortal() {
20
- const apiBaseUrl = useDevPortalApiBaseUrl();
21
- return useMutation({ mutationFn: async (returnUrl) => {
22
- if (!apiBaseUrl) throw new Error("Dev portal API is not configured.");
23
- const response = await fetch(`${apiBaseUrl}/api/v1/subscriptions/billing-portal`, {
24
- method: "POST",
25
- headers: {
26
- Accept: "application/json",
27
- "Content-Type": "application/json"
28
- },
29
- credentials: "include",
30
- body: JSON.stringify({ returnUrl: returnUrl ?? `${window.location.origin}/account/subscriptions` })
31
- });
32
- if (!response.ok) throw new Error("Unable to open billing portal.");
33
- const body = await response.json();
34
- if (!body.url) throw new Error("Billing portal URL was not returned.");
35
- window.location.assign(body.url);
36
- } });
37
- }
38
-
39
- //#endregion
40
- //#region src/pages/SubscriptionsPage.tsx
41
- const SubscriptionsPage = () => {
42
- const { billing } = useDevPortalBillingConfig();
43
- const auth = useAuth();
44
- const [searchParams] = useSearchParams();
45
- const checkoutSuccess = searchParams.get("checkout") === "success";
46
- const checkoutPaid = searchParams.get("paid") !== "0";
47
- const { data: me, isLoading, refetch } = useMySubscriptions();
48
- const { data: catalog } = usePlans();
49
- const billingPortal = useBillingPortal();
50
- const { data: usage } = useUsage("30d");
51
- const { data: apiKeys } = useApiKeys();
52
- const planNote = keyPlanNote(apiKeys?.plans ?? []);
53
- const planBySku = new Map((catalog?.plans ?? []).map((plan) => [plan.sku, plan]));
54
- const subscriptions = me?.subscriptions ?? [];
55
- const activeSubscription = subscriptions.find((sub) => isActiveSubscription(sub.status));
56
- const activePlan = activeSubscription ? planBySku.get(activeSubscription.planSku) : void 0;
57
- const confirmingPayment = checkoutSuccess && !isLoading && !activeSubscription;
58
- useEffect(() => {
59
- if (!confirmingPayment) return;
60
- let attempts = 0;
61
- const timer = setInterval(() => {
62
- attempts += 1;
63
- refetch();
64
- if (attempts >= 30) clearInterval(timer);
65
- }, 2e3);
66
- return () => clearInterval(timer);
67
- }, [confirmingPayment, refetch]);
68
- const monthlyQuota = activePlan ? parseMonthlyQuotaCalls(activePlan.limitsJson) : null;
69
- const usedRequests = usage?.requestCount ?? 0;
70
- const quotaPct = monthlyQuota && monthlyQuota > 0 ? Math.min(100, Math.round(usedRequests / monthlyQuota * 100)) : null;
71
- return /* @__PURE__ */ jsxs("div", {
72
- className: "px-8 py-7",
73
- children: [
74
- /* @__PURE__ */ jsx(Head, { children: /* @__PURE__ */ jsx("title", { children: "Billing" }) }),
75
- /* @__PURE__ */ jsx("h1", {
76
- className: "mb-2 text-[1.6rem] font-bold tracking-[-0.01em]",
77
- children: "Billing"
78
- }),
79
- /* @__PURE__ */ jsx("p", {
80
- className: "mb-8 text-muted-foreground",
81
- children: "Manage your plan, payment method, and invoices."
82
- }),
83
- confirmingPayment ? /* @__PURE__ */ jsx("div", {
84
- className: "mb-6 rounded-xl border border-border bg-card p-4 text-sm",
85
- children: checkoutConfirmingText(checkoutPaid)
86
- }) : null,
87
- checkoutSuccess && activeSubscription ? /* @__PURE__ */ jsx("div", {
88
- className: "mb-6 rounded-xl border border-green-500/30 bg-green-500/10 p-4 text-sm",
89
- children: planNote ? /* @__PURE__ */ jsxs(Fragment, { children: [
90
- planNote.text,
91
- " ",
92
- /* @__PURE__ */ jsx(Link$1, {
93
- to: CREDENTIALS_PATH,
94
- className: "font-medium underline",
95
- children: "Create the key"
96
- }),
97
- "."
98
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
99
- "Your subscription is active.",
100
- " ",
101
- /* @__PURE__ */ jsx(Link$1, {
102
- to: CREDENTIALS_PATH,
103
- className: "font-medium underline",
104
- children: "View your API credentials"
105
- }),
106
- "."
107
- ] })
108
- }) : null,
109
- isLoading ? /* @__PURE__ */ jsx("p", {
110
- className: "text-muted-foreground",
111
- children: "Loading billing details…"
112
- }) : null,
113
- !isLoading && activeSubscription ? /* @__PURE__ */ jsxs("div", {
114
- className: "mb-8 rounded-xl border border-border bg-card p-6",
115
- children: [
116
- /* @__PURE__ */ jsx("p", {
117
- className: "text-xs font-medium uppercase tracking-[0.05em] text-muted-foreground",
118
- children: "Current plan"
119
- }),
120
- /* @__PURE__ */ jsx("h2", {
121
- className: "mt-2 text-2xl font-bold",
122
- children: activePlan?.displayName ?? activeSubscription.planSku
123
- }),
124
- activePlan ? /* @__PURE__ */ jsx("p", {
125
- className: "mt-1 text-sm text-muted-foreground",
126
- children: formatPlanPrice(activePlan)
127
- }) : /* @__PURE__ */ jsx("p", {
128
- className: "mt-1 text-sm text-muted-foreground",
129
- children: "This plan is no longer offered. Your access continues until you cancel or change plan."
130
- }),
131
- activeSubscription.currentPeriodEnd ? /* @__PURE__ */ jsxs("p", {
132
- className: "mt-3 text-sm text-muted-foreground",
133
- children: [
134
- "Renews",
135
- " ",
136
- new Date(activeSubscription.currentPeriodEnd).toLocaleDateString()
137
- ]
138
- }) : null,
139
- quotaPct != null && monthlyQuota != null ? /* @__PURE__ */ jsxs("div", {
140
- className: "mt-5",
141
- children: [/* @__PURE__ */ jsxs("div", {
142
- className: "mb-1.5 flex justify-between text-xs text-muted-foreground",
143
- children: [/* @__PURE__ */ jsx("span", { children: "Monthly requests" }), /* @__PURE__ */ jsxs("span", { children: [
144
- usedRequests.toLocaleString(),
145
- " /",
146
- " ",
147
- monthlyQuota.toLocaleString()
148
- ] })]
149
- }), /* @__PURE__ */ jsx("div", {
150
- className: "h-2 rounded-full bg-muted",
151
- children: /* @__PURE__ */ jsx("div", {
152
- className: "h-full rounded-full bg-primary transition-[width]",
153
- style: { width: `${quotaPct}%` }
154
- })
155
- })]
156
- }) : null
157
- ]
158
- }) : null,
159
- !isLoading && subscriptions.length === 0 ? /* @__PURE__ */ jsx(ChoosePlanNotice, {
160
- className: "mb-8 rounded-xl border border-dashed p-8 text-center",
161
- body: "You do not have a plan yet. Choose one to start using the API; your billing details appear here afterwards."
162
- }) : null,
163
- !isLoading && subscriptions.length > 1 ? /* @__PURE__ */ jsx("div", {
164
- className: "mb-8 space-y-4",
165
- children: subscriptions.map((subscription) => {
166
- const plan = planBySku.get(subscription.planSku);
167
- return /* @__PURE__ */ jsx("article", {
168
- className: "rounded-xl border bg-card p-5",
169
- children: /* @__PURE__ */ jsxs("div", {
170
- className: "flex flex-wrap items-start justify-between gap-4",
171
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
172
- className: "font-semibold",
173
- children: plan?.displayName ?? subscription.planSku
174
- }), plan ? /* @__PURE__ */ jsx("p", {
175
- className: "mt-1 text-sm text-muted-foreground",
176
- children: formatPlanPrice(plan)
177
- }) : null] }), /* @__PURE__ */ jsx("span", {
178
- className: "rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground",
179
- children: subscription.status
180
- })]
181
- })
182
- }, `${subscription.planSku}-${subscription.status}`);
183
- })
184
- }) : null,
185
- /* @__PURE__ */ jsxs("div", {
186
- className: "flex flex-wrap gap-3",
187
- children: [
188
- /* @__PURE__ */ jsx(Button$1, {
189
- variant: "outline",
190
- asChild: true,
191
- children: /* @__PURE__ */ jsx(Link$1, {
192
- to: ACCOUNT_PLANS_PATH,
193
- children: "Change plan"
194
- })
195
- }),
196
- auth.isAuthenticated && hasPaymentProvider(billing) ? /* @__PURE__ */ jsx(Button$1, {
197
- variant: "default",
198
- disabled: billingPortal.isPending,
199
- onClick: () => {
200
- billingPortal.mutate();
201
- },
202
- children: billingPortal.isPending ? /* @__PURE__ */ jsx(Spinner, {}) : "Manage payment & invoices"
203
- }) : null,
204
- auth.isAuthenticated ? /* @__PURE__ */ jsx(Button$1, {
205
- variant: "secondary",
206
- asChild: true,
207
- children: /* @__PURE__ */ jsx(Link$1, {
208
- to: CREDENTIALS_PATH,
209
- children: "API credentials"
210
- })
211
- }) : null
212
- ]
213
- }),
214
- billingPortal.isError ? /* @__PURE__ */ jsx("p", {
215
- className: "mt-4 text-sm text-destructive",
216
- children: billingPortal.error instanceof Error ? billingPortal.error.message : "Unable to open billing portal."
217
- }) : null
218
- ]
219
- });
220
- };
221
-
222
- //#endregion
223
- export { SubscriptionsPage as default };
@@ -1,12 +0,0 @@
1
- //#region src/paths.ts
2
- const PRICING_PATH = "/pricing";
3
- const ACCOUNT_PLANS_PATH = "/account/plans";
4
- const CHECKOUT_PATH = "/checkout";
5
- const CONTACT_SALES_PATH = "/contact-sales";
6
- const SUBSCRIPTIONS_PATH = "/account/subscriptions";
7
- const CREDENTIALS_PATH = "/account/credentials";
8
- const USAGE_PATH = "/account/usage";
9
- const LOGIN_NAV_PATH = "/signin";
10
-
11
- //#endregion
12
- export { LOGIN_NAV_PATH as a, USAGE_PATH as c, CREDENTIALS_PATH as i, CHECKOUT_PATH as n, PRICING_PATH as o, CONTACT_SALES_PATH as r, SUBSCRIPTIONS_PATH as s, ACCOUNT_PLANS_PATH as t };