@stack-mode/site-manager-kit 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +618 -606
  2. package/audit/ts-hooks.mjs +68 -68
  3. package/audit/wiring-audit.d.mts +37 -37
  4. package/audit/wiring-audit.mjs +604 -604
  5. package/bin/site-manager-kit.mjs +10 -10
  6. package/package.json +73 -72
  7. package/src/announcements.ts +13 -13
  8. package/src/attribution-fields.ts +52 -52
  9. package/src/attribution.tsx +47 -47
  10. package/src/blog.ts +122 -122
  11. package/src/chat-route.ts +136 -136
  12. package/src/chat-types.ts +76 -76
  13. package/src/client.ts +116 -116
  14. package/src/config.ts +78 -78
  15. package/src/copy-keys-route.ts +21 -21
  16. package/src/copy-keys.ts +77 -77
  17. package/src/data.ts +189 -189
  18. package/src/downloads.ts +7 -7
  19. package/src/faq.ts +21 -21
  20. package/src/floor-plans.ts +45 -45
  21. package/src/form-guard.tsx +37 -37
  22. package/src/form-types.ts +13 -13
  23. package/src/forms.ts +76 -76
  24. package/src/glossary.ts +19 -19
  25. package/src/health-route.ts +34 -34
  26. package/src/index.ts +36 -36
  27. package/src/indexnow.ts +40 -40
  28. package/src/listings.ts +38 -38
  29. package/src/llms-route.ts +45 -0
  30. package/src/metadata.ts +55 -55
  31. package/src/not-found-reporter.tsx +16 -16
  32. package/src/not-found.ts +53 -53
  33. package/src/payment.ts +70 -70
  34. package/src/phone-input.tsx +26 -26
  35. package/src/phone.ts +37 -37
  36. package/src/preview-route.ts +48 -48
  37. package/src/preview.ts +94 -94
  38. package/src/residents.ts +23 -23
  39. package/src/revalidate-route.ts +34 -34
  40. package/src/robots.ts +5 -1
  41. package/src/rss-route.ts +26 -26
  42. package/src/services.ts +52 -52
  43. package/src/site-chat-panel.tsx +537 -537
  44. package/src/site-chat.tsx +26 -26
  45. package/src/sitemap.ts +26 -26
  46. package/src/structured-data.tsx +207 -207
  47. package/src/types.ts +581 -580
  48. package/src/version.ts +1 -1
  49. package/src/visual-accents.ts +126 -126
  50. package/src/visual-bridge-client.tsx +1070 -1070
  51. package/src/visual-bridge.tsx +30 -30
  52. package/src/visual-clicks.ts +13 -13
  53. package/src/visual-lists.ts +37 -37
  54. package/src/visual-match.ts +77 -77
  55. package/src/visual-messages.ts +160 -160
  56. package/src/visual-mode.ts +18 -18
  57. package/src/visual-preview.ts +69 -69
  58. package/src/visual-tags.ts +90 -90
  59. package/src/visual.ts +59 -59
  60. package/src/waitlist-form.ts +39 -39
  61. package/src/waitlist.ts +48 -48
package/src/copy-keys.ts CHANGED
@@ -1,77 +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
- }
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 CHANGED
@@ -1,189 +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") })) ?? [];
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") })) ?? [];
package/src/downloads.ts CHANGED
@@ -1,7 +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
- }
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 CHANGED
@@ -1,21 +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
- }
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
+ }