@nubitio/admin 0.5.23 → 0.5.24

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.cjs CHANGED
@@ -481,6 +481,190 @@ function FeatureGate({ featureKey, ...props }) {
481
481
  });
482
482
  }
483
483
  //#endregion
484
+ //#region packages/admin/quota/QuotaUsageBanner.tsx
485
+ function QuotaUsageBanner({ count, max, unitLabel, planLabel, atLimitMessage, nearLimitMessage, defaultMessage, upgradeHref, upgradeLabel = "View plans", className = "" }) {
486
+ if (max <= 0) return null;
487
+ const atLimit = count >= max;
488
+ const nearLimit = count === max - 1;
489
+ const tone = atLimit ? "limit" : nearLimit ? "warn" : "default";
490
+ const detail = atLimit ? atLimitMessage ?? `You reached your ${planLabel ?? "plan"} limit.` : nearLimit ? nearLimitMessage ?? `Only 1 ${unitLabel} slot left on your ${planLabel ?? "plan"}.` : defaultMessage ?? `${planLabel ?? "Plan"} usage.`;
491
+ const upgradeLink = upgradeHref ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
492
+ className: "nb-quota-banner__cta",
493
+ href: upgradeHref,
494
+ children: upgradeLabel
495
+ }) : null;
496
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
497
+ className: `nb-quota-banner nb-quota-banner--${tone}${className ? ` ${className}` : ""}`,
498
+ role: "status",
499
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
500
+ className: "nb-quota-banner__copy",
501
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [
502
+ count,
503
+ " / ",
504
+ max,
505
+ " ",
506
+ unitLabel
507
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: detail })]
508
+ }), (atLimit || nearLimit) && upgradeLink]
509
+ });
510
+ }
511
+ function quotaUsageAboveGrid(banner) {
512
+ return () => banner;
513
+ }
514
+ //#endregion
515
+ //#region packages/admin/billing/PlanPanel.tsx
516
+ function formatTrialEnds(iso) {
517
+ const date = new Date(iso);
518
+ if (Number.isNaN(date.getTime())) return iso;
519
+ return new Intl.DateTimeFormat(void 0, { dateStyle: "medium" }).format(date);
520
+ }
521
+ function PlanPanel({ plans, currentPlanId, subscriptionStatus, trialEndsAt, onSelectPlan, selectingPlanId = null, labels, className = "" }) {
522
+ const title = labels?.title ?? "Plans";
523
+ const subtitle = labels?.subtitle ?? "Choose the plan that fits your team.";
524
+ const currentBadge = labels?.currentBadge ?? "Current plan";
525
+ const selectingLabel = labels?.selecting ?? "Updating…";
526
+ let statusLine = null;
527
+ if (subscriptionStatus === "trialing" && trialEndsAt) statusLine = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
528
+ className: "nb-plan-panel__status",
529
+ children: (labels?.trialEnds ?? "Trial ends {date}").replace("{date}", formatTrialEnds(trialEndsAt))
530
+ });
531
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
532
+ className: `nb-plan-panel${className ? ` ${className}` : ""}`,
533
+ "aria-labelledby": "plan-panel-title",
534
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("header", {
535
+ className: "nb-plan-panel__header",
536
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
538
+ id: "plan-panel-title",
539
+ className: "nb-plan-panel__title",
540
+ children: title
541
+ }),
542
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
543
+ className: "nb-plan-panel__subtitle",
544
+ children: subtitle
545
+ }),
546
+ statusLine
547
+ ] })
548
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
549
+ className: "nb-plan-panel__grid",
550
+ role: "list",
551
+ children: plans.map((plan) => {
552
+ const isCurrent = plan.id === currentPlanId;
553
+ const isSelecting = selectingPlanId === plan.id;
554
+ const ctaLabel = plan.ctaLabel ?? (isCurrent ? currentBadge : `Choose ${plan.name}`);
555
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
556
+ role: "listitem",
557
+ className: [
558
+ "nb-plan-card",
559
+ plan.highlighted ? "nb-plan-card--highlighted" : "",
560
+ isCurrent ? "nb-plan-card--current" : ""
561
+ ].filter(Boolean).join(" "),
562
+ children: [
563
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
564
+ className: "nb-plan-card__head",
565
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
566
+ className: "nb-plan-card__name",
567
+ children: plan.name
568
+ }), isCurrent ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Badge, {
569
+ variant: "primary",
570
+ size: "sm",
571
+ pill: true,
572
+ children: currentBadge
573
+ }) : null]
574
+ }),
575
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
576
+ className: "nb-plan-card__price",
577
+ children: plan.priceLabel
578
+ }),
579
+ plan.description ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
580
+ className: "nb-plan-card__description",
581
+ children: plan.description
582
+ }) : null,
583
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
584
+ className: "nb-plan-card__features",
585
+ children: plan.features.map((feature) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: feature }, feature))
586
+ }),
587
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
588
+ variant: plan.highlighted && !isCurrent ? "primary" : "secondary",
589
+ size: "sm",
590
+ disabled: isCurrent || !onSelectPlan || isSelecting,
591
+ onClick: () => void onSelectPlan?.(plan.id),
592
+ children: isSelecting ? selectingLabel : ctaLabel
593
+ })
594
+ ]
595
+ }, plan.id);
596
+ })
597
+ })]
598
+ });
599
+ }
600
+ //#endregion
601
+ //#region packages/admin/quota/parseQuotaError.ts
602
+ function readErrorDetail(error) {
603
+ if (typeof error !== "object" || error === null) return "";
604
+ const record = error;
605
+ return record.data?.detail ?? record.data?.message ?? record.message ?? "";
606
+ }
607
+ function parseQuotaError(error) {
608
+ if (typeof error !== "object" || error === null) return null;
609
+ if (error.status !== 429) return null;
610
+ const detail = readErrorDetail(error);
611
+ const match = detail.match(/Quota exceeded for "([^"]+)": (\d+)\/(\d+)/);
612
+ if (!match) return {
613
+ resource: "unknown",
614
+ current: 0,
615
+ limit: 0,
616
+ message: detail || "Plan limit reached."
617
+ };
618
+ return {
619
+ resource: match[1],
620
+ current: Number(match[2]),
621
+ limit: Number(match[3]),
622
+ message: detail
623
+ };
624
+ }
625
+ function quotaErrorToastMessage(quota, labels) {
626
+ const label = labels?.[quota.resource] ?? quota.resource;
627
+ return `Plan limit reached: ${quota.current}/${quota.limit} ${label}. Upgrade your plan to continue.`;
628
+ }
629
+ //#endregion
630
+ //#region packages/admin/quota/useQuotaUsage.ts
631
+ function readCollectionTotal(body) {
632
+ if (Array.isArray(body)) return body.length;
633
+ if (typeof body !== "object" || body === null) return 0;
634
+ const record = body;
635
+ const member = record["hydra:member"] ?? record["member"];
636
+ const listed = Array.isArray(member) ? member.length : 0;
637
+ return Number(record["hydra:totalItems"] ?? record["totalItems"] ?? listed);
638
+ }
639
+ function useQuotaUsage({ featureKey, collectionPath, staleTimeMs = 1e4 }) {
640
+ const http = (0, _nubitio_core.useCoreHttpClient)();
641
+ const config = useFeatureConfig(featureKey);
642
+ const max = Number(config.max ?? 0);
643
+ const query = (0, _tanstack_react_query.useQuery)({
644
+ queryKey: [
645
+ "quota-usage",
646
+ featureKey,
647
+ collectionPath
648
+ ],
649
+ queryFn: async () => {
650
+ return readCollectionTotal((await http.get(`${collectionPath}?itemsPerPage=100`, { headers: { Accept: "application/ld+json" } })).data);
651
+ },
652
+ staleTime: staleTimeMs
653
+ });
654
+ const count = query.data ?? 0;
655
+ const atLimit = max > 0 && count >= max;
656
+ const nearLimit = max > 0 && count === max - 1;
657
+ const refetch = (0, react.useCallback)(() => query.refetch(), [query]);
658
+ return {
659
+ count,
660
+ max,
661
+ atLimit,
662
+ nearLimit,
663
+ loading: query.isLoading,
664
+ refetch
665
+ };
666
+ }
667
+ //#endregion
484
668
  //#region packages/admin/auth/LoginPage.tsx
485
669
  function joinApiPath$2(apiBaseUrl, path) {
486
670
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
@@ -961,6 +1145,8 @@ exports.AdminShell = AdminShell;
961
1145
  exports.AdminSidebarMenu = AdminSidebarMenu;
962
1146
  exports.FeatureGate = FeatureGate;
963
1147
  exports.LoginPage = LoginPage;
1148
+ exports.PlanPanel = PlanPanel;
1149
+ exports.QuotaUsageBanner = QuotaUsageBanner;
964
1150
  exports.RegisterPage = RegisterPage;
965
1151
  exports.SessionProvider = SessionProvider;
966
1152
  exports.StaticSessionProvider = StaticSessionProvider;
@@ -968,9 +1154,13 @@ exports.ToastHost = ToastHost;
968
1154
  exports.createNubitApp = createNubitApp;
969
1155
  exports.filterMenuByRoles = filterMenuByRoles;
970
1156
  exports.hasAnyRole = hasAnyRole;
1157
+ exports.parseQuotaError = parseQuotaError;
1158
+ exports.quotaErrorToastMessage = quotaErrorToastMessage;
1159
+ exports.quotaUsageAboveGrid = quotaUsageAboveGrid;
971
1160
  exports.useAppRuntime = useAppRuntime;
972
1161
  exports.useFeature = useFeature;
973
1162
  exports.useFeatureConfig = useFeatureConfig;
1163
+ exports.useQuotaUsage = useQuotaUsage;
974
1164
  exports.useRuntimeConfig = useRuntimeConfig;
975
1165
  exports.useScreenSize = useScreenSize;
976
1166
  exports.useScreenSizeClass = useScreenSizeClass;
package/dist/index.d.cts CHANGED
@@ -168,6 +168,100 @@ declare function FeatureGate({
168
168
  ...props
169
169
  }: FeatureGateProps): React$1.JSX.Element;
170
170
  //#endregion
171
+ //#region packages/admin/quota/QuotaUsageBanner.d.ts
172
+ interface QuotaUsageBannerProps {
173
+ count: number;
174
+ max: number;
175
+ unitLabel: string;
176
+ planLabel?: string;
177
+ atLimitMessage?: string;
178
+ nearLimitMessage?: string;
179
+ defaultMessage?: string;
180
+ upgradeHref?: string;
181
+ upgradeLabel?: string;
182
+ className?: string;
183
+ }
184
+ declare function QuotaUsageBanner({
185
+ count,
186
+ max,
187
+ unitLabel,
188
+ planLabel,
189
+ atLimitMessage,
190
+ nearLimitMessage,
191
+ defaultMessage,
192
+ upgradeHref,
193
+ upgradeLabel,
194
+ className
195
+ }: QuotaUsageBannerProps): import("react").JSX.Element | null;
196
+ declare function quotaUsageAboveGrid(banner: ReactNode): () => ReactNode;
197
+ //#endregion
198
+ //#region packages/admin/billing/PlanPanel.d.ts
199
+ interface PlanDefinition {
200
+ id: string;
201
+ name: string;
202
+ priceLabel: string;
203
+ description?: string;
204
+ features: string[];
205
+ highlighted?: boolean;
206
+ ctaLabel?: string;
207
+ }
208
+ interface PlanPanelLabels {
209
+ title?: string;
210
+ subtitle?: string;
211
+ currentBadge?: string;
212
+ trialEnds?: string;
213
+ selecting?: string;
214
+ }
215
+ interface PlanPanelProps {
216
+ plans: PlanDefinition[];
217
+ currentPlanId: string;
218
+ subscriptionStatus?: string;
219
+ trialEndsAt?: string | null;
220
+ onSelectPlan?: (planId: string) => void | Promise<void>;
221
+ selectingPlanId?: string | null;
222
+ labels?: PlanPanelLabels;
223
+ className?: string;
224
+ }
225
+ declare function PlanPanel({
226
+ plans,
227
+ currentPlanId,
228
+ subscriptionStatus,
229
+ trialEndsAt,
230
+ onSelectPlan,
231
+ selectingPlanId,
232
+ labels,
233
+ className
234
+ }: PlanPanelProps): import("react").JSX.Element;
235
+ //#endregion
236
+ //#region packages/admin/quota/parseQuotaError.d.ts
237
+ interface ParsedQuotaError {
238
+ resource: string;
239
+ current: number;
240
+ limit: number;
241
+ message: string;
242
+ }
243
+ declare function parseQuotaError(error: unknown): ParsedQuotaError | null;
244
+ declare function quotaErrorToastMessage(quota: ParsedQuotaError, labels?: Partial<Record<string, string>>): string;
245
+ //#endregion
246
+ //#region packages/admin/quota/useQuotaUsage.d.ts
247
+ interface UseQuotaUsageOptions {
248
+ featureKey: string;
249
+ collectionPath: string;
250
+ staleTimeMs?: number;
251
+ }
252
+ declare function useQuotaUsage({
253
+ featureKey,
254
+ collectionPath,
255
+ staleTimeMs
256
+ }: UseQuotaUsageOptions): {
257
+ count: number;
258
+ max: number;
259
+ atLimit: boolean;
260
+ nearLimit: boolean;
261
+ loading: boolean;
262
+ refetch: () => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
263
+ };
264
+ //#endregion
171
265
  //#region packages/admin/auth/LoginPage.d.ts
172
266
  interface LoginPageProps {
173
267
  onLoggedIn: () => void;
@@ -350,4 +444,4 @@ declare function hasAnyRole(required: string | string[] | undefined, roles: stri
350
444
  */
351
445
  declare function filterMenuByRoles(items: NubitAppMenuItem[], roles: string[]): AdminMenuItem[];
352
446
  //#endregion
353
- export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, LoginPage, type LoginPageProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, useAppRuntime, useFeature, useFeatureConfig, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
447
+ export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, LoginPage, type LoginPageProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, type ParsedQuotaError, type PlanDefinition, PlanPanel, type PlanPanelLabels, type PlanPanelProps, QuotaUsageBanner, type QuotaUsageBannerProps, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseQuotaUsageOptions, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
package/dist/index.d.mts CHANGED
@@ -168,6 +168,100 @@ declare function FeatureGate({
168
168
  ...props
169
169
  }: FeatureGateProps): React$1.JSX.Element;
170
170
  //#endregion
171
+ //#region packages/admin/quota/QuotaUsageBanner.d.ts
172
+ interface QuotaUsageBannerProps {
173
+ count: number;
174
+ max: number;
175
+ unitLabel: string;
176
+ planLabel?: string;
177
+ atLimitMessage?: string;
178
+ nearLimitMessage?: string;
179
+ defaultMessage?: string;
180
+ upgradeHref?: string;
181
+ upgradeLabel?: string;
182
+ className?: string;
183
+ }
184
+ declare function QuotaUsageBanner({
185
+ count,
186
+ max,
187
+ unitLabel,
188
+ planLabel,
189
+ atLimitMessage,
190
+ nearLimitMessage,
191
+ defaultMessage,
192
+ upgradeHref,
193
+ upgradeLabel,
194
+ className
195
+ }: QuotaUsageBannerProps): import("react").JSX.Element | null;
196
+ declare function quotaUsageAboveGrid(banner: ReactNode): () => ReactNode;
197
+ //#endregion
198
+ //#region packages/admin/billing/PlanPanel.d.ts
199
+ interface PlanDefinition {
200
+ id: string;
201
+ name: string;
202
+ priceLabel: string;
203
+ description?: string;
204
+ features: string[];
205
+ highlighted?: boolean;
206
+ ctaLabel?: string;
207
+ }
208
+ interface PlanPanelLabels {
209
+ title?: string;
210
+ subtitle?: string;
211
+ currentBadge?: string;
212
+ trialEnds?: string;
213
+ selecting?: string;
214
+ }
215
+ interface PlanPanelProps {
216
+ plans: PlanDefinition[];
217
+ currentPlanId: string;
218
+ subscriptionStatus?: string;
219
+ trialEndsAt?: string | null;
220
+ onSelectPlan?: (planId: string) => void | Promise<void>;
221
+ selectingPlanId?: string | null;
222
+ labels?: PlanPanelLabels;
223
+ className?: string;
224
+ }
225
+ declare function PlanPanel({
226
+ plans,
227
+ currentPlanId,
228
+ subscriptionStatus,
229
+ trialEndsAt,
230
+ onSelectPlan,
231
+ selectingPlanId,
232
+ labels,
233
+ className
234
+ }: PlanPanelProps): import("react").JSX.Element;
235
+ //#endregion
236
+ //#region packages/admin/quota/parseQuotaError.d.ts
237
+ interface ParsedQuotaError {
238
+ resource: string;
239
+ current: number;
240
+ limit: number;
241
+ message: string;
242
+ }
243
+ declare function parseQuotaError(error: unknown): ParsedQuotaError | null;
244
+ declare function quotaErrorToastMessage(quota: ParsedQuotaError, labels?: Partial<Record<string, string>>): string;
245
+ //#endregion
246
+ //#region packages/admin/quota/useQuotaUsage.d.ts
247
+ interface UseQuotaUsageOptions {
248
+ featureKey: string;
249
+ collectionPath: string;
250
+ staleTimeMs?: number;
251
+ }
252
+ declare function useQuotaUsage({
253
+ featureKey,
254
+ collectionPath,
255
+ staleTimeMs
256
+ }: UseQuotaUsageOptions): {
257
+ count: number;
258
+ max: number;
259
+ atLimit: boolean;
260
+ nearLimit: boolean;
261
+ loading: boolean;
262
+ refetch: () => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
263
+ };
264
+ //#endregion
171
265
  //#region packages/admin/auth/LoginPage.d.ts
172
266
  interface LoginPageProps {
173
267
  onLoggedIn: () => void;
@@ -350,4 +444,4 @@ declare function hasAnyRole(required: string | string[] | undefined, roles: stri
350
444
  */
351
445
  declare function filterMenuByRoles(items: NubitAppMenuItem[], roles: string[]): AdminMenuItem[];
352
446
  //#endregion
353
- export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, LoginPage, type LoginPageProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, useAppRuntime, useFeature, useFeatureConfig, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
447
+ export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, LoginPage, type LoginPageProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, type ParsedQuotaError, type PlanDefinition, PlanPanel, type PlanPanelLabels, type PlanPanelProps, QuotaUsageBanner, type QuotaUsageBannerProps, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseQuotaUsageOptions, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
package/dist/index.mjs CHANGED
@@ -2,8 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
2
2
  import { BrowserRouter, Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
3
3
  import { Badge, Button, Card, FeatureGate as FeatureGate$1, IconButton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel } from "@nubitio/ui";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
- import { CoreConfigProvider, CoreProvider, MercureProvider } from "@nubitio/core";
5
+ import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
6
+ import { CoreConfigProvider, CoreProvider, MercureProvider, useCoreHttpClient } from "@nubitio/core";
7
7
  import { SmartCrudRolesProvider } from "@nubitio/crud";
8
8
  import { HydraResourceSchemaProvider, HydraResourceStoreProvider, SchemaProvider } from "@nubitio/hydra";
9
9
  //#region packages/admin/AdminHeader.tsx
@@ -457,6 +457,190 @@ function FeatureGate({ featureKey, ...props }) {
457
457
  });
458
458
  }
459
459
  //#endregion
460
+ //#region packages/admin/quota/QuotaUsageBanner.tsx
461
+ function QuotaUsageBanner({ count, max, unitLabel, planLabel, atLimitMessage, nearLimitMessage, defaultMessage, upgradeHref, upgradeLabel = "View plans", className = "" }) {
462
+ if (max <= 0) return null;
463
+ const atLimit = count >= max;
464
+ const nearLimit = count === max - 1;
465
+ const tone = atLimit ? "limit" : nearLimit ? "warn" : "default";
466
+ const detail = atLimit ? atLimitMessage ?? `You reached your ${planLabel ?? "plan"} limit.` : nearLimit ? nearLimitMessage ?? `Only 1 ${unitLabel} slot left on your ${planLabel ?? "plan"}.` : defaultMessage ?? `${planLabel ?? "Plan"} usage.`;
467
+ const upgradeLink = upgradeHref ? /* @__PURE__ */ jsx("a", {
468
+ className: "nb-quota-banner__cta",
469
+ href: upgradeHref,
470
+ children: upgradeLabel
471
+ }) : null;
472
+ return /* @__PURE__ */ jsxs("div", {
473
+ className: `nb-quota-banner nb-quota-banner--${tone}${className ? ` ${className}` : ""}`,
474
+ role: "status",
475
+ children: [/* @__PURE__ */ jsxs("div", {
476
+ className: "nb-quota-banner__copy",
477
+ children: [/* @__PURE__ */ jsxs("strong", { children: [
478
+ count,
479
+ " / ",
480
+ max,
481
+ " ",
482
+ unitLabel
483
+ ] }), /* @__PURE__ */ jsx("span", { children: detail })]
484
+ }), (atLimit || nearLimit) && upgradeLink]
485
+ });
486
+ }
487
+ function quotaUsageAboveGrid(banner) {
488
+ return () => banner;
489
+ }
490
+ //#endregion
491
+ //#region packages/admin/billing/PlanPanel.tsx
492
+ function formatTrialEnds(iso) {
493
+ const date = new Date(iso);
494
+ if (Number.isNaN(date.getTime())) return iso;
495
+ return new Intl.DateTimeFormat(void 0, { dateStyle: "medium" }).format(date);
496
+ }
497
+ function PlanPanel({ plans, currentPlanId, subscriptionStatus, trialEndsAt, onSelectPlan, selectingPlanId = null, labels, className = "" }) {
498
+ const title = labels?.title ?? "Plans";
499
+ const subtitle = labels?.subtitle ?? "Choose the plan that fits your team.";
500
+ const currentBadge = labels?.currentBadge ?? "Current plan";
501
+ const selectingLabel = labels?.selecting ?? "Updating…";
502
+ let statusLine = null;
503
+ if (subscriptionStatus === "trialing" && trialEndsAt) statusLine = /* @__PURE__ */ jsx("p", {
504
+ className: "nb-plan-panel__status",
505
+ children: (labels?.trialEnds ?? "Trial ends {date}").replace("{date}", formatTrialEnds(trialEndsAt))
506
+ });
507
+ return /* @__PURE__ */ jsxs("section", {
508
+ className: `nb-plan-panel${className ? ` ${className}` : ""}`,
509
+ "aria-labelledby": "plan-panel-title",
510
+ children: [/* @__PURE__ */ jsx("header", {
511
+ className: "nb-plan-panel__header",
512
+ children: /* @__PURE__ */ jsxs("div", { children: [
513
+ /* @__PURE__ */ jsx("h2", {
514
+ id: "plan-panel-title",
515
+ className: "nb-plan-panel__title",
516
+ children: title
517
+ }),
518
+ /* @__PURE__ */ jsx("p", {
519
+ className: "nb-plan-panel__subtitle",
520
+ children: subtitle
521
+ }),
522
+ statusLine
523
+ ] })
524
+ }), /* @__PURE__ */ jsx("div", {
525
+ className: "nb-plan-panel__grid",
526
+ role: "list",
527
+ children: plans.map((plan) => {
528
+ const isCurrent = plan.id === currentPlanId;
529
+ const isSelecting = selectingPlanId === plan.id;
530
+ const ctaLabel = plan.ctaLabel ?? (isCurrent ? currentBadge : `Choose ${plan.name}`);
531
+ return /* @__PURE__ */ jsxs("article", {
532
+ role: "listitem",
533
+ className: [
534
+ "nb-plan-card",
535
+ plan.highlighted ? "nb-plan-card--highlighted" : "",
536
+ isCurrent ? "nb-plan-card--current" : ""
537
+ ].filter(Boolean).join(" "),
538
+ children: [
539
+ /* @__PURE__ */ jsxs("div", {
540
+ className: "nb-plan-card__head",
541
+ children: [/* @__PURE__ */ jsx("h3", {
542
+ className: "nb-plan-card__name",
543
+ children: plan.name
544
+ }), isCurrent ? /* @__PURE__ */ jsx(Badge, {
545
+ variant: "primary",
546
+ size: "sm",
547
+ pill: true,
548
+ children: currentBadge
549
+ }) : null]
550
+ }),
551
+ /* @__PURE__ */ jsx("p", {
552
+ className: "nb-plan-card__price",
553
+ children: plan.priceLabel
554
+ }),
555
+ plan.description ? /* @__PURE__ */ jsx("p", {
556
+ className: "nb-plan-card__description",
557
+ children: plan.description
558
+ }) : null,
559
+ /* @__PURE__ */ jsx("ul", {
560
+ className: "nb-plan-card__features",
561
+ children: plan.features.map((feature) => /* @__PURE__ */ jsx("li", { children: feature }, feature))
562
+ }),
563
+ /* @__PURE__ */ jsx(Button, {
564
+ variant: plan.highlighted && !isCurrent ? "primary" : "secondary",
565
+ size: "sm",
566
+ disabled: isCurrent || !onSelectPlan || isSelecting,
567
+ onClick: () => void onSelectPlan?.(plan.id),
568
+ children: isSelecting ? selectingLabel : ctaLabel
569
+ })
570
+ ]
571
+ }, plan.id);
572
+ })
573
+ })]
574
+ });
575
+ }
576
+ //#endregion
577
+ //#region packages/admin/quota/parseQuotaError.ts
578
+ function readErrorDetail(error) {
579
+ if (typeof error !== "object" || error === null) return "";
580
+ const record = error;
581
+ return record.data?.detail ?? record.data?.message ?? record.message ?? "";
582
+ }
583
+ function parseQuotaError(error) {
584
+ if (typeof error !== "object" || error === null) return null;
585
+ if (error.status !== 429) return null;
586
+ const detail = readErrorDetail(error);
587
+ const match = detail.match(/Quota exceeded for "([^"]+)": (\d+)\/(\d+)/);
588
+ if (!match) return {
589
+ resource: "unknown",
590
+ current: 0,
591
+ limit: 0,
592
+ message: detail || "Plan limit reached."
593
+ };
594
+ return {
595
+ resource: match[1],
596
+ current: Number(match[2]),
597
+ limit: Number(match[3]),
598
+ message: detail
599
+ };
600
+ }
601
+ function quotaErrorToastMessage(quota, labels) {
602
+ const label = labels?.[quota.resource] ?? quota.resource;
603
+ return `Plan limit reached: ${quota.current}/${quota.limit} ${label}. Upgrade your plan to continue.`;
604
+ }
605
+ //#endregion
606
+ //#region packages/admin/quota/useQuotaUsage.ts
607
+ function readCollectionTotal(body) {
608
+ if (Array.isArray(body)) return body.length;
609
+ if (typeof body !== "object" || body === null) return 0;
610
+ const record = body;
611
+ const member = record["hydra:member"] ?? record["member"];
612
+ const listed = Array.isArray(member) ? member.length : 0;
613
+ return Number(record["hydra:totalItems"] ?? record["totalItems"] ?? listed);
614
+ }
615
+ function useQuotaUsage({ featureKey, collectionPath, staleTimeMs = 1e4 }) {
616
+ const http = useCoreHttpClient();
617
+ const config = useFeatureConfig(featureKey);
618
+ const max = Number(config.max ?? 0);
619
+ const query = useQuery({
620
+ queryKey: [
621
+ "quota-usage",
622
+ featureKey,
623
+ collectionPath
624
+ ],
625
+ queryFn: async () => {
626
+ return readCollectionTotal((await http.get(`${collectionPath}?itemsPerPage=100`, { headers: { Accept: "application/ld+json" } })).data);
627
+ },
628
+ staleTime: staleTimeMs
629
+ });
630
+ const count = query.data ?? 0;
631
+ const atLimit = max > 0 && count >= max;
632
+ const nearLimit = max > 0 && count === max - 1;
633
+ const refetch = useCallback(() => query.refetch(), [query]);
634
+ return {
635
+ count,
636
+ max,
637
+ atLimit,
638
+ nearLimit,
639
+ loading: query.isLoading,
640
+ refetch
641
+ };
642
+ }
643
+ //#endregion
460
644
  //#region packages/admin/auth/LoginPage.tsx
461
645
  function joinApiPath$2(apiBaseUrl, path) {
462
646
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
@@ -932,4 +1116,4 @@ function createNubitApp(config) {
932
1116
  return { App };
933
1117
  }
934
1118
  //#endregion
935
- export { AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, LoginPage, RegisterPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, useAppRuntime, useFeature, useFeatureConfig, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
1119
+ export { AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, LoginPage, PlanPanel, QuotaUsageBanner, RegisterPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
package/dist/style.css CHANGED
@@ -369,6 +369,151 @@
369
369
  opacity: 0;
370
370
  pointer-events: none;
371
371
  }
372
+ .nb-quota-banner {
373
+ align-items: center;
374
+ background: color-mix(in srgb, var(--accent-color, #888) 8%, var(--surface-1, transparent));
375
+ border: 1px solid var(--border-subtle);
376
+ border-radius: var(--radius-lg);
377
+ color: var(--text-primary);
378
+ display: flex;
379
+ gap: var(--space-3);
380
+ justify-content: space-between;
381
+ padding: var(--space-3) var(--space-4);
382
+ width: 100%;
383
+ }
384
+
385
+ .nb-quota-banner--warn {
386
+ background: color-mix(in srgb, var(--color-warning, #f59e0b) 10%, var(--surface-1, transparent));
387
+ border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 45%, var(--border-subtle));
388
+ }
389
+
390
+ .nb-quota-banner--limit {
391
+ background: color-mix(in srgb, var(--color-error, #dc2626) 8%, var(--surface-1, transparent));
392
+ border-color: color-mix(in srgb, var(--color-error, #dc2626) 40%, var(--border-subtle));
393
+ }
394
+
395
+ .nb-quota-banner__copy {
396
+ display: flex;
397
+ flex-direction: column;
398
+ gap: 2px;
399
+ font-size: 0.9rem;
400
+ }
401
+
402
+ .nb-quota-banner__copy span {
403
+ color: var(--text-secondary);
404
+ }
405
+
406
+ .nb-quota-banner__cta {
407
+ color: var(--accent-color);
408
+ flex-shrink: 0;
409
+ font-weight: 600;
410
+ text-decoration: none;
411
+ }
412
+
413
+ .nb-quota-banner__cta:hover {
414
+ text-decoration: underline;
415
+ }
416
+ @charset "UTF-8";
417
+ .nb-plan-panel {
418
+ display: flex;
419
+ flex-direction: column;
420
+ gap: var(--space-4);
421
+ width: 100%;
422
+ }
423
+
424
+ .nb-plan-panel__header {
425
+ display: flex;
426
+ justify-content: space-between;
427
+ gap: var(--space-3);
428
+ }
429
+
430
+ .nb-plan-panel__title {
431
+ font-size: 1.15rem;
432
+ font-weight: 700;
433
+ margin: 0;
434
+ }
435
+
436
+ .nb-plan-panel__subtitle {
437
+ color: var(--text-secondary);
438
+ font-size: 0.9rem;
439
+ margin: var(--space-1) 0 0;
440
+ }
441
+
442
+ .nb-plan-panel__status {
443
+ color: var(--text-secondary);
444
+ font-size: 0.85rem;
445
+ margin: var(--space-2) 0 0;
446
+ }
447
+
448
+ .nb-plan-panel__grid {
449
+ display: grid;
450
+ gap: var(--space-3);
451
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
452
+ }
453
+
454
+ .nb-plan-card {
455
+ background: var(--surface-1);
456
+ border: 1px solid var(--border-subtle);
457
+ border-radius: var(--radius-lg);
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: var(--space-3);
461
+ min-width: 0;
462
+ padding: var(--space-4);
463
+ }
464
+
465
+ .nb-plan-card--highlighted {
466
+ border-color: color-mix(in srgb, var(--accent-color) 45%, var(--border-subtle));
467
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-color) 20%, transparent);
468
+ }
469
+
470
+ .nb-plan-card--current {
471
+ background: color-mix(in srgb, var(--accent-color) 6%, var(--surface-1));
472
+ }
473
+
474
+ .nb-plan-card__head {
475
+ align-items: flex-start;
476
+ display: flex;
477
+ gap: var(--space-2);
478
+ justify-content: space-between;
479
+ }
480
+
481
+ .nb-plan-card__name {
482
+ font-size: 1rem;
483
+ font-weight: 700;
484
+ margin: 0;
485
+ }
486
+
487
+ .nb-plan-card__price {
488
+ font-size: 1.35rem;
489
+ font-variant-numeric: tabular-nums;
490
+ font-weight: 700;
491
+ margin: 0;
492
+ }
493
+
494
+ .nb-plan-card__description {
495
+ color: var(--text-secondary);
496
+ font-size: 0.85rem;
497
+ margin: 0;
498
+ }
499
+
500
+ .nb-plan-card__features {
501
+ color: var(--text-primary);
502
+ display: flex;
503
+ flex: 1;
504
+ flex-direction: column;
505
+ font-size: 0.85rem;
506
+ gap: var(--space-1);
507
+ list-style: none;
508
+ margin: 0;
509
+ padding: 0;
510
+ }
511
+
512
+ .nb-plan-card__features li::before {
513
+ color: var(--accent-color);
514
+ content: "✓ ";
515
+ font-weight: 700;
516
+ }
372
517
  .nb-toast-host {
373
518
  position: fixed;
374
519
  right: 1rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubitio/admin",
3
- "version": "0.5.23",
3
+ "version": "0.5.24",
4
4
  "type": "module",
5
5
  "description": "Admin shell layout components: responsive sidebar, header, and screen size utilities for Nubit apps.",
6
6
  "license": "MIT",
@@ -53,9 +53,9 @@
53
53
  "react": "^19.0.0",
54
54
  "react-dom": "^19.0.0",
55
55
  "react-router-dom": "^6.0.0",
56
- "@nubitio/crud": "^0.5.23",
57
- "@nubitio/core": "^0.5.23",
58
- "@nubitio/hydra": "^0.5.23",
59
- "@nubitio/ui": "^0.5.23"
56
+ "@nubitio/hydra": "^0.5.24",
57
+ "@nubitio/crud": "^0.5.24",
58
+ "@nubitio/ui": "^0.5.24",
59
+ "@nubitio/core": "^0.5.24"
60
60
  }
61
61
  }