@carlonicora/nextjs-jsonapi 5.1.2 → 5.2.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 (105) hide show
  1. package/dist/{BlockNoteEditor-AZ627NB7.mjs → BlockNoteEditor-2QJVK2HH.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-OP7LWYOG.js → BlockNoteEditor-EGY4ZAAM.js} +19 -19
  3. package/dist/{BlockNoteEditor-OP7LWYOG.js.map → BlockNoteEditor-EGY4ZAAM.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-6BQXJ4TB.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-I2MMFH3S.js} +3 -3
  6. package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-I2MMFH3S.js.map} +1 -1
  7. package/dist/auth.interface-CSzJ1oP1.d.ts +64 -0
  8. package/dist/auth.interface-DD4uQv2K.d.mts +64 -0
  9. package/dist/billing/index.js +441 -441
  10. package/dist/billing/index.mjs +3 -3
  11. package/dist/{chunk-DYITOVR5.mjs → chunk-2Z3ZCYXX.mjs} +173 -96
  12. package/dist/chunk-2Z3ZCYXX.mjs.map +1 -0
  13. package/dist/{chunk-YZN2774P.js → chunk-3VEQ5JZJ.js} +1336 -1259
  14. package/dist/chunk-3VEQ5JZJ.js.map +1 -0
  15. package/dist/{chunk-TO6IYGML.js → chunk-75MLT74B.js} +126 -8
  16. package/dist/chunk-75MLT74B.js.map +1 -0
  17. package/dist/{chunk-67NJY7SM.js → chunk-EXICQC4A.js} +3 -3
  18. package/dist/{chunk-67NJY7SM.js.map → chunk-EXICQC4A.js.map} +1 -1
  19. package/dist/{chunk-OCJEXAGE.mjs → chunk-HNOWSFM5.mjs} +55 -4
  20. package/dist/chunk-HNOWSFM5.mjs.map +1 -0
  21. package/dist/{chunk-OKUGTXDN.mjs → chunk-JEZQTZQ4.mjs} +121 -3
  22. package/dist/chunk-JEZQTZQ4.mjs.map +1 -0
  23. package/dist/{chunk-KL7CAQP3.mjs → chunk-NPKFMRH5.mjs} +2 -2
  24. package/dist/{chunk-VVJDWQEI.js → chunk-S5MQDWWC.js} +55 -4
  25. package/dist/chunk-S5MQDWWC.js.map +1 -0
  26. package/dist/{chunk-XCHXOCEJ.js → chunk-THKYH65N.js} +4 -4
  27. package/dist/{chunk-XCHXOCEJ.js.map → chunk-THKYH65N.js.map} +1 -1
  28. package/dist/{chunk-XLVRAHUK.mjs → chunk-YMUPTXOT.mjs} +2 -2
  29. package/dist/client/index.js +4 -4
  30. package/dist/client/index.mjs +3 -3
  31. package/dist/components/index.d.mts +17 -3
  32. package/dist/components/index.d.ts +17 -3
  33. package/dist/components/index.js +6 -4
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/components/index.mjs +5 -3
  36. package/dist/contexts/index.js +4 -4
  37. package/dist/contexts/index.mjs +3 -3
  38. package/dist/core/index.d.mts +27 -7
  39. package/dist/core/index.d.ts +27 -7
  40. package/dist/core/index.js +2 -2
  41. package/dist/core/index.mjs +1 -1
  42. package/dist/features/analytics/index.js +122 -122
  43. package/dist/features/analytics/index.mjs +4 -4
  44. package/dist/features/analytics/server.js +4 -4
  45. package/dist/features/analytics/server.mjs +2 -2
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.js +125 -125
  49. package/dist/features/tokenusage/index.mjs +4 -4
  50. package/dist/index.d.mts +3 -3
  51. package/dist/index.d.ts +3 -3
  52. package/dist/index.js +13 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +13 -3
  55. package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
  56. package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
  57. package/dist/server/index.d.mts +2 -2
  58. package/dist/server/index.d.ts +2 -2
  59. package/dist/server/index.js +3 -3
  60. package/dist/server/index.mjs +2 -2
  61. package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
  62. package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
  63. package/messages/en.json +15 -1
  64. package/messages/it.json +15 -1
  65. package/package.json +1 -1
  66. package/scripts/validate-messages.mjs +1 -0
  67. package/src/components/index.ts +1 -0
  68. package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
  69. package/src/features/auth/components/forms/Register.tsx +20 -8
  70. package/src/features/auth/data/auth.interface.ts +2 -0
  71. package/src/features/auth/data/auth.ts +10 -0
  72. package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
  73. package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
  74. package/src/features/signup-source/index.ts +9 -0
  75. package/src/features/signup-source/signup-source.ts +135 -0
  76. package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
  77. package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
  78. package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
  79. package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
  80. package/src/features/waitlist/config/waitlist.config.ts +23 -0
  81. package/src/features/waitlist/data/Waitlist.ts +51 -3
  82. package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
  83. package/src/features/waitlist/data/WaitlistService.ts +3 -2
  84. package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
  85. package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
  86. package/src/features/waitlist/data/waitlist.fields.ts +1 -0
  87. package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
  88. package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
  89. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
  90. package/src/hooks/__tests__/useDataListRetriever.spec.ts +69 -0
  91. package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
  92. package/src/hooks/useDataListRetriever.ts +6 -2
  93. package/src/index.ts +3 -0
  94. package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
  95. package/dist/auth.interface-D1R7VVky.d.ts +0 -34
  96. package/dist/chunk-DYITOVR5.mjs.map +0 -1
  97. package/dist/chunk-OCJEXAGE.mjs.map +0 -1
  98. package/dist/chunk-OKUGTXDN.mjs.map +0 -1
  99. package/dist/chunk-TO6IYGML.js.map +0 -1
  100. package/dist/chunk-VVJDWQEI.js.map +0 -1
  101. package/dist/chunk-YZN2774P.js.map +0 -1
  102. /package/dist/{BlockNoteEditor-AZ627NB7.mjs.map → BlockNoteEditor-2QJVK2HH.mjs.map} +0 -0
  103. /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-6BQXJ4TB.mjs.map} +0 -0
  104. /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-NPKFMRH5.mjs.map} +0 -0
  105. /package/dist/{chunk-XLVRAHUK.mjs.map → chunk-YMUPTXOT.mjs.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { ApiDataInterface } from "../../../core";
2
+ import { SignupSource } from "../../signup-source/signup-source";
2
3
  import { UserInterface } from "../../user";
3
4
 
4
5
  export type AuthInput = {
@@ -14,6 +15,7 @@ export type AuthInput = {
14
15
  marketingConsentAt?: string | null;
15
16
  inviteCode?: string;
16
17
  referralCode?: string;
18
+ source?: SignupSource;
17
19
  };
18
20
 
19
21
  export type AuthQuery = {
@@ -52,6 +52,16 @@ export class Auth extends AbstractApiData implements AuthInterface {
52
52
  if (data.inviteCode !== undefined) response.data.attributes.inviteCode = data.inviteCode;
53
53
  if (data.referralCode !== undefined) response.data.attributes.referralCode = data.referralCode;
54
54
 
55
+ if (data.source !== undefined) {
56
+ const attributes = response.data.attributes;
57
+ if (data.source.sourceRef !== undefined) attributes.sourceRef = data.source.sourceRef;
58
+ if (data.source.sourceUtmSource !== undefined) attributes.sourceUtmSource = data.source.sourceUtmSource;
59
+ if (data.source.sourceUtmMedium !== undefined) attributes.sourceUtmMedium = data.source.sourceUtmMedium;
60
+ if (data.source.sourceUtmCampaign !== undefined) attributes.sourceUtmCampaign = data.source.sourceUtmCampaign;
61
+ if (data.source.sourceReferrer !== undefined) attributes.sourceReferrer = data.source.sourceReferrer;
62
+ if (data.source.sourceLandingPath !== undefined) attributes.sourceLandingPath = data.source.sourceLandingPath;
63
+ }
64
+
55
65
  return response;
56
66
  }
57
67
  }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { usePathname, useSearchParams } from "next/navigation";
4
+ import { useEffect } from "react";
5
+
6
+ import { captureSignupSource } from "./signup-source";
7
+
8
+ /**
9
+ * SignupSourceCapture records where a visitor came from (ref, UTM, external referrer, landing path).
10
+ *
11
+ * Behavior:
12
+ * - Captures on mount and on every client navigation (pathname/search change)
13
+ * - First touch wins: an existing in-memory value or cookie is never overwritten
14
+ * - Writes the `signup_source` cookie only when `persist` is true (cookie consent accepted);
15
+ * when `persist` flips to true later, the in-memory value is written then
16
+ * - Renders nothing (null)
17
+ */
18
+ export function SignupSourceCapture({ persist }: { persist: boolean }): null {
19
+ const searchParams = useSearchParams();
20
+ const pathname = usePathname();
21
+
22
+ useEffect(() => {
23
+ captureSignupSource({ persist });
24
+ }, [pathname, searchParams, persist]);
25
+
26
+ return null;
27
+ }
@@ -0,0 +1,161 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ type SignupSourceModule = typeof import("../signup-source");
4
+
5
+ let cookieStore: Record<string, string>;
6
+ let cookieWrites: string[];
7
+
8
+ function stubBrowser(params: { href: string; referrer?: string }) {
9
+ const url = new URL(params.href);
10
+ vi.stubGlobal("window", { location: { href: url.href, host: url.host } });
11
+ vi.stubGlobal("document", {
12
+ referrer: params.referrer ?? "",
13
+ get cookie() {
14
+ return Object.entries(cookieStore)
15
+ .map(([name, value]) => `${name}=${value}`)
16
+ .join("; ");
17
+ },
18
+ set cookie(cookieString: string) {
19
+ cookieWrites.push(cookieString);
20
+ const [nameValue] = cookieString.split(";");
21
+ const separator = nameValue.indexOf("=");
22
+ cookieStore[nameValue.slice(0, separator).trim()] = nameValue.slice(separator + 1);
23
+ },
24
+ });
25
+ }
26
+
27
+ async function loadModule(): Promise<SignupSourceModule> {
28
+ return import("../signup-source");
29
+ }
30
+
31
+ describe("signup-source", () => {
32
+ beforeEach(() => {
33
+ vi.resetModules();
34
+ cookieStore = {};
35
+ cookieWrites = [];
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ describe("readSignupSourceFromLocation()", () => {
43
+ it("reads the ref param and the landing path", async () => {
44
+ const { readSignupSourceFromLocation } = await loadModule();
45
+ expect(
46
+ readSignupSourceFromLocation({ url: "https://wyrd.li/?ref=discord-embed", referrer: "", ownHost: "wyrd.li" }),
47
+ ).toEqual({ sourceRef: "discord-embed", sourceLandingPath: "/" });
48
+ });
49
+
50
+ it("reads the UTM params", async () => {
51
+ const { readSignupSourceFromLocation } = await loadModule();
52
+ expect(
53
+ readSignupSourceFromLocation({
54
+ url: "https://wyrd.li/it/pricing?utm_source=reddit&utm_medium=social&utm_campaign=r_rpg",
55
+ referrer: "",
56
+ ownHost: "wyrd.li",
57
+ }),
58
+ ).toEqual({
59
+ sourceUtmSource: "reddit",
60
+ sourceUtmMedium: "social",
61
+ sourceUtmCampaign: "r_rpg",
62
+ sourceLandingPath: "/it/pricing",
63
+ });
64
+ });
65
+
66
+ it("keeps the host of an external referrer", async () => {
67
+ const { readSignupSourceFromLocation } = await loadModule();
68
+ const source = readSignupSourceFromLocation({
69
+ url: "https://wyrd.li/",
70
+ referrer: "https://www.enworld.org/threads/1",
71
+ ownHost: "wyrd.li",
72
+ });
73
+ expect(source?.sourceReferrer).toBe("www.enworld.org");
74
+ });
75
+
76
+ it("returns undefined for an own-host referrer and no params", async () => {
77
+ const { readSignupSourceFromLocation } = await loadModule();
78
+ expect(
79
+ readSignupSourceFromLocation({
80
+ url: "https://wyrd.li/",
81
+ referrer: "https://wyrd.li/pricing",
82
+ ownHost: "wyrd.li",
83
+ }),
84
+ ).toBeUndefined();
85
+ });
86
+
87
+ it("truncates values to 200 characters", async () => {
88
+ const { readSignupSourceFromLocation } = await loadModule();
89
+ const source = readSignupSourceFromLocation({
90
+ url: `https://wyrd.li/?ref=${"a".repeat(500)}`,
91
+ referrer: "",
92
+ ownHost: "wyrd.li",
93
+ });
94
+ expect(source?.sourceRef).toHaveLength(200);
95
+ });
96
+ });
97
+
98
+ describe("captureSignupSource()", () => {
99
+ it("persist false never writes the cookie", async () => {
100
+ stubBrowser({ href: "https://wyrd.li/?ref=x" });
101
+ const { captureSignupSource, getSignupSource } = await loadModule();
102
+
103
+ captureSignupSource({ persist: false });
104
+
105
+ expect(getSignupSource()).toEqual({ sourceRef: "x", sourceLandingPath: "/" });
106
+ expect(document.cookie).not.toContain("signup_source");
107
+ expect(cookieWrites).toHaveLength(0);
108
+ });
109
+
110
+ it("persist true writes the cookie once", async () => {
111
+ stubBrowser({ href: "https://wyrd.li/?ref=x" });
112
+ const { captureSignupSource } = await loadModule();
113
+
114
+ captureSignupSource({ persist: true });
115
+ captureSignupSource({ persist: true });
116
+
117
+ expect(cookieWrites).toHaveLength(1);
118
+ expect(cookieWrites[0]).toContain("signup_source=");
119
+ expect(cookieWrites[0]).toContain("max-age=2592000");
120
+ expect(cookieWrites[0]).toContain("SameSite=Lax");
121
+ expect(cookieWrites[0]).toContain("path=/");
122
+ });
123
+
124
+ it("first touch wins over an existing cookie", async () => {
125
+ cookieStore["signup_source"] = encodeURIComponent(JSON.stringify({ sourceRef: "discord-embed" }));
126
+ stubBrowser({ href: "https://wyrd.li/?utm_source=reddit" });
127
+ const { captureSignupSource, getSignupSource } = await loadModule();
128
+
129
+ captureSignupSource({ persist: true });
130
+
131
+ expect(getSignupSource()?.sourceRef).toBe("discord-embed");
132
+ expect(getSignupSource()?.sourceUtmSource).toBeUndefined();
133
+ expect(cookieWrites).toHaveLength(0);
134
+ });
135
+
136
+ it("a later persist=true writes the in-memory value", async () => {
137
+ stubBrowser({ href: "https://wyrd.li/?ref=x" });
138
+ const { captureSignupSource } = await loadModule();
139
+
140
+ captureSignupSource({ persist: false });
141
+ expect(cookieWrites).toHaveLength(0);
142
+
143
+ stubBrowser({ href: "https://wyrd.li/pricing" });
144
+ captureSignupSource({ persist: true });
145
+
146
+ expect(cookieWrites).toHaveLength(1);
147
+ expect(JSON.parse(decodeURIComponent(cookieStore["signup_source"]))).toEqual({
148
+ sourceRef: "x",
149
+ sourceLandingPath: "/",
150
+ });
151
+ });
152
+
153
+ it("treats a malformed cookie as absent", async () => {
154
+ cookieStore["signup_source"] = "not-json%";
155
+ stubBrowser({ href: "https://wyrd.li/" });
156
+ const { getSignupSource } = await loadModule();
157
+
158
+ expect(getSignupSource()).toBeUndefined();
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,9 @@
1
+ // Client component SignupSourceCapture is exported from the /components entry point.
2
+ export {
3
+ SIGNUP_SOURCE_COOKIE,
4
+ SIGNUP_SOURCE_COOKIE_MAX_AGE,
5
+ captureSignupSource,
6
+ getSignupSource,
7
+ readSignupSourceFromLocation,
8
+ } from "./signup-source";
9
+ export type { SignupSource } from "./signup-source";
@@ -0,0 +1,135 @@
1
+ export type SignupSource = {
2
+ sourceRef?: string;
3
+ sourceUtmSource?: string;
4
+ sourceUtmMedium?: string;
5
+ sourceUtmCampaign?: string;
6
+ sourceReferrer?: string;
7
+ sourceLandingPath?: string;
8
+ };
9
+
10
+ export const SIGNUP_SOURCE_COOKIE = "signup_source";
11
+ export const SIGNUP_SOURCE_COOKIE_MAX_AGE = 2592000;
12
+
13
+ /** The six source fields, in display order. */
14
+ export const SIGNUP_SOURCE_FIELDS: readonly (keyof SignupSource)[] = [
15
+ "sourceRef",
16
+ "sourceUtmSource",
17
+ "sourceUtmMedium",
18
+ "sourceUtmCampaign",
19
+ "sourceReferrer",
20
+ "sourceLandingPath",
21
+ ];
22
+
23
+ const SIGNUP_SOURCE_MAX_LENGTH = 200;
24
+
25
+ // First touch for the current page session. Survives client navigations, lost on a full reload.
26
+ let memorySource: SignupSource | undefined;
27
+
28
+ function clean(value: unknown): string | undefined {
29
+ if (typeof value !== "string") return undefined;
30
+ const trimmed = value.trim();
31
+ if (!trimmed) return undefined;
32
+ return trimmed.slice(0, SIGNUP_SOURCE_MAX_LENGTH);
33
+ }
34
+
35
+ function sanitise(input: unknown): SignupSource | undefined {
36
+ if (!input || typeof input !== "object") return undefined;
37
+ const result: SignupSource = {};
38
+ for (const field of SIGNUP_SOURCE_FIELDS) {
39
+ const value = clean((input as Record<string, unknown>)[field]);
40
+ if (value !== undefined) result[field] = value;
41
+ }
42
+ return Object.keys(result).length > 0 ? result : undefined;
43
+ }
44
+
45
+ /**
46
+ * Pure. Reads ref and utm_source/medium/campaign from the URL, the referrer host when it is not ownHost,
47
+ * and the landing path. Returns undefined for a direct visit (no ref, no UTM, no external referrer).
48
+ */
49
+ export function readSignupSourceFromLocation(params: {
50
+ url: string;
51
+ referrer: string;
52
+ ownHost: string;
53
+ }): SignupSource | undefined {
54
+ let url: URL;
55
+ try {
56
+ url = new URL(params.url);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+
61
+ const sourceRef = clean(url.searchParams.get("ref"));
62
+ const sourceUtmSource = clean(url.searchParams.get("utm_source"));
63
+ const sourceUtmMedium = clean(url.searchParams.get("utm_medium"));
64
+ const sourceUtmCampaign = clean(url.searchParams.get("utm_campaign"));
65
+
66
+ let sourceReferrer: string | undefined;
67
+ if (params.referrer) {
68
+ try {
69
+ const referrerHost = new URL(params.referrer).host;
70
+ if (referrerHost && referrerHost !== params.ownHost) sourceReferrer = clean(referrerHost);
71
+ } catch {
72
+ sourceReferrer = undefined;
73
+ }
74
+ }
75
+
76
+ if (!sourceRef && !sourceUtmSource && !sourceUtmMedium && !sourceUtmCampaign && !sourceReferrer) return undefined;
77
+
78
+ return sanitise({
79
+ sourceRef,
80
+ sourceUtmSource,
81
+ sourceUtmMedium,
82
+ sourceUtmCampaign,
83
+ sourceReferrer,
84
+ sourceLandingPath: url.pathname,
85
+ });
86
+ }
87
+
88
+ function readSignupSourceCookie(): SignupSource | undefined {
89
+ if (typeof document === "undefined") return undefined;
90
+ const cookies = document.cookie ? document.cookie.split("; ") : [];
91
+ for (const cookie of cookies) {
92
+ const separator = cookie.indexOf("=");
93
+ if (separator === -1) continue;
94
+ if (cookie.slice(0, separator) !== SIGNUP_SOURCE_COOKIE) continue;
95
+ const value = cookie.slice(separator + 1);
96
+ if (!value) return undefined;
97
+ try {
98
+ return sanitise(JSON.parse(decodeURIComponent(value)));
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ function writeSignupSourceCookie(source: SignupSource): void {
107
+ document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(JSON.stringify(source))}; path=/; max-age=${SIGNUP_SOURCE_COOKIE_MAX_AGE}; SameSite=Lax`;
108
+ }
109
+
110
+ /**
111
+ * First touch: an existing in-memory value or cookie is never overwritten.
112
+ * Writes the cookie only when persist is true and no cookie exists yet.
113
+ */
114
+ export function captureSignupSource(params: { persist: boolean }): void {
115
+ if (typeof window === "undefined" || typeof document === "undefined") return;
116
+
117
+ const existingCookie = readSignupSourceCookie();
118
+
119
+ if (!memorySource) {
120
+ memorySource =
121
+ existingCookie ??
122
+ readSignupSourceFromLocation({
123
+ url: window.location.href,
124
+ referrer: document.referrer,
125
+ ownHost: window.location.host,
126
+ });
127
+ }
128
+
129
+ if (params.persist && !existingCookie && memorySource) writeSignupSourceCookie(memorySource);
130
+ }
131
+
132
+ /** The in-memory value, else the cookie value, else undefined. */
133
+ export function getSignupSource(): SignupSource | undefined {
134
+ return memorySource ?? readSignupSourceCookie();
135
+ }
@@ -11,6 +11,7 @@ import { errorToast, FormInput, GdprConsentCheckbox } from "../../../../componen
11
11
  import { Button, Form, Link } from "../../../../shadcnui";
12
12
  import { SectionHeader } from "../../../../components/typography";
13
13
  import { getWaitlistConfig } from "../../config/waitlist.config";
14
+ import { getSignupSource } from "../../../signup-source/signup-source";
14
15
  import { WaitlistService } from "../../data/WaitlistService";
15
16
  import { WaitlistQuestionnaireRenderer } from "./WaitlistQuestionnaireRenderer";
16
17
 
@@ -110,6 +111,7 @@ export function WaitlistForm({ onSuccess }: WaitlistFormProps) {
110
111
  marketingConsent: values.marketingConsent ?? false,
111
112
  marketingConsentAt: values.marketingConsent ? now : undefined,
112
113
  questionnaire: values.questionnaire,
114
+ source: getSignupSource(),
113
115
  });
114
116
 
115
117
  setIsSuccess(true);
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { FieldValues, Path, UseFormReturn } from "react-hook-form";
4
4
  import { FormInput, FormTextarea, FormSelect, FormCheckbox } from "../../../../components";
5
- import { QuestionnaireField } from "../../config/waitlist.config";
5
+ import { QuestionnaireField, useWaitlistText } from "../../config/waitlist.config";
6
6
 
7
7
  interface WaitlistQuestionnaireRendererProps<T extends FieldValues> {
8
8
  form: UseFormReturn<T>;
@@ -15,6 +15,8 @@ export function WaitlistQuestionnaireRenderer<T extends FieldValues>({
15
15
  fields,
16
16
  fieldPrefix = "questionnaire",
17
17
  }: WaitlistQuestionnaireRendererProps<T>) {
18
+ const text = useWaitlistText();
19
+
18
20
  if (!fields || fields.length === 0) {
19
21
  return null;
20
22
  }
@@ -31,8 +33,8 @@ export function WaitlistQuestionnaireRenderer<T extends FieldValues>({
31
33
  key={field.id}
32
34
  form={form}
33
35
  id={fieldId}
34
- name={field.label}
35
- placeholder={field.placeholder}
36
+ name={text(field.label) ?? field.label}
37
+ placeholder={text(field.placeholder)}
36
38
  isRequired={field.required}
37
39
  />
38
40
  );
@@ -43,8 +45,8 @@ export function WaitlistQuestionnaireRenderer<T extends FieldValues>({
43
45
  key={field.id}
44
46
  form={form}
45
47
  id={fieldId}
46
- name={field.label}
47
- placeholder={field.placeholder}
48
+ name={text(field.label) ?? field.label}
49
+ placeholder={text(field.placeholder)}
48
50
  className="min-h-24"
49
51
  />
50
52
  );
@@ -58,10 +60,10 @@ export function WaitlistQuestionnaireRenderer<T extends FieldValues>({
58
60
  key={field.id}
59
61
  form={form}
60
62
  id={fieldId}
61
- name={field.label}
63
+ name={text(field.label) ?? field.label}
62
64
  values={field.options.map((opt) => ({
63
65
  id: opt.value,
64
- text: opt.label,
66
+ text: text(opt.label) ?? opt.label,
65
67
  }))}
66
68
  />
67
69
  );
@@ -73,8 +75,8 @@ export function WaitlistQuestionnaireRenderer<T extends FieldValues>({
73
75
  key={field.id}
74
76
  form={form}
75
77
  id={fieldId}
76
- name={field.label}
77
- description={field.description}
78
+ name={text(field.label) ?? field.label}
79
+ description={text(field.description)}
78
80
  isRequired={field.required}
79
81
  />
80
82
  );
@@ -83,18 +85,18 @@ export function WaitlistQuestionnaireRenderer<T extends FieldValues>({
83
85
  return (
84
86
  <div key={field.id} className="space-y-2">
85
87
  <span className="text-sm font-medium">
86
- {field.label}
88
+ {text(field.label) ?? field.label}
87
89
  {field.required && <span className="ms-1 text-destructive">*</span>}
88
90
  </span>
89
- {field.description && <p className="text-muted-foreground text-xs">{field.description}</p>}
91
+ {field.description && <p className="text-muted-foreground text-xs">{text(field.description)}</p>}
90
92
  <div className="space-y-2">
91
93
  {field.options.map((option) => (
92
94
  <FormCheckbox
93
95
  key={option.value}
94
96
  form={form}
95
97
  id={`${fieldId}.${option.value}` as Path<T>}
96
- name={option.label}
97
- description={option.description}
98
+ name={text(option.label) ?? option.label}
99
+ description={text(option.description)}
98
100
  />
99
101
  ))}
100
102
  </div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { RefreshCw } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
- import { useMemo, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
6
  import { ContentListTable, errorToast } from "../../../../components";
7
7
  import { Modules } from "../../../../core";
8
8
  import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
@@ -11,6 +11,7 @@ import { showToast } from "../../../../utils/toast";
11
11
  import { WaitlistFields } from "../../data/waitlist.fields";
12
12
  import { WaitlistInterface } from "../../data/WaitlistInterface";
13
13
  import { WaitlistService } from "../../data/WaitlistService";
14
+ import { WaitlistStatsInterface } from "../../data/waitlist-stats.interface";
14
15
 
15
16
  type WaitlistListProps = {
16
17
  /**
@@ -25,6 +26,7 @@ type WaitlistListProps = {
25
26
  export function WaitlistList({ fullWidth }: WaitlistListProps = {}) {
26
27
  const t = useTranslations();
27
28
  const [statusFilter, setStatusFilter] = useState<string>("all");
29
+ const [stats, setStats] = useState<WaitlistStatsInterface | undefined>(undefined);
28
30
 
29
31
  const data: DataListRetriever<WaitlistInterface> = useDataListRetriever({
30
32
  retriever: (params) => WaitlistService.findMany(params),
@@ -32,11 +34,28 @@ export function WaitlistList({ fullWidth }: WaitlistListProps = {}) {
32
34
  module: Modules.Waitlist,
33
35
  });
34
36
 
37
+ const loadStats = useCallback(async () => {
38
+ try {
39
+ setStats(await WaitlistService.getStats());
40
+ } catch (error) {
41
+ errorToast({ title: t("common.errors.error"), error });
42
+ }
43
+ }, [t]);
44
+
45
+ useEffect(() => {
46
+ void loadStats();
47
+ }, [loadStats]);
48
+
49
+ const refresh = () => {
50
+ void data.refresh();
51
+ void loadStats();
52
+ };
53
+
35
54
  const handleInvite = async (entry: WaitlistInterface) => {
36
55
  try {
37
56
  await WaitlistService.invite(entry.id);
38
57
  showToast(t("waitlist.admin.invite_sent", { email: entry.email }));
39
- void data.refresh();
58
+ refresh();
40
59
  } catch (error) {
41
60
  errorToast({ title: t("common.errors.error"), error });
42
61
  }
@@ -74,31 +93,50 @@ export function WaitlistList({ fullWidth }: WaitlistListProps = {}) {
74
93
  </SelectContent>
75
94
  </Select>
76
95
 
77
- <Button variant="outline" size="icon" onClick={() => void data.refresh()} disabled={!data.isLoaded}>
96
+ <Button variant="outline" size="icon" onClick={refresh} disabled={!data.isLoaded}>
78
97
  <RefreshCw className={`h-4 w-4 ${!data.isLoaded ? "animate-spin" : ""}`} />
79
98
  </Button>
80
99
  </div>
81
100
  );
82
101
 
102
+ const totals = [
103
+ { key: "total", label: t("waitlist.admin.total"), value: stats?.total },
104
+ { key: "pending", label: t("waitlist.admin.status.pending"), value: stats?.pending },
105
+ { key: "confirmed", label: t("waitlist.admin.status.confirmed"), value: stats?.confirmed },
106
+ { key: "invited", label: t("waitlist.admin.status.invited"), value: stats?.invited },
107
+ { key: "registered", label: t("waitlist.admin.status.registered"), value: stats?.registered },
108
+ ];
109
+
83
110
  return (
84
- <ContentListTable
85
- data={data}
86
- fields={[
87
- WaitlistFields.email,
88
- WaitlistFields.status,
89
- WaitlistFields.createdAt,
90
- WaitlistFields.questionnaire,
91
- WaitlistFields.actions,
92
- ]}
93
- tableGeneratorType={Modules.Waitlist}
94
- title={t("waitlist.admin.title")}
95
- filters={filters}
96
- /* The waitlist endpoint takes no search term, so rendering the box would
97
- give the page a control that silently does nothing. */
98
- allowSearch={false}
99
- context={{ onInvite: handleInvite }}
100
- fullWidth={fullWidth}
101
- emptyState={t("waitlist.admin.empty")}
102
- />
111
+ <div className="flex flex-col gap-4">
112
+ <dl className="grid grid-cols-5 divide-x divide-border rounded-md border" data-testid="waitlist-totals">
113
+ {totals.map((total) => (
114
+ <div key={total.key} className="flex flex-col-reverse items-center gap-1 p-3">
115
+ <dt className="text-muted-foreground text-xs">{total.label}</dt>
116
+ <dd className="text-primary text-xl font-semibold tabular-nums">{total.value ?? "-"}</dd>
117
+ </div>
118
+ ))}
119
+ </dl>
120
+ <ContentListTable
121
+ data={data}
122
+ fields={[
123
+ WaitlistFields.email,
124
+ WaitlistFields.status,
125
+ WaitlistFields.createdAt,
126
+ WaitlistFields.source,
127
+ WaitlistFields.questionnaire,
128
+ WaitlistFields.actions,
129
+ ]}
130
+ tableGeneratorType={Modules.Waitlist}
131
+ title={t("waitlist.admin.title")}
132
+ filters={filters}
133
+ /* The waitlist endpoint takes no search term, so rendering the box would
134
+ give the page a control that silently does nothing. */
135
+ allowSearch={false}
136
+ context={{ onInvite: handleInvite }}
137
+ fullWidth={fullWidth}
138
+ emptyState={t("waitlist.admin.empty")}
139
+ />
140
+ </div>
103
141
  );
104
142
  }
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { CheckCircle } from "lucide-react";
4
- import { getWaitlistConfig } from "../../config/waitlist.config";
4
+ import { getWaitlistConfig, useWaitlistText } from "../../config/waitlist.config";
5
5
  import { WaitlistForm } from "../forms/WaitlistForm";
6
6
 
7
7
  export function WaitlistHeroSection() {
8
8
  const config = getWaitlistConfig();
9
+ const text = useWaitlistText();
9
10
 
10
11
  return (
11
12
  <section className="relative overflow-hidden py-16 md:py-24">
@@ -15,12 +16,12 @@ export function WaitlistHeroSection() {
15
16
  <div className="space-y-8">
16
17
  <div className="space-y-4">
17
18
  {config.heroTitle && (
18
- <h1 className="text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl">{config.heroTitle}</h1>
19
+ <h1 className="text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl">{text(config.heroTitle)}</h1>
19
20
  )}
20
21
  {config.heroSubtitle && (
21
- <p className="text-muted-foreground text-xl md:text-2xl">{config.heroSubtitle}</p>
22
+ <p className="text-muted-foreground text-xl md:text-2xl">{text(config.heroSubtitle)}</p>
22
23
  )}
23
- {config.heroDescription && <p className="text-muted-foreground">{config.heroDescription}</p>}
24
+ {config.heroDescription && <p className="text-muted-foreground">{text(config.heroDescription)}</p>}
24
25
  </div>
25
26
 
26
27
  {/* Benefits */}
@@ -29,7 +30,7 @@ export function WaitlistHeroSection() {
29
30
  {config.benefits.map((benefit, index) => (
30
31
  <li key={index} className="flex items-start gap-3">
31
32
  <CheckCircle className="text-primary mt-0.5 h-5 w-5 shrink-0" />
32
- <span>{benefit}</span>
33
+ <span>{text(benefit)}</span>
33
34
  </li>
34
35
  ))}
35
36
  </ul>
@@ -1,3 +1,5 @@
1
+ import { useI18nTranslations } from "../../../i18n";
2
+
1
3
  export type QuestionnaireFieldType = "text" | "textarea" | "select" | "checkbox";
2
4
 
3
5
  export interface QuestionnaireOption {
@@ -22,6 +24,13 @@ export interface WaitlistConfig {
22
24
  heroSubtitle?: string;
23
25
  heroDescription?: string;
24
26
  benefits?: string[];
27
+ /**
28
+ * When true, every text above (hero title, subtitle, description, benefits and
29
+ * the questionnaire's labels, descriptions, placeholders and option labels) is
30
+ * a message key and renders through `t()`. Defaults to false: the strings
31
+ * render as given.
32
+ */
33
+ translate?: boolean;
25
34
  }
26
35
 
27
36
  let _waitlistConfig: WaitlistConfig = {};
@@ -33,3 +42,17 @@ export function configureWaitlist(config: WaitlistConfig): void {
33
42
  export function getWaitlistConfig(): WaitlistConfig {
34
43
  return _waitlistConfig;
35
44
  }
45
+
46
+ /**
47
+ * Resolves a waitlist config text: `t(value)` when the config sets
48
+ * `translate`, the value itself otherwise. Empty values stay undefined.
49
+ */
50
+ export function useWaitlistText(): (value?: string) => string | undefined {
51
+ const t = useI18nTranslations();
52
+ const translate = getWaitlistConfig().translate === true;
53
+
54
+ return (value?: string) => {
55
+ if (!value) return undefined;
56
+ return translate ? t(value) : value;
57
+ };
58
+ }