@stack-mode/site-manager-kit 0.0.0-stage → 1.44.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 (67) hide show
  1. package/README.md +606 -3
  2. package/audit/ts-hooks.mjs +68 -0
  3. package/audit/wiring-audit.d.mts +37 -0
  4. package/audit/wiring-audit.mjs +604 -0
  5. package/bin/site-manager-kit.mjs +10 -0
  6. package/package.json +72 -6
  7. package/src/analytics.tsx +23 -0
  8. package/src/announcements.ts +13 -0
  9. package/src/attribution-fields.ts +52 -0
  10. package/src/attribution.tsx +47 -0
  11. package/src/blog.ts +122 -0
  12. package/src/chat-route.ts +136 -0
  13. package/src/chat-types.ts +76 -0
  14. package/src/client.ts +116 -0
  15. package/src/config.ts +78 -0
  16. package/src/copy-keys-route.ts +21 -0
  17. package/src/copy-keys.ts +77 -0
  18. package/src/data.ts +189 -0
  19. package/src/downloads.ts +7 -0
  20. package/src/faq.ts +21 -0
  21. package/src/floor-plans.ts +45 -0
  22. package/src/form-guard.tsx +37 -0
  23. package/src/form-token.ts +33 -0
  24. package/src/form-types.ts +13 -0
  25. package/src/forms.ts +76 -0
  26. package/src/glossary.ts +19 -0
  27. package/src/health-route.ts +34 -0
  28. package/src/image.ts +12 -0
  29. package/src/index.ts +36 -0
  30. package/src/indexnow.ts +40 -0
  31. package/src/listings.ts +38 -0
  32. package/src/metadata.ts +55 -0
  33. package/src/money.ts +10 -0
  34. package/src/not-found-reporter.tsx +16 -0
  35. package/src/not-found.ts +53 -0
  36. package/src/payment.ts +70 -0
  37. package/src/phone-input.tsx +26 -0
  38. package/src/phone.ts +37 -0
  39. package/src/preview-banner.tsx +49 -0
  40. package/src/preview-route.ts +48 -0
  41. package/src/preview.ts +94 -0
  42. package/src/redirects.ts +57 -0
  43. package/src/residents.ts +23 -0
  44. package/src/revalidate-route.ts +34 -0
  45. package/src/robots.ts +26 -0
  46. package/src/rss-route.ts +26 -0
  47. package/src/services.ts +52 -0
  48. package/src/signature.ts +23 -0
  49. package/src/site-chat-panel.tsx +537 -0
  50. package/src/site-chat.tsx +26 -0
  51. package/src/sitemap.ts +26 -0
  52. package/src/structured-data.tsx +207 -0
  53. package/src/types.ts +580 -0
  54. package/src/version.ts +1 -0
  55. package/src/visual-accents.ts +126 -0
  56. package/src/visual-bridge-client.tsx +1070 -0
  57. package/src/visual-bridge.tsx +30 -0
  58. package/src/visual-clicks.ts +13 -0
  59. package/src/visual-lists.ts +37 -0
  60. package/src/visual-match.ts +77 -0
  61. package/src/visual-messages.ts +160 -0
  62. package/src/visual-mode.ts +18 -0
  63. package/src/visual-preview.ts +69 -0
  64. package/src/visual-tags.ts +90 -0
  65. package/src/visual.ts +59 -0
  66. package/src/waitlist-form.ts +39 -0
  67. package/src/waitlist.ts +48 -0
package/src/client.ts ADDED
@@ -0,0 +1,116 @@
1
+ /*
2
+ * The one place requests to Site Manager are made. Server only: the API key must never reach a browser.
3
+ *
4
+ * Env (server only, never NEXT_PUBLIC_):
5
+ * BLUEPRINT_URL Site Manager's address, for example https://manager.example.com
6
+ * BLUEPRINT_API_KEY this site's key (bp_...)
7
+ * BLUEPRINT_SITE_ID this site's id, shown on its Connection tab in Site Manager
8
+ * BLUEPRINT_REVALIDATE_SECRET the website connection secret, also used to sign preview reads
9
+ */
10
+
11
+ import { unstable_cache } from "next/cache";
12
+ import { draftMode } from "next/headers";
13
+ import { PREVIEW_HEADER, signPreviewHeader } from "./preview";
14
+
15
+ export class BlueprintError extends Error {
16
+ constructor(
17
+ message: string,
18
+ public status: number,
19
+ public code?: string,
20
+ ) {
21
+ super(message);
22
+ this.name = "BlueprintError";
23
+ }
24
+ }
25
+
26
+ function env(name: string): string {
27
+ const v = process.env[name]?.trim();
28
+ if (!v) throw new BlueprintError(`${name} is not set`, 0, "config");
29
+ return v;
30
+ }
31
+
32
+ export function siteId(): string {
33
+ return env("BLUEPRINT_SITE_ID");
34
+ }
35
+
36
+ /** Cache tags match what Site Manager sends when content changes: bp:<siteId> and bp:<siteId>:<module>. */
37
+ export function tagsFor(...modules: string[]): string[] {
38
+ const id = siteId();
39
+ return [`bp:${id}`, ...modules.map((m) => `bp:${id}:${m}`)];
40
+ }
41
+
42
+ type Options = { tags?: string[]; method?: "GET" | "POST"; body?: unknown; cache?: "no-store" };
43
+
44
+ type Envelope<T> = { data: T; meta: Record<string, unknown> };
45
+
46
+ async function request<T>(base: string, path: string, init: RequestInit, whole = false): Promise<T | null> {
47
+ const res = await fetch(`${base}/api/v1${path}`, init);
48
+ if (res.status === 404) return null;
49
+ if (res.status === 204) return null;
50
+ const json = (await res.json().catch(() => null)) as { data?: unknown; meta?: Record<string, unknown>; error?: { code?: string } } | null;
51
+ if (!res.ok) throw new BlueprintError(`Site Manager answered ${res.status} for ${path}`, res.status, json?.error?.code);
52
+ if (whole) return (json && json.data !== undefined ? { data: json.data, meta: json.meta ?? {} } : null) as T | null;
53
+ return (json?.data ?? null) as T | null;
54
+ }
55
+
56
+ /**
57
+ * Fetches `/api/v1<path>`. Reads are cached for an hour and refreshed early by tag when Site Manager says content
58
+ * changed. Returns null for a 404, throws BlueprintError for anything else that isn't OK (errors aren't cached).
59
+ *
60
+ * Reads use unstable_cache rather than fetch's own cache options because it behaves the same with Cache Components
61
+ * on or off, and like the fetch cache it lasts across deployments and servers.
62
+ *
63
+ * In a preview opened from Site Manager (draft mode), reads skip the cache and include drafts and scheduled changes.
64
+ */
65
+ export async function bp<T>(path: string, opts: Options = {}): Promise<T | null> {
66
+ return read<T>(path, opts, false);
67
+ }
68
+
69
+ /** Like bp, but keeps the response's `meta` too (paging counts), as `{ data, meta }`. */
70
+ export async function bpWithMeta<T>(path: string, opts: Options = {}): Promise<Envelope<T> | null> {
71
+ return read<Envelope<T>>(path, opts, true);
72
+ }
73
+
74
+ async function read<T>(path: string, opts: Options, whole: boolean): Promise<T | null> {
75
+ const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
76
+ const method = opts.method ?? "GET";
77
+ const preview = method === "GET" && (await inPreview());
78
+ const headers: Record<string, string> = { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}` };
79
+ if (opts.body !== undefined) headers["content-type"] = "application/json";
80
+ if (preview) headers[PREVIEW_HEADER] = signPreviewHeader(env("BLUEPRINT_REVALIDATE_SECRET"));
81
+ const init: RequestInit = {
82
+ method,
83
+ cache: "no-store",
84
+ headers,
85
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
86
+ };
87
+ if (preview || opts.cache === "no-store" || method === "POST") return request<T>(base, path, init, whole);
88
+ return unstable_cache(() => request<T>(base, path, init, whole), ["site-manager-kit", base, siteId(), path, ...(whole ? ["meta"] : [])], {
89
+ tags: opts.tags ?? tagsFor(),
90
+ revalidate: 3600,
91
+ })();
92
+ }
93
+
94
+ /**
95
+ * Whether this request is a preview opened from Site Manager (Next.js draft mode). Readable inside `use cache`
96
+ * scopes too. Outside a request (build time, generateStaticParams) there is no preview.
97
+ */
98
+ async function inPreview(): Promise<boolean> {
99
+ try {
100
+ return (await draftMode()).isEnabled;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** For writes that return an error body worth reading (form submits). */
107
+ export async function bpRaw(path: string, body: unknown): Promise<{ status: number; json: unknown }> {
108
+ const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
109
+ const res = await fetch(`${base}/api/v1${path}`, {
110
+ method: "POST",
111
+ cache: "no-store",
112
+ headers: { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}`, "content-type": "application/json" },
113
+ body: JSON.stringify(body),
114
+ });
115
+ return { status: res.status, json: await res.json().catch(() => null) };
116
+ }
package/src/config.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { NextConfig } from "next";
2
+ import { VISUAL_COOKIE } from "./preview";
3
+
4
+ // For next.config.ts. Use it inside withBotId: withBotId(withSiteManager({ ... })).
5
+
6
+ type Header = { key: string; value: string };
7
+ type HeaderRule = Awaited<ReturnType<NonNullable<NextConfig["headers"]>>>[number];
8
+
9
+ export type SiteManagerConfigOptions = {
10
+ /** Browser features to switch off for every page. Default: camera, microphone and USB. Payment and location stay on for checkout and maps. */
11
+ disableFeatures?: string[];
12
+ /** Send HSTS for subdomains too. Only when every subdomain serves HTTPS. Default true. */
13
+ hstsSubdomains?: boolean;
14
+ /** Who may show this site in a frame: "SAMEORIGIN" (default) or "DENY". */
15
+ frameOptions?: "SAMEORIGIN" | "DENY";
16
+ };
17
+
18
+ /** The baseline security headers, so every website connected to Site Manager sends the same ones. */
19
+ export function securityHeaders(opts: SiteManagerConfigOptions = {}): Header[] {
20
+ const features = opts.disableFeatures ?? ["camera", "microphone", "usb"];
21
+ return [
22
+ { key: "X-Content-Type-Options", value: "nosniff" },
23
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
24
+ { key: "X-Frame-Options", value: opts.frameOptions ?? "SAMEORIGIN" },
25
+ { key: "Permissions-Policy", value: features.map((f) => `${f}=()`).join(", ") },
26
+ { key: "Strict-Transport-Security", value: `max-age=63072000${opts.hstsSubdomains === false ? "" : "; includeSubDomains"}` },
27
+ ];
28
+ }
29
+
30
+ /** Where Site Manager's uploaded media lives, so next/image can resize it. */
31
+ export const MEDIA_REMOTE_PATTERN = { protocol: "https", hostname: "*.public.blob.vercel-storage.com" } as const;
32
+
33
+ const FRAME_ANCESTORS = /^frame-ancestors(\s|$)/i;
34
+
35
+ /**
36
+ * Lets the Visual Editor show the site when the site sets its own Content-Security-Policy here, where there is no
37
+ * request to read the editor's address from. A browser that opened the page from the Visual Editor (it has the visual
38
+ * cookie) gets the same policy without its frame-ancestors; visualFrameHeaders in the proxy then adds frame-ancestors
39
+ * for the signed Site Manager address only. X-Frame-Options stays on every response, so a cookie the proxy doesn't
40
+ * accept, or a path the proxy doesn't run on, still can't be framed: browsers ignore X-Frame-Options only when a
41
+ * frame-ancestors is present.
42
+ */
43
+ export function allowVisualFrames(rules: HeaderRule[]): HeaderRule[] {
44
+ const cookie = { type: "cookie" as const, key: VISUAL_COOKIE };
45
+ return rules.flatMap((rule) => {
46
+ const csp = rule.headers.find(
47
+ (h) => h.key.toLowerCase() === "content-security-policy" && h.value.split(";").some((d) => FRAME_ANCESTORS.test(d.trim())),
48
+ );
49
+ if (!csp) return [rule];
50
+ const policy = csp.value
51
+ .split(";")
52
+ .map((d) => d.trim())
53
+ .filter((d) => d && !FRAME_ANCESTORS.test(d))
54
+ .join("; ");
55
+ return [
56
+ { ...rule, missing: [...(rule.missing ?? []), cookie] },
57
+ { ...rule, has: [...(rule.has ?? []), cookie], headers: rule.headers.map((h) => (h === csp ? { key: h.key, value: policy } : h)) },
58
+ ];
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Adds the baseline security headers to every route, lets next/image resize Site Manager's media, and stops sending
64
+ * X-Powered-By. Keeps everything already in the config: its own headers come after these, so a site can override one.
65
+ * A Content-Security-Policy of its own keeps working with the Visual Editor (allowVisualFrames).
66
+ */
67
+ export function withSiteManager(config: NextConfig = {}, opts: SiteManagerConfigOptions = {}): NextConfig {
68
+ const own = config.headers;
69
+ const patterns = config.images?.remotePatterns ?? [];
70
+ return {
71
+ ...config,
72
+ poweredByHeader: false,
73
+ images: { ...config.images, remotePatterns: [...patterns, MEDIA_REMOTE_PATTERN] },
74
+ async headers() {
75
+ return allowVisualFrames([{ source: "/:path*", headers: securityHeaders(opts) }, ...(own ? await own() : [])]);
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,21 @@
1
+ import { declaredKeys, declaredLists, type CopyKeyDef, type CopyListRule } from "./copy-keys";
2
+ import { SIGNATURE_HEADER, verify } from "./signature";
3
+ import { KIT_VERSION } from "./version";
4
+
5
+ /**
6
+ * Site Manager's Sync from Website reads the site's declared copy keys here. Put in
7
+ * app/api/blueprint/copy-keys/route.ts:
8
+ *
9
+ * import { copyKeysRoute } from "site-manager-kit/copy-keys-route";
10
+ * import { COPY } from "@/copy-keys";
11
+ * export const GET = copyKeysRoute(COPY);
12
+ *
13
+ * Signed like the health route, over an empty body. Unsigned requests get a 401. Sends the keys and the list rules.
14
+ */
15
+ export function copyKeysRoute(copy: { __defs: Record<string, CopyKeyDef>; __lists?: Record<string, CopyListRule> }): (req: Request) => Promise<Response> {
16
+ return async (req) => {
17
+ const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), "");
18
+ if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
19
+ return Response.json({ kitVersion: KIT_VERSION, keys: declaredKeys(copy), lists: declaredLists(copy) }, { headers: { "cache-control": "no-store" } });
20
+ };
21
+ }
@@ -0,0 +1,77 @@
1
+ // A site's built-in text and photos, declared in its own code so Site Manager can create them as Site Copy keys
2
+ // (Site Copy > Sync from Website). Site Manager checks every definition again when it syncs; this file checks only
3
+ // what would break the site's build.
4
+
5
+ export const copyKeyPattern = /^[a-z0-9]+([._-][a-z0-9]+)*$/;
6
+
7
+ // page (1.41.0): the page the value belongs to, like "/about", so Site Manager's Pages files a new key under it.
8
+ // Left out for values shared across pages. Used only when Sync adds the key; Site Manager's own choice wins after.
9
+ export type CopyKeyDef =
10
+ | { label: string; kind: "text" | "rich"; maxLength?: number; helpText?: string; text: string; page?: string }
11
+ | { label: string; kind: "image"; helpText?: string; image: string; alt: string; page?: string };
12
+
13
+ /**
14
+ * What Site Manager's Pages may do with a list of items (`<path>.1.title`, `<path>.2.title`, ...): move them, and
15
+ * add or remove them. Adding and removing works on the declared slots, so declare as many items as the page has room
16
+ * for and show only the ones with something in them. Leave a list out when anything about an item stays in code by
17
+ * position (an icon, a link), since it would no longer match the words after a move.
18
+ */
19
+ export type CopyListRule = { reorder?: boolean; addRemove?: boolean };
20
+
21
+ export type CopyKeys<T extends Record<string, CopyKeyDef>> = { readonly [K in keyof T]: K } & {
22
+ readonly __defs: T;
23
+ readonly __lists: Record<string, CopyListRule>;
24
+ };
25
+
26
+ /**
27
+ * Returns an object whose properties are the key names (`COPY["home.hero.title"] === "home.hero.title"`), so pages
28
+ * never type a key string twice, plus the definitions for the copy keys route. Throws at module load on a bad key, a
29
+ * missing or long label, or an image path outside the site's own files, so a mistake fails the site's build.
30
+ */
31
+ export function defineCopyKeys<T extends Record<string, CopyKeyDef>>(keys: T, options: { lists?: Record<string, CopyListRule> } = {}): CopyKeys<T> {
32
+ const out: Record<string, unknown> = {};
33
+ for (const [key, def] of Object.entries(keys)) {
34
+ if (key.length > 80 || !copyKeyPattern.test(key)) {
35
+ throw new Error(`Copy key "${key}": use lowercase letters and numbers separated by dots, dashes or underscores, up to 80 characters.`);
36
+ }
37
+ if (!def.label?.trim() || def.label.length > 120) throw new Error(`Copy key "${key}": the label must be 1 to 120 characters.`);
38
+ if (def.page !== undefined && !/^\/(?!\/)[^?#\\]*$/.test(def.page)) throw new Error(`Copy key "${key}": the page must be a path on the site, like /about.`);
39
+ if (def.kind === "image" && !/^\/(?!\/)[^?#\\]*$/.test(def.image)) {
40
+ throw new Error(`Copy key "${key}": the image must be a path under public/, like /images/hero.jpg.`);
41
+ }
42
+ out[key] = key;
43
+ }
44
+ const lists = options.lists ?? {};
45
+ for (const path of Object.keys(lists)) {
46
+ if (path.length > 80 || !copyKeyPattern.test(path)) throw new Error(`Copy list "${path}": use the key rules for a list's path.`);
47
+ if (!Object.keys(keys).some((k) => k === `${path}.1` || k.startsWith(`${path}.1.`))) {
48
+ throw new Error(`Copy list "${path}": declare its items as ${path}.1..., ${path}.2... and so on.`);
49
+ }
50
+ }
51
+ Object.defineProperty(out, "__defs", { value: keys, enumerable: false });
52
+ Object.defineProperty(out, "__lists", { value: lists, enumerable: false });
53
+ return out as CopyKeys<T>;
54
+ }
55
+
56
+ export type DeclaredCopyKey =
57
+ | { key: string; label: string; kind: "text" | "rich"; maxLength: number | null; helpText: string | null; text: string; page?: string }
58
+ | { key: string; label: string; kind: "image"; maxLength: null; helpText: string | null; image: string; alt: string; page?: string };
59
+
60
+ /** The list rules as the copy keys route sends them: only lists that allow something. */
61
+ export function declaredLists(copy: { __lists?: Record<string, CopyListRule> }): Record<string, { reorder: boolean; addRemove: boolean }> {
62
+ const out: Record<string, { reorder: boolean; addRemove: boolean }> = {};
63
+ for (const [path, r] of Object.entries(copy.__lists ?? {})) {
64
+ // Adding and removing moves the items after it, so it always allows reordering too.
65
+ if (r.reorder || r.addRemove) out[path] = { reorder: true, addRemove: !!r.addRemove };
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** The definitions as the copy keys route sends them. */
71
+ export function declaredKeys(copy: { __defs: Record<string, CopyKeyDef> }): DeclaredCopyKey[] {
72
+ return Object.entries(copy.__defs).map(([key, d]) =>
73
+ d.kind === "image"
74
+ ? { key, label: d.label, kind: "image", maxLength: null, helpText: d.helpText ?? null, image: d.image, alt: d.alt, ...(d.page ? { page: d.page } : {}) }
75
+ : { key, label: d.label, kind: d.kind, maxLength: d.maxLength ?? null, helpText: d.helpText ?? null, text: d.text, ...(d.page ? { page: d.page } : {}) },
76
+ );
77
+ }
package/src/data.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { bp, bpWithMeta, tagsFor } from "./client";
2
+ import type { CopyLink, Glossary, GlossaryTerm, ReviewPlatform } from "./types";
3
+ import type { Amenity, Announcement, Area, BookingDays, CopyValue, Download, DownloadCategory, Event, Faq, FaqSet, Fee, FloorPlan, Form, Image, LegalDoc, LegalSummary, Listing, ListingDetail, ListingFilters, Location, NearbyPlace, Parking, PaymentDefaults, PetPolicy, Policies, ResidentSection, Review, Service, Site, Special, TeamMember, Unit, UnitMap, WaitlistOptions } from "./types";
4
+ import { announcementsLiveAt, cachedUntilPassed } from "./announcements";
5
+ import { visualMode } from "./visual-mode";
6
+ import { tagCopy } from "./visual-tags";
7
+
8
+ // Typed reads for every content module. Each is cached under its module's tag, so a change in Site Manager
9
+ // refreshes exactly what it affects. A module that's off for the site answers 403 and throws.
10
+
11
+ const q = (params: Record<string, string | number | boolean | undefined | null>) => {
12
+ const sp = new URLSearchParams();
13
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== false) sp.set(k, v === true ? "1" : String(v));
14
+ const s = sp.toString();
15
+ return s ? `?${s}` : "";
16
+ };
17
+
18
+ export const getSite = () => bp<Site>("/site", { tags: tagsFor("business_info") });
19
+
20
+ /** Site Copy as stored, never tagged. The Visual Editor's word matching reads this. */
21
+ export const getCopyUntagged = async () => (await bp<Record<string, CopyValue>>("/copy", { tags: tagsFor("site_copy") })) ?? {};
22
+
23
+ /**
24
+ * Every Site Copy value by key. In Site Manager's Visual Editor (and only there) each text and rich value carries an
25
+ * invisible tag naming it, which the editor removes from the page; see src/visual-tags.ts.
26
+ */
27
+ export async function getCopy(): Promise<Record<string, CopyValue>> {
28
+ const all = await getCopyUntagged();
29
+ return (await visualMode()) ? tagCopy(all) : all;
30
+ }
31
+
32
+ /** One copy value's text or HTML, or the fallback when it isn't set. */
33
+ export async function copyText(key: string, fallback = ""): Promise<string> {
34
+ const v = (await getCopy())[key];
35
+ if (!v) return fallback;
36
+ if (v.kind === "rich") return v.html ?? fallback;
37
+ return v.kind === "text" ? (v.text ?? fallback) : fallback;
38
+ }
39
+
40
+ /** A built-in photo from Site Copy (an image key), or null when it isn't set. */
41
+ export async function copyImage(key: string): Promise<Image | null> {
42
+ const v = (await getCopy())[key];
43
+ return v?.kind === "image" ? v.image : null;
44
+ }
45
+
46
+ /**
47
+ * Where a link key points (Site Manager's Pages): `{ href, label }`, or null when it isn't set or its record is hidden
48
+ * or gone, so a button can be left out rather than linking nowhere.
49
+ */
50
+ export async function copyLink(key: string): Promise<CopyLink | null> {
51
+ const v = (await getCopy())[key];
52
+ return v?.kind === "link" && v.link?.href ? v.link : null;
53
+ }
54
+
55
+ /**
56
+ * The records a records key picked (which reviews, which FAQ set...), in order, in their module's own shape, such as
57
+ * `copyRecords<Review>("home.reviews")`. Hidden ones are already left out.
58
+ */
59
+ export async function copyRecords<T = unknown>(key: string): Promise<T[]> {
60
+ const v = (await getCopy())[key];
61
+ return v?.kind === "records" ? (v.items as T[]) : [];
62
+ }
63
+
64
+ /** The site's review platforms (Reviews > Platforms) in order, with logos, for a badge row or a review link list. */
65
+ export const getReviewPlatforms = async () => {
66
+ const r = await bpWithMeta<unknown[]>("/reviews", { tags: tagsFor("reviews") });
67
+ return ((r?.meta as { platforms?: ReviewPlatform[] } | undefined)?.platforms ?? []) as ReviewPlatform[];
68
+ };
69
+
70
+ /** The glossary index: categories and published terms, alphabetically. */
71
+ export const getGlossary = async () => (await bp<Glossary>("/glossary", { tags: tagsFor("glossary") })) ?? { categories: [], terms: [] };
72
+
73
+ /** One published term with everything on it and its neighbors, or null for an unknown or hidden one. */
74
+ export const getGlossaryTerm = (slug: string) => bp<GlossaryTerm>(`/glossary/${encodeURIComponent(slug)}`, { tags: tagsFor("glossary") });
75
+
76
+ /** Published reviews, in the order set in Site Manager (Reviews > Order) or, with `sort: "date"`, newest first. */
77
+ export const getReviews = async (opts: { featured?: boolean; sort?: "custom" | "date" } = {}) =>
78
+ (await bp<Review[]>(`/reviews${q({ featured: opts.featured, sort: opts.sort === "date" ? "date" : undefined })}`, { tags: tagsFor("reviews") })) ?? [];
79
+
80
+ export const getFloorPlans = async () => (await bp<FloorPlan[]>("/floor-plans", { tags: tagsFor("floor_plans") })) ?? [];
81
+
82
+ export const getFloorPlan = (slug: string) => bp<FloorPlan & { units: Unit[] }>(`/floor-plans/${encodeURIComponent(slug)}`, { tags: tagsFor("floor_plans") });
83
+
84
+ /** Site plan drawings with open units placed on them, cached with floor plans. */
85
+ export const getUnitMaps = async () => (await bp<UnitMap[]>("/unit-maps", { tags: tagsFor("floor_plans") })) ?? [];
86
+
87
+ /** The plans and bedroom counts a waitlist form can offer. Cached with floor plans, since availability decides it. */
88
+ export const getWaitlistOptions = async () => (await bp<WaitlistOptions>("/waitlist", { tags: tagsFor("floor_plans", "waitlist") })) ?? { floorPlans: [], bedrooms: [] };
89
+
90
+ export const getAmenities = async (opts: { category?: "community" | "apartment" } = {}) => (await bp<Amenity[]>(`/amenities${q({ category: opts.category })}`, { tags: tagsFor("amenities") })) ?? [];
91
+
92
+ export const getFees = async () => (await bp<{ fees: Fee[]; requiredMonthlyTotal: string | null }>("/fees", { tags: tagsFor("fees") })) ?? { fees: [], requiredMonthlyTotal: null };
93
+
94
+ export const getPetPolicy = () => bp<PetPolicy>("/pet-policy", { tags: tagsFor("pet_policy") });
95
+
96
+ export const getParking = () => bp<Parking>("/parking", { tags: tagsFor("parking") });
97
+
98
+ /** The residents page: visible sections with something in them, in the order set in Site Manager. */
99
+ export const getResidentResources = async () => (await bp<ResidentSection[]>("/resident-resources", { tags: tagsFor("resident_resources") })) ?? [];
100
+
101
+ /** Leasing policies: application, fees, deposit, lease lengths, smoking, guests, insurance and extra policies. */
102
+ export const getPolicies = () => bp<Policies>("/policies", { tags: tagsFor("policies") });
103
+
104
+ export const getSpecials = async () => (await bp<Special[]>("/specials", { tags: tagsFor("specials") })) ?? [];
105
+
106
+ export const getEvents = async (opts: { from?: string; limit?: number } = {}) => (await bp<Event[]>(`/events${q(opts)}`, { tags: tagsFor("events") })) ?? [];
107
+
108
+ export const getEvent = (slug: string) => bp<Event>(`/events/${encodeURIComponent(slug)}`, { tags: tagsFor("events") });
109
+
110
+ /** Published services in the order set in Site Manager, optionally one category (matched without regard to case). */
111
+ export const getServices = async (opts: { category?: string } = {}) => (await bp<Service[]>(`/services${q({ category: opts.category })}`, { tags: tagsFor("services") })) ?? [];
112
+
113
+ /** One published service, or null when it doesn't exist or is a draft. */
114
+ export const getService = (slug: string) => bp<Service>(`/services/${encodeURIComponent(slug)}`, { tags: tagsFor("services") });
115
+
116
+ export const getLocations = async () => (await bp<Location[]>("/locations", { tags: tagsFor("locations") })) ?? [];
117
+
118
+ export const getNearbyPlaces = async (opts: { category?: string } = {}) => (await bp<NearbyPlace[]>(`/nearby-places${q({ category: opts.category })}`, { tags: tagsFor("nearby_places") })) ?? [];
119
+
120
+ export const getForm = (key: string) => bp<Form>(`/forms/${encodeURIComponent(key)}`, { tags: tagsFor("forms") });
121
+
122
+ export const getLegal = async () => (await bp<LegalSummary[]>("/legal", { tags: tagsFor("legal") })) ?? [];
123
+
124
+ export const getLegalDoc = (slug: string) => bp<LegalDoc>(`/legal/${encodeURIComponent(slug)}`, { tags: tagsFor("legal") });
125
+
126
+ /**
127
+ * A booking form's open times, never cached: they change with every booking. `from` is a YYYY-MM-DD date in the
128
+ * site's time zone (default today), `days` up to 60. Null when the form isn't a booking form.
129
+ */
130
+ export const getBookingSlots = (key: string, opts: { from?: string; days?: number } = {}) =>
131
+ bp<BookingDays>(`/forms/${encodeURIComponent(key)}/slots${q(opts)}`, { cache: "no-store" });
132
+
133
+ // Listings show their agent's card, so they refresh when the team changes too.
134
+ const listingTags = () => tagsFor("listings", "team_members");
135
+
136
+ /**
137
+ * Listings on the website, active first then newest, each with a cover photo and agent. Every filter is optional;
138
+ * prices are compared to the sold price for sold listings. Site Manager answers 400 for a filter it can't read.
139
+ */
140
+ export const getListings = async (filters: ListingFilters = {}) => {
141
+ const status = Array.isArray(filters.status) ? filters.status.join(",") : filters.status;
142
+ // Which listings are in an area follows the area's names, so an area filter refreshes when areas change too.
143
+ const tags = filters.area ? [...listingTags(), ...tagsFor("areas")] : listingTags();
144
+ return (await bp<Listing[]>(`/listings${q({ ...filters, status })}`, { tags })) ?? [];
145
+ };
146
+
147
+ /**
148
+ * Sold listings in the usual list order, newest listed first, for a sold portfolio or "recently sold" section. Each carries
149
+ * `soldPrice` and `soldOn`; price filters compare the sold price. Same as `getListings({ ...filters, status: "sold" })`.
150
+ */
151
+ export const getSoldListings = (filters: Omit<ListingFilters, "status"> = {}) => getListings({ ...filters, status: "sold" });
152
+
153
+ /** Published area guides in Site Manager's order, each with `listingCount` (homes for sale now; null without Listings). */
154
+ export const getAreas = async () => (await bp<Area[]>("/areas", { tags: tagsFor("areas", "listings") })) ?? [];
155
+
156
+ /** One published area guide. Null when it isn't on the website. Its homes: `getListings({ area: slug })`. */
157
+ export const getArea = (slug: string) => bp<Area>(`/areas/${encodeURIComponent(slug)}`, { tags: tagsFor("areas", "listings") });
158
+
159
+ /** One listing with every photo, its features, schools and price history. Null when it isn't on the website. */
160
+ export const getListing = (slug: string) => bp<ListingDetail>(`/listings/${encodeURIComponent(slug)}`, { tags: listingTags() });
161
+
162
+ /** The site's numbers for `estimatePayment`. */
163
+ export const getPaymentDefaults = async () =>
164
+ (await bp<PaymentDefaults>("/payment-defaults", { tags: tagsFor("listings") })) ?? { ratePercent: null, downPaymentPercent: null, termYears: null, taxRatePercent: null, insuranceYearly: null };
165
+
166
+ export const getTeam = async () => (await bp<TeamMember[]>("/team", { tags: tagsFor("team_members") })) ?? [];
167
+
168
+ export const getTeamMember = (slug: string) => bp<TeamMember>(`/team/${encodeURIComponent(slug)}`, { tags: tagsFor("team_members") });
169
+
170
+ /** Published questions in category order, then their own order. `category` is a category's slug. */
171
+ export const getFaq = async (opts: { category?: string } = {}) => (await bp<Faq>(`/faq${q({ category: opts.category })}`, { tags: tagsFor("faq") })) ?? { categories: [], questions: [] };
172
+
173
+ /** A named set of questions a page asks for by key (like "leasing-page"), in the set's order. Null for an unknown key. */
174
+ export const getFaqSet = (key: string) => bp<FaqSet>(`/faq/sets/${encodeURIComponent(key)}`, { tags: tagsFor("faq") });
175
+
176
+ /**
177
+ * Notices live now, urgent first. Never shown past an end or before a start: the cached copy is used only until the
178
+ * time Site Manager says the list next changes, then read fresh, and the list is filtered by `now` again. Call it
179
+ * where the request time is known (after `connection()` with Cache Components), not in a prerendered shell.
180
+ */
181
+ export async function getAnnouncements(now = new Date()): Promise<Announcement[]> {
182
+ const tags = tagsFor("announcements");
183
+ let r = await bpWithMeta<Announcement[]>("/announcements", { tags });
184
+ if (r && !cachedUntilPassed(r.meta, now)) r = await bpWithMeta<Announcement[]>("/announcements", { tags, cache: "no-store" });
185
+ return announcementsLiveAt(r?.data ?? [], now);
186
+ }
187
+
188
+ /** Published downloads in the order set in Site Manager. Link to `url` so each download is counted. */
189
+ export const getDownloads = async (opts: { category?: DownloadCategory } = {}) => (await bp<Download[]>(`/downloads${q({ category: opts.category })}`, { tags: tagsFor("downloads") })) ?? [];
@@ -0,0 +1,7 @@
1
+ /** A file size for a download link, like "840 KB" or "2.4 MB". Empty when the size isn't a usable number. */
2
+ export function fileSizeText(bytes: number): string {
3
+ if (!Number.isFinite(bytes) || bytes < 0) return "";
4
+ if (bytes < 1024) return `${bytes} bytes`;
5
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
6
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
7
+ }
package/src/faq.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { FaqQuestion } from "./types";
2
+
3
+ type Json = Record<string, unknown>;
4
+
5
+ /**
6
+ * schema.org `FAQPage` structured data for the questions a page shows. Use it only on a page that shows these
7
+ * questions and answers. Answers are Site Manager's sanitized HTML, which search engines accept in `text`.
8
+ * Render it with `jsonLdScript`, which escapes `<` so an answer can't close the script tag.
9
+ */
10
+ export function faqJsonLd(questions: Pick<FaqQuestion, "question" | "answerHtml">[]): Json {
11
+ return {
12
+ "@context": "https://schema.org",
13
+ "@type": "FAQPage",
14
+ mainEntity: questions.map((q) => ({ "@type": "Question", name: q.question, acceptedAnswer: { "@type": "Answer", text: q.answerHtml } })),
15
+ };
16
+ }
17
+
18
+ /** JSON-LD as text for `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(data) }} />`. */
19
+ export function jsonLdScript(data: Json): string {
20
+ return JSON.stringify(data).replace(/</g, "\\u003c");
21
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,33 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ // A signed render time, so a form posted less than 3 seconds after it was shown (a bot, not a person) is refused.
4
+ export const MIN_AGE_MS = 3000;
5
+ export const MAX_AGE_MS = 24 * 60 * 60 * 1000;
6
+
7
+ function secret(): string {
8
+ const s = process.env.BLUEPRINT_FORM_SECRET?.trim();
9
+ if (!s) throw new Error("BLUEPRINT_FORM_SECRET is not set");
10
+ return s;
11
+ }
12
+
13
+ const mac = (key: string, t: number) => createHmac("sha256", key).update(`form:${t}`).digest("hex");
14
+
15
+ export function issueFormToken(now = Date.now(), key = secret()): string {
16
+ return `${now}.${mac(key, now)}`;
17
+ }
18
+
19
+ export type TokenCheck = { ok: true } | { ok: false; reason: "missing" | "bad" | "too_fast" | "expired" };
20
+
21
+ export function checkFormToken(token: string | null | undefined, now = Date.now(), key = secret()): TokenCheck {
22
+ if (!token) return { ok: false, reason: "missing" };
23
+ const m = token.match(/^(\d{13})\.([0-9a-f]{64})$/);
24
+ if (!m) return { ok: false, reason: "bad" };
25
+ const t = Number(m[1]);
26
+ const expected = Buffer.from(mac(key, t));
27
+ const given = Buffer.from(m[2]);
28
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, reason: "bad" };
29
+ const age = now - t;
30
+ if (age < MIN_AGE_MS) return { ok: false, reason: "too_fast" };
31
+ if (age > MAX_AGE_MS) return { ok: false, reason: "expired" };
32
+ return { ok: true };
33
+ }
@@ -0,0 +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";