@carlonicora/nextjs-jsonapi 2.2.3 → 2.4.0

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 (92) hide show
  1. package/dist/{BlockNoteEditor-QTIDATPF.js → BlockNoteEditor-7WGCH2KB.js} +27 -26
  2. package/dist/BlockNoteEditor-7WGCH2KB.js.map +1 -0
  3. package/dist/{BlockNoteEditor-ADT6II2V.mjs → BlockNoteEditor-GWAV5ESO.mjs} +12 -11
  4. package/dist/BlockNoteEditor-GWAV5ESO.mjs.map +1 -0
  5. package/dist/billing/index.d.mts +40 -1
  6. package/dist/billing/index.d.ts +40 -1
  7. package/dist/billing/index.js +519 -462
  8. package/dist/billing/index.js.map +1 -1
  9. package/dist/billing/index.mjs +298 -241
  10. package/dist/billing/index.mjs.map +1 -1
  11. package/dist/{chunk-7DJOQTNN.mjs → chunk-3LVSA7IM.mjs} +2 -2
  12. package/dist/{chunk-F3BPNQDB.js → chunk-GYGMEDVS.js} +1135 -952
  13. package/dist/chunk-GYGMEDVS.js.map +1 -0
  14. package/dist/{chunk-DLTMYGLR.mjs → chunk-LCCRUWWY.mjs} +13 -1
  15. package/dist/chunk-LCCRUWWY.mjs.map +1 -0
  16. package/dist/{chunk-YEPE2HT6.mjs → chunk-M2EGUO2F.mjs} +2538 -2355
  17. package/dist/chunk-M2EGUO2F.mjs.map +1 -0
  18. package/dist/{chunk-LANOKEFZ.js → chunk-TOKHHZSP.js} +7 -7
  19. package/dist/{chunk-LANOKEFZ.js.map → chunk-TOKHHZSP.js.map} +1 -1
  20. package/dist/{chunk-KSU6CCW6.js → chunk-V66AZWPG.js} +14 -2
  21. package/dist/chunk-V66AZWPG.js.map +1 -0
  22. package/dist/client/index.d.mts +1 -1
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.d.mts +129 -4
  27. package/dist/components/index.d.ts +129 -4
  28. package/dist/components/index.js +16 -4
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/index.mjs +15 -3
  31. package/dist/{content.fields-xH3TGvVk.d.mts → content.fields-1AlHDtDb.d.mts} +1 -0
  32. package/dist/{content.fields-xH3TGvVk.d.ts → content.fields-1AlHDtDb.d.ts} +1 -0
  33. package/dist/contexts/index.d.mts +22 -1
  34. package/dist/contexts/index.d.ts +22 -1
  35. package/dist/contexts/index.js +8 -4
  36. package/dist/contexts/index.js.map +1 -1
  37. package/dist/contexts/index.mjs +7 -3
  38. package/dist/core/index.d.mts +12 -2
  39. package/dist/core/index.d.ts +12 -2
  40. package/dist/core/index.js +4 -2
  41. package/dist/core/index.js.map +1 -1
  42. package/dist/core/index.mjs +3 -1
  43. package/dist/features/help/index.js +37 -37
  44. package/dist/features/help/index.mjs +3 -3
  45. package/dist/features/tokenusage/index.js +71 -71
  46. package/dist/features/tokenusage/index.mjs +3 -3
  47. package/dist/index.d.mts +2 -2
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.js +5 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/index.mjs +4 -2
  52. package/dist/server/index.js +3 -3
  53. package/dist/server/index.mjs +1 -1
  54. package/package.json +1 -1
  55. package/src/billing/index.ts +1 -0
  56. package/src/components/editors/BlockNoteEditor.tsx +12 -6
  57. package/src/components/index.ts +1 -0
  58. package/src/contexts/index.ts +1 -0
  59. package/src/features/administration/components/AdminIndexContainer.tsx +150 -0
  60. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +96 -0
  61. package/src/features/administration/components/index.ts +1 -0
  62. package/src/features/administration/contexts/AdministrationContext.tsx +47 -0
  63. package/src/features/administration/contexts/index.ts +1 -0
  64. package/src/features/administration/data/admin-section.types.ts +30 -0
  65. package/src/features/administration/i18n-keys.ts +26 -0
  66. package/src/features/administration/index.ts +3 -0
  67. package/src/features/assistant/components/containers/AssistantPageContainer.tsx +8 -1
  68. package/src/features/billing/contexts/BillingContext.tsx +95 -0
  69. package/src/features/billing/contexts/index.ts +1 -0
  70. package/src/features/billing/index.ts +1 -0
  71. package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +39 -27
  72. package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +27 -0
  73. package/src/features/billing/stripe-product/components/containers/index.ts +1 -0
  74. package/src/features/company/components/containers/CompaniesListContainer.tsx +25 -0
  75. package/src/features/company/components/containers/index.ts +1 -0
  76. package/src/features/company/components/lists/CompaniesList.tsx +12 -1
  77. package/src/features/company/contexts/CompanyContext.tsx +13 -1
  78. package/src/features/company/data/entity-ai-status.ts +13 -0
  79. package/src/features/company/data/index.ts +1 -0
  80. package/src/features/user/components/containers/PlatformUsersContainer.tsx +23 -0
  81. package/src/features/user/components/containers/index.ts +1 -0
  82. package/src/features/user/components/lists/PlatformUsersList.tsx +45 -0
  83. package/src/features/user/components/lists/index.ts +1 -0
  84. package/src/features/user/data/user.fields.ts +1 -0
  85. package/src/features/user/hooks/useUserTableStructure.tsx +17 -0
  86. package/dist/BlockNoteEditor-ADT6II2V.mjs.map +0 -1
  87. package/dist/BlockNoteEditor-QTIDATPF.js.map +0 -1
  88. package/dist/chunk-DLTMYGLR.mjs.map +0 -1
  89. package/dist/chunk-F3BPNQDB.js.map +0 -1
  90. package/dist/chunk-KSU6CCW6.js.map +0 -1
  91. package/dist/chunk-YEPE2HT6.mjs.map +0 -1
  92. /package/dist/{chunk-7DJOQTNN.mjs.map → chunk-3LVSA7IM.mjs.map} +0 -0
@@ -15,7 +15,7 @@ import {
15
15
  S3Service,
16
16
  UserService,
17
17
  checkPermissionsFromServer
18
- } from "../chunk-DLTMYGLR.mjs";
18
+ } from "../chunk-LCCRUWWY.mjs";
19
19
  import "../chunk-AUXK7QSA.mjs";
20
20
  import "../chunk-C7C7VY4F.mjs";
21
21
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "2.2.3",
3
+ "version": "2.4.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -1,5 +1,6 @@
1
1
  // Billing components - separate entry point to avoid loading Stripe.js globally
2
2
  export * from "../features/billing/components";
3
+ export * from "../features/billing/contexts";
3
4
  export * from "../features/billing/stripe-customer/components";
4
5
  export * from "../features/billing/stripe-invoice/components";
5
6
  export * from "../features/billing/stripe-price/components";
@@ -93,6 +93,11 @@ export type BlockNoteEditorProps = {
93
93
  // height and scrolls internally. Without it the editor grows to fit its
94
94
  // content and pushes the surrounding form to scroll instead.
95
95
  stretch?: boolean;
96
+ // Gates AI on top of `aiConfig` (credit availability, etc.). Defaults to
97
+ // true so existing callers that only pass `aiConfig` are unaffected. When
98
+ // false, no AI extension is created, the AI toolbar button and AI slash
99
+ // items are hidden, and the editor falls back to the plain slash menu.
100
+ aiEnabled?: boolean;
96
101
  };
97
102
 
98
103
  function isBlockEmpty(block: any): boolean {
@@ -408,6 +413,7 @@ export default function BlockNoteEditor({
408
413
  onWarmMentions,
409
414
  aiConfig,
410
415
  stretch,
416
+ aiEnabled = true,
411
417
  }: BlockNoteEditorProps): React.JSX.Element {
412
418
  const t = useTranslations();
413
419
  const locale = useI18nLocale();
@@ -483,7 +489,7 @@ export default function BlockNoteEditor({
483
489
 
484
490
  const companyId = company?.id;
485
491
  const aiExtension = useMemo(() => {
486
- if (!aiConfig) return undefined;
492
+ if (!aiConfig || !aiEnabled) return undefined;
487
493
  const base = getPublicApiUrl();
488
494
  const url = new URL(aiConfig.endpoint, base.endsWith("/") ? base : base + "/").toString();
489
495
  return AIExtension({
@@ -524,7 +530,7 @@ export default function BlockNoteEditor({
524
530
  },
525
531
  }),
526
532
  });
527
- }, [aiConfig, companyId, locale]);
533
+ }, [aiConfig, aiEnabled, companyId, locale]);
528
534
 
529
535
  const uploadImage = useCallback(
530
536
  async (file: File): Promise<string> => {
@@ -834,7 +840,7 @@ export default function BlockNoteEditor({
834
840
  onChange={handleChange}
835
841
  editable={onChange !== undefined}
836
842
  formattingToolbar={false}
837
- slashMenu={!aiConfig}
843
+ slashMenu={!aiConfig || !aiEnabled}
838
844
  // Follow the app theme: hardcoding "light" leaves dark (light-theme)
839
845
  // text on the dark card, which is nearly unreadable in dark mode.
840
846
  theme={resolvedTheme === "dark" ? "dark" : "light"}
@@ -854,7 +860,7 @@ export default function BlockNoteEditor({
854
860
  size === "sm" && "small",
855
861
  )}
856
862
  >
857
- <BlockNoteEditorFormattingToolbar showAI={!!aiConfig} />
863
+ <BlockNoteEditorFormattingToolbar showAI={!!aiConfig && aiEnabled} />
858
864
  {enableMentions && mentionSearchFn && (
859
865
  <BlockNoteEditorMentionSuggestionMenu
860
866
  editor={editor}
@@ -867,7 +873,7 @@ export default function BlockNoteEditor({
867
873
  {enableMentions && mentionResolveFn && (
868
874
  <BlockNoteEditorMentionHoverCard containerRef={editorRef} mentionResolveFn={mentionResolveFn} />
869
875
  )}
870
- {aiConfig && (
876
+ {aiConfig && aiEnabled && (
871
877
  <SuggestionMenuController
872
878
  triggerCharacter="/"
873
879
  getItems={async (query: string) =>
@@ -875,7 +881,7 @@ export default function BlockNoteEditor({
875
881
  }
876
882
  />
877
883
  )}
878
- {aiConfig && <AIMenuController aiMenu={() => <NarrAIMenu />} />}
884
+ {aiConfig && aiEnabled && <AIMenuController aiMenu={() => <NarrAIMenu />} />}
879
885
  </BlockNoteView>
880
886
  </div>
881
887
  );
@@ -20,6 +20,7 @@ export { parseFiscalData } from "../utils/fiscal-utils";
20
20
 
21
21
  // Feature components
22
22
  export * from "../features/auth/components";
23
+ export * from "../features/administration";
23
24
  // Billing components moved to separate entry point: @carlonicora/nextjs-jsonapi/billing
24
25
  export * from "../features/company/components";
25
26
  export * from "../features/content/components";
@@ -1,3 +1,4 @@
1
+ export * from "../features/administration/contexts";
1
2
  export * from "../features/company/contexts/CompanyContext";
2
3
  export * from "../features/notification/contexts/NotificationContext";
3
4
  export * from "../features/onboarding/contexts";
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import { Building2Icon, CoinsIcon, CreditCardIcon, ShieldCheckIcon, UsersIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { getStripePublishableKey } from "../../../client/config";
6
+ import { MicroLabel, RoundPageContainer } from "../../../components";
7
+ import { Link } from "../../../shadcnui";
8
+ import { AdministrationProvider } from "../contexts/AdministrationContext";
9
+ import type { AdminGroup, AdminSection } from "../data/admin-section.types";
10
+
11
+ /**
12
+ * The sections every NJA project has. Hrefs are constants rather than props:
13
+ * the path convention IS the contract that makes this component reusable, and
14
+ * it already exists in the package (see TOKEN_USAGE_ADMIN_PAGE_URL in
15
+ * features/tokenusage/contexts/TokenUsageAdminContext.tsx).
16
+ */
17
+ const PLATFORM_SECTIONS: AdminSection[] = [
18
+ {
19
+ key: "companies",
20
+ icon: Building2Icon,
21
+ labelKey: "entities.companies",
22
+ labelValues: { count: 2 },
23
+ descriptionKey: "administration.companies.description",
24
+ href: "/administration/companies",
25
+ },
26
+ {
27
+ key: "users",
28
+ icon: UsersIcon,
29
+ labelKey: "entities.users",
30
+ labelValues: { count: 2 },
31
+ descriptionKey: "administration.users.description",
32
+ href: "/administration/users",
33
+ },
34
+ {
35
+ key: "token-usage",
36
+ icon: CoinsIcon,
37
+ labelKey: "token_usage.admin.title",
38
+ descriptionKey: "administration.token_usage.description",
39
+ href: "/administration/token-usage",
40
+ },
41
+ {
42
+ key: "rbac",
43
+ icon: ShieldCheckIcon,
44
+ labelKey: "entities.rbac",
45
+ labelValues: { count: 2 },
46
+ descriptionKey: "administration.rbac.description",
47
+ href: "/administration/rbac",
48
+ },
49
+ ];
50
+
51
+ /**
52
+ * The only conditional entry. getStripePublishableKey comes from /client rather
53
+ * than isStripeConfigured() from /billing on purpose: /billing exists to keep
54
+ * Stripe.js out of the global bundle, and isStripeConfigured lives in
55
+ * StripeProvider.tsx, which imports @stripe/stripe-js at module scope. The two
56
+ * are the same check — isStripeConfigured is a one-line wrapper over this call.
57
+ */
58
+ const BILLING_SECTION: AdminSection = {
59
+ key: "billing",
60
+ icon: CreditCardIcon,
61
+ labelKey: "billing.admin.products.title",
62
+ descriptionKey: "administration.billing.description",
63
+ href: "/administration/billing",
64
+ };
65
+
66
+ /** Keys of the built-in platform sections, for `hiddenSections`. */
67
+ export type PlatformSectionKey = "companies" | "users" | "token-usage" | "rbac" | "billing";
68
+
69
+ type AdminIndexProps = {
70
+ /** Project-specific groups, appended after the built-in platform group. */
71
+ additionalGroups?: AdminGroup[];
72
+ /**
73
+ * Built-in sections to leave out. Every NJA project gets the same platform
74
+ * group by default, but a project whose backend does not wire a foundation
75
+ * must be able to drop its entry rather than advertise a route that throws —
76
+ * narr8, for instance, never calls `RbacModule.register()`, so it has no RBAC
77
+ * controllers at all.
78
+ */
79
+ hiddenSections?: PlatformSectionKey[];
80
+ };
81
+
82
+ /**
83
+ * The grid itself, with no page shell — this is what the specs render, because
84
+ * RoundPageContainer pulls in the app Header and its four header contexts.
85
+ */
86
+ export function AdminIndexGrid({ additionalGroups = [], hiddenSections = [] }: AdminIndexProps) {
87
+ const t = useTranslations();
88
+
89
+ const available = getStripePublishableKey() ? [...PLATFORM_SECTIONS, BILLING_SECTION] : PLATFORM_SECTIONS;
90
+
91
+ const platformGroup: AdminGroup = {
92
+ key: "platform",
93
+ labelKey: "administration.group.platform",
94
+ sections: available.filter((section) => !hiddenSections.includes(section.key as PlatformSectionKey)),
95
+ };
96
+
97
+ const groups = [platformGroup, ...additionalGroups].filter((group) => group.sections.length > 0);
98
+
99
+ return (
100
+ <div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 py-8">
101
+ {/* No page title here: AdministrationProvider feeds "Administration" to
102
+ RoundPageContainerTitle, which owns the page's name. Repeating it as a
103
+ role-1 title would triple up with that strip and the breadcrumb. */}
104
+ <p className="text-muted-foreground text-sm">{t("administration.subtitle")}</p>
105
+
106
+ {groups.map((group) => (
107
+ <div key={group.key}>
108
+ <MicroLabel as="h3" className="mb-2 px-3">
109
+ {t(group.labelKey, group.labelValues)}
110
+ </MicroLabel>
111
+ <div className="grid grid-cols-1 gap-1 md:grid-cols-2 lg:grid-cols-3">
112
+ {group.sections.map((section) => (
113
+ <Link
114
+ key={section.key}
115
+ href={section.href}
116
+ className="text-foreground font-normal"
117
+ data-testid={`admin-index-${section.key}`}
118
+ >
119
+ <div className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-md px-3 py-2">
120
+ <section.icon className="text-muted-foreground h-4 w-4 shrink-0" />
121
+ <div className="flex flex-col">
122
+ <span className="text-sm font-medium">{t(section.labelKey, section.labelValues)}</span>
123
+ <span className="text-muted-foreground text-xs">
124
+ {t(section.descriptionKey, section.descriptionValues)}
125
+ </span>
126
+ </div>
127
+ </div>
128
+ </Link>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Drop-in administration landing page. Mount it bare — it brings its own shell
139
+ * and its own page chrome (AdministrationProvider), so it does not inherit the
140
+ * title and breadcrumb of whatever page rendered before it.
141
+ */
142
+ export function AdminIndexContainer({ additionalGroups, hiddenSections }: AdminIndexProps) {
143
+ return (
144
+ <AdministrationProvider>
145
+ <RoundPageContainer>
146
+ <AdminIndexGrid additionalGroups={additionalGroups} hiddenSections={hiddenSections} />
147
+ </RoundPageContainer>
148
+ </AdministrationProvider>
149
+ );
150
+ }
@@ -0,0 +1,96 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { BookOpenIcon } from "lucide-react";
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { configureJsonApi } from "../../../../client/config";
5
+ import { configureI18n } from "../../../../i18n";
6
+ import { AdminIndexGrid } from "../AdminIndexContainer";
7
+ import type { AdminGroup } from "../../data/admin-section.types";
8
+
9
+ // next-intl is globally mocked in vitest.setup.ts with `t = (key) => key`, so
10
+ // every label below renders as its own key. That is what the assertions match.
11
+ const configure = (stripePublishableKey?: string) => {
12
+ configureJsonApi({ apiUrl: "http://api.test", stripePublishableKey });
13
+ configureI18n({
14
+ useRouter: () => ({
15
+ push: () => {},
16
+ replace: () => {},
17
+ back: () => {},
18
+ forward: () => {},
19
+ refresh: () => {},
20
+ prefetch: () => {},
21
+ }),
22
+ useTranslations: () => (key: string) => key,
23
+ usePathname: () => "/administration",
24
+ Link: ({ href, children, ...props }: any) => (
25
+ <a href={href} {...props}>
26
+ {children}
27
+ </a>
28
+ ),
29
+ });
30
+ };
31
+
32
+ const extraGroup: AdminGroup = {
33
+ key: "documentation",
34
+ labelKey: "administration.group.documentation",
35
+ sections: [
36
+ {
37
+ key: "howtos",
38
+ icon: BookOpenIcon,
39
+ labelKey: "entities.howtos",
40
+ labelValues: { count: 2 },
41
+ descriptionKey: "administration.howtos.description",
42
+ href: "/administration/howtos",
43
+ },
44
+ ],
45
+ };
46
+
47
+ describe("AdminIndexGrid", () => {
48
+ beforeEach(() => configure());
49
+
50
+ it("renders the four unconditional platform entries with their hrefs", () => {
51
+ render(<AdminIndexGrid />);
52
+
53
+ expect(screen.getByTestId("admin-index-companies")).toHaveAttribute("href", "/administration/companies");
54
+ expect(screen.getByTestId("admin-index-users")).toHaveAttribute("href", "/administration/users");
55
+ expect(screen.getByTestId("admin-index-token-usage")).toHaveAttribute("href", "/administration/token-usage");
56
+ expect(screen.getByTestId("admin-index-rbac")).toHaveAttribute("href", "/administration/rbac");
57
+ });
58
+
59
+ it("omits the billing entry when no Stripe publishable key is configured", () => {
60
+ render(<AdminIndexGrid />);
61
+
62
+ expect(screen.queryByTestId("admin-index-billing")).not.toBeInTheDocument();
63
+ });
64
+
65
+ it("includes the billing entry when a Stripe publishable key is configured", () => {
66
+ configure("pk_test_123");
67
+ render(<AdminIndexGrid />);
68
+
69
+ expect(screen.getByTestId("admin-index-billing")).toHaveAttribute("href", "/administration/billing");
70
+ });
71
+
72
+ it("appends additionalGroups after the platform group, in order", () => {
73
+ render(<AdminIndexGrid additionalGroups={[extraGroup]} />);
74
+
75
+ const headings = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
76
+ expect(headings).toEqual(["administration.group.platform", "administration.group.documentation"]);
77
+ expect(screen.getByTestId("admin-index-howtos")).toHaveAttribute("href", "/administration/howtos");
78
+ });
79
+
80
+ it("skips a group that has no sections", () => {
81
+ render(
82
+ <AdminIndexGrid additionalGroups={[{ key: "empty", labelKey: "administration.group.empty", sections: [] }]} />,
83
+ );
84
+
85
+ expect(screen.queryByText("administration.group.empty")).not.toBeInTheDocument();
86
+ });
87
+
88
+ it("renders the subtitle but not a page title of its own", () => {
89
+ render(<AdminIndexGrid />);
90
+
91
+ expect(screen.getByText("administration.subtitle")).toBeInTheDocument();
92
+ // The page's name comes from AdministrationProvider via
93
+ // RoundPageContainerTitle, so the grid must not repeat it.
94
+ expect(screen.queryByText("administration.title")).not.toBeInTheDocument();
95
+ });
96
+ });
@@ -0,0 +1 @@
1
+ export { AdminIndexContainer, AdminIndexGrid } from "./AdminIndexContainer";
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useContext } from "react";
5
+ import { SharedProvider } from "../../../contexts/SharedContext";
6
+ import { BreadcrumbItemData } from "../../../interfaces";
7
+
8
+ interface AdministrationContextType {}
9
+
10
+ const AdministrationContext = createContext<AdministrationContextType | undefined>(undefined);
11
+
12
+ type AdministrationProviderProps = {
13
+ children: ReactNode;
14
+ };
15
+
16
+ /**
17
+ * Supplies the page chrome for every administration surface.
18
+ *
19
+ * `RoundPageContainerTitle` and the header breadcrumb both read
20
+ * `useSharedContext()`, and that context is provided far up the tree by the
21
+ * layout — so an administration page that provides nothing of its own silently
22
+ * inherits whatever the *previously rendered* page put there (a company name, a
23
+ * "Notifications" title). Wrapping in this provider replaces that value, which
24
+ * is why every administration container mounts inside it.
25
+ *
26
+ * Mirrors CommonProvider (contexts/CommonContext.tsx), which does the same job
27
+ * for the ordinary application shell.
28
+ */
29
+ export const AdministrationProvider = ({ children }: AdministrationProviderProps) => {
30
+ const t = useTranslations();
31
+
32
+ const breadcrumbs: BreadcrumbItemData[] = [];
33
+
34
+ return (
35
+ <SharedProvider value={{ breadcrumbs, title: { type: t(`administration.title`) } }}>
36
+ <AdministrationContext.Provider value={{}}>{children}</AdministrationContext.Provider>
37
+ </SharedProvider>
38
+ );
39
+ };
40
+
41
+ export const useAdministrationContext = (): AdministrationContextType => {
42
+ const context = useContext(AdministrationContext);
43
+ if (context === undefined) {
44
+ throw new Error("useAdministrationContext must be used within an AdministrationProvider");
45
+ }
46
+ return context;
47
+ };
@@ -0,0 +1 @@
1
+ export * from "./AdministrationContext";
@@ -0,0 +1,30 @@
1
+ import { LucideIcon } from "lucide-react";
2
+
3
+ /**
4
+ * One row of the administration index: an icon, a translated label and
5
+ * description, and the route it opens.
6
+ *
7
+ * Labels and descriptions are i18n *keys*, never resolved strings, so
8
+ * AdminIndexGrid stays the single place that calls useTranslations().
9
+ * `labelValues` / `descriptionValues` carry ICU arguments — `{ count: 2 }` for
10
+ * the `entities.*` plural keys, or `{ type: "..." }` for an app reusing its own
11
+ * parameterised heading key.
12
+ */
13
+ export type AdminSection = {
14
+ /** Stable React key, and the `admin-index-<key>` test id on the rendered link. */
15
+ key: string;
16
+ icon: LucideIcon;
17
+ labelKey: string;
18
+ labelValues?: Record<string, string | number>;
19
+ descriptionKey: string;
20
+ descriptionValues?: Record<string, string | number>;
21
+ href: string;
22
+ };
23
+
24
+ /** A titled block of sections. The built-in "platform" group always renders first. */
25
+ export type AdminGroup = {
26
+ key: string;
27
+ labelKey: string;
28
+ labelValues?: Record<string, string | number>;
29
+ sections: AdminSection[];
30
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The fixed namespace of i18n keys the administration index reads via
3
+ * useTranslations. Consuming apps must define each entry in their
4
+ * messages/<locale>.json — this list is the contract between the package and
5
+ * the app, and the names are frozen: an app that wants different wording
6
+ * changes its own translation, never the key.
7
+ *
8
+ * Section LABELS are deliberately absent: they reuse keys every NJA app already
9
+ * defines — `entities.companies`, `entities.users`, `entities.rbac` (all with
10
+ * `{ count: 2 }`), `token_usage.admin.title`, and
11
+ * `billing.admin.products.title` — so section names stay in each app's own
12
+ * vocabulary.
13
+ *
14
+ * Mirrors the contract shipped by the token-usage feature
15
+ * (features/tokenusage/i18n-keys.ts).
16
+ */
17
+ export const ADMINISTRATION_I18N_KEYS = [
18
+ "administration.title",
19
+ "administration.subtitle",
20
+ "administration.group.platform",
21
+ "administration.companies.description",
22
+ "administration.users.description",
23
+ "administration.token_usage.description",
24
+ "administration.rbac.description",
25
+ "administration.billing.description",
26
+ ] as const;
@@ -0,0 +1,3 @@
1
+ export * from "./components";
2
+ export * from "./data/admin-section.types";
3
+ export { ADMINISTRATION_I18N_KEYS } from "./i18n-keys";
@@ -31,6 +31,12 @@ interface Props {
31
31
  /** Rendered inside the sidebar's details panel above the thread list. */
32
32
  detailsTitle: string;
33
33
  detailsIcon: React.ReactNode;
34
+ /**
35
+ * Disables the composer for a reason external to this component (e.g. the
36
+ * consuming app's credit gate) — ORed with the in-flight `ctx.sending`
37
+ * disable, never replacing it.
38
+ */
39
+ disabled?: boolean;
34
40
  }
35
41
 
36
42
  /**
@@ -47,6 +53,7 @@ export function AssistantPageContainer({
47
53
  suggestionMenuComponent,
48
54
  detailsTitle,
49
55
  detailsIcon,
56
+ disabled,
50
57
  }: Props) {
51
58
  const t = useTranslations();
52
59
  const ctx = useAssistantContext();
@@ -111,7 +118,7 @@ export function AssistantPageContainer({
111
118
  )}
112
119
  <AssistantBlockNoteComposer
113
120
  onSend={(content, opts) => ctx.sendMessage(content, { contentBlocks: opts.contentBlocks })}
114
- disabled={ctx.sending}
121
+ disabled={ctx.sending || disabled}
115
122
  mentionSearchFn={mentionSearchFn}
116
123
  mentionSearchParams={mentionSearchParams}
117
124
  mentionResolveFn={mentionResolveFn}
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useCallback, useContext, useState } from "react";
5
+ import { SharedProvider } from "../../../contexts/SharedContext";
6
+ import { BreadcrumbItemData } from "../../../interfaces";
7
+ import { getRoleId } from "../../../roles";
8
+ import { Button } from "../../../shadcnui";
9
+ import { useCurrentUserContext } from "../../user/contexts";
10
+ import { ProductEditor } from "../stripe-product/components/forms/ProductEditor";
11
+
12
+ interface BillingContextType {
13
+ /**
14
+ * Bumped every time the provider's own actions change the product catalogue.
15
+ * ProductsAdminContainer reloads on it — the create action lives up here in
16
+ * the page title bar, so it cannot call the container's loader directly.
17
+ */
18
+ refreshToken: number;
19
+ refresh: () => void;
20
+ }
21
+
22
+ const BillingContext = createContext<BillingContextType | undefined>(undefined);
23
+
24
+ type BillingProviderProps = {
25
+ children: ReactNode;
26
+ };
27
+
28
+ /**
29
+ * Page chrome for the administrative billing surface (Stripe products, prices
30
+ * and their credit allowances).
31
+ *
32
+ * Billing gets its own provider rather than borrowing AdministrationProvider so
33
+ * the page carries its own name — and, like CompanyProvider with CompanyEditor,
34
+ * it owns the page's ACTIONS: the create-product control is published through
35
+ * `title.functions`, which RoundPageContainerTitle renders in the header bar.
36
+ * That is where every other page in this codebase puts its primary action.
37
+ */
38
+ export const BillingProvider = ({ children }: BillingProviderProps) => {
39
+ const t = useTranslations();
40
+ const { hasRole } = useCurrentUserContext();
41
+ const [showCreateProduct, setShowCreateProduct] = useState<boolean>(false);
42
+ const [refreshToken, setRefreshToken] = useState<number>(0);
43
+
44
+ const refresh = useCallback(() => setRefreshToken((value) => value + 1), []);
45
+
46
+ // No href: this crumb IS the current page. Linking it would also point at the
47
+ // StripeProduct module's `pageUrl` (/stripe-products), which no consuming app
48
+ // routes — the page lives under the administration tree.
49
+ const breadcrumbs: BreadcrumbItemData[] = [{ name: t(`billing.admin.products.title`) }];
50
+
51
+ const title = () => {
52
+ const response: any = { type: t(`billing.admin.products.title`) };
53
+
54
+ if (hasRole(getRoleId().Administrator)) {
55
+ response.functions = [
56
+ <Button key="productEditorTrigger" onClick={() => setShowCreateProduct(true)}>
57
+ {t(`billing.admin.products.create`)}
58
+ </Button>,
59
+ showCreateProduct ? (
60
+ <ProductEditor
61
+ key="productEditor"
62
+ open={showCreateProduct}
63
+ onOpenChange={setShowCreateProduct}
64
+ onSuccess={() => {
65
+ setShowCreateProduct(false);
66
+ refresh();
67
+ }}
68
+ />
69
+ ) : null,
70
+ ];
71
+ }
72
+
73
+ return response;
74
+ };
75
+
76
+ return (
77
+ <SharedProvider value={{ breadcrumbs, title: title() }}>
78
+ <BillingContext.Provider value={{ refreshToken, refresh }}>{children}</BillingContext.Provider>
79
+ </SharedProvider>
80
+ );
81
+ };
82
+
83
+ export const useBillingContext = (): BillingContextType => {
84
+ const context = useContext(BillingContext);
85
+ if (context === undefined) {
86
+ throw new Error("useBillingContext must be used within a BillingProvider");
87
+ }
88
+ return context;
89
+ };
90
+
91
+ /**
92
+ * Non-throwing variant. ProductsAdminContainer predates this provider and is
93
+ * still mounted bare by other consumers, so it must keep working without one.
94
+ */
95
+ export const useOptionalBillingContext = (): BillingContextType | undefined => useContext(BillingContext);
@@ -0,0 +1 @@
1
+ export * from "./BillingContext";
@@ -1,3 +1,4 @@
1
1
  export * from "./components";
2
+ export * from "./contexts";
2
3
  export * from "./data";
3
4
  export * from "./modules";