@carlonicora/nextjs-jsonapi 1.131.1 → 1.132.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 (79) hide show
  1. package/dist/{BlockNoteEditor-BR3IRGJW.js → BlockNoteEditor-JAELO23S.js} +19 -19
  2. package/dist/{BlockNoteEditor-BR3IRGJW.js.map → BlockNoteEditor-JAELO23S.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-PVBZYPMF.mjs → BlockNoteEditor-WZFEJS2B.mjs} +4 -4
  4. package/dist/billing/index.js +357 -357
  5. package/dist/billing/index.mjs +3 -3
  6. package/dist/{chunk-LBMNRFCY.js → chunk-5IE6DZ2D.js} +153 -77
  7. package/dist/chunk-5IE6DZ2D.js.map +1 -0
  8. package/dist/{chunk-MDXT47V4.mjs → chunk-67LIVKYU.mjs} +198 -203
  9. package/dist/chunk-67LIVKYU.mjs.map +1 -0
  10. package/dist/{chunk-5MNE72ZE.mjs → chunk-DZL2G7NB.mjs} +77 -1
  11. package/dist/chunk-DZL2G7NB.mjs.map +1 -0
  12. package/dist/{chunk-XTLTQQ7X.js → chunk-F6KBHC2X.js} +7 -7
  13. package/dist/{chunk-XTLTQQ7X.js.map → chunk-F6KBHC2X.js.map} +1 -1
  14. package/dist/{chunk-5SQFK35K.js → chunk-MP5VURBB.js} +1028 -1033
  15. package/dist/chunk-MP5VURBB.js.map +1 -0
  16. package/dist/{chunk-NATPK5ME.mjs → chunk-VSAAVVHY.mjs} +2 -2
  17. package/dist/client/index.js +4 -4
  18. package/dist/client/index.mjs +3 -3
  19. package/dist/components/index.d.mts +3 -2
  20. package/dist/components/index.d.ts +3 -2
  21. package/dist/components/index.js +4 -4
  22. package/dist/components/index.mjs +3 -3
  23. package/dist/contexts/index.d.mts +9 -2
  24. package/dist/contexts/index.d.ts +9 -2
  25. package/dist/contexts/index.js +6 -4
  26. package/dist/contexts/index.js.map +1 -1
  27. package/dist/contexts/index.mjs +5 -3
  28. package/dist/core/index.d.mts +11 -1
  29. package/dist/core/index.d.ts +11 -1
  30. package/dist/core/index.js +12 -2
  31. package/dist/core/index.js.map +1 -1
  32. package/dist/core/index.mjs +13 -3
  33. package/dist/features/help/index.js +39 -39
  34. package/dist/features/help/index.js.map +1 -1
  35. package/dist/features/help/index.mjs +5 -5
  36. package/dist/features/help/index.mjs.map +1 -1
  37. package/dist/index.d.mts +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.js +13 -3
  40. package/dist/index.js.map +1 -1
  41. package/dist/index.mjs +14 -4
  42. package/dist/server/index.js +3 -3
  43. package/dist/server/index.mjs +1 -1
  44. package/package.json +1 -1
  45. package/src/components/containers/ReactMarkdownContainer.tsx +1 -1
  46. package/src/components/containers/RoundPageContainer.tsx +7 -0
  47. package/src/components/forms/EditorSheet.tsx +24 -1
  48. package/src/components/forms/FormDateTime.tsx +5 -0
  49. package/src/components/navigations/Breadcrumb.tsx +13 -11
  50. package/src/components/navigations/Header.tsx +3 -1
  51. package/src/contexts/HeaderChildrenContext.tsx +17 -2
  52. package/src/features/assistant-message/components/MessageItem.tsx +1 -1
  53. package/src/features/auth/components/GdprConsentSection.tsx +2 -2
  54. package/src/features/auth/components/forms/Register.tsx +2 -2
  55. package/src/features/help/components/HelpArticleBody.tsx +2 -2
  56. package/src/features/notification/components/common/NotificationErrorBoundary.tsx +1 -4
  57. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +34 -8
  58. package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +2 -2
  59. package/src/features/user/contexts/CurrentUserContext.tsx +43 -9
  60. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +149 -6
  61. package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -2
  62. package/src/features/waitlist/components/lists/WaitlistList.tsx +13 -2
  63. package/src/shadcnui/custom/link.tsx +1 -1
  64. package/src/shadcnui/ui/accordion.tsx +1 -1
  65. package/src/shadcnui/ui/alert-dialog.tsx +1 -1
  66. package/src/shadcnui/ui/alert.tsx +2 -5
  67. package/src/shadcnui/ui/badge.tsx +1 -1
  68. package/src/shadcnui/ui/button.tsx +1 -1
  69. package/src/shadcnui/ui/dialog.tsx +1 -4
  70. package/src/shadcnui/ui/field.tsx +1 -1
  71. package/src/utils/__tests__/italian-validators.test.ts +63 -0
  72. package/src/utils/index.ts +8 -0
  73. package/src/utils/italian-validators.ts +18 -5
  74. package/dist/chunk-5MNE72ZE.mjs.map +0 -1
  75. package/dist/chunk-5SQFK35K.js.map +0 -1
  76. package/dist/chunk-LBMNRFCY.js.map +0 -1
  77. package/dist/chunk-MDXT47V4.mjs.map +0 -1
  78. /package/dist/{BlockNoteEditor-PVBZYPMF.mjs.map → BlockNoteEditor-WZFEJS2B.mjs.map} +0 -0
  79. /package/dist/{chunk-NATPK5ME.mjs.map → chunk-VSAAVVHY.mjs.map} +0 -0
@@ -19,18 +19,18 @@ import {
19
19
  Breadcrumb as UIBreadcrumb,
20
20
  } from "../../shadcnui";
21
21
 
22
- type BreadcrumbProps = { items: BreadcrumbItemData[] };
22
+ type BreadcrumbProps = { items: BreadcrumbItemData[]; rootLabel?: string };
23
23
 
24
24
  const ITEMS_TO_DISPLAY = 4;
25
25
 
26
26
  function BreadcrumbDesktop({
27
27
  items,
28
28
  generateUrl,
29
- t,
29
+ rootLabel,
30
30
  }: {
31
31
  items: BreadcrumbItemData[];
32
32
  generateUrl: ReturnType<typeof usePageUrlGenerator>;
33
- t: ReturnType<typeof useTranslations>;
33
+ rootLabel: string;
34
34
  }) {
35
35
  const [open, setOpen] = useState<boolean>(false);
36
36
 
@@ -38,7 +38,7 @@ function BreadcrumbDesktop({
38
38
  <UIBreadcrumb>
39
39
  <BreadcrumbList>
40
40
  <BreadcrumbItem>
41
- <Link href={generateUrl({ page: `/` })}>{t(`common.home`)}</Link>
41
+ <Link href={generateUrl({ page: `/` })}>{rootLabel}</Link>
42
42
  </BreadcrumbItem>
43
43
  {items.length > 0 && <BreadcrumbSeparator />}
44
44
 
@@ -112,23 +112,23 @@ function BreadcrumbDesktop({
112
112
  function BreadcrumbMobile({
113
113
  items,
114
114
  generateUrl,
115
- t,
115
+ rootLabel,
116
116
  }: {
117
117
  items: BreadcrumbItemData[];
118
118
  generateUrl: ReturnType<typeof usePageUrlGenerator>;
119
- t: ReturnType<typeof useTranslations>;
119
+ rootLabel: string;
120
120
  }) {
121
121
  const [open, setOpen] = useState<boolean>(false);
122
122
 
123
123
  const lastItem = items[items.length - 1];
124
- const allItems = [{ name: t(`common.home`), href: generateUrl({ page: `/` }) }, ...items];
124
+ const allItems = [{ name: rootLabel, href: generateUrl({ page: `/` }) }, ...items];
125
125
 
126
126
  if (!lastItem && items.length === 0) {
127
127
  return (
128
128
  <UIBreadcrumb>
129
129
  <BreadcrumbList>
130
130
  <BreadcrumbItem>
131
- <Link href={generateUrl({ page: `/` })}>{t(`common.home`)}</Link>
131
+ <Link href={generateUrl({ page: `/` })}>{rootLabel}</Link>
132
132
  </BreadcrumbItem>
133
133
  </BreadcrumbList>
134
134
  </UIBreadcrumb>
@@ -158,14 +158,16 @@ function BreadcrumbMobile({
158
158
  );
159
159
  }
160
160
 
161
- export function BreadcrumbNavigation({ items }: BreadcrumbProps) {
161
+ export function BreadcrumbNavigation({ items, rootLabel }: BreadcrumbProps) {
162
162
  const generateUrl = usePageUrlGenerator();
163
163
  const t = useTranslations();
164
164
  const isMobile = useIsMobile();
165
165
 
166
+ const root = rootLabel?.trim() ? rootLabel : t(`common.home`);
167
+
166
168
  if (isMobile) {
167
- return <BreadcrumbMobile items={items} generateUrl={generateUrl} t={t} />;
169
+ return <BreadcrumbMobile items={items} generateUrl={generateUrl} rootLabel={root} />;
168
170
  }
169
171
 
170
- return <BreadcrumbDesktop items={items} generateUrl={generateUrl} t={t} />;
172
+ return <BreadcrumbDesktop items={items} generateUrl={generateUrl} rootLabel={root} />;
171
173
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useIsMobile } from "@/utils";
4
+ import { useHeaderRootLabel } from "../../contexts/HeaderChildrenContext";
4
5
  import { useSharedContext } from "../../contexts/SharedContext";
5
6
  import { SidebarTrigger } from "../../shadcnui";
6
7
  import { BreadcrumbNavigation } from "./Breadcrumb";
@@ -13,6 +14,7 @@ type HeaderProps = {
13
14
 
14
15
  export function Header({ children, leftContent, className }: HeaderProps) {
15
16
  const { breadcrumbs } = useSharedContext();
17
+ const rootLabel = useHeaderRootLabel();
16
18
  const isMobile = useIsMobile();
17
19
 
18
20
  return (
@@ -21,7 +23,7 @@ export function Header({ children, leftContent, className }: HeaderProps) {
21
23
  <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />
22
24
  {leftContent}
23
25
  <div className="flex w-full flex-row items-center justify-start">
24
- <BreadcrumbNavigation items={breadcrumbs} />
26
+ <BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} />
25
27
  </div>
26
28
  {!isMobile && children && (
27
29
  <div className="flex w-64 flex-row items-center justify-end gap-x-4 whitespace-nowrap">
@@ -4,24 +4,30 @@ import { createContext, useContext, ReactNode } from "react";
4
4
 
5
5
  interface HeaderChildrenContextType {
6
6
  headerChildren: ReactNode | null;
7
+ headerRootLabel: string | null;
7
8
  }
8
9
 
9
10
  const HeaderChildrenContext = createContext<HeaderChildrenContextType>({
10
11
  headerChildren: null,
12
+ headerRootLabel: null,
11
13
  });
12
14
 
13
15
  interface HeaderChildrenProviderProps {
14
16
  children: ReactNode;
15
17
  content: ReactNode;
18
+ rootLabel?: string;
16
19
  }
17
20
 
18
21
  /**
19
22
  * Provider to supply custom content to be rendered in the Header component.
20
23
  * Wrap your layout with this provider and pass the content you want in the header.
24
+ * Optionally pass `rootLabel` to override the first breadcrumb entry (defaults to `common.home`).
21
25
  */
22
- export function HeaderChildrenProvider({ children, content }: HeaderChildrenProviderProps) {
26
+ export function HeaderChildrenProvider({ children, content, rootLabel }: HeaderChildrenProviderProps) {
23
27
  return (
24
- <HeaderChildrenContext.Provider value={{ headerChildren: content }}>{children}</HeaderChildrenContext.Provider>
28
+ <HeaderChildrenContext.Provider value={{ headerChildren: content, headerRootLabel: rootLabel ?? null }}>
29
+ {children}
30
+ </HeaderChildrenContext.Provider>
25
31
  );
26
32
  }
27
33
 
@@ -33,3 +39,12 @@ export function useHeaderChildren(): ReactNode | null {
33
39
  const context = useContext(HeaderChildrenContext);
34
40
  return context.headerChildren;
35
41
  }
42
+
43
+ /**
44
+ * Hook to get the label of the first breadcrumb entry, when the application supplies one.
45
+ * Returns null when no override was provided, in which case the Header falls back to `common.home`.
46
+ */
47
+ export function useHeaderRootLabel(): string | null {
48
+ const context = useContext(HeaderChildrenContext);
49
+ return context.headerRootLabel;
50
+ }
@@ -46,7 +46,7 @@ export function MessageItem({
46
46
  <div className="text-destructive flex items-center gap-2 text-xs">
47
47
  <AlertCircle className="h-3.5 w-3.5" />
48
48
  <span>{t("features.assistant.send_failed")}</span>
49
- <button type="button" className="underline" onClick={() => onRetry?.(message.id)}>
49
+ <button type="button" onClick={() => onRetry?.(message.id)}>
50
50
  {t("features.assistant.retry")}
51
51
  </button>
52
52
  </div>
@@ -21,11 +21,11 @@ export function GdprConsentSection<T extends FieldValues>({
21
21
  const termsLabel = (
22
22
  <>
23
23
  {t("terms_prefix")}{" "}
24
- <Link href="/terms" target="_blank" rel="noopener" className="underline">
24
+ <Link href="/terms" target="_blank" rel="noopener">
25
25
  {t("terms_of_service")}
26
26
  </Link>{" "}
27
27
  {t("and")}{" "}
28
- <Link href="/privacy" target="_blank" rel="noopener" className="underline">
28
+ <Link href="/privacy" target="_blank" rel="noopener">
29
29
  {t("privacy_policy")}
30
30
  </Link>
31
31
  </>
@@ -194,7 +194,7 @@ export default function Register() {
194
194
  <CardContent className="text-center">
195
195
  <p className="text-destructive mb-4">{inviteError}</p>
196
196
  <p className="mb-4">{t("waitlist.invite.join_prompt")}</p>
197
- <Link href="/waitlist" className="text-primary underline">
197
+ <Link href="/waitlist" className="text-primary">
198
198
  {t("waitlist.invite.join_link")}
199
199
  </Link>
200
200
  </CardContent>
@@ -215,7 +215,7 @@ export default function Register() {
215
215
  <CardContent className="text-center">
216
216
  <p className="mb-4">{t("waitlist.invite.registration_description")}</p>
217
217
  <p className="mb-4">{t("waitlist.invite.registration_hint")}</p>
218
- <Link href="/waitlist" className="text-primary underline">
218
+ <Link href="/waitlist" className="text-primary">
219
219
  {t("waitlist.invite.join_link")}
220
220
  </Link>
221
221
  </CardContent>
@@ -43,14 +43,14 @@ export function HelpArticleBody(props: {
43
43
  ) : null}
44
44
  <div className="mt-4 flex justify-between text-sm">
45
45
  {prev ? (
46
- <Link href={generateUrl({ page: `/help/${prev.howToType}/${prev.slug}` })} className="hover:underline">
46
+ <Link href={generateUrl({ page: `/help/${prev.howToType}/${prev.slug}` })}>
47
47
  ← {t("help.article.previous")}: {prev.title}
48
48
  </Link>
49
49
  ) : (
50
50
  <span />
51
51
  )}
52
52
  {next ? (
53
- <Link href={generateUrl({ page: `/help/${next.howToType}/${next.slug}` })} className="hover:underline">
53
+ <Link href={generateUrl({ page: `/help/${next.howToType}/${next.slug}` })}>
54
54
  {t("help.article.next")}: {next.title} →
55
55
  </Link>
56
56
  ) : (
@@ -34,10 +34,7 @@ export class NotificationErrorBoundary extends Component<Props, State> {
34
34
  <div className="flex items-center justify-center p-4 text-center">
35
35
  <div className="text-muted-foreground text-sm">
36
36
  <p>Something went wrong with notifications.</p>
37
- <button
38
- onClick={() => this.setState({ hasError: false })}
39
- className="text-primary mt-2 underline hover:no-underline"
40
- >
37
+ <button onClick={() => this.setState({ hasError: false })} className="text-primary mt-2">
41
38
  Try again
42
39
  </button>
43
40
  </div>
@@ -2,12 +2,12 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
2
2
  import { renderHook, act } from "@testing-library/react";
3
3
  import { configureI18n } from "../../../../i18n";
4
4
 
5
- // currentUser undefined → the provider's initial-load effect is a no-op
6
- // (it returns early on `!currentUser`), so the ONLY fetches in this test come
7
- // from the explicit loadNotifications() calls below. This isolates the
8
- // in-flight de-duplication behaviour from the mount-time auto-load.
5
+ // The provider's initial-load effect returns early on `!currentUser`. Tests set
6
+ // `userMock.currentUser` to choose whether the mount-time auto-load fires.
7
+ const userMock = vi.hoisted(() => ({ currentUser: undefined as any }));
8
+
9
9
  vi.mock("../../../user/contexts/CurrentUserContext", () => ({
10
- useCurrentUserContext: () => ({ currentUser: undefined }),
10
+ useCurrentUserContext: () => userMock,
11
11
  }));
12
12
  vi.mock("../../data/notification.service", () => ({
13
13
  NotificationService: { findMany: vi.fn(), markAsRead: vi.fn() },
@@ -41,6 +41,7 @@ describe("NotificationContext.loadNotifications", () => {
41
41
  });
42
42
 
43
43
  beforeEach(() => {
44
+ userMock.currentUser = undefined;
44
45
  vi.mocked(NotificationService.findMany).mockReset();
45
46
  });
46
47
 
@@ -55,9 +56,9 @@ describe("NotificationContext.loadNotifications", () => {
55
56
 
56
57
  const { result } = renderHook(() => useNotificationContext(), { wrapper });
57
58
 
58
- // Three concurrent callers, mirroring the three mount-time triggers
59
- // (provider + LayoutDetails + NotificationModal) firing before the first
60
- // request resolves.
59
+ // Three concurrent callers, mirroring the mount-time triggers (provider +
60
+ // NotificationModal) plus any consumer that loads on mount, all firing
61
+ // before the first request resolves.
61
62
  await act(async () => {
62
63
  result.current.loadNotifications();
63
64
  result.current.loadNotifications();
@@ -68,3 +69,28 @@ describe("NotificationContext.loadNotifications", () => {
68
69
  expect(NotificationService.findMany).toHaveBeenCalledTimes(1);
69
70
  });
70
71
  });
72
+
73
+ describe("NotificationContextProvider mount load", () => {
74
+ beforeEach(() => {
75
+ vi.mocked(NotificationService.findMany).mockReset();
76
+ vi.mocked(NotificationService.findMany).mockResolvedValue([] as never);
77
+ });
78
+
79
+ it("loads notifications exactly once on mount when a user is present", async () => {
80
+ userMock.currentUser = { id: "user-1", roles: [] };
81
+
82
+ const { rerender } = renderHook(() => useNotificationContext(), { wrapper });
83
+
84
+ await act(async () => {
85
+ await new Promise((resolve) => setTimeout(resolve, 0));
86
+ });
87
+
88
+ rerender();
89
+
90
+ await act(async () => {
91
+ await new Promise((resolve) => setTimeout(resolve, 0));
92
+ });
93
+
94
+ expect(NotificationService.findMany).toHaveBeenCalledTimes(1);
95
+ });
96
+ });
@@ -111,11 +111,11 @@ export function OAuthConsentScreen({
111
111
  <CardFooter className="justify-center">
112
112
  <p className="text-xs text-center text-muted-foreground">
113
113
  By authorizing, you agree to the app's{" "}
114
- <a href={termsUrl} className="underline hover:text-foreground" target="_blank" rel="noopener">
114
+ <a href={termsUrl} className="hover:text-foreground" target="_blank" rel="noopener">
115
115
  Terms of Service
116
116
  </a>{" "}
117
117
  and{" "}
118
- <a href={privacyUrl} className="underline hover:text-foreground" target="_blank" rel="noopener">
118
+ <a href={privacyUrl} className="hover:text-foreground" target="_blank" rel="noopener">
119
119
  Privacy Policy
120
120
  </a>
121
121
  .
@@ -3,7 +3,7 @@
3
3
  import { useAtom } from "jotai";
4
4
  import { atomWithStorage } from "jotai/utils";
5
5
  import { usePathname } from "next/navigation";
6
- import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
6
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { useSocketContext } from "../../../contexts/SocketContext";
8
8
  import { Modules, rehydrate } from "../../../core";
9
9
  import { Action, checkPermissions, ModuleWithPermissions } from "../../../permissions";
@@ -66,13 +66,31 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
66
66
  return descriptor?.get?.call(Modules);
67
67
  };
68
68
 
69
- const currentUser = dehydratedUser ? rehydrate<UserInterface>(Modules.User, dehydratedUser) : null;
69
+ const currentUser = useMemo(
70
+ () => (dehydratedUser ? rehydrate<UserInterface>(Modules.User, dehydratedUser) : null),
71
+ [dehydratedUser],
72
+ );
70
73
 
71
- const company = currentUser?.company ?? null;
74
+ // Balances pushed by company:tokens_updated. Transient: a real user refresh is
75
+ // authoritative and clears this.
76
+ const [tokenOverride, setTokenOverride] = useState<{
77
+ availableMonthlyTokens: number;
78
+ availableExtraTokens: number;
79
+ } | null>(null);
80
+
81
+ const company = useMemo<CompanyInterface | null>(() => {
82
+ const c = currentUser?.company ?? null;
83
+ if (!c || !tokenOverride) return c;
84
+ return Object.assign(Object.create(Object.getPrototypeOf(c)), c, {
85
+ _availableMonthlyTokens: tokenOverride.availableMonthlyTokens,
86
+ _availableExtraTokens: tokenOverride.availableExtraTokens,
87
+ });
88
+ }, [currentUser, tokenOverride]);
72
89
 
73
90
  const setUser = (user?: UserInterface): void => {
74
91
  if (user) setDehydratedUser(user.dehydrate() as any);
75
92
  else setDehydratedUser(null);
93
+ setTokenOverride(null);
76
94
  };
77
95
 
78
96
  const hasRole = (roleId: string): boolean => {
@@ -159,6 +177,7 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
159
177
  }
160
178
 
161
179
  setDehydratedUser(fullUser.dehydrate() as any);
180
+ setTokenOverride(null);
162
181
  }
163
182
  } catch (error) {
164
183
  console.error("Failed to refresh user data:", error);
@@ -185,7 +204,23 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
185
204
  return;
186
205
  }
187
206
 
188
- const handleCompanyUpdate = (data: { companyId: string; type: string }) => {
207
+ // Hot path: fires once per LLM call. Fully described by its payload — must
208
+ // never trigger an HTTP request.
209
+ const handleTokensUpdated = (data: {
210
+ companyId: string;
211
+ availableMonthlyTokens: number;
212
+ availableExtraTokens: number;
213
+ }) => {
214
+ if (data.companyId !== currentUser.company?.id) return;
215
+ setTokenOverride({
216
+ availableMonthlyTokens: data.availableMonthlyTokens,
217
+ availableExtraTokens: data.availableExtraTokens,
218
+ });
219
+ };
220
+
221
+ // Rare (Stripe webhooks only) and genuinely changes features, modules and
222
+ // permissions — a payload cannot economically carry that, so refetch.
223
+ const handleSubscriptionUpdated = (data: { companyId: string; type: string }) => {
189
224
  if (data.companyId === currentUser.company?.id && !isRefreshingRef.current) {
190
225
  isRefreshingRef.current = true;
191
226
  // Skip cookie update to prevent page reload - only update React state
@@ -195,13 +230,12 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
195
230
  }
196
231
  };
197
232
 
198
- // Both events trigger the same refresh behavior
199
- socket.on("company:tokens_updated", handleCompanyUpdate);
200
- socket.on("company:subscription_updated", handleCompanyUpdate);
233
+ socket.on("company:tokens_updated", handleTokensUpdated);
234
+ socket.on("company:subscription_updated", handleSubscriptionUpdated);
201
235
 
202
236
  return () => {
203
- socket.off("company:tokens_updated", handleCompanyUpdate);
204
- socket.off("company:subscription_updated", handleCompanyUpdate);
237
+ socket.off("company:tokens_updated", handleTokensUpdated);
238
+ socket.off("company:subscription_updated", handleSubscriptionUpdated);
205
239
  };
206
240
  }, [socket, isConnected, currentUser?.company?.id]);
207
241
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { render } from "@testing-library/react";
2
+ import { render, act } from "@testing-library/react";
3
3
 
4
4
  vi.mock("cookies-next", () => ({
5
5
  getCookie: vi.fn(() => undefined),
@@ -9,14 +9,54 @@ vi.mock("next/navigation", () => ({
9
9
  usePathname: () => "/",
10
10
  }));
11
11
 
12
+ const socketMock = vi.hoisted(() => {
13
+ const handlers: Record<string, ((data: any) => void)[]> = {};
14
+ return {
15
+ socket: {
16
+ on: (event: string, handler: (data: any) => void) => {
17
+ (handlers[event] ??= []).push(handler);
18
+ },
19
+ off: (event: string, handler: (data: any) => void) => {
20
+ handlers[event] = (handlers[event] ?? []).filter((h) => h !== handler);
21
+ },
22
+ },
23
+ emit: (event: string, data: any) => {
24
+ (handlers[event] ?? []).forEach((h) => h(data));
25
+ },
26
+ reset: () => {
27
+ for (const key of Object.keys(handlers)) delete handlers[key];
28
+ },
29
+ };
30
+ });
31
+
12
32
  vi.mock("../../../../contexts/SocketContext", () => ({
13
- useSocketContext: () => ({ socket: null, isConnected: false }),
33
+ useSocketContext: () => ({ socket: socketMock.socket, isConnected: true }),
14
34
  }));
15
35
 
16
- vi.mock("../../../../core", () => ({
17
- Modules: { User: { name: "users" } },
18
- rehydrate: (_module: unknown, data: any) => data,
19
- }));
36
+ vi.mock("../../../../core", () => {
37
+ // Mirrors the real Company model: public getters over private backing fields.
38
+ // The token overlay writes the backing fields, so a plain object would not
39
+ // exercise it.
40
+ class FakeCompany {
41
+ id = "";
42
+ monthlyTokens = 0;
43
+ _availableMonthlyTokens = 0;
44
+ _availableExtraTokens = 0;
45
+ get availableMonthlyTokens() {
46
+ return this._availableMonthlyTokens ?? 0;
47
+ }
48
+ get availableExtraTokens() {
49
+ return this._availableExtraTokens ?? 0;
50
+ }
51
+ }
52
+ return {
53
+ Modules: { User: { name: "users" } },
54
+ rehydrate: (_module: unknown, data: any) => ({
55
+ ...data,
56
+ company: data.company ? Object.assign(new FakeCompany(), data.company) : null,
57
+ }),
58
+ };
59
+ });
20
60
 
21
61
  vi.mock("../../../../permissions", () => ({
22
62
  Action: { Read: "read", Write: "write", Create: "create", Update: "update", Delete: "delete" },
@@ -139,3 +179,106 @@ describe("CurrentUserProvider", () => {
139
179
  expect(getByTestId("current-user-id").textContent).toBe("new");
140
180
  });
141
181
  });
182
+
183
+ describe("CurrentUserProvider token updates", () => {
184
+ const STORED_USER = {
185
+ id: "user-1",
186
+ roles: [],
187
+ modules: [],
188
+ company: {
189
+ id: "company-1",
190
+ monthlyTokens: 1000,
191
+ _availableMonthlyTokens: 900,
192
+ _availableExtraTokens: 50,
193
+ },
194
+ };
195
+
196
+ function CompanyConsumer() {
197
+ const { company } = useCurrentUserContext();
198
+ return (
199
+ <div data-testid="balances">
200
+ {company ? `${(company as any).availableMonthlyTokens}/${(company as any).availableExtraTokens}` : "null"}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ beforeEach(() => {
206
+ localStorage.clear();
207
+ socketMock.reset();
208
+ vi.clearAllMocks();
209
+ });
210
+
211
+ it("patches balances from company:tokens_updated without refetching the user", async () => {
212
+ localStorage.setItem("user", JSON.stringify(STORED_USER));
213
+
214
+ const { getByTestId } = render(
215
+ <CurrentUserProvider>
216
+ <CompanyConsumer />
217
+ </CurrentUserProvider>,
218
+ );
219
+
220
+ await new Promise((resolve) => setTimeout(resolve, 50));
221
+ expect(getByTestId("balances").textContent).toBe("900/50");
222
+
223
+ await act(async () => {
224
+ socketMock.emit("company:tokens_updated", {
225
+ type: "company:tokens_updated",
226
+ companyId: "company-1",
227
+ availableMonthlyTokens: 750,
228
+ availableExtraTokens: 40,
229
+ });
230
+ });
231
+
232
+ expect(getByTestId("balances").textContent).toBe("750/40");
233
+ expect(UserService.findFullUser).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("ignores company:tokens_updated for a different company", async () => {
237
+ localStorage.setItem("user", JSON.stringify(STORED_USER));
238
+
239
+ const { getByTestId } = render(
240
+ <CurrentUserProvider>
241
+ <CompanyConsumer />
242
+ </CurrentUserProvider>,
243
+ );
244
+
245
+ await new Promise((resolve) => setTimeout(resolve, 50));
246
+
247
+ await act(async () => {
248
+ socketMock.emit("company:tokens_updated", {
249
+ type: "company:tokens_updated",
250
+ companyId: "someone-else",
251
+ availableMonthlyTokens: 1,
252
+ availableExtraTokens: 1,
253
+ });
254
+ });
255
+
256
+ expect(getByTestId("balances").textContent).toBe("900/50");
257
+ });
258
+
259
+ it("still refetches the full user on company:subscription_updated", async () => {
260
+ localStorage.setItem("user", JSON.stringify(STORED_USER));
261
+ vi.mocked(UserService.findFullUser).mockResolvedValue({
262
+ ...STORED_USER,
263
+ dehydrate: () => STORED_USER,
264
+ } as any);
265
+
266
+ render(
267
+ <CurrentUserProvider>
268
+ <CompanyConsumer />
269
+ </CurrentUserProvider>,
270
+ );
271
+
272
+ await new Promise((resolve) => setTimeout(resolve, 50));
273
+
274
+ await act(async () => {
275
+ socketMock.emit("company:subscription_updated", {
276
+ type: "company:subscription_updated",
277
+ companyId: "company-1",
278
+ });
279
+ await new Promise((resolve) => setTimeout(resolve, 50));
280
+ });
281
+
282
+ expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
283
+ });
284
+ });
@@ -159,11 +159,11 @@ export function WaitlistForm({ onSuccess }: WaitlistFormProps) {
159
159
  label={
160
160
  <>
161
161
  {t("auth.gdpr.terms_prefix")}
162
- <Link href="/terms" target="_blank" rel="noopener" className="underline">
162
+ <Link href="/terms" target="_blank" rel="noopener">
163
163
  {t("auth.gdpr.terms_of_service")}
164
164
  </Link>
165
165
  {t("auth.gdpr.and")}
166
- <Link href="/privacy" target="_blank" rel="noopener" className="underline">
166
+ <Link href="/privacy" target="_blank" rel="noopener">
167
167
  {t("auth.gdpr.privacy_policy")}
168
168
  </Link>
169
169
  </>
@@ -3,7 +3,7 @@
3
3
  import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
4
4
  import { RefreshCw, Users } from "lucide-react";
5
5
  import { useTranslations } from "next-intl";
6
- import { useCallback, useEffect, useState } from "react";
6
+ import { useCallback, useEffect, useMemo, useState } from "react";
7
7
  import { errorToast } from "../../../../components";
8
8
  import { SectionHeader } from "../../../../components/typography";
9
9
  import {
@@ -64,6 +64,17 @@ export function WaitlistList() {
64
64
 
65
65
  const columns = useWaitlistTableStructure({ onInvite: handleInvite });
66
66
 
67
+ const statusItems = useMemo(
68
+ () => ({
69
+ all: t("waitlist.admin.all_statuses"),
70
+ pending: t("waitlist.admin.status.pending"),
71
+ confirmed: t("waitlist.admin.status.confirmed"),
72
+ invited: t("waitlist.admin.status.invited"),
73
+ registered: t("waitlist.admin.status.registered"),
74
+ }),
75
+ [t],
76
+ );
77
+
67
78
  const table = useReactTable({
68
79
  data: entries,
69
80
  columns,
@@ -82,7 +93,7 @@ export function WaitlistList() {
82
93
 
83
94
  <div className="flex items-center gap-4">
84
95
  {/* Status Filter */}
85
- <Select value={statusFilter} onValueChange={(value) => setStatusFilter(value ?? "all")}>
96
+ <Select items={statusItems} value={statusFilter} onValueChange={(value) => setStatusFilter(value ?? "all")}>
86
97
  <SelectTrigger className="w-40">
87
98
  <SelectValue placeholder={t("waitlist.admin.filter_placeholder")} />
88
99
  </SelectTrigger>
@@ -21,7 +21,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ className, href,
21
21
  prefetch={false}
22
22
  ref={ref}
23
23
  href={href}
24
- className={cn("text-primary font-medium hover:underline", className)}
24
+ className={cn("text-primary font-medium", className)}
25
25
  {...props}
26
26
  >
27
27
  {children}
@@ -59,7 +59,7 @@ function AccordionContent({ className, children, ...props }: AccordionPrimitive.
59
59
  >
60
60
  <div
61
61
  className={cn(
62
- "pt-0 pb-4 [&_a]:hover:text-foreground h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
62
+ "pt-0 pb-4 [&_a]:hover:text-foreground h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0 [&_p:not(:last-child)]:mb-4",
63
63
  className,
64
64
  )}
65
65
  >
@@ -114,7 +114,7 @@ function AlertDialogDescription({
114
114
  <AlertDialogPrimitive.Description
115
115
  data-slot="alert-dialog-description"
116
116
  className={cn(
117
- "text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3",
117
+ "text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty",
118
118
  className,
119
119
  )}
120
120
  {...props}