@stack-mode/site-manager-kit 1.44.0 → 1.45.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 (61) hide show
  1. package/README.md +618 -606
  2. package/audit/ts-hooks.mjs +68 -68
  3. package/audit/wiring-audit.d.mts +37 -37
  4. package/audit/wiring-audit.mjs +604 -604
  5. package/bin/site-manager-kit.mjs +10 -10
  6. package/package.json +73 -72
  7. package/src/announcements.ts +13 -13
  8. package/src/attribution-fields.ts +52 -52
  9. package/src/attribution.tsx +47 -47
  10. package/src/blog.ts +122 -122
  11. package/src/chat-route.ts +136 -136
  12. package/src/chat-types.ts +76 -76
  13. package/src/client.ts +116 -116
  14. package/src/config.ts +78 -78
  15. package/src/copy-keys-route.ts +21 -21
  16. package/src/copy-keys.ts +77 -77
  17. package/src/data.ts +189 -189
  18. package/src/downloads.ts +7 -7
  19. package/src/faq.ts +21 -21
  20. package/src/floor-plans.ts +45 -45
  21. package/src/form-guard.tsx +37 -37
  22. package/src/form-types.ts +13 -13
  23. package/src/forms.ts +76 -76
  24. package/src/glossary.ts +19 -19
  25. package/src/health-route.ts +34 -34
  26. package/src/index.ts +36 -36
  27. package/src/indexnow.ts +40 -40
  28. package/src/listings.ts +38 -38
  29. package/src/llms-route.ts +45 -0
  30. package/src/metadata.ts +55 -55
  31. package/src/not-found-reporter.tsx +16 -16
  32. package/src/not-found.ts +53 -53
  33. package/src/payment.ts +70 -70
  34. package/src/phone-input.tsx +26 -26
  35. package/src/phone.ts +37 -37
  36. package/src/preview-route.ts +48 -48
  37. package/src/preview.ts +94 -94
  38. package/src/residents.ts +23 -23
  39. package/src/revalidate-route.ts +34 -34
  40. package/src/robots.ts +5 -1
  41. package/src/rss-route.ts +26 -26
  42. package/src/services.ts +52 -52
  43. package/src/site-chat-panel.tsx +537 -537
  44. package/src/site-chat.tsx +26 -26
  45. package/src/sitemap.ts +26 -26
  46. package/src/structured-data.tsx +207 -207
  47. package/src/types.ts +581 -580
  48. package/src/version.ts +1 -1
  49. package/src/visual-accents.ts +126 -126
  50. package/src/visual-bridge-client.tsx +1070 -1070
  51. package/src/visual-bridge.tsx +30 -30
  52. package/src/visual-clicks.ts +13 -13
  53. package/src/visual-lists.ts +37 -37
  54. package/src/visual-match.ts +77 -77
  55. package/src/visual-messages.ts +160 -160
  56. package/src/visual-mode.ts +18 -18
  57. package/src/visual-preview.ts +69 -69
  58. package/src/visual-tags.ts +90 -90
  59. package/src/visual.ts +59 -59
  60. package/src/waitlist-form.ts +39 -39
  61. package/src/waitlist.ts +48 -48
@@ -1,90 +1,90 @@
1
- /*
2
- * Exact tags for the Visual Editor. In visual mode only, each Site Copy text the site reads carries an invisible tag
3
- * naming its value, so the page can say exactly which words are which value instead of matching words. The tag is
4
- * zero width characters: a start marker, the ref's UTF-8 bytes as base-4 digits, an end marker. The bridge removes
5
- * every tag from the page before anyone sees or copies it.
6
- */
7
-
8
- const DIGITS = ["​", "‌", "‍", "⁠"] as const;
9
- // Marker pairs no ordinary text contains: a word joiner and a zero width no-break space around the digits.
10
- const START = "⁠⁠";
11
- const END = "⁠";
12
- const MAX_REF = 200;
13
-
14
- const TAG = new RegExp(`${START}([${DIGITS.join("")}]+)${END}`, "g");
15
- const ANY_MARK = /[​‌‍⁠]/;
16
-
17
- export function encodeTag(ref: string): string {
18
- const bytes = new TextEncoder().encode(ref);
19
- let out = START;
20
- for (const b of bytes) out += DIGITS[(b >> 6) & 3]! + DIGITS[(b >> 4) & 3]! + DIGITS[(b >> 2) & 3]! + DIGITS[b & 3]!;
21
- return out + END;
22
- }
23
-
24
- function decode(digits: string): string | null {
25
- if (digits.length % 4 !== 0) return null;
26
- const bytes = new Uint8Array(digits.length / 4);
27
- for (let i = 0; i < bytes.length; i++) {
28
- let b = 0;
29
- for (let j = 0; j < 4; j++) b = (b << 2) | DIGITS.indexOf(digits[i * 4 + j] as (typeof DIGITS)[number]);
30
- bytes[i] = b;
31
- }
32
- try {
33
- const ref = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
34
- return ref.length > 0 && ref.length <= MAX_REF ? ref : null;
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- /** A tag found in a string: the ref it names and where it ends in the clean text. */
41
- export type FoundTag = { ref: string; at: number };
42
-
43
- /** The text with its tags removed, and the tags in order. A broken tag is removed but not reported. */
44
- export function readTags(text: string): { clean: string; tags: FoundTag[] } {
45
- if (!ANY_MARK.test(text)) return { clean: text, tags: [] };
46
- const tags: FoundTag[] = [];
47
- let clean = "";
48
- let last = 0;
49
- for (const m of text.matchAll(TAG)) {
50
- clean += text.slice(last, m.index);
51
- const ref = decode(m[1]!);
52
- if (ref) tags.push({ ref, at: clean.length });
53
- last = m.index! + m[0].length;
54
- }
55
- clean += text.slice(last);
56
- return { clean, tags };
57
- }
58
-
59
- /** The text alone, for attributes and anywhere else a tag must never show. */
60
- export function stripTags(text: string): string {
61
- return ANY_MARK.test(text) ? text.replace(TAG, "") : text;
62
- }
63
-
64
- /** True when the text may hold a tag; a cheap test before the full read. */
65
- export const mayHaveTag = (text: string) => text.includes(START);
66
-
67
- /** Puts the tag inside rich HTML's last text, before its closing tags, so it lands in the element the words are in. */
68
- export function tagHtml(html: string, ref: string): string {
69
- const m = /(?:<\/[a-zA-Z][a-zA-Z0-9]*>\s*)+$/.exec(html);
70
- const at = m ? m.index : html.length;
71
- return html.slice(0, at) + encodeTag(ref) + html.slice(at);
72
- }
73
-
74
- type CopyLike = { kind: string; text?: string | null; html?: string | null; image?: { alt: string } | null };
75
-
76
- /**
77
- * Site Copy with each non-empty text and rich value tagged as `copy/<key>`, and each photo tagged in its alt text (the
78
- * one thing about a photo the page always renders). Links and picks are untouched.
79
- */
80
- export function tagCopy<T extends CopyLike>(all: Record<string, T>): Record<string, T> {
81
- const out: Record<string, T> = {};
82
- for (const [key, v] of Object.entries(all)) {
83
- const ref = `copy/${key}`;
84
- if (v.kind === "text" && v.text) out[key] = { ...v, text: v.text + encodeTag(ref) } as T;
85
- else if (v.kind === "rich" && v.html) out[key] = { ...v, html: tagHtml(v.html, ref) } as T;
86
- else if (v.kind === "image" && v.image) out[key] = { ...v, image: { ...v.image, alt: (v.image.alt ?? "") + encodeTag(ref) } } as T;
87
- else out[key] = v;
88
- }
89
- return out;
90
- }
1
+ /*
2
+ * Exact tags for the Visual Editor. In visual mode only, each Site Copy text the site reads carries an invisible tag
3
+ * naming its value, so the page can say exactly which words are which value instead of matching words. The tag is
4
+ * zero width characters: a start marker, the ref's UTF-8 bytes as base-4 digits, an end marker. The bridge removes
5
+ * every tag from the page before anyone sees or copies it.
6
+ */
7
+
8
+ const DIGITS = ["​", "‌", "‍", "⁠"] as const;
9
+ // Marker pairs no ordinary text contains: a word joiner and a zero width no-break space around the digits.
10
+ const START = "⁠⁠";
11
+ const END = "⁠";
12
+ const MAX_REF = 200;
13
+
14
+ const TAG = new RegExp(`${START}([${DIGITS.join("")}]+)${END}`, "g");
15
+ const ANY_MARK = /[​‌‍⁠]/;
16
+
17
+ export function encodeTag(ref: string): string {
18
+ const bytes = new TextEncoder().encode(ref);
19
+ let out = START;
20
+ for (const b of bytes) out += DIGITS[(b >> 6) & 3]! + DIGITS[(b >> 4) & 3]! + DIGITS[(b >> 2) & 3]! + DIGITS[b & 3]!;
21
+ return out + END;
22
+ }
23
+
24
+ function decode(digits: string): string | null {
25
+ if (digits.length % 4 !== 0) return null;
26
+ const bytes = new Uint8Array(digits.length / 4);
27
+ for (let i = 0; i < bytes.length; i++) {
28
+ let b = 0;
29
+ for (let j = 0; j < 4; j++) b = (b << 2) | DIGITS.indexOf(digits[i * 4 + j] as (typeof DIGITS)[number]);
30
+ bytes[i] = b;
31
+ }
32
+ try {
33
+ const ref = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
34
+ return ref.length > 0 && ref.length <= MAX_REF ? ref : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** A tag found in a string: the ref it names and where it ends in the clean text. */
41
+ export type FoundTag = { ref: string; at: number };
42
+
43
+ /** The text with its tags removed, and the tags in order. A broken tag is removed but not reported. */
44
+ export function readTags(text: string): { clean: string; tags: FoundTag[] } {
45
+ if (!ANY_MARK.test(text)) return { clean: text, tags: [] };
46
+ const tags: FoundTag[] = [];
47
+ let clean = "";
48
+ let last = 0;
49
+ for (const m of text.matchAll(TAG)) {
50
+ clean += text.slice(last, m.index);
51
+ const ref = decode(m[1]!);
52
+ if (ref) tags.push({ ref, at: clean.length });
53
+ last = m.index! + m[0].length;
54
+ }
55
+ clean += text.slice(last);
56
+ return { clean, tags };
57
+ }
58
+
59
+ /** The text alone, for attributes and anywhere else a tag must never show. */
60
+ export function stripTags(text: string): string {
61
+ return ANY_MARK.test(text) ? text.replace(TAG, "") : text;
62
+ }
63
+
64
+ /** True when the text may hold a tag; a cheap test before the full read. */
65
+ export const mayHaveTag = (text: string) => text.includes(START);
66
+
67
+ /** Puts the tag inside rich HTML's last text, before its closing tags, so it lands in the element the words are in. */
68
+ export function tagHtml(html: string, ref: string): string {
69
+ const m = /(?:<\/[a-zA-Z][a-zA-Z0-9]*>\s*)+$/.exec(html);
70
+ const at = m ? m.index : html.length;
71
+ return html.slice(0, at) + encodeTag(ref) + html.slice(at);
72
+ }
73
+
74
+ type CopyLike = { kind: string; text?: string | null; html?: string | null; image?: { alt: string } | null };
75
+
76
+ /**
77
+ * Site Copy with each non-empty text and rich value tagged as `copy/<key>`, and each photo tagged in its alt text (the
78
+ * one thing about a photo the page always renders). Links and picks are untouched.
79
+ */
80
+ export function tagCopy<T extends CopyLike>(all: Record<string, T>): Record<string, T> {
81
+ const out: Record<string, T> = {};
82
+ for (const [key, v] of Object.entries(all)) {
83
+ const ref = `copy/${key}`;
84
+ if (v.kind === "text" && v.text) out[key] = { ...v, text: v.text + encodeTag(ref) } as T;
85
+ else if (v.kind === "rich" && v.html) out[key] = { ...v, html: tagHtml(v.html, ref) } as T;
86
+ else if (v.kind === "image" && v.image) out[key] = { ...v, image: { ...v.image, alt: (v.image.alt ?? "") + encodeTag(ref) } } as T;
87
+ else out[key] = v;
88
+ }
89
+ return out;
90
+ }
package/src/visual.ts CHANGED
@@ -1,59 +1,59 @@
1
- import { draftMode } from "next/headers";
2
- import type { NextRequest, NextResponse } from "next/server";
3
- import { verifyVisualCookie, VISUAL_COOKIE } from "./preview";
4
-
5
- /*
6
- * Marking values for Site Manager's Visual Editor. A marked element is a value Site Manager already stores; the
7
- * Visual Editor only changes the value, never the page around it. Marks exist only in a preview, so visitors never
8
- * see them.
9
- */
10
-
11
- export const REF_PATTERN = /^[a-z_]+\/[A-Za-z0-9_\-./]{1,200}$/;
12
-
13
- type Mark = (ref: string) => Record<string, string>;
14
-
15
- const none: Mark = () => ({});
16
-
17
- /**
18
- * Attributes for marking a value, or nothing outside a preview:
19
- *
20
- * const mark = await editMarks();
21
- * <h1 {...mark("copy/hero_title")}>{title}</h1>
22
- *
23
- * A bad ref gives nothing rather than an error, so a typo can never break a visitor's page.
24
- */
25
- export async function editMarks(): Promise<Mark> {
26
- try {
27
- if (!(await draftMode()).isEnabled) return none;
28
- } catch {
29
- return none;
30
- }
31
- return (ref): Record<string, string> => (REF_PATTERN.test(ref) ? { "data-bp-edit": ref } : {});
32
- }
33
-
34
- function adminOrigin(request: NextRequest): string | null {
35
- const id = process.env.BLUEPRINT_SITE_ID?.trim();
36
- if (!id) return null;
37
- return verifyVisualCookie(process.env.BLUEPRINT_REVALIDATE_SECRET, request.cookies.get(VISUAL_COOKIE)?.value, id)?.adminOrigin ?? null;
38
- }
39
-
40
- /** The frame-ancestors value for sites that build their own Content-Security-Policy: "'self'", plus the Visual Editor's address while it's open. */
41
- export function frameAncestors(request: NextRequest): string {
42
- const origin = adminOrigin(request);
43
- return origin ? `'self' ${origin}` : "'self'";
44
- }
45
-
46
- /**
47
- * For the site's `src/proxy.ts`. While the Visual Editor is open in this browser, lets its Site Manager address show
48
- * the page in a frame. Browsers ignore X-Frame-Options when frame-ancestors is sent, so every other response keeps
49
- * the site's usual SAMEORIGIN. Redirects are left alone: they have no page to show.
50
- */
51
- export function visualFrameHeaders(request: NextRequest, response: NextResponse): NextResponse {
52
- if (response.headers.has("location")) return response;
53
- const origin = adminOrigin(request);
54
- if (!origin) return response;
55
- const existing = response.headers.get("content-security-policy");
56
- const value = `frame-ancestors 'self' ${origin}`;
57
- response.headers.set("content-security-policy", existing ? `${existing}; ${value}` : value);
58
- return response;
59
- }
1
+ import { draftMode } from "next/headers";
2
+ import type { NextRequest, NextResponse } from "next/server";
3
+ import { verifyVisualCookie, VISUAL_COOKIE } from "./preview";
4
+
5
+ /*
6
+ * Marking values for Site Manager's Visual Editor. A marked element is a value Site Manager already stores; the
7
+ * Visual Editor only changes the value, never the page around it. Marks exist only in a preview, so visitors never
8
+ * see them.
9
+ */
10
+
11
+ export const REF_PATTERN = /^[a-z_]+\/[A-Za-z0-9_\-./]{1,200}$/;
12
+
13
+ type Mark = (ref: string) => Record<string, string>;
14
+
15
+ const none: Mark = () => ({});
16
+
17
+ /**
18
+ * Attributes for marking a value, or nothing outside a preview:
19
+ *
20
+ * const mark = await editMarks();
21
+ * <h1 {...mark("copy/hero_title")}>{title}</h1>
22
+ *
23
+ * A bad ref gives nothing rather than an error, so a typo can never break a visitor's page.
24
+ */
25
+ export async function editMarks(): Promise<Mark> {
26
+ try {
27
+ if (!(await draftMode()).isEnabled) return none;
28
+ } catch {
29
+ return none;
30
+ }
31
+ return (ref): Record<string, string> => (REF_PATTERN.test(ref) ? { "data-bp-edit": ref } : {});
32
+ }
33
+
34
+ function adminOrigin(request: NextRequest): string | null {
35
+ const id = process.env.BLUEPRINT_SITE_ID?.trim();
36
+ if (!id) return null;
37
+ return verifyVisualCookie(process.env.BLUEPRINT_REVALIDATE_SECRET, request.cookies.get(VISUAL_COOKIE)?.value, id)?.adminOrigin ?? null;
38
+ }
39
+
40
+ /** The frame-ancestors value for sites that build their own Content-Security-Policy: "'self'", plus the Visual Editor's address while it's open. */
41
+ export function frameAncestors(request: NextRequest): string {
42
+ const origin = adminOrigin(request);
43
+ return origin ? `'self' ${origin}` : "'self'";
44
+ }
45
+
46
+ /**
47
+ * For the site's `src/proxy.ts`. While the Visual Editor is open in this browser, lets its Site Manager address show
48
+ * the page in a frame. Browsers ignore X-Frame-Options when frame-ancestors is sent, so every other response keeps
49
+ * the site's usual SAMEORIGIN. Redirects are left alone: they have no page to show.
50
+ */
51
+ export function visualFrameHeaders(request: NextRequest, response: NextResponse): NextResponse {
52
+ if (response.headers.has("location")) return response;
53
+ const origin = adminOrigin(request);
54
+ if (!origin) return response;
55
+ const existing = response.headers.get("content-security-policy");
56
+ const value = `frame-ancestors 'self' ${origin}`;
57
+ response.headers.set("content-security-policy", existing ? `${existing}; ${value}` : value);
58
+ return response;
59
+ }
@@ -1,39 +1,39 @@
1
- import type { WaitlistOptions } from "./types";
2
-
3
- // The waitlist form's pure parts, kept out of the "use server" file so they can be tested and shared.
4
-
5
- /** The fields a waitlist form sends. `choice` is "plan:<slug>" or "beds:<n>", for a form with one picker. */
6
- export const WAITLIST_FIELDS = ["choice", "floorPlan", "bedrooms", "email", "name", "phone", "moveIn"] as const;
7
-
8
- export type WaitlistInput = { floorPlan?: string; bedrooms?: number; email: string; name?: string; phone?: string; moveIn?: string };
9
-
10
- const text = (fd: FormData, k: string) => {
11
- const v = fd.get(k);
12
- return typeof v === "string" && v.trim() ? v.trim() : undefined;
13
- };
14
-
15
- /**
16
- * Reads a submitted waitlist form. A plan page usually sends a hidden `floorPlan`; a general form sends one `choice`
17
- * from waitlistChoices(). A choice wins over the separate fields.
18
- */
19
- export function readWaitlistForm(fd: FormData): WaitlistInput {
20
- const choice = text(fd, "choice");
21
- let floorPlan = text(fd, "floorPlan");
22
- let bedrooms = text(fd, "bedrooms");
23
- if (choice?.startsWith("plan:")) [floorPlan, bedrooms] = [choice.slice(5), undefined];
24
- else if (choice?.startsWith("beds:")) [floorPlan, bedrooms] = [undefined, choice.slice(5)];
25
- const beds = bedrooms !== undefined && /^\d{1,2}$/.test(bedrooms) ? Number(bedrooms) : undefined;
26
- return { floorPlan, bedrooms: floorPlan ? undefined : beds, email: text(fd, "email") ?? "", name: text(fd, "name"), phone: text(fd, "phone"), moveIn: text(fd, "moveIn") };
27
- }
28
-
29
- /**
30
- * Options for one waitlist picker: each bedroom count ("Any 2 bedroom plan") and then each plan. Plans with a home
31
- * available now are left out unless asked for, since a visitor can apply for those today.
32
- */
33
- export function waitlistChoices(options: WaitlistOptions, opts: { includeAvailable?: boolean } = {}): { value: string; label: string }[] {
34
- const plans = options.floorPlans.filter((p) => opts.includeAvailable || !p.available);
35
- return [
36
- ...options.bedrooms.map((b) => ({ value: `beds:${b.value}`, label: b.value === 0 ? "Any studio" : `Any ${b.label.replace(/s$/, "")} plan` })),
37
- ...plans.map((p) => ({ value: `plan:${p.slug}`, label: p.name })),
38
- ];
39
- }
1
+ import type { WaitlistOptions } from "./types";
2
+
3
+ // The waitlist form's pure parts, kept out of the "use server" file so they can be tested and shared.
4
+
5
+ /** The fields a waitlist form sends. `choice` is "plan:<slug>" or "beds:<n>", for a form with one picker. */
6
+ export const WAITLIST_FIELDS = ["choice", "floorPlan", "bedrooms", "email", "name", "phone", "moveIn"] as const;
7
+
8
+ export type WaitlistInput = { floorPlan?: string; bedrooms?: number; email: string; name?: string; phone?: string; moveIn?: string };
9
+
10
+ const text = (fd: FormData, k: string) => {
11
+ const v = fd.get(k);
12
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
13
+ };
14
+
15
+ /**
16
+ * Reads a submitted waitlist form. A plan page usually sends a hidden `floorPlan`; a general form sends one `choice`
17
+ * from waitlistChoices(). A choice wins over the separate fields.
18
+ */
19
+ export function readWaitlistForm(fd: FormData): WaitlistInput {
20
+ const choice = text(fd, "choice");
21
+ let floorPlan = text(fd, "floorPlan");
22
+ let bedrooms = text(fd, "bedrooms");
23
+ if (choice?.startsWith("plan:")) [floorPlan, bedrooms] = [choice.slice(5), undefined];
24
+ else if (choice?.startsWith("beds:")) [floorPlan, bedrooms] = [undefined, choice.slice(5)];
25
+ const beds = bedrooms !== undefined && /^\d{1,2}$/.test(bedrooms) ? Number(bedrooms) : undefined;
26
+ return { floorPlan, bedrooms: floorPlan ? undefined : beds, email: text(fd, "email") ?? "", name: text(fd, "name"), phone: text(fd, "phone"), moveIn: text(fd, "moveIn") };
27
+ }
28
+
29
+ /**
30
+ * Options for one waitlist picker: each bedroom count ("Any 2 bedroom plan") and then each plan. Plans with a home
31
+ * available now are left out unless asked for, since a visitor can apply for those today.
32
+ */
33
+ export function waitlistChoices(options: WaitlistOptions, opts: { includeAvailable?: boolean } = {}): { value: string; label: string }[] {
34
+ const plans = options.floorPlans.filter((p) => opts.includeAvailable || !p.available);
35
+ return [
36
+ ...options.bedrooms.map((b) => ({ value: `beds:${b.value}`, label: b.value === 0 ? "Any studio" : `Any ${b.label.replace(/s$/, "")} plan` })),
37
+ ...plans.map((p) => ({ value: `plan:${p.slug}`, label: p.name })),
38
+ ];
39
+ }
package/src/waitlist.ts CHANGED
@@ -1,48 +1,48 @@
1
- "use server";
2
-
3
- import { createHash } from "node:crypto";
4
- import { headers } from "next/headers";
5
- import { bpRaw } from "./client";
6
- import { checkFormToken } from "./form-token";
7
- import type { WaitlistResult } from "./types";
8
- import { readWaitlistForm } from "./waitlist-form";
9
-
10
- async function isBot(): Promise<boolean> {
11
- try {
12
- const { checkBotId } = await import("botid/server");
13
- const r = await checkBotId();
14
- return r.isBot && !r.isVerifiedBot;
15
- } catch {
16
- // BotID isn't set up on this site. The honeypot and time check still apply.
17
- return false;
18
- }
19
- }
20
-
21
- /**
22
- * Adds a visitor to a floor plan's waitlist, or a bedroom count's, from the site's server. The same spam checks as
23
- * forms (FormGuard's honeypot and render time, and BotID) run first; suspected spam gets the usual thank-you and is
24
- * dropped. The answer is the same whether or not the visitor was already waiting.
25
- */
26
- export async function joinWaitlist(formData: FormData): Promise<WaitlistResult> {
27
- const token = checkFormToken(String(formData.get("bp_ts") ?? ""));
28
- const spam = String(formData.get("bp_hp") ?? "").trim() !== "" ? "honeypot" : !token.ok ? (token.reason === "too_fast" ? "too_fast" : "bot") : (await isBot()) ? "bot" : null;
29
- if (spam) return { ok: true, waitingFor: null };
30
-
31
- const h = await headers();
32
- const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
33
- const { status, json } = await bpRaw("/waitlist", {
34
- ...readWaitlistForm(formData),
35
- pageUrl: h.get("referer") ?? undefined,
36
- ipHash: ip ? createHash("sha256").update(ip).digest("hex") : undefined,
37
- });
38
- const body = json as { data?: { waitingFor: string | null }; error?: { fields?: Record<string, string> } } | null;
39
- if (status === 201 || status === 200) return { ok: true, waitingFor: body?.data?.waitingFor ?? null };
40
- if (status === 422) return { ok: false, error: "invalid", fields: body?.error?.fields ?? {} };
41
- if (status === 429) return { ok: false, error: "rate_limited" };
42
- return { ok: false, error: "unavailable" };
43
- }
44
-
45
- /** The same, shaped for React's useActionState: `useActionState(waitlistAction, null)`. */
46
- export async function waitlistAction(_prev: WaitlistResult | null, formData: FormData): Promise<WaitlistResult> {
47
- return joinWaitlist(formData);
48
- }
1
+ "use server";
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { headers } from "next/headers";
5
+ import { bpRaw } from "./client";
6
+ import { checkFormToken } from "./form-token";
7
+ import type { WaitlistResult } from "./types";
8
+ import { readWaitlistForm } from "./waitlist-form";
9
+
10
+ async function isBot(): Promise<boolean> {
11
+ try {
12
+ const { checkBotId } = await import("botid/server");
13
+ const r = await checkBotId();
14
+ return r.isBot && !r.isVerifiedBot;
15
+ } catch {
16
+ // BotID isn't set up on this site. The honeypot and time check still apply.
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Adds a visitor to a floor plan's waitlist, or a bedroom count's, from the site's server. The same spam checks as
23
+ * forms (FormGuard's honeypot and render time, and BotID) run first; suspected spam gets the usual thank-you and is
24
+ * dropped. The answer is the same whether or not the visitor was already waiting.
25
+ */
26
+ export async function joinWaitlist(formData: FormData): Promise<WaitlistResult> {
27
+ const token = checkFormToken(String(formData.get("bp_ts") ?? ""));
28
+ const spam = String(formData.get("bp_hp") ?? "").trim() !== "" ? "honeypot" : !token.ok ? (token.reason === "too_fast" ? "too_fast" : "bot") : (await isBot()) ? "bot" : null;
29
+ if (spam) return { ok: true, waitingFor: null };
30
+
31
+ const h = await headers();
32
+ const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
33
+ const { status, json } = await bpRaw("/waitlist", {
34
+ ...readWaitlistForm(formData),
35
+ pageUrl: h.get("referer") ?? undefined,
36
+ ipHash: ip ? createHash("sha256").update(ip).digest("hex") : undefined,
37
+ });
38
+ const body = json as { data?: { waitingFor: string | null }; error?: { fields?: Record<string, string> } } | null;
39
+ if (status === 201 || status === 200) return { ok: true, waitingFor: body?.data?.waitingFor ?? null };
40
+ if (status === 422) return { ok: false, error: "invalid", fields: body?.error?.fields ?? {} };
41
+ if (status === 429) return { ok: false, error: "rate_limited" };
42
+ return { ok: false, error: "unavailable" };
43
+ }
44
+
45
+ /** The same, shaped for React's useActionState: `useActionState(waitlistAction, null)`. */
46
+ export async function waitlistAction(_prev: WaitlistResult | null, formData: FormData): Promise<WaitlistResult> {
47
+ return joinWaitlist(formData);
48
+ }