@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.
- package/dist/{BlockNoteEditor-BR3IRGJW.js → BlockNoteEditor-JAELO23S.js} +19 -19
- package/dist/{BlockNoteEditor-BR3IRGJW.js.map → BlockNoteEditor-JAELO23S.js.map} +1 -1
- package/dist/{BlockNoteEditor-PVBZYPMF.mjs → BlockNoteEditor-WZFEJS2B.mjs} +4 -4
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-LBMNRFCY.js → chunk-5IE6DZ2D.js} +153 -77
- package/dist/chunk-5IE6DZ2D.js.map +1 -0
- package/dist/{chunk-MDXT47V4.mjs → chunk-67LIVKYU.mjs} +198 -203
- package/dist/chunk-67LIVKYU.mjs.map +1 -0
- package/dist/{chunk-5MNE72ZE.mjs → chunk-DZL2G7NB.mjs} +77 -1
- package/dist/chunk-DZL2G7NB.mjs.map +1 -0
- package/dist/{chunk-XTLTQQ7X.js → chunk-F6KBHC2X.js} +7 -7
- package/dist/{chunk-XTLTQQ7X.js.map → chunk-F6KBHC2X.js.map} +1 -1
- package/dist/{chunk-5SQFK35K.js → chunk-MP5VURBB.js} +1028 -1033
- package/dist/chunk-MP5VURBB.js.map +1 -0
- package/dist/{chunk-NATPK5ME.mjs → chunk-VSAAVVHY.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +3 -2
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.d.mts +9 -2
- package/dist/contexts/index.d.ts +9 -2
- package/dist/contexts/index.js +6 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +5 -3
- package/dist/core/index.d.mts +11 -1
- package/dist/core/index.d.ts +11 -1
- package/dist/core/index.js +12 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +13 -3
- package/dist/features/help/index.js +39 -39
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +5 -5
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -4
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/containers/ReactMarkdownContainer.tsx +1 -1
- package/src/components/containers/RoundPageContainer.tsx +7 -0
- package/src/components/forms/EditorSheet.tsx +24 -1
- package/src/components/forms/FormDateTime.tsx +5 -0
- package/src/components/navigations/Breadcrumb.tsx +13 -11
- package/src/components/navigations/Header.tsx +3 -1
- package/src/contexts/HeaderChildrenContext.tsx +17 -2
- package/src/features/assistant-message/components/MessageItem.tsx +1 -1
- package/src/features/auth/components/GdprConsentSection.tsx +2 -2
- package/src/features/auth/components/forms/Register.tsx +2 -2
- package/src/features/help/components/HelpArticleBody.tsx +2 -2
- package/src/features/notification/components/common/NotificationErrorBoundary.tsx +1 -4
- package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +34 -8
- package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +2 -2
- package/src/features/user/contexts/CurrentUserContext.tsx +43 -9
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +149 -6
- package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -2
- package/src/features/waitlist/components/lists/WaitlistList.tsx +13 -2
- package/src/shadcnui/custom/link.tsx +1 -1
- package/src/shadcnui/ui/accordion.tsx +1 -1
- package/src/shadcnui/ui/alert-dialog.tsx +1 -1
- package/src/shadcnui/ui/alert.tsx +2 -5
- package/src/shadcnui/ui/badge.tsx +1 -1
- package/src/shadcnui/ui/button.tsx +1 -1
- package/src/shadcnui/ui/dialog.tsx +1 -4
- package/src/shadcnui/ui/field.tsx +1 -1
- package/src/utils/__tests__/italian-validators.test.ts +63 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/italian-validators.ts +18 -5
- package/dist/chunk-5MNE72ZE.mjs.map +0 -1
- package/dist/chunk-5SQFK35K.js.map +0 -1
- package/dist/chunk-LBMNRFCY.js.map +0 -1
- package/dist/chunk-MDXT47V4.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-PVBZYPMF.mjs.map → BlockNoteEditor-WZFEJS2B.mjs.map} +0 -0
- /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
|
-
|
|
29
|
+
rootLabel,
|
|
30
30
|
}: {
|
|
31
31
|
items: BreadcrumbItemData[];
|
|
32
32
|
generateUrl: ReturnType<typeof usePageUrlGenerator>;
|
|
33
|
-
|
|
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: `/` })}>{
|
|
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
|
-
|
|
115
|
+
rootLabel,
|
|
116
116
|
}: {
|
|
117
117
|
items: BreadcrumbItemData[];
|
|
118
118
|
generateUrl: ReturnType<typeof usePageUrlGenerator>;
|
|
119
|
-
|
|
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:
|
|
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: `/` })}>{
|
|
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}
|
|
169
|
+
return <BreadcrumbMobile items={items} generateUrl={generateUrl} rootLabel={root} />;
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
return <BreadcrumbDesktop items={items} generateUrl={generateUrl}
|
|
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 }}>
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
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}` })}
|
|
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}` })}
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
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: () =>
|
|
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
|
|
59
|
-
//
|
|
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="
|
|
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="
|
|
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 =
|
|
69
|
+
const currentUser = useMemo(
|
|
70
|
+
() => (dehydratedUser ? rehydrate<UserInterface>(Modules.User, dehydratedUser) : null),
|
|
71
|
+
[dehydratedUser],
|
|
72
|
+
);
|
|
70
73
|
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
socket.on("company:
|
|
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",
|
|
204
|
-
socket.off("company:subscription_updated",
|
|
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:
|
|
33
|
+
useSocketContext: () => ({ socket: socketMock.socket, isConnected: true }),
|
|
14
34
|
}));
|
|
15
35
|
|
|
16
|
-
vi.mock("../../../../core", () =>
|
|
17
|
-
|
|
18
|
-
|
|
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"
|
|
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"
|
|
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
|
|
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 [&
|
|
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
|
|
117
|
+
"text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty",
|
|
118
118
|
className,
|
|
119
119
|
)}
|
|
120
120
|
{...props}
|