@carlonicora/nextjs-jsonapi 5.1.3 → 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.
- package/dist/{BlockNoteEditor-66FJUDL4.mjs → BlockNoteEditor-2QJVK2HH.mjs} +4 -4
- package/dist/{BlockNoteEditor-KO6YFNIU.js → BlockNoteEditor-EGY4ZAAM.js} +19 -19
- package/dist/{BlockNoteEditor-KO6YFNIU.js.map → BlockNoteEditor-EGY4ZAAM.js.map} +1 -1
- package/dist/{BlockNoteViewer-SUK7OMWH.mjs → BlockNoteViewer-6BQXJ4TB.mjs} +2 -2
- package/dist/{BlockNoteViewer-LK22AE7Z.js → BlockNoteViewer-I2MMFH3S.js} +3 -3
- package/dist/{BlockNoteViewer-LK22AE7Z.js.map → BlockNoteViewer-I2MMFH3S.js.map} +1 -1
- package/dist/auth.interface-CSzJ1oP1.d.ts +64 -0
- package/dist/auth.interface-DD4uQv2K.d.mts +64 -0
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-5JADF7ZA.mjs → chunk-2Z3ZCYXX.mjs} +171 -95
- package/dist/chunk-2Z3ZCYXX.mjs.map +1 -0
- package/dist/{chunk-SZQNUGDG.js → chunk-3VEQ5JZJ.js} +1005 -929
- package/dist/chunk-3VEQ5JZJ.js.map +1 -0
- package/dist/{chunk-TO6IYGML.js → chunk-75MLT74B.js} +126 -8
- package/dist/chunk-75MLT74B.js.map +1 -0
- package/dist/{chunk-67NJY7SM.js → chunk-EXICQC4A.js} +3 -3
- package/dist/{chunk-67NJY7SM.js.map → chunk-EXICQC4A.js.map} +1 -1
- package/dist/{chunk-OCJEXAGE.mjs → chunk-HNOWSFM5.mjs} +55 -4
- package/dist/chunk-HNOWSFM5.mjs.map +1 -0
- package/dist/{chunk-OKUGTXDN.mjs → chunk-JEZQTZQ4.mjs} +121 -3
- package/dist/chunk-JEZQTZQ4.mjs.map +1 -0
- package/dist/{chunk-KL7CAQP3.mjs → chunk-NPKFMRH5.mjs} +2 -2
- package/dist/{chunk-VVJDWQEI.js → chunk-S5MQDWWC.js} +55 -4
- package/dist/chunk-S5MQDWWC.js.map +1 -0
- package/dist/{chunk-XCHXOCEJ.js → chunk-THKYH65N.js} +4 -4
- package/dist/{chunk-XCHXOCEJ.js.map → chunk-THKYH65N.js.map} +1 -1
- package/dist/{chunk-XLVRAHUK.mjs → chunk-YMUPTXOT.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +17 -3
- package/dist/components/index.d.ts +17 -3
- package/dist/components/index.js +6 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +5 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +27 -7
- package/dist/core/index.d.ts +27 -7
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13 -3
- package/dist/{s3.service-C-4YpEMu.d.ts → s3.service-CY-jgOAM.d.ts} +1 -1
- package/dist/{s3.service-dVj-nxMl.d.mts → s3.service-Sri0QP7Z.d.mts} +1 -1
- package/dist/server/index.d.mts +2 -2
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/{waitlist.config-kPfjImle.d.mts → waitlist.config-Cagmgm7-.d.mts} +7 -0
- package/dist/{waitlist.config-kPfjImle.d.ts → waitlist.config-Cagmgm7-.d.ts} +7 -0
- package/messages/en.json +15 -1
- package/messages/it.json +15 -1
- package/package.json +1 -1
- package/scripts/validate-messages.mjs +1 -0
- package/src/components/index.ts +1 -0
- package/src/features/auth/components/buttons/GoogleSignInButton.tsx +8 -4
- package/src/features/auth/components/forms/Register.tsx +20 -8
- package/src/features/auth/data/auth.interface.ts +2 -0
- package/src/features/auth/data/auth.ts +10 -0
- package/src/features/signup-source/SignupSourceCapture.tsx +27 -0
- package/src/features/signup-source/__tests__/signup-source.spec.ts +161 -0
- package/src/features/signup-source/index.ts +9 -0
- package/src/features/signup-source/signup-source.ts +135 -0
- package/src/features/waitlist/components/forms/WaitlistForm.tsx +2 -0
- package/src/features/waitlist/components/forms/WaitlistQuestionnaireRenderer.tsx +15 -13
- package/src/features/waitlist/components/lists/WaitlistList.tsx +60 -22
- package/src/features/waitlist/components/sections/WaitlistHeroSection.tsx +6 -5
- package/src/features/waitlist/config/waitlist.config.ts +23 -0
- package/src/features/waitlist/data/Waitlist.ts +51 -3
- package/src/features/waitlist/data/WaitlistInterface.ts +9 -1
- package/src/features/waitlist/data/WaitlistService.ts +3 -2
- package/src/features/waitlist/data/__tests__/Waitlist.spec.ts +82 -0
- package/src/features/waitlist/data/__tests__/WaitlistService.spec.ts +48 -0
- package/src/features/waitlist/data/waitlist.fields.ts +1 -0
- package/src/features/waitlist/hooks/__tests__/formatSignupSource.spec.ts +24 -0
- package/src/features/waitlist/hooks/formatSignupSource.ts +14 -0
- package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +26 -14
- package/src/hooks/__tests__/usePageTracker.spec.tsx +16 -10
- package/src/index.ts +3 -0
- package/dist/auth.interface-CeFCz9NU.d.mts +0 -34
- package/dist/auth.interface-D1R7VVky.d.ts +0 -34
- package/dist/chunk-5JADF7ZA.mjs.map +0 -1
- package/dist/chunk-OCJEXAGE.mjs.map +0 -1
- package/dist/chunk-OKUGTXDN.mjs.map +0 -1
- package/dist/chunk-SZQNUGDG.js.map +0 -1
- package/dist/chunk-TO6IYGML.js.map +0 -1
- package/dist/chunk-VVJDWQEI.js.map +0 -1
- /package/dist/{BlockNoteEditor-66FJUDL4.mjs.map → BlockNoteEditor-2QJVK2HH.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-SUK7OMWH.mjs.map → BlockNoteViewer-6BQXJ4TB.mjs.map} +0 -0
- /package/dist/{chunk-KL7CAQP3.mjs.map → chunk-NPKFMRH5.mjs.map} +0 -0
- /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
|
-
|
|
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={
|
|
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
|
-
<
|
|
85
|
-
data=
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|