@nubitio/admin 0.5.22 → 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
@@ -389,14 +389,14 @@ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThe
389
389
  //#endregion
390
390
  //#region packages/admin/auth/SessionContext.tsx
391
391
  const SessionContext = (0, react.createContext)(null);
392
- function joinApiPath$2(apiBaseUrl, path) {
392
+ function joinApiPath$3(apiBaseUrl, path) {
393
393
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
394
394
  }
395
395
  function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "auth/logout", children }) {
396
396
  const [session, setSession] = (0, react.useState)({ status: "loading" });
397
397
  const refresh = (0, react.useCallback)(async () => {
398
398
  try {
399
- const response = await fetch(joinApiPath$2(apiBaseUrl, mePath), { credentials: "include" });
399
+ const response = await fetch(joinApiPath$3(apiBaseUrl, mePath), { credentials: "include" });
400
400
  if (!response.ok) {
401
401
  setSession({ status: "anonymous" });
402
402
  return;
@@ -413,7 +413,7 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
413
413
  refresh();
414
414
  }, [refresh]);
415
415
  const logout = (0, react.useCallback)(async () => {
416
- await fetch(joinApiPath$2(apiBaseUrl, logoutPath), {
416
+ await fetch(joinApiPath$3(apiBaseUrl, logoutPath), {
417
417
  method: "POST",
418
418
  credentials: "include"
419
419
  });
@@ -481,8 +481,192 @@ 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
- function joinApiPath$1(apiBaseUrl, path) {
669
+ function joinApiPath$2(apiBaseUrl, path) {
486
670
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
487
671
  }
488
672
  function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login", title = "Nubit Admin", hint, defaultUsername = "" }) {
@@ -495,7 +679,7 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
495
679
  setBusy(true);
496
680
  setError(null);
497
681
  try {
498
- const response = await fetch(joinApiPath$1(apiBaseUrl, loginPath), {
682
+ const response = await fetch(joinApiPath$2(apiBaseUrl, loginPath), {
499
683
  method: "POST",
500
684
  headers: { "Content-Type": "application/json" },
501
685
  credentials: "include",
@@ -573,6 +757,125 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
573
757
  });
574
758
  }
575
759
  //#endregion
760
+ //#region packages/admin/auth/RegisterPage.tsx
761
+ function joinApiPath$1(apiBaseUrl, path) {
762
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
763
+ }
764
+ function initialValues(fields) {
765
+ return Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? ""]));
766
+ }
767
+ function RegisterPage({ fields, onRegistered, apiBaseUrl = "/api/", registerPath = "auth/register", title = "Create account", hint, submitLabel = "Create account", busyLabel = "Creating…", loginLink, loginPrompt = "Already have an account?" }) {
768
+ const [values, setValues] = (0, react.useState)((0, react.useMemo)(() => initialValues(fields), [fields]));
769
+ const [error, setError] = (0, react.useState)(null);
770
+ const [busy, setBusy] = (0, react.useState)(false);
771
+ const setValue = (name, value) => {
772
+ setValues((current) => ({
773
+ ...current,
774
+ [name]: value
775
+ }));
776
+ };
777
+ const submit = async (event) => {
778
+ event.preventDefault();
779
+ setBusy(true);
780
+ setError(null);
781
+ try {
782
+ const response = await fetch(joinApiPath$1(apiBaseUrl, registerPath), {
783
+ method: "POST",
784
+ headers: { "Content-Type": "application/json" },
785
+ credentials: "include",
786
+ body: JSON.stringify(values)
787
+ });
788
+ const body = await response.json().catch(() => null);
789
+ if (!response.ok) {
790
+ setError(body?.error ?? body?.message ?? "Registration failed");
791
+ return;
792
+ }
793
+ onRegistered();
794
+ } catch {
795
+ setError("Network error");
796
+ } finally {
797
+ setBusy(false);
798
+ }
799
+ };
800
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
801
+ style: {
802
+ display: "grid",
803
+ placeItems: "center",
804
+ minHeight: "100vh"
805
+ },
806
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
807
+ onSubmit: submit,
808
+ style: {
809
+ display: "flex",
810
+ flexDirection: "column",
811
+ gap: 12,
812
+ width: 360,
813
+ padding: 8
814
+ },
815
+ children: [
816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
817
+ style: { margin: 0 },
818
+ children: title
819
+ }),
820
+ hint && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
821
+ style: {
822
+ margin: 0,
823
+ color: "var(--text-secondary)"
824
+ },
825
+ children: hint
826
+ }),
827
+ fields.map((field) => {
828
+ const type = field.type ?? "text";
829
+ if (type === "select") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
830
+ value: values[field.name] ?? "",
831
+ onChange: (e) => setValue(field.name, e.target.value),
832
+ style: { width: "100%" },
833
+ "aria-label": field.placeholder ?? field.name,
834
+ children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
835
+ value: option.value,
836
+ children: option.label
837
+ }, option.value))
838
+ }, field.name);
839
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
840
+ placeholder: field.placeholder,
841
+ type: type === "password" ? "password" : type === "email" ? "email" : "text",
842
+ value: values[field.name] ?? "",
843
+ autoComplete: field.autoComplete,
844
+ onChange: (e) => setValue(field.name, e.target.value)
845
+ }, field.name);
846
+ }),
847
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
848
+ style: {
849
+ margin: 0,
850
+ color: "var(--error-color, #dc2626)"
851
+ },
852
+ children: error
853
+ }),
854
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
855
+ variant: "primary",
856
+ type: "submit",
857
+ disabled: busy,
858
+ children: busy ? busyLabel : submitLabel
859
+ }),
860
+ loginLink && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
861
+ style: {
862
+ margin: 0,
863
+ color: "var(--text-secondary)"
864
+ },
865
+ children: [
866
+ loginPrompt,
867
+ " ",
868
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Link, {
869
+ to: loginLink.to,
870
+ children: loginLink.label
871
+ })
872
+ ]
873
+ })
874
+ ]
875
+ }) })
876
+ });
877
+ }
878
+ //#endregion
576
879
  //#region packages/admin/runtime/useAppRuntime.ts
577
880
  function useAppRuntime() {
578
881
  const [toasts, setToasts] = (0, react.useState)([]);
@@ -842,15 +1145,22 @@ exports.AdminShell = AdminShell;
842
1145
  exports.AdminSidebarMenu = AdminSidebarMenu;
843
1146
  exports.FeatureGate = FeatureGate;
844
1147
  exports.LoginPage = LoginPage;
1148
+ exports.PlanPanel = PlanPanel;
1149
+ exports.QuotaUsageBanner = QuotaUsageBanner;
1150
+ exports.RegisterPage = RegisterPage;
845
1151
  exports.SessionProvider = SessionProvider;
846
1152
  exports.StaticSessionProvider = StaticSessionProvider;
847
1153
  exports.ToastHost = ToastHost;
848
1154
  exports.createNubitApp = createNubitApp;
849
1155
  exports.filterMenuByRoles = filterMenuByRoles;
850
1156
  exports.hasAnyRole = hasAnyRole;
1157
+ exports.parseQuotaError = parseQuotaError;
1158
+ exports.quotaErrorToastMessage = quotaErrorToastMessage;
1159
+ exports.quotaUsageAboveGrid = quotaUsageAboveGrid;
851
1160
  exports.useAppRuntime = useAppRuntime;
852
1161
  exports.useFeature = useFeature;
853
1162
  exports.useFeatureConfig = useFeatureConfig;
1163
+ exports.useQuotaUsage = useQuotaUsage;
854
1164
  exports.useRuntimeConfig = useRuntimeConfig;
855
1165
  exports.useScreenSize = useScreenSize;
856
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;
@@ -186,6 +280,49 @@ declare function LoginPage({
186
280
  defaultUsername
187
281
  }: LoginPageProps): import("react").JSX.Element;
188
282
  //#endregion
283
+ //#region packages/admin/auth/RegisterPage.d.ts
284
+ type RegisterFieldType = 'text' | 'email' | 'password' | 'select';
285
+ interface RegisterSelectOption {
286
+ value: string;
287
+ label: string;
288
+ }
289
+ interface RegisterField {
290
+ /** JSON body key sent to the register endpoint. */
291
+ name: string;
292
+ placeholder?: string;
293
+ type?: RegisterFieldType;
294
+ defaultValue?: string;
295
+ autoComplete?: string;
296
+ options?: RegisterSelectOption[];
297
+ }
298
+ interface RegisterPageProps {
299
+ fields: RegisterField[];
300
+ onRegistered: () => void;
301
+ apiBaseUrl?: string;
302
+ registerPath?: string;
303
+ title?: string;
304
+ hint?: string;
305
+ submitLabel?: string;
306
+ busyLabel?: string;
307
+ loginLink?: {
308
+ to: string;
309
+ label: string;
310
+ };
311
+ loginPrompt?: string;
312
+ }
313
+ declare function RegisterPage({
314
+ fields,
315
+ onRegistered,
316
+ apiBaseUrl,
317
+ registerPath,
318
+ title,
319
+ hint,
320
+ submitLabel,
321
+ busyLabel,
322
+ loginLink,
323
+ loginPrompt
324
+ }: RegisterPageProps): import("react").JSX.Element;
325
+ //#endregion
189
326
  //#region packages/admin/runtime/useAppRuntime.d.ts
190
327
  type NotificationType = 'success' | 'error' | 'warning' | 'info';
191
328
  type ToastItem = {
@@ -307,4 +444,4 @@ declare function hasAnyRole(required: string | string[] | undefined, roles: stri
307
444
  */
308
445
  declare function filterMenuByRoles(items: NubitAppMenuItem[], roles: string[]): AdminMenuItem[];
309
446
  //#endregion
310
- 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 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;
@@ -186,6 +280,49 @@ declare function LoginPage({
186
280
  defaultUsername
187
281
  }: LoginPageProps): import("react").JSX.Element;
188
282
  //#endregion
283
+ //#region packages/admin/auth/RegisterPage.d.ts
284
+ type RegisterFieldType = 'text' | 'email' | 'password' | 'select';
285
+ interface RegisterSelectOption {
286
+ value: string;
287
+ label: string;
288
+ }
289
+ interface RegisterField {
290
+ /** JSON body key sent to the register endpoint. */
291
+ name: string;
292
+ placeholder?: string;
293
+ type?: RegisterFieldType;
294
+ defaultValue?: string;
295
+ autoComplete?: string;
296
+ options?: RegisterSelectOption[];
297
+ }
298
+ interface RegisterPageProps {
299
+ fields: RegisterField[];
300
+ onRegistered: () => void;
301
+ apiBaseUrl?: string;
302
+ registerPath?: string;
303
+ title?: string;
304
+ hint?: string;
305
+ submitLabel?: string;
306
+ busyLabel?: string;
307
+ loginLink?: {
308
+ to: string;
309
+ label: string;
310
+ };
311
+ loginPrompt?: string;
312
+ }
313
+ declare function RegisterPage({
314
+ fields,
315
+ onRegistered,
316
+ apiBaseUrl,
317
+ registerPath,
318
+ title,
319
+ hint,
320
+ submitLabel,
321
+ busyLabel,
322
+ loginLink,
323
+ loginPrompt
324
+ }: RegisterPageProps): import("react").JSX.Element;
325
+ //#endregion
189
326
  //#region packages/admin/runtime/useAppRuntime.d.ts
190
327
  type NotificationType = 'success' | 'error' | 'warning' | 'info';
191
328
  type ToastItem = {
@@ -307,4 +444,4 @@ declare function hasAnyRole(required: string | string[] | undefined, roles: stri
307
444
  */
308
445
  declare function filterMenuByRoles(items: NubitAppMenuItem[], roles: string[]): AdminMenuItem[];
309
446
  //#endregion
310
- 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 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
@@ -1,9 +1,9 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
- import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
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
@@ -365,14 +365,14 @@ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThe
365
365
  //#endregion
366
366
  //#region packages/admin/auth/SessionContext.tsx
367
367
  const SessionContext = createContext(null);
368
- function joinApiPath$2(apiBaseUrl, path) {
368
+ function joinApiPath$3(apiBaseUrl, path) {
369
369
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
370
370
  }
371
371
  function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "auth/logout", children }) {
372
372
  const [session, setSession] = useState({ status: "loading" });
373
373
  const refresh = useCallback(async () => {
374
374
  try {
375
- const response = await fetch(joinApiPath$2(apiBaseUrl, mePath), { credentials: "include" });
375
+ const response = await fetch(joinApiPath$3(apiBaseUrl, mePath), { credentials: "include" });
376
376
  if (!response.ok) {
377
377
  setSession({ status: "anonymous" });
378
378
  return;
@@ -389,7 +389,7 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
389
389
  refresh();
390
390
  }, [refresh]);
391
391
  const logout = useCallback(async () => {
392
- await fetch(joinApiPath$2(apiBaseUrl, logoutPath), {
392
+ await fetch(joinApiPath$3(apiBaseUrl, logoutPath), {
393
393
  method: "POST",
394
394
  credentials: "include"
395
395
  });
@@ -457,8 +457,192 @@ 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
- function joinApiPath$1(apiBaseUrl, path) {
645
+ function joinApiPath$2(apiBaseUrl, path) {
462
646
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
463
647
  }
464
648
  function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login", title = "Nubit Admin", hint, defaultUsername = "" }) {
@@ -471,7 +655,7 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
471
655
  setBusy(true);
472
656
  setError(null);
473
657
  try {
474
- const response = await fetch(joinApiPath$1(apiBaseUrl, loginPath), {
658
+ const response = await fetch(joinApiPath$2(apiBaseUrl, loginPath), {
475
659
  method: "POST",
476
660
  headers: { "Content-Type": "application/json" },
477
661
  credentials: "include",
@@ -549,6 +733,125 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
549
733
  });
550
734
  }
551
735
  //#endregion
736
+ //#region packages/admin/auth/RegisterPage.tsx
737
+ function joinApiPath$1(apiBaseUrl, path) {
738
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
739
+ }
740
+ function initialValues(fields) {
741
+ return Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? ""]));
742
+ }
743
+ function RegisterPage({ fields, onRegistered, apiBaseUrl = "/api/", registerPath = "auth/register", title = "Create account", hint, submitLabel = "Create account", busyLabel = "Creating…", loginLink, loginPrompt = "Already have an account?" }) {
744
+ const [values, setValues] = useState(useMemo(() => initialValues(fields), [fields]));
745
+ const [error, setError] = useState(null);
746
+ const [busy, setBusy] = useState(false);
747
+ const setValue = (name, value) => {
748
+ setValues((current) => ({
749
+ ...current,
750
+ [name]: value
751
+ }));
752
+ };
753
+ const submit = async (event) => {
754
+ event.preventDefault();
755
+ setBusy(true);
756
+ setError(null);
757
+ try {
758
+ const response = await fetch(joinApiPath$1(apiBaseUrl, registerPath), {
759
+ method: "POST",
760
+ headers: { "Content-Type": "application/json" },
761
+ credentials: "include",
762
+ body: JSON.stringify(values)
763
+ });
764
+ const body = await response.json().catch(() => null);
765
+ if (!response.ok) {
766
+ setError(body?.error ?? body?.message ?? "Registration failed");
767
+ return;
768
+ }
769
+ onRegistered();
770
+ } catch {
771
+ setError("Network error");
772
+ } finally {
773
+ setBusy(false);
774
+ }
775
+ };
776
+ return /* @__PURE__ */ jsx("div", {
777
+ style: {
778
+ display: "grid",
779
+ placeItems: "center",
780
+ minHeight: "100vh"
781
+ },
782
+ children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("form", {
783
+ onSubmit: submit,
784
+ style: {
785
+ display: "flex",
786
+ flexDirection: "column",
787
+ gap: 12,
788
+ width: 360,
789
+ padding: 8
790
+ },
791
+ children: [
792
+ /* @__PURE__ */ jsx("h2", {
793
+ style: { margin: 0 },
794
+ children: title
795
+ }),
796
+ hint && /* @__PURE__ */ jsx("p", {
797
+ style: {
798
+ margin: 0,
799
+ color: "var(--text-secondary)"
800
+ },
801
+ children: hint
802
+ }),
803
+ fields.map((field) => {
804
+ const type = field.type ?? "text";
805
+ if (type === "select") return /* @__PURE__ */ jsx("select", {
806
+ value: values[field.name] ?? "",
807
+ onChange: (e) => setValue(field.name, e.target.value),
808
+ style: { width: "100%" },
809
+ "aria-label": field.placeholder ?? field.name,
810
+ children: (field.options ?? []).map((option) => /* @__PURE__ */ jsx("option", {
811
+ value: option.value,
812
+ children: option.label
813
+ }, option.value))
814
+ }, field.name);
815
+ return /* @__PURE__ */ jsx(TextField, {
816
+ placeholder: field.placeholder,
817
+ type: type === "password" ? "password" : type === "email" ? "email" : "text",
818
+ value: values[field.name] ?? "",
819
+ autoComplete: field.autoComplete,
820
+ onChange: (e) => setValue(field.name, e.target.value)
821
+ }, field.name);
822
+ }),
823
+ error && /* @__PURE__ */ jsx("p", {
824
+ style: {
825
+ margin: 0,
826
+ color: "var(--error-color, #dc2626)"
827
+ },
828
+ children: error
829
+ }),
830
+ /* @__PURE__ */ jsx(Button, {
831
+ variant: "primary",
832
+ type: "submit",
833
+ disabled: busy,
834
+ children: busy ? busyLabel : submitLabel
835
+ }),
836
+ loginLink && /* @__PURE__ */ jsxs("p", {
837
+ style: {
838
+ margin: 0,
839
+ color: "var(--text-secondary)"
840
+ },
841
+ children: [
842
+ loginPrompt,
843
+ " ",
844
+ /* @__PURE__ */ jsx(Link, {
845
+ to: loginLink.to,
846
+ children: loginLink.label
847
+ })
848
+ ]
849
+ })
850
+ ]
851
+ }) })
852
+ });
853
+ }
854
+ //#endregion
552
855
  //#region packages/admin/runtime/useAppRuntime.ts
553
856
  function useAppRuntime() {
554
857
  const [toasts, setToasts] = useState([]);
@@ -813,4 +1116,4 @@ function createNubitApp(config) {
813
1116
  return { App };
814
1117
  }
815
1118
  //#endregion
816
- export { AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, LoginPage, 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.22",
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/core": "^0.5.22",
57
- "@nubitio/crud": "^0.5.22",
58
- "@nubitio/hydra": "^0.5.22",
59
- "@nubitio/ui": "^0.5.22"
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
  }