@carlonicora/nextjs-jsonapi 5.1.3 → 5.3.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 (173) hide show
  1. package/dist/{BlockNoteEditor-KO6YFNIU.js → BlockNoteEditor-UJ6363LC.js} +19 -19
  2. package/dist/{BlockNoteEditor-KO6YFNIU.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-66FJUDL4.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
  5. package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
  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-SZQNUGDG.js → chunk-3BAM23VA.js} +1005 -929
  12. package/dist/chunk-3BAM23VA.js.map +1 -0
  13. package/dist/{chunk-OKUGTXDN.mjs → chunk-6WU74VFU.mjs} +121 -3
  14. package/dist/chunk-6WU74VFU.mjs.map +1 -0
  15. package/dist/{chunk-VVJDWQEI.js → chunk-CRI4T47Y.js} +790 -179
  16. package/dist/chunk-CRI4T47Y.js.map +1 -0
  17. package/dist/{chunk-TO6IYGML.js → chunk-EO75CV76.js} +126 -8
  18. package/dist/chunk-EO75CV76.js.map +1 -0
  19. package/dist/{chunk-KL7CAQP3.mjs → chunk-FNTQQFLH.mjs} +2 -2
  20. package/dist/{chunk-OCJEXAGE.mjs → chunk-JQWI62NV.mjs} +617 -6
  21. package/dist/chunk-JQWI62NV.mjs.map +1 -0
  22. package/dist/{chunk-5JADF7ZA.mjs → chunk-MIZTWBPX.mjs} +171 -95
  23. package/dist/chunk-MIZTWBPX.mjs.map +1 -0
  24. package/dist/{chunk-XLVRAHUK.mjs → chunk-QKZUE5OF.mjs} +2 -2
  25. package/dist/{chunk-67NJY7SM.js → chunk-QQQE3N2X.js} +3 -3
  26. package/dist/{chunk-67NJY7SM.js.map → chunk-QQQE3N2X.js.map} +1 -1
  27. package/dist/{chunk-XCHXOCEJ.js → chunk-XGTQWBDJ.js} +4 -4
  28. package/dist/{chunk-XCHXOCEJ.js.map → chunk-XGTQWBDJ.js.map} +1 -1
  29. package/dist/client/index.d.mts +6 -39
  30. package/dist/client/index.d.ts +6 -39
  31. package/dist/client/index.js +4 -4
  32. package/dist/client/index.mjs +3 -3
  33. package/dist/components/index.d.mts +19 -5
  34. package/dist/components/index.d.ts +19 -5
  35. package/dist/components/index.js +6 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/index.mjs +5 -3
  38. package/dist/contexts/index.js +4 -4
  39. package/dist/contexts/index.mjs +3 -3
  40. package/dist/core/index.d.mts +214 -8
  41. package/dist/core/index.d.ts +214 -8
  42. package/dist/core/index.js +30 -2
  43. package/dist/core/index.js.map +1 -1
  44. package/dist/core/index.mjs +29 -1
  45. package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
  46. package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
  47. package/dist/email-automations/index.css +30 -0
  48. package/dist/email-automations/index.css.map +1 -0
  49. package/dist/email-automations/index.d.mts +303 -0
  50. package/dist/email-automations/index.d.ts +303 -0
  51. package/dist/email-automations/index.js +1705 -0
  52. package/dist/email-automations/index.js.map +1 -0
  53. package/dist/email-automations/index.mjs +1705 -0
  54. package/dist/email-automations/index.mjs.map +1 -0
  55. package/dist/features/analytics/index.js +122 -122
  56. package/dist/features/analytics/index.mjs +4 -4
  57. package/dist/features/analytics/server.js +4 -4
  58. package/dist/features/analytics/server.mjs +2 -2
  59. package/dist/features/help/index.js +37 -37
  60. package/dist/features/help/index.mjs +3 -3
  61. package/dist/features/tokenusage/index.js +125 -125
  62. package/dist/features/tokenusage/index.mjs +4 -4
  63. package/dist/index.d.mts +5 -4
  64. package/dist/index.d.ts +5 -4
  65. package/dist/index.js +41 -3
  66. package/dist/index.js.map +1 -1
  67. package/dist/index.mjs +41 -3
  68. package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
  69. package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
  70. package/dist/server/index.d.mts +2 -2
  71. package/dist/server/index.d.ts +2 -2
  72. package/dist/server/index.js +3 -3
  73. package/dist/server/index.mjs +2 -2
  74. package/dist/types-70acd-8C.d.mts +39 -0
  75. package/dist/types-l8SFleXi.d.ts +39 -0
  76. package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
  77. package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
  78. package/messages/en.json +132 -1
  79. package/messages/it.json +132 -1
  80. package/package.json +11 -1
  81. package/scripts/validate-messages.mjs +1 -0
  82. package/src/components/forms/FormFieldWrapper.tsx +1 -1
  83. package/src/components/forms/FormTextarea.tsx +2 -1
  84. package/src/components/index.ts +1 -0
  85. package/src/core/index.ts +8 -0
  86. package/src/core/registry/ModuleRegistry.ts +5 -0
  87. package/src/email-automations/index.ts +5 -0
  88. package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
  89. package/src/features/auth/components/forms/Register.tsx +20 -8
  90. package/src/features/auth/data/auth.interface.ts +2 -0
  91. package/src/features/auth/data/auth.ts +10 -0
  92. package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
  93. package/src/features/email-automation/EmailAutomationModule.ts +30 -0
  94. package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
  95. package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
  96. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
  97. package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
  98. package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
  99. package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
  100. package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
  101. package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
  102. package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
  103. package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
  104. package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
  105. package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
  106. package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
  107. package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
  108. package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
  109. package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
  110. package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
  111. package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
  112. package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
  113. package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
  114. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
  115. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
  116. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
  117. package/src/features/email-automation/components/index.ts +15 -0
  118. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
  119. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
  120. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
  121. package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
  122. package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
  123. package/src/features/email-automation/data/EmailAutomation.ts +87 -0
  124. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
  125. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
  126. package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
  127. package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
  128. package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
  129. package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
  130. package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
  131. package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
  132. package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
  133. package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
  134. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
  135. package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
  136. package/src/features/email-automation/data/email-automation.fields.ts +9 -0
  137. package/src/features/email-automation/data/index.ts +13 -0
  138. package/src/features/email-automation/hooks/index.ts +2 -0
  139. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
  140. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
  141. package/src/features/email-automation/index.ts +5 -0
  142. package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
  143. package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
  144. package/src/features/signup-source/index.ts +9 -0
  145. package/src/features/signup-source/signup-source.ts +135 -0
  146. package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
  147. package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
  148. package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
  149. package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
  150. package/src/features/waitlist/config/waitlist.config.ts +23 -0
  151. package/src/features/waitlist/data/Waitlist.ts +51 -3
  152. package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
  153. package/src/features/waitlist/data/WaitlistService.ts +3 -2
  154. package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
  155. package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
  156. package/src/features/waitlist/data/waitlist.fields.ts +1 -0
  157. package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
  158. package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
  159. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
  160. package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
  161. package/src/index.ts +3 -0
  162. package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
  163. package/dist/auth.interface-D1R7VVky.d.ts +0 -34
  164. package/dist/chunk-5JADF7ZA.mjs.map +0 -1
  165. package/dist/chunk-OCJEXAGE.mjs.map +0 -1
  166. package/dist/chunk-OKUGTXDN.mjs.map +0 -1
  167. package/dist/chunk-SZQNUGDG.js.map +0 -1
  168. package/dist/chunk-TO6IYGML.js.map +0 -1
  169. package/dist/chunk-VVJDWQEI.js.map +0 -1
  170. /package/dist/{BlockNoteEditor-66FJUDL4.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
  171. /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
  172. /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
  173. /package/dist/{chunk-XLVRAHUK.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import { ColumnDef } from "@tanstack/react-table";
4
+ import { useTranslations } from "next-intl";
5
+ import { useMemo } from "react";
6
+ import { Badge, Link } from "../../../shadcnui";
7
+ import { Modules } from "../../../core";
8
+ import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
9
+ import { EmailAutomationFields } from "../data/email-automation.fields";
10
+ import { EmailAutomationInterface } from "../data/EmailAutomationInterface";
11
+
12
+ /**
13
+ * `context.triggerLabels` maps a trigger key to the label the API declares for
14
+ * it (EmailAutomationsList loads them). A key the app no longer declares falls
15
+ * back to the raw key, so a stale automation stays readable.
16
+ */
17
+ export const useEmailAutomationTableStructure: UseTableStructureHook<
18
+ EmailAutomationInterface,
19
+ EmailAutomationFields
20
+ > = (params) => {
21
+ const t = useTranslations();
22
+ const generateUrl = usePageUrlGenerator();
23
+ const triggerLabels: Record<string, string> = params.context?.triggerLabels ?? {};
24
+
25
+ const tableData = useMemo(() => {
26
+ return params.data.map((automation: EmailAutomationInterface) => {
27
+ const entry: TableContent<EmailAutomationInterface> = { jsonApiData: automation };
28
+ params.fields.forEach((field) => {
29
+ entry[field] = (automation as any)[field as keyof EmailAutomationInterface];
30
+ });
31
+ return entry;
32
+ });
33
+ }, [params.data, params.fields]);
34
+
35
+ // Typography role 15 (numeric): tabular-nums, end-aligned. NEVER
36
+ // font-mono — no monospace font is loaded in these apps.
37
+ const numericColumn = (
38
+ field: EmailAutomationFields,
39
+ header: string,
40
+ read: (a: EmailAutomationInterface) => number,
41
+ ) => ({
42
+ id: field,
43
+ accessorKey: field,
44
+ header: header,
45
+ cell: ({ row }: { row: TableContent<EmailAutomationInterface> }) => {
46
+ const automation: EmailAutomationInterface = row.original.jsonApiData;
47
+ return <span className="block text-xs tabular-nums text-end">{read(automation) ?? 0}</span>;
48
+ },
49
+ enableSorting: false,
50
+ enableHiding: false,
51
+ });
52
+
53
+ const fieldColumnMap: Partial<Record<EmailAutomationFields, () => any>> = {
54
+ [EmailAutomationFields.name]: () => ({
55
+ id: "name",
56
+ accessorKey: "name",
57
+ header: t("emailAutomations.list.columns.name"),
58
+ cell: ({ row }: { row: TableContent<EmailAutomationInterface> }) => {
59
+ const automation: EmailAutomationInterface = row.original.jsonApiData;
60
+ return <Link href={generateUrl({ page: Modules.EmailAutomation, id: automation.id })}>{automation.name}</Link>;
61
+ },
62
+ enableSorting: false,
63
+ enableHiding: false,
64
+ }),
65
+ [EmailAutomationFields.trigger]: () => ({
66
+ id: "trigger",
67
+ accessorKey: "trigger",
68
+ header: t("emailAutomations.list.columns.trigger"),
69
+ cell: ({ row }: { row: TableContent<EmailAutomationInterface> }) => {
70
+ const automation: EmailAutomationInterface = row.original.jsonApiData;
71
+ if (!automation.trigger) return <span className="text-muted-foreground text-xs">—</span>;
72
+ return <span className="text-sm">{triggerLabels[automation.trigger] ?? automation.trigger}</span>;
73
+ },
74
+ enableSorting: false,
75
+ enableHiding: false,
76
+ }),
77
+ [EmailAutomationFields.status]: () => ({
78
+ id: "status",
79
+ accessorKey: "status",
80
+ header: t("emailAutomations.list.columns.status"),
81
+ cell: ({ row }: { row: TableContent<EmailAutomationInterface> }) => {
82
+ const automation: EmailAutomationInterface = row.original.jsonApiData;
83
+ return automation.isActive ? (
84
+ <Badge variant="softGreen">{t("emailAutomations.list.status.on")}</Badge>
85
+ ) : (
86
+ <Badge variant="softGray">{t("emailAutomations.list.status.off")}</Badge>
87
+ );
88
+ },
89
+ enableSorting: false,
90
+ enableHiding: false,
91
+ }),
92
+ [EmailAutomationFields.steps]: () =>
93
+ numericColumn(EmailAutomationFields.steps, t("emailAutomations.list.columns.steps"), (a) => a.stepCount),
94
+ [EmailAutomationFields.active]: () =>
95
+ numericColumn(EmailAutomationFields.active, t("emailAutomations.list.columns.active"), (a) => a.activeCount),
96
+ [EmailAutomationFields.completed]: () =>
97
+ numericColumn(
98
+ EmailAutomationFields.completed,
99
+ t("emailAutomations.list.columns.completed"),
100
+ (a) => a.completedCount,
101
+ ),
102
+ [EmailAutomationFields.unsubscribed]: () =>
103
+ numericColumn(
104
+ EmailAutomationFields.unsubscribed,
105
+ t("emailAutomations.list.columns.unsubscribed"),
106
+ (a) => a.unsubscribedCount,
107
+ ),
108
+ };
109
+
110
+ const columns = useMemo(() => {
111
+ return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
112
+ TableContent<EmailAutomationInterface>
113
+ >[];
114
+ }, [params.fields, fieldColumnMap, t, generateUrl]);
115
+
116
+ return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
117
+ };
118
+
119
+ registerTableGenerator("email-automations", useEmailAutomationTableStructure);
@@ -0,0 +1,5 @@
1
+ export * from "./EmailAutomationModule";
2
+ export * from "./EmailAutomationStepModule";
3
+ export * from "./EmailAutomationEnrollmentModule";
4
+ export * from "./EmailAutomationTriggerModule";
5
+ export * from "./data";
@@ -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>