@stack-mode/site-manager-kit 1.44.0 → 1.44.1
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/README.md +606 -606
- package/audit/ts-hooks.mjs +68 -68
- package/audit/wiring-audit.d.mts +37 -37
- package/audit/wiring-audit.mjs +604 -604
- package/bin/site-manager-kit.mjs +10 -10
- package/package.json +72 -72
- package/src/announcements.ts +13 -13
- package/src/attribution-fields.ts +52 -52
- package/src/attribution.tsx +47 -47
- package/src/blog.ts +122 -122
- package/src/chat-route.ts +136 -136
- package/src/chat-types.ts +76 -76
- package/src/client.ts +116 -116
- package/src/config.ts +78 -78
- package/src/copy-keys-route.ts +21 -21
- package/src/copy-keys.ts +77 -77
- package/src/data.ts +189 -189
- package/src/downloads.ts +7 -7
- package/src/faq.ts +21 -21
- package/src/floor-plans.ts +45 -45
- package/src/form-guard.tsx +37 -37
- package/src/form-types.ts +13 -13
- package/src/forms.ts +76 -76
- package/src/glossary.ts +19 -19
- package/src/health-route.ts +34 -34
- package/src/index.ts +36 -36
- package/src/indexnow.ts +40 -40
- package/src/listings.ts +38 -38
- package/src/metadata.ts +55 -55
- package/src/not-found-reporter.tsx +16 -16
- package/src/not-found.ts +53 -53
- package/src/payment.ts +70 -70
- package/src/phone-input.tsx +26 -26
- package/src/phone.ts +37 -37
- package/src/preview-route.ts +48 -48
- package/src/preview.ts +94 -94
- package/src/residents.ts +23 -23
- package/src/revalidate-route.ts +34 -34
- package/src/rss-route.ts +26 -26
- package/src/services.ts +52 -52
- package/src/site-chat-panel.tsx +537 -537
- package/src/site-chat.tsx +26 -26
- package/src/sitemap.ts +26 -26
- package/src/structured-data.tsx +207 -207
- package/src/types.ts +580 -580
- package/src/version.ts +1 -1
- package/src/visual-accents.ts +126 -126
- package/src/visual-bridge-client.tsx +1070 -1070
- package/src/visual-bridge.tsx +30 -30
- package/src/visual-clicks.ts +13 -13
- package/src/visual-lists.ts +37 -37
- package/src/visual-match.ts +77 -77
- package/src/visual-messages.ts +160 -160
- package/src/visual-mode.ts +18 -18
- package/src/visual-preview.ts +69 -69
- package/src/visual-tags.ts +90 -90
- package/src/visual.ts +59 -59
- package/src/waitlist-form.ts +39 -39
- package/src/waitlist.ts +48 -48
package/src/floor-plans.ts
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
import { formatMoney } from "./money";
|
|
2
|
-
import type { Availability, FloorPlan } from "./types";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* What a plan card should say about availability. Follows the site's setting in Site Manager: the count only when
|
|
6
|
-
* the site chose to show it. Falls back to availableCount for Site Manager versions without `availability`.
|
|
7
|
-
*/
|
|
8
|
-
export function availabilityText(plan: Pick<FloorPlan, "availability" | "availableCount">, labels: { available?: string; none?: string; unknown?: string } = {}): string | null {
|
|
9
|
-
const a: Availability = plan.availability ?? (plan.availableCount === null ? { status: "unknown", count: null } : { status: plan.availableCount > 0 ? "available" : "none", count: null });
|
|
10
|
-
if (a.status === "unknown") return labels.unknown ?? null;
|
|
11
|
-
if (a.status === "none") return labels.none ?? "Join the waitlist";
|
|
12
|
-
if (a.count !== null) return `${a.count} available`;
|
|
13
|
-
return labels.available ?? "Available now";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** "$2,207.12 effective with 6 weeks free" style copy for a plan with a running concession, or null. */
|
|
17
|
-
export function effectiveRentText(plan: Pick<FloorPlan, "concession">): string | null {
|
|
18
|
-
const c = plan.concession;
|
|
19
|
-
if (!c) return null;
|
|
20
|
-
return `${formatMoney(c.effectiveRent, { cents: true })} effective`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type ComparisonRow = { label: string; values: (string | null)[] };
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Rows for a side by side table of plans: the facts every plan has, then one row per feature any plan lists, marked
|
|
27
|
-
* "Yes" or left empty. Values are display text; null means the plan doesn't say.
|
|
28
|
-
*/
|
|
29
|
-
export function comparisonRows(plans: FloorPlan[]): ComparisonRow[] {
|
|
30
|
-
const sqft = (p: FloorPlan) => (p.sqftMin === null ? null : p.sqftMax && p.sqftMax !== p.sqftMin ? `${p.sqftMin.toLocaleString("en-US")} to ${p.sqftMax.toLocaleString("en-US")}` : p.sqftMin.toLocaleString("en-US"));
|
|
31
|
-
const layouts = { flat: "Single level", townhome: "Townhome", loft: "Loft", penthouse: "Penthouse" } as const;
|
|
32
|
-
const rows: ComparisonRow[] = [
|
|
33
|
-
{ label: "Bedrooms", values: plans.map((p) => (p.beds === 0 ? "Studio" : String(p.beds))) },
|
|
34
|
-
{ label: "Bathrooms", values: plans.map((p) => p.baths.replace(/\.0$/, "")) },
|
|
35
|
-
{ label: "Square feet", values: plans.map(sqft) },
|
|
36
|
-
{ label: "Starting at", values: plans.map((p) => formatMoney(p.startingRent)) },
|
|
37
|
-
{ label: "With special", values: plans.map((p) => effectiveRentText(p)) },
|
|
38
|
-
{ label: "Layout", values: plans.map((p) => (p.layout ? layouts[p.layout] : null)) },
|
|
39
|
-
{ label: "Levels", values: plans.map((p) => (p.levels ? String(p.levels) : null)) },
|
|
40
|
-
{ label: "Availability", values: plans.map((p) => availabilityText(p)) },
|
|
41
|
-
].filter((r) => r.values.some((v) => v !== null));
|
|
42
|
-
const features: string[] = [];
|
|
43
|
-
for (const p of plans) for (const f of p.features ?? []) if (!features.includes(f)) features.push(f);
|
|
44
|
-
return [...rows, ...features.map((f) => ({ label: f, values: plans.map((p) => ((p.features ?? []).includes(f) ? "Yes" : null)) }))];
|
|
45
|
-
}
|
|
1
|
+
import { formatMoney } from "./money";
|
|
2
|
+
import type { Availability, FloorPlan } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a plan card should say about availability. Follows the site's setting in Site Manager: the count only when
|
|
6
|
+
* the site chose to show it. Falls back to availableCount for Site Manager versions without `availability`.
|
|
7
|
+
*/
|
|
8
|
+
export function availabilityText(plan: Pick<FloorPlan, "availability" | "availableCount">, labels: { available?: string; none?: string; unknown?: string } = {}): string | null {
|
|
9
|
+
const a: Availability = plan.availability ?? (plan.availableCount === null ? { status: "unknown", count: null } : { status: plan.availableCount > 0 ? "available" : "none", count: null });
|
|
10
|
+
if (a.status === "unknown") return labels.unknown ?? null;
|
|
11
|
+
if (a.status === "none") return labels.none ?? "Join the waitlist";
|
|
12
|
+
if (a.count !== null) return `${a.count} available`;
|
|
13
|
+
return labels.available ?? "Available now";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** "$2,207.12 effective with 6 weeks free" style copy for a plan with a running concession, or null. */
|
|
17
|
+
export function effectiveRentText(plan: Pick<FloorPlan, "concession">): string | null {
|
|
18
|
+
const c = plan.concession;
|
|
19
|
+
if (!c) return null;
|
|
20
|
+
return `${formatMoney(c.effectiveRent, { cents: true })} effective`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ComparisonRow = { label: string; values: (string | null)[] };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rows for a side by side table of plans: the facts every plan has, then one row per feature any plan lists, marked
|
|
27
|
+
* "Yes" or left empty. Values are display text; null means the plan doesn't say.
|
|
28
|
+
*/
|
|
29
|
+
export function comparisonRows(plans: FloorPlan[]): ComparisonRow[] {
|
|
30
|
+
const sqft = (p: FloorPlan) => (p.sqftMin === null ? null : p.sqftMax && p.sqftMax !== p.sqftMin ? `${p.sqftMin.toLocaleString("en-US")} to ${p.sqftMax.toLocaleString("en-US")}` : p.sqftMin.toLocaleString("en-US"));
|
|
31
|
+
const layouts = { flat: "Single level", townhome: "Townhome", loft: "Loft", penthouse: "Penthouse" } as const;
|
|
32
|
+
const rows: ComparisonRow[] = [
|
|
33
|
+
{ label: "Bedrooms", values: plans.map((p) => (p.beds === 0 ? "Studio" : String(p.beds))) },
|
|
34
|
+
{ label: "Bathrooms", values: plans.map((p) => p.baths.replace(/\.0$/, "")) },
|
|
35
|
+
{ label: "Square feet", values: plans.map(sqft) },
|
|
36
|
+
{ label: "Starting at", values: plans.map((p) => formatMoney(p.startingRent)) },
|
|
37
|
+
{ label: "With special", values: plans.map((p) => effectiveRentText(p)) },
|
|
38
|
+
{ label: "Layout", values: plans.map((p) => (p.layout ? layouts[p.layout] : null)) },
|
|
39
|
+
{ label: "Levels", values: plans.map((p) => (p.levels ? String(p.levels) : null)) },
|
|
40
|
+
{ label: "Availability", values: plans.map((p) => availabilityText(p)) },
|
|
41
|
+
].filter((r) => r.values.some((v) => v !== null));
|
|
42
|
+
const features: string[] = [];
|
|
43
|
+
for (const p of plans) for (const f of p.features ?? []) if (!features.includes(f)) features.push(f);
|
|
44
|
+
return [...rows, ...features.map((f) => ({ label: f, values: plans.map((p) => ((p.features ?? []).includes(f) ? "Yes" : null)) }))];
|
|
45
|
+
}
|
package/src/form-guard.tsx
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import { connection } from "next/server";
|
|
2
|
-
import { Suspense } from "react";
|
|
3
|
-
import { AttributionFields } from "./attribution";
|
|
4
|
-
import { issueFormToken } from "./form-token";
|
|
5
|
-
|
|
6
|
-
export const HONEYPOT_FIELD = "bp_hp";
|
|
7
|
-
export const TOKEN_FIELD = "bp_ts";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Put inside every form that uses submitForm or formAction. It adds the signed render time and a hidden honeypot
|
|
11
|
-
* field that people never see but form-filling bots do. A server component: the token is signed on the server, for
|
|
12
|
-
* each visit, because a token signed once at build time would make every form on a static page "too old" a day
|
|
13
|
-
* later. The token sits in its own Suspense boundary, so with Cache Components the rest of the page stays static and
|
|
14
|
-
* only the token is filled in per request. It also carries where the visitor first came from (AttributionFields),
|
|
15
|
-
* when the layout has <SiteAttribution />.
|
|
16
|
-
*/
|
|
17
|
-
export function FormGuard() {
|
|
18
|
-
return (
|
|
19
|
-
<>
|
|
20
|
-
<Suspense fallback={null}>
|
|
21
|
-
<FormToken />
|
|
22
|
-
</Suspense>
|
|
23
|
-
<AttributionFields />
|
|
24
|
-
<div aria-hidden="true" style={{ position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }}>
|
|
25
|
-
<label>
|
|
26
|
-
Leave this empty
|
|
27
|
-
<input type="text" name={HONEYPOT_FIELD} tabIndex={-1} autoComplete="off" defaultValue="" />
|
|
28
|
-
</label>
|
|
29
|
-
</div>
|
|
30
|
-
</>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function FormToken() {
|
|
35
|
-
await connection();
|
|
36
|
-
return <input type="hidden" name={TOKEN_FIELD} value={issueFormToken()} />;
|
|
37
|
-
}
|
|
1
|
+
import { connection } from "next/server";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { AttributionFields } from "./attribution";
|
|
4
|
+
import { issueFormToken } from "./form-token";
|
|
5
|
+
|
|
6
|
+
export const HONEYPOT_FIELD = "bp_hp";
|
|
7
|
+
export const TOKEN_FIELD = "bp_ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Put inside every form that uses submitForm or formAction. It adds the signed render time and a hidden honeypot
|
|
11
|
+
* field that people never see but form-filling bots do. A server component: the token is signed on the server, for
|
|
12
|
+
* each visit, because a token signed once at build time would make every form on a static page "too old" a day
|
|
13
|
+
* later. The token sits in its own Suspense boundary, so with Cache Components the rest of the page stays static and
|
|
14
|
+
* only the token is filled in per request. It also carries where the visitor first came from (AttributionFields),
|
|
15
|
+
* when the layout has <SiteAttribution />.
|
|
16
|
+
*/
|
|
17
|
+
export function FormGuard() {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<Suspense fallback={null}>
|
|
21
|
+
<FormToken />
|
|
22
|
+
</Suspense>
|
|
23
|
+
<AttributionFields />
|
|
24
|
+
<div aria-hidden="true" style={{ position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }}>
|
|
25
|
+
<label>
|
|
26
|
+
Leave this empty
|
|
27
|
+
<input type="text" name={HONEYPOT_FIELD} tabIndex={-1} autoComplete="off" defaultValue="" />
|
|
28
|
+
</label>
|
|
29
|
+
</div>
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function FormToken() {
|
|
35
|
+
await connection();
|
|
36
|
+
return <input type="hidden" name={TOKEN_FIELD} value={issueFormToken()} />;
|
|
37
|
+
}
|
package/src/form-types.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
export type SubmitResult =
|
|
2
|
-
| { ok: true; id: string; successMessage: string }
|
|
3
|
-
| { ok: false; error: "invalid"; fields: Record<string, string> }
|
|
4
|
-
// A booking form's time was taken by someone else a moment ago: show the open times again.
|
|
5
|
-
| { ok: false; error: "slot_taken"; fields: Record<string, string> }
|
|
6
|
-
| { ok: false; error: "rejected" | "too_fast" | "rate_limited" | "unavailable" };
|
|
7
|
-
|
|
8
|
-
/** A booking form's chosen time: the `start` of one of getBookingSlots' slots. (Not in forms.ts: a "use server" file
|
|
9
|
-
* can only export async functions.) */
|
|
10
|
-
export const SLOT_FIELD = "bp_slot";
|
|
11
|
-
|
|
12
|
-
/** Why the website's own checks thought a submission was spam. */
|
|
13
|
-
export type SpamReason = "honeypot" | "too_fast" | "bot";
|
|
1
|
+
export type SubmitResult =
|
|
2
|
+
| { ok: true; id: string; successMessage: string }
|
|
3
|
+
| { ok: false; error: "invalid"; fields: Record<string, string> }
|
|
4
|
+
// A booking form's time was taken by someone else a moment ago: show the open times again.
|
|
5
|
+
| { ok: false; error: "slot_taken"; fields: Record<string, string> }
|
|
6
|
+
| { ok: false; error: "rejected" | "too_fast" | "rate_limited" | "unavailable" };
|
|
7
|
+
|
|
8
|
+
/** A booking form's chosen time: the `start` of one of getBookingSlots' slots. (Not in forms.ts: a "use server" file
|
|
9
|
+
* can only export async functions.) */
|
|
10
|
+
export const SLOT_FIELD = "bp_slot";
|
|
11
|
+
|
|
12
|
+
/** Why the website's own checks thought a submission was spam. */
|
|
13
|
+
export type SpamReason = "honeypot" | "too_fast" | "bot";
|
package/src/forms.ts
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
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 { ATTRIBUTION_FIELD_NAMES, attributionFrom } from "./attribution-fields";
|
|
8
|
-
import { SLOT_FIELD, type SpamReason, type SubmitResult } from "./form-types";
|
|
9
|
-
|
|
10
|
-
const GUARD_FIELDS = new Set(["bp_hp", "bp_ts"]);
|
|
11
|
-
|
|
12
|
-
async function isBot(): Promise<boolean> {
|
|
13
|
-
try {
|
|
14
|
-
const { checkBotId } = await import("botid/server");
|
|
15
|
-
const r = await checkBotId();
|
|
16
|
-
return r.isBot && !r.isVerifiedBot;
|
|
17
|
-
} catch {
|
|
18
|
-
// BotID isn't set up on this site. The honeypot and time check still apply.
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type SubmitOptions = {
|
|
24
|
-
/**
|
|
25
|
-
* The site checked how long the form was open itself (its own signed timer), or the send has no form at all (a
|
|
26
|
-
* remembered visitor's one-click download), so there's no FormGuard timer to check. The honeypot (when sent) and
|
|
27
|
-
* BotID still apply. Use only after the site's own checks; anything else should render FormGuard.
|
|
28
|
-
*/
|
|
29
|
-
timedBySite?: boolean;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Sends one form to Site Manager from the site's server. Checks BotID, the honeypot and the 3-second render
|
|
34
|
-
* time first, hashes the visitor's IP (Site Manager never sees it), then forwards the fields.
|
|
35
|
-
*
|
|
36
|
-
* It returns the result, so use it from server code, or use formAction with useActionState in a client form.
|
|
37
|
-
*/
|
|
38
|
-
export async function submitForm(formKey: string, formData: FormData, opts: SubmitOptions = {}): Promise<SubmitResult> {
|
|
39
|
-
// Spam checks: a filled honeypot, a form sent too fast (or with a missing or stale timer), or BotID. Suspected
|
|
40
|
-
// spam still goes to Site Manager, marked, for its spam folder (where a real person caught by mistake can be
|
|
41
|
-
// rescued), and the visitor sees the usual thank-you, so bots learn nothing.
|
|
42
|
-
const token = opts.timedBySite ? { ok: true as const } : checkFormToken(String(formData.get("bp_ts") ?? ""));
|
|
43
|
-
const spam: SpamReason | null = String(formData.get("bp_hp") ?? "").trim() !== "" ? "honeypot" : !token.ok ? (token.reason === "too_fast" ? "too_fast" : "bot") : (await isBot()) ? "bot" : null;
|
|
44
|
-
|
|
45
|
-
const h = await headers();
|
|
46
|
-
const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
|
|
47
|
-
const data: Record<string, string> = {};
|
|
48
|
-
for (const [k, v] of formData.entries()) {
|
|
49
|
-
if (GUARD_FIELDS.has(k) || ATTRIBUTION_FIELD_NAMES.has(k) || k === SLOT_FIELD || k.startsWith("$ACTION") || typeof v !== "string") continue;
|
|
50
|
-
data[k] = v;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const { status, json } = await bpRaw(`/forms/${encodeURIComponent(formKey)}/submit`, {
|
|
54
|
-
data,
|
|
55
|
-
pageUrl: h.get("referer") ?? undefined,
|
|
56
|
-
ipHash: ip ? createHash("sha256").update(ip).digest("hex") : undefined,
|
|
57
|
-
userAgent: h.get("user-agent")?.slice(0, 500) ?? undefined,
|
|
58
|
-
attribution: attributionFrom(formData),
|
|
59
|
-
slot: typeof formData.get(SLOT_FIELD) === "string" && formData.get(SLOT_FIELD) ? String(formData.get(SLOT_FIELD)) : undefined,
|
|
60
|
-
// A Site Manager from before the spam folder ignores `spam` but still refuses a filled honeypot, so spam is
|
|
61
|
-
// never delivered as a lead there either.
|
|
62
|
-
...(spam ? { spam, hp: `kit:${spam}` } : {}),
|
|
63
|
-
});
|
|
64
|
-
const body = json as { data?: { id: string; successMessage: string }; error?: { code: string; fields?: Record<string, string> } } | null;
|
|
65
|
-
if (spam) return { ok: true, id: body?.data?.id ?? "", successMessage: body?.data?.successMessage ?? "Thanks! We'll be in touch soon." };
|
|
66
|
-
if (status === 201 && body?.data) return { ok: true, id: body.data.id, successMessage: body.data.successMessage };
|
|
67
|
-
if (status === 422) return { ok: false, error: "invalid", fields: body?.error?.fields ?? {} };
|
|
68
|
-
if (status === 409) return { ok: false, error: "slot_taken", fields: body?.error?.fields ?? {} };
|
|
69
|
-
if (status === 429) return { ok: false, error: "rate_limited" };
|
|
70
|
-
return { ok: false, error: "unavailable" };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The same, shaped for React's useActionState: `useActionState(formAction.bind(null, "inquiry"), null)`. */
|
|
74
|
-
export async function formAction(formKey: string, _prev: SubmitResult | null, formData: FormData): Promise<SubmitResult> {
|
|
75
|
-
return submitForm(formKey, formData);
|
|
76
|
-
}
|
|
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 { ATTRIBUTION_FIELD_NAMES, attributionFrom } from "./attribution-fields";
|
|
8
|
+
import { SLOT_FIELD, type SpamReason, type SubmitResult } from "./form-types";
|
|
9
|
+
|
|
10
|
+
const GUARD_FIELDS = new Set(["bp_hp", "bp_ts"]);
|
|
11
|
+
|
|
12
|
+
async function isBot(): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
const { checkBotId } = await import("botid/server");
|
|
15
|
+
const r = await checkBotId();
|
|
16
|
+
return r.isBot && !r.isVerifiedBot;
|
|
17
|
+
} catch {
|
|
18
|
+
// BotID isn't set up on this site. The honeypot and time check still apply.
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SubmitOptions = {
|
|
24
|
+
/**
|
|
25
|
+
* The site checked how long the form was open itself (its own signed timer), or the send has no form at all (a
|
|
26
|
+
* remembered visitor's one-click download), so there's no FormGuard timer to check. The honeypot (when sent) and
|
|
27
|
+
* BotID still apply. Use only after the site's own checks; anything else should render FormGuard.
|
|
28
|
+
*/
|
|
29
|
+
timedBySite?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sends one form to Site Manager from the site's server. Checks BotID, the honeypot and the 3-second render
|
|
34
|
+
* time first, hashes the visitor's IP (Site Manager never sees it), then forwards the fields.
|
|
35
|
+
*
|
|
36
|
+
* It returns the result, so use it from server code, or use formAction with useActionState in a client form.
|
|
37
|
+
*/
|
|
38
|
+
export async function submitForm(formKey: string, formData: FormData, opts: SubmitOptions = {}): Promise<SubmitResult> {
|
|
39
|
+
// Spam checks: a filled honeypot, a form sent too fast (or with a missing or stale timer), or BotID. Suspected
|
|
40
|
+
// spam still goes to Site Manager, marked, for its spam folder (where a real person caught by mistake can be
|
|
41
|
+
// rescued), and the visitor sees the usual thank-you, so bots learn nothing.
|
|
42
|
+
const token = opts.timedBySite ? { ok: true as const } : checkFormToken(String(formData.get("bp_ts") ?? ""));
|
|
43
|
+
const spam: SpamReason | null = String(formData.get("bp_hp") ?? "").trim() !== "" ? "honeypot" : !token.ok ? (token.reason === "too_fast" ? "too_fast" : "bot") : (await isBot()) ? "bot" : null;
|
|
44
|
+
|
|
45
|
+
const h = await headers();
|
|
46
|
+
const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
|
|
47
|
+
const data: Record<string, string> = {};
|
|
48
|
+
for (const [k, v] of formData.entries()) {
|
|
49
|
+
if (GUARD_FIELDS.has(k) || ATTRIBUTION_FIELD_NAMES.has(k) || k === SLOT_FIELD || k.startsWith("$ACTION") || typeof v !== "string") continue;
|
|
50
|
+
data[k] = v;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { status, json } = await bpRaw(`/forms/${encodeURIComponent(formKey)}/submit`, {
|
|
54
|
+
data,
|
|
55
|
+
pageUrl: h.get("referer") ?? undefined,
|
|
56
|
+
ipHash: ip ? createHash("sha256").update(ip).digest("hex") : undefined,
|
|
57
|
+
userAgent: h.get("user-agent")?.slice(0, 500) ?? undefined,
|
|
58
|
+
attribution: attributionFrom(formData),
|
|
59
|
+
slot: typeof formData.get(SLOT_FIELD) === "string" && formData.get(SLOT_FIELD) ? String(formData.get(SLOT_FIELD)) : undefined,
|
|
60
|
+
// A Site Manager from before the spam folder ignores `spam` but still refuses a filled honeypot, so spam is
|
|
61
|
+
// never delivered as a lead there either.
|
|
62
|
+
...(spam ? { spam, hp: `kit:${spam}` } : {}),
|
|
63
|
+
});
|
|
64
|
+
const body = json as { data?: { id: string; successMessage: string }; error?: { code: string; fields?: Record<string, string> } } | null;
|
|
65
|
+
if (spam) return { ok: true, id: body?.data?.id ?? "", successMessage: body?.data?.successMessage ?? "Thanks! We'll be in touch soon." };
|
|
66
|
+
if (status === 201 && body?.data) return { ok: true, id: body.data.id, successMessage: body.data.successMessage };
|
|
67
|
+
if (status === 422) return { ok: false, error: "invalid", fields: body?.error?.fields ?? {} };
|
|
68
|
+
if (status === 409) return { ok: false, error: "slot_taken", fields: body?.error?.fields ?? {} };
|
|
69
|
+
if (status === 429) return { ok: false, error: "rate_limited" };
|
|
70
|
+
return { ok: false, error: "unavailable" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The same, shaped for React's useActionState: `useActionState(formAction.bind(null, "inquiry"), null)`. */
|
|
74
|
+
export async function formAction(formKey: string, _prev: SubmitResult | null, formData: FormData): Promise<SubmitResult> {
|
|
75
|
+
return submitForm(formKey, formData);
|
|
76
|
+
}
|
package/src/glossary.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type { GlossaryTerm } from "./types";
|
|
2
|
-
|
|
3
|
-
type Json = Record<string, unknown>;
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* schema.org `DefinedTerm` structured data for a term's own page, in the glossary named `setName` at `setUrl`.
|
|
7
|
-
* Render it with `jsonLdScript`.
|
|
8
|
-
*/
|
|
9
|
-
export function definedTermJsonLd(t: Pick<GlossaryTerm, "term" | "shortDefinition" | "url" | "alsoKnownAs">, set: { setName: string; setUrl?: string | null }): Json {
|
|
10
|
-
return {
|
|
11
|
-
"@context": "https://schema.org",
|
|
12
|
-
"@type": "DefinedTerm",
|
|
13
|
-
name: t.term,
|
|
14
|
-
description: t.shortDefinition,
|
|
15
|
-
...(t.url ? { url: t.url } : {}),
|
|
16
|
-
...(t.alsoKnownAs.length ? { alternateName: t.alsoKnownAs } : {}),
|
|
17
|
-
inDefinedTermSet: { "@type": "DefinedTermSet", name: set.setName, ...(set.setUrl ? { url: set.setUrl } : {}) },
|
|
18
|
-
};
|
|
19
|
-
}
|
|
1
|
+
import type { GlossaryTerm } from "./types";
|
|
2
|
+
|
|
3
|
+
type Json = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* schema.org `DefinedTerm` structured data for a term's own page, in the glossary named `setName` at `setUrl`.
|
|
7
|
+
* Render it with `jsonLdScript`.
|
|
8
|
+
*/
|
|
9
|
+
export function definedTermJsonLd(t: Pick<GlossaryTerm, "term" | "shortDefinition" | "url" | "alsoKnownAs">, set: { setName: string; setUrl?: string | null }): Json {
|
|
10
|
+
return {
|
|
11
|
+
"@context": "https://schema.org",
|
|
12
|
+
"@type": "DefinedTerm",
|
|
13
|
+
name: t.term,
|
|
14
|
+
description: t.shortDefinition,
|
|
15
|
+
...(t.url ? { url: t.url } : {}),
|
|
16
|
+
...(t.alsoKnownAs.length ? { alternateName: t.alsoKnownAs } : {}),
|
|
17
|
+
inDefinedTermSet: { "@type": "DefinedTermSet", name: set.setName, ...(set.setUrl ? { url: set.setUrl } : {}) },
|
|
18
|
+
};
|
|
19
|
+
}
|
package/src/health-route.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import { SIGNATURE_HEADER, verify } from "./signature";
|
|
2
|
-
import { KIT_VERSION } from "./version";
|
|
3
|
-
|
|
4
|
-
async function botIdInstalled(): Promise<boolean> {
|
|
5
|
-
try {
|
|
6
|
-
await import("botid/server");
|
|
7
|
-
return true;
|
|
8
|
-
} catch {
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** The deployment serving this site (Vercel sets these), so Site Manager can tell a new deploy happened. */
|
|
14
|
-
function deployId(): string | null {
|
|
15
|
-
return process.env.VERCEL_DEPLOYMENT_ID?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Site Manager calls this to show Form protection and Site connection in its System Status. Put in
|
|
20
|
-
* app/api/blueprint/health/route.ts:
|
|
21
|
-
*
|
|
22
|
-
* export { GET } from "site-manager-kit/health-route";
|
|
23
|
-
*
|
|
24
|
-
* Signed with the revalidate secret over an empty body. Unsigned requests get a 401. `deployId` changing tells Site
|
|
25
|
-
* Manager the site was deployed, so it runs its wiring check the same day.
|
|
26
|
-
*/
|
|
27
|
-
export async function GET(req: Request): Promise<Response> {
|
|
28
|
-
const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), "");
|
|
29
|
-
if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
|
|
30
|
-
return Response.json(
|
|
31
|
-
{ kitVersion: KIT_VERSION, botId: await botIdInstalled(), formSecret: !!process.env.BLUEPRINT_FORM_SECRET?.trim(), revalidate: true, deployId: deployId() },
|
|
32
|
-
{ headers: { "cache-control": "no-store" } },
|
|
33
|
-
);
|
|
34
|
-
}
|
|
1
|
+
import { SIGNATURE_HEADER, verify } from "./signature";
|
|
2
|
+
import { KIT_VERSION } from "./version";
|
|
3
|
+
|
|
4
|
+
async function botIdInstalled(): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
await import("botid/server");
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The deployment serving this site (Vercel sets these), so Site Manager can tell a new deploy happened. */
|
|
14
|
+
function deployId(): string | null {
|
|
15
|
+
return process.env.VERCEL_DEPLOYMENT_ID?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Site Manager calls this to show Form protection and Site connection in its System Status. Put in
|
|
20
|
+
* app/api/blueprint/health/route.ts:
|
|
21
|
+
*
|
|
22
|
+
* export { GET } from "site-manager-kit/health-route";
|
|
23
|
+
*
|
|
24
|
+
* Signed with the revalidate secret over an empty body. Unsigned requests get a 401. `deployId` changing tells Site
|
|
25
|
+
* Manager the site was deployed, so it runs its wiring check the same day.
|
|
26
|
+
*/
|
|
27
|
+
export async function GET(req: Request): Promise<Response> {
|
|
28
|
+
const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), "");
|
|
29
|
+
if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
|
|
30
|
+
return Response.json(
|
|
31
|
+
{ kitVersion: KIT_VERSION, botId: await botIdInstalled(), formSecret: !!process.env.BLUEPRINT_FORM_SECRET?.trim(), revalidate: true, deployId: deployId() },
|
|
32
|
+
{ headers: { "cache-control": "no-store" } },
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
// Server-side helpers for a Next.js site that reads its content from Site Manager.
|
|
2
|
-
// Route handlers, the form action and the form guard have their own entry points:
|
|
3
|
-
// site-manager-kit/revalidate-route, site-manager-kit/health-route, site-manager-kit/forms, site-manager-kit/waitlist,
|
|
4
|
-
// site-manager-kit/form-guard, site-manager-kit/redirects, site-manager-kit/preview-route, site-manager-kit/rss-route,
|
|
5
|
-
// site-manager-kit/visual, site-manager-kit/copy-keys-route, site-manager-kit/chat-route. A site's src/copy-keys.ts uses site-manager-kit/copy-keys.
|
|
6
|
-
// next.config.ts uses
|
|
7
|
-
// site-manager-kit/config, and the root layout site-manager-kit/analytics, site-manager-kit/attribution and
|
|
8
|
-
// site-manager-kit/preview-banner, site-manager-kit/visual-editing and site-manager-kit/chat.
|
|
9
|
-
|
|
10
|
-
export { bp, bpWithMeta, BlueprintError, siteId, tagsFor } from "./client";
|
|
11
|
-
export * from "./data";
|
|
12
|
-
export { blogPostingJsonLd, buildRss, getBlogCategories, getBlogCategory, getBlogFeed, getBlogTags, getPost, getPosts } from "./blog";
|
|
13
|
-
export { buildMetadata, defaultMetadata, pageMetadata, titleWithSuffix, TITLE_CHAR_LIMIT } from "./metadata";
|
|
14
|
-
export { buildSitemap, sitemap } from "./sitemap";
|
|
15
|
-
export { buildRobots, robots } from "./robots";
|
|
16
|
-
export { buildRedirectMap, handleRedirect, matchRedirect, normalizePath } from "./redirects";
|
|
17
|
-
export { formatMoney } from "./money";
|
|
18
|
-
export { availabilityText, comparisonRows, effectiveRentText, type ComparisonRow } from "./floor-plans";
|
|
19
|
-
export { bathsText, hoaText, listingAddress, listingPrice, lotText } from "./listings";
|
|
20
|
-
export { estimatePayment } from "./payment";
|
|
21
|
-
export { serviceJsonLd } from "./services";
|
|
22
|
-
export { leaseTermsText, pickupText } from "./residents";
|
|
23
|
-
export { faqJsonLd, jsonLdScript } from "./faq";
|
|
24
|
-
export { absoluteUrl, breadcrumbJsonLd, breadcrumbsFor, businessJsonLd, eventJsonLd, floorPlanJsonLd, listingJsonLd, openingHours, placeMapUrl, plainText, StructuredData } from "./structured-data";
|
|
25
|
-
export { definedTermJsonLd } from "./glossary";
|
|
26
|
-
export { announcementsLiveAt } from "./announcements";
|
|
27
|
-
export { fileSizeText } from "./downloads";
|
|
28
|
-
export { imageProps } from "./image";
|
|
29
|
-
export { sign, verify, SIGNATURE_HEADER } from "./signature";
|
|
30
|
-
export { SLOT_FIELD, type SubmitResult } from "./form-types";
|
|
31
|
-
export { readWaitlistForm, waitlistChoices, WAITLIST_FIELDS, type WaitlistInput } from "./waitlist-form";
|
|
32
|
-
export { formatUsPhone, maskPhoneInput, normalizeUsPhone, phoneDigits } from "./phone";
|
|
33
|
-
export { attributionFrom, firstTouch, ATTRIBUTION_FIELDS } from "./attribution-fields";
|
|
34
|
-
export type * from "./types";
|
|
35
|
-
export { parseChatLines, type ChatConfig, type ChatEvent, type ChatSlots } from "./chat-types";
|
|
36
|
-
export { KIT_VERSION } from "./version";
|
|
1
|
+
// Server-side helpers for a Next.js site that reads its content from Site Manager.
|
|
2
|
+
// Route handlers, the form action and the form guard have their own entry points:
|
|
3
|
+
// site-manager-kit/revalidate-route, site-manager-kit/health-route, site-manager-kit/forms, site-manager-kit/waitlist,
|
|
4
|
+
// site-manager-kit/form-guard, site-manager-kit/redirects, site-manager-kit/preview-route, site-manager-kit/rss-route,
|
|
5
|
+
// site-manager-kit/visual, site-manager-kit/copy-keys-route, site-manager-kit/chat-route. A site's src/copy-keys.ts uses site-manager-kit/copy-keys.
|
|
6
|
+
// next.config.ts uses
|
|
7
|
+
// site-manager-kit/config, and the root layout site-manager-kit/analytics, site-manager-kit/attribution and
|
|
8
|
+
// site-manager-kit/preview-banner, site-manager-kit/visual-editing and site-manager-kit/chat.
|
|
9
|
+
|
|
10
|
+
export { bp, bpWithMeta, BlueprintError, siteId, tagsFor } from "./client";
|
|
11
|
+
export * from "./data";
|
|
12
|
+
export { blogPostingJsonLd, buildRss, getBlogCategories, getBlogCategory, getBlogFeed, getBlogTags, getPost, getPosts } from "./blog";
|
|
13
|
+
export { buildMetadata, defaultMetadata, pageMetadata, titleWithSuffix, TITLE_CHAR_LIMIT } from "./metadata";
|
|
14
|
+
export { buildSitemap, sitemap } from "./sitemap";
|
|
15
|
+
export { buildRobots, robots } from "./robots";
|
|
16
|
+
export { buildRedirectMap, handleRedirect, matchRedirect, normalizePath } from "./redirects";
|
|
17
|
+
export { formatMoney } from "./money";
|
|
18
|
+
export { availabilityText, comparisonRows, effectiveRentText, type ComparisonRow } from "./floor-plans";
|
|
19
|
+
export { bathsText, hoaText, listingAddress, listingPrice, lotText } from "./listings";
|
|
20
|
+
export { estimatePayment } from "./payment";
|
|
21
|
+
export { serviceJsonLd } from "./services";
|
|
22
|
+
export { leaseTermsText, pickupText } from "./residents";
|
|
23
|
+
export { faqJsonLd, jsonLdScript } from "./faq";
|
|
24
|
+
export { absoluteUrl, breadcrumbJsonLd, breadcrumbsFor, businessJsonLd, eventJsonLd, floorPlanJsonLd, listingJsonLd, openingHours, placeMapUrl, plainText, StructuredData } from "./structured-data";
|
|
25
|
+
export { definedTermJsonLd } from "./glossary";
|
|
26
|
+
export { announcementsLiveAt } from "./announcements";
|
|
27
|
+
export { fileSizeText } from "./downloads";
|
|
28
|
+
export { imageProps } from "./image";
|
|
29
|
+
export { sign, verify, SIGNATURE_HEADER } from "./signature";
|
|
30
|
+
export { SLOT_FIELD, type SubmitResult } from "./form-types";
|
|
31
|
+
export { readWaitlistForm, waitlistChoices, WAITLIST_FIELDS, type WaitlistInput } from "./waitlist-form";
|
|
32
|
+
export { formatUsPhone, maskPhoneInput, normalizeUsPhone, phoneDigits } from "./phone";
|
|
33
|
+
export { attributionFrom, firstTouch, ATTRIBUTION_FIELDS } from "./attribution-fields";
|
|
34
|
+
export type * from "./types";
|
|
35
|
+
export { parseChatLines, type ChatConfig, type ChatEvent, type ChatSlots } from "./chat-types";
|
|
36
|
+
export { KIT_VERSION } from "./version";
|
package/src/indexnow.ts
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
-
import { bp } from "./client";
|
|
3
|
-
|
|
4
|
-
// IndexNow: Site Manager tells search engines (Bing and others; not Google) when pages change, and they check the
|
|
5
|
-
// site serves the same key at /{key}.txt. The key comes from Site Manager, kept in memory for an hour.
|
|
6
|
-
|
|
7
|
-
const TTL_MS = 60 * 60_000;
|
|
8
|
-
let cache: { at: number; key: string | null } | null = null;
|
|
9
|
-
|
|
10
|
-
/** The IndexNow key Site Manager uses for this site, or null when IndexNow isn't set up. */
|
|
11
|
-
export async function indexNowKey(): Promise<string | null> {
|
|
12
|
-
if (cache && Date.now() - cache.at < TTL_MS) return cache.key;
|
|
13
|
-
try {
|
|
14
|
-
const r = await bp<{ key: string | null }>("/indexnow", { cache: "no-store" });
|
|
15
|
-
cache = { at: Date.now(), key: r?.key ?? null };
|
|
16
|
-
} catch {
|
|
17
|
-
// Site Manager unreachable: don't remember the failure, try again next time.
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
return cache.key;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Clears the remembered key (tests, or after Site Manager's key changes). */
|
|
24
|
-
export function resetIndexNowKey() {
|
|
25
|
-
cache = null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const KEY_FILE = /^\/([A-Za-z0-9-]{8,128})\.txt$/;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* For `src/proxy.ts`: answers `/{key}.txt` with the key when it matches Site Manager's, or undefined to carry on.
|
|
32
|
-
* Any other .txt address passes through untouched.
|
|
33
|
-
*/
|
|
34
|
-
export async function handleIndexNow(request: NextRequest): Promise<NextResponse | undefined> {
|
|
35
|
-
const m = KEY_FILE.exec(request.nextUrl.pathname);
|
|
36
|
-
if (!m) return undefined;
|
|
37
|
-
const key = await indexNowKey();
|
|
38
|
-
if (!key || key !== m[1]) return undefined;
|
|
39
|
-
return new NextResponse(key, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" } });
|
|
40
|
-
}
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { bp } from "./client";
|
|
3
|
+
|
|
4
|
+
// IndexNow: Site Manager tells search engines (Bing and others; not Google) when pages change, and they check the
|
|
5
|
+
// site serves the same key at /{key}.txt. The key comes from Site Manager, kept in memory for an hour.
|
|
6
|
+
|
|
7
|
+
const TTL_MS = 60 * 60_000;
|
|
8
|
+
let cache: { at: number; key: string | null } | null = null;
|
|
9
|
+
|
|
10
|
+
/** The IndexNow key Site Manager uses for this site, or null when IndexNow isn't set up. */
|
|
11
|
+
export async function indexNowKey(): Promise<string | null> {
|
|
12
|
+
if (cache && Date.now() - cache.at < TTL_MS) return cache.key;
|
|
13
|
+
try {
|
|
14
|
+
const r = await bp<{ key: string | null }>("/indexnow", { cache: "no-store" });
|
|
15
|
+
cache = { at: Date.now(), key: r?.key ?? null };
|
|
16
|
+
} catch {
|
|
17
|
+
// Site Manager unreachable: don't remember the failure, try again next time.
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return cache.key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Clears the remembered key (tests, or after Site Manager's key changes). */
|
|
24
|
+
export function resetIndexNowKey() {
|
|
25
|
+
cache = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const KEY_FILE = /^\/([A-Za-z0-9-]{8,128})\.txt$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* For `src/proxy.ts`: answers `/{key}.txt` with the key when it matches Site Manager's, or undefined to carry on.
|
|
32
|
+
* Any other .txt address passes through untouched.
|
|
33
|
+
*/
|
|
34
|
+
export async function handleIndexNow(request: NextRequest): Promise<NextResponse | undefined> {
|
|
35
|
+
const m = KEY_FILE.exec(request.nextUrl.pathname);
|
|
36
|
+
if (!m) return undefined;
|
|
37
|
+
const key = await indexNowKey();
|
|
38
|
+
if (!key || key !== m[1]) return undefined;
|
|
39
|
+
return new NextResponse(key, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" } });
|
|
40
|
+
}
|