@stack-mode/site-manager-kit 0.0.0-stage → 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.
Files changed (67) hide show
  1. package/README.md +605 -2
  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 +70 -4
  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/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";
package/src/forms.ts ADDED
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }