@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/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@stack-mode/site-manager-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.44.1",
4
+ "description": "Reads a site's content from Site Manager and builds its metadata, sitemap, robots, redirects, forms and refreshes. For Next.js App Router sites on Vercel.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/stack-mode/site-manager-kit.git"
9
+ },
10
+ "type": "module",
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./revalidate-route": "./src/revalidate-route.ts",
16
+ "./health-route": "./src/health-route.ts",
17
+ "./forms": "./src/forms.ts",
18
+ "./waitlist": "./src/waitlist.ts",
19
+ "./form-guard": "./src/form-guard.tsx",
20
+ "./redirects": "./src/redirects.ts",
21
+ "./not-found": "./src/not-found.ts",
22
+ "./not-found-reporter": "./src/not-found-reporter.tsx",
23
+ "./indexnow": "./src/indexnow.ts",
24
+ "./config": "./src/config.ts",
25
+ "./analytics": "./src/analytics.tsx",
26
+ "./preview-route": "./src/preview-route.ts",
27
+ "./preview-banner": "./src/preview-banner.tsx",
28
+ "./attribution": "./src/attribution.tsx",
29
+ "./phone": "./src/phone-input.tsx",
30
+ "./rss-route": "./src/rss-route.ts",
31
+ "./visual": "./src/visual.ts",
32
+ "./visual-tags": "./src/visual-tags.ts",
33
+ "./visual-accents": "./src/visual-accents.ts",
34
+ "./visual-editing": "./src/visual-bridge.tsx",
35
+ "./copy-keys": "./src/copy-keys.ts",
36
+ "./copy-keys-route": "./src/copy-keys-route.ts",
37
+ "./chat": "./src/site-chat.tsx",
38
+ "./chat-route": "./src/chat-route.ts"
39
+ },
40
+ "bin": {
41
+ "site-manager-kit": "./bin/site-manager-kit.mjs"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "bin",
46
+ "audit",
47
+ "README.md"
48
+ ],
49
+ "scripts": {
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "vitest run"
52
+ },
53
+ "peerDependencies": {
54
+ "botid": ">=1",
55
+ "next": ">=16",
56
+ "react": ">=19"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^22.20.5",
60
+ "@types/react": "^19.3.0",
61
+ "botid": "^1.5.12",
62
+ "next": "^16.4.3",
63
+ "react": "^19.3.0",
64
+ "react-dom": "^19.3.0",
65
+ "typescript": "^5.9.3",
66
+ "vite": "^8.3.5",
67
+ "vitest": "^5.0.5"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ }
72
+ }
@@ -0,0 +1,23 @@
1
+ import Script from "next/script";
2
+ import { getSite } from "./data";
3
+
4
+ /** Where the StackOS tracker loads from. A site with its own Content-Security-Policy allows it in script-src and connect-src. */
5
+ export const STACKOS_TRACKER_ORIGIN = "https://app.mystackos.com";
6
+
7
+ /**
8
+ * Put once inside <body> in app/layout.tsx. Adds StackOS's visitor tracking with the key the agency set in Site
9
+ * Manager, so changing or removing the key needs no code change. Renders nothing when there's no key, or when Site
10
+ * Manager can't be reached, so tracking never breaks a page.
11
+ */
12
+ export async function SiteAnalytics() {
13
+ let key: string | undefined;
14
+ try {
15
+ const site = await getSite();
16
+ key = site?.tracking?.provider === "stackos" ? site.tracking.siteKey : undefined;
17
+ } catch {
18
+ return null;
19
+ }
20
+ // Site Manager only stores plain tokens; checked again because it ends up in every page.
21
+ if (!key || !/^[A-Za-z0-9_-]{8,64}$/.test(key)) return null;
22
+ return <Script src={`${STACKOS_TRACKER_ORIGIN}/v.js`} data-site={key} data-attribution="on" strategy="afterInteractive" />;
23
+ }
@@ -0,0 +1,13 @@
1
+ import type { Announcement } from "./types";
2
+
3
+ /** Notices live at `now`: started (or no start) and not yet ended. Start inclusive, end exclusive, as in Site Manager. */
4
+ export function announcementsLiveAt(list: Announcement[], now = new Date()): Announcement[] {
5
+ const t = now.getTime();
6
+ return list.filter((a) => (!a.startsAt || Date.parse(a.startsAt) <= t) && (!a.endsAt || Date.parse(a.endsAt) > t));
7
+ }
8
+
9
+ /** True while a cached response is still current: Site Manager's `meta.nextChangeAt` hasn't passed. */
10
+ export function cachedUntilPassed(meta: Record<string, unknown>, now = new Date()): boolean {
11
+ const next = typeof meta.nextChangeAt === "string" ? Date.parse(meta.nextChangeAt) : NaN;
12
+ return Number.isNaN(next) || next > now.getTime();
13
+ }
@@ -0,0 +1,52 @@
1
+ // Where a visitor came from, as hidden form fields. Shared by the browser side (attribution.tsx) and the form action
2
+ // (forms.ts), so both use the same names. No cookies: values live in the tab's session storage.
3
+
4
+ export const ATTRIBUTION_STORAGE_KEY = "bp_attribution";
5
+
6
+ /** Hidden field name for each value Site Manager accepts as `attribution`. */
7
+ export const ATTRIBUTION_FIELDS = {
8
+ source: "bp_utm_source",
9
+ medium: "bp_utm_medium",
10
+ campaign: "bp_utm_campaign",
11
+ content: "bp_utm_content",
12
+ term: "bp_utm_term",
13
+ landingPath: "bp_landing",
14
+ referrer: "bp_referrer",
15
+ } as const;
16
+
17
+ export type Attribution = Partial<Record<keyof typeof ATTRIBUTION_FIELDS, string>>;
18
+
19
+ const MAX = 300;
20
+
21
+ /**
22
+ * The visit's first touch, from the landing page's address and referrer: utm_ values, the path (no query), and the
23
+ * referring site's host when it's another site. Pure, for tests and for the browser.
24
+ */
25
+ export function firstTouch(href: string, referrer: string): Attribution {
26
+ const url = new URL(href);
27
+ const a: Attribution = {};
28
+ for (const k of ["source", "medium", "campaign", "content", "term"] as const) {
29
+ const v = url.searchParams.get(`utm_${k}`)?.trim();
30
+ if (v) a[k] = v.slice(0, MAX);
31
+ }
32
+ a.landingPath = url.pathname.slice(0, MAX);
33
+ try {
34
+ const ref = referrer ? new URL(referrer) : null;
35
+ if (ref && ref.host !== url.host && (ref.protocol === "https:" || ref.protocol === "http:")) a.referrer = ref.host;
36
+ } catch {
37
+ // An unreadable referrer is simply left out.
38
+ }
39
+ return a;
40
+ }
41
+
42
+ /** The attribution a submitted form carries, read from its hidden fields. Undefined when there's none. */
43
+ export function attributionFrom(formData: FormData): Attribution | undefined {
44
+ const a: Attribution = {};
45
+ for (const [k, name] of Object.entries(ATTRIBUTION_FIELDS) as [keyof Attribution, string][]) {
46
+ const v = formData.get(name);
47
+ if (typeof v === "string" && v.trim()) a[k] = v.trim().slice(0, MAX);
48
+ }
49
+ return Object.keys(a).length ? a : undefined;
50
+ }
51
+
52
+ export const ATTRIBUTION_FIELD_NAMES: ReadonlySet<string> = new Set(Object.values(ATTRIBUTION_FIELDS));
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ATTRIBUTION_FIELDS, ATTRIBUTION_STORAGE_KEY, firstTouch, type Attribution } from "./attribution-fields";
5
+
6
+ /** The visit's first touch: stored once per tab, so later pages keep where the visitor first came from. */
7
+ function rememberedTouch(): Attribution | null {
8
+ try {
9
+ const saved = sessionStorage.getItem(ATTRIBUTION_STORAGE_KEY);
10
+ if (saved) return JSON.parse(saved) as Attribution;
11
+ const a = firstTouch(window.location.href, document.referrer);
12
+ sessionStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(a));
13
+ return a;
14
+ } catch {
15
+ // Storage can be off (some private modes). The current page is then the best we know.
16
+ try {
17
+ return firstTouch(window.location.href, document.referrer);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Put once inside <body> in app/layout.tsx. Remembers where the visitor first came from (utm_ values, landing page,
26
+ * referring site) for this browser tab, with no cookie, so forms can send it with a submission. Renders nothing.
27
+ */
28
+ export function SiteAttribution() {
29
+ useEffect(() => {
30
+ rememberedTouch();
31
+ }, []);
32
+ return null;
33
+ }
34
+
35
+ /** Hidden fields with the remembered first touch. FormGuard includes these, so forms send them with no extra code. */
36
+ export function AttributionFields() {
37
+ const [a, setA] = useState<Attribution | null>(null);
38
+ useEffect(() => {
39
+ setA(rememberedTouch());
40
+ }, []);
41
+ if (!a) return null;
42
+ return (
43
+ <>
44
+ {(Object.entries(ATTRIBUTION_FIELDS) as [keyof Attribution, string][]).map(([k, name]) => (a[k] ? <input key={name} type="hidden" name={name} value={a[k]} /> : null))}
45
+ </>
46
+ );
47
+ }
package/src/blog.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { bp, bpWithMeta, tagsFor } from "./client";
2
+ import type { BlogCategory, BlogFeed, BlogPage, BlogPost, BlogPostSummary, BlogTag, Paging } from "./types";
3
+
4
+ // Reads for the Blog section. Posts are cached under the blog tag; a single post also under seo, since its search
5
+ // listing is edited in Search Listings.
6
+
7
+ type PageOpts = { page?: number; perPage?: number };
8
+
9
+ const q = (params: Record<string, string | number | undefined | null>) => {
10
+ const sp = new URLSearchParams();
11
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
12
+ const s = sp.toString();
13
+ return s ? `?${s}` : "";
14
+ };
15
+
16
+ const emptyPaging = (o: PageOpts): Paging => ({ page: o.page ?? 1, perPage: o.perPage ?? 10, total: 0, totalPages: 0 });
17
+
18
+ function paging(meta: Record<string, unknown>, o: PageOpts): Paging {
19
+ const n = (k: keyof Paging) => (typeof meta[k] === "number" ? (meta[k] as number) : emptyPaging(o)[k]);
20
+ return { page: n("page"), perPage: n("perPage"), total: n("total"), totalPages: n("totalPages") };
21
+ }
22
+
23
+ /** Published posts, newest first, a page at a time (`perPage` up to 50), optionally in one category or with one tag (by slug). */
24
+ export async function getPosts(opts: PageOpts & { category?: string; tag?: string } = {}): Promise<BlogPage> {
25
+ const r = await bpWithMeta<BlogPostSummary[]>(`/blog/posts${q({ page: opts.page, perPage: opts.perPage, category: opts.category, tag: opts.tag })}`, { tags: tagsFor("blog") });
26
+ return r ? { posts: r.data, paging: paging(r.meta, opts) } : { posts: [], paging: emptyPaging(opts) };
27
+ }
28
+
29
+ /** One published post with its body and search listing, or null. */
30
+ export const getPost = (slug: string) => bp<BlogPost>(`/blog/posts/${encodeURIComponent(slug)}`, { tags: tagsFor("blog", "seo") });
31
+
32
+ export const getBlogCategories = async () => (await bp<BlogCategory[]>("/blog/categories", { tags: tagsFor("blog") })) ?? [];
33
+
34
+ /** One category and a page of its posts, or null when there's no such category. */
35
+ export async function getBlogCategory(slug: string, opts: PageOpts = {}): Promise<{ category: BlogCategory; posts: BlogPostSummary[]; paging: Paging } | null> {
36
+ const r = await bpWithMeta<{ category: BlogCategory; posts: BlogPostSummary[] }>(`/blog/categories/${encodeURIComponent(slug)}${q({ page: opts.page, perPage: opts.perPage })}`, { tags: tagsFor("blog") });
37
+ return r ? { ...r.data, paging: paging(r.meta, opts) } : null;
38
+ }
39
+
40
+ export const getBlogTags = async () => (await bp<BlogTag[]>("/blog/tags", { tags: tagsFor("blog") })) ?? [];
41
+
42
+ /** The newest posts with full bodies, shaped for RSS (`limit` up to 50, default 20). */
43
+ export const getBlogFeed = (opts: { limit?: number } = {}) => bp<BlogFeed>(`/blog/feed${q({ limit: opts.limit })}`, { tags: tagsFor("blog", "business_info", "seo") });
44
+
45
+ const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
46
+ // CDATA can't contain its own end marker, so one is split across two sections.
47
+ const cdata = (s: string) => `<![CDATA[${s.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
48
+
49
+ /**
50
+ * Pure: an RSS 2.0 document from the feed. `feedUrl` is this feed's own address (for the atom:link self reference),
51
+ * and `link` overrides the website address when Site Manager doesn't have one.
52
+ */
53
+ export function buildRss(feed: BlogFeed, opts: { feedUrl?: string; link?: string; title?: string; description?: string } = {}): string {
54
+ const link = opts.link ?? feed.link ?? "";
55
+ const self = opts.feedUrl ?? feed.feedUrl;
56
+ const items = feed.items.map((i) =>
57
+ [
58
+ "<item>",
59
+ `<title>${xml(i.title)}</title>`,
60
+ i.link ? `<link>${xml(i.link)}</link>` : "",
61
+ `<guid isPermaLink="${i.guidIsPermaLink ? "true" : "false"}">${xml(i.guid)}</guid>`,
62
+ i.pubDate ? `<pubDate>${xml(i.pubDate)}</pubDate>` : "",
63
+ i.author ? `<dc:creator>${xml(i.author)}</dc:creator>` : "",
64
+ ...i.categories.map((c) => `<category>${xml(c)}</category>`),
65
+ i.summary ? `<description>${xml(i.summary)}</description>` : "",
66
+ i.contentHtml ? `<content:encoded>${cdata(i.contentHtml)}</content:encoded>` : "",
67
+ i.image ? `<enclosure url="${xml(i.image.url)}" type="${xml(imageType(i.image.url))}" length="0"/>` : "",
68
+ "</item>",
69
+ ]
70
+ .filter(Boolean)
71
+ .join(""),
72
+ );
73
+ return [
74
+ '<?xml version="1.0" encoding="UTF-8"?>',
75
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">',
76
+ "<channel>",
77
+ `<title>${xml(opts.title ?? feed.title)}</title>`,
78
+ `<link>${xml(link)}</link>`,
79
+ `<description>${xml(opts.description ?? feed.description ?? feed.title)}</description>`,
80
+ `<language>${xml(feed.language)}</language>`,
81
+ feed.updatedAt ? `<lastBuildDate>${new Date(feed.updatedAt).toUTCString()}</lastBuildDate>` : "",
82
+ self ? `<atom:link href="${xml(self)}" rel="self" type="application/rss+xml"/>` : "",
83
+ ...items,
84
+ "</channel>",
85
+ "</rss>",
86
+ ]
87
+ .filter(Boolean)
88
+ .join("\n");
89
+ }
90
+
91
+ function imageType(url: string): string {
92
+ const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
93
+ return ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : ext === "avif" ? "image/avif" : "image/jpeg";
94
+ }
95
+
96
+ /**
97
+ * Pure: schema.org BlogPosting JSON-LD for a post page. Put it in a `<script type="application/ld+json">` with
98
+ * `JSON.stringify(...).replace(/</g, "\\u003c")`. Fields that aren't known are left out rather than guessed.
99
+ */
100
+ export function blogPostingJsonLd(post: BlogPost, opts: { publisherName?: string; publisherLogo?: string; url?: string } = {}): Record<string, unknown> {
101
+ const url = opts.url ?? post.seo?.canonical ?? post.url ?? undefined;
102
+ const image = post.cover?.url ?? post.seo?.ogImage?.url;
103
+ const out: Record<string, unknown> = {
104
+ "@context": "https://schema.org",
105
+ "@type": "BlogPosting",
106
+ headline: post.title.slice(0, 110),
107
+ };
108
+ if (url) {
109
+ out.url = url;
110
+ out.mainEntityOfPage = { "@type": "WebPage", "@id": url };
111
+ }
112
+ if (post.seo?.description ?? post.excerpt) out.description = post.seo?.description ?? post.excerpt;
113
+ if (image) out.image = [image];
114
+ if (post.publishedAt) out.datePublished = post.publishedAt;
115
+ out.dateModified = post.updatedAt;
116
+ if (post.author) out.author = { "@type": "Person", name: post.author.name, ...(post.author.url ? { url: post.author.url } : {}) };
117
+ if (opts.publisherName) out.publisher = { "@type": "Organization", name: opts.publisherName, ...(opts.publisherLogo ? { logo: { "@type": "ImageObject", url: opts.publisherLogo } } : {}) };
118
+ if (post.categories.length) out.articleSection = post.categories.map((c) => c.name);
119
+ if (post.tags.length) out.keywords = post.tags.map((t) => t.name).join(", ");
120
+ if (post.wordCount) out.wordCount = post.wordCount;
121
+ return out;
122
+ }
@@ -0,0 +1,136 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /*
4
+ * The website's own chat route, between the visitor's browser and Site Manager: the browser never sees the API key,
5
+ * Site Manager never sees the visitor's IP address (only its hash), and only this website's pages can use it.
6
+ *
7
+ * // app/api/blueprint/chat/route.ts
8
+ * export { GET, POST } from "site-manager-kit/chat-route";
9
+ *
10
+ * POST { type: "message", conversationId?, visitorId, message, pageUrl?, hp? } streams the reply (NDJSON)
11
+ * POST { type: "lead", conversationId, visitorId, name, email, phone, message, slot?, hp?, attribution? }
12
+ * GET ?slots=1&from=YYYY-MM-DD open times the chat can book
13
+ */
14
+
15
+ const MAX_BODY = 20_000;
16
+
17
+ function env(name: string): string {
18
+ const v = process.env[name]?.trim();
19
+ if (!v) throw new Error(`${name} is not set`);
20
+ return v;
21
+ }
22
+
23
+ const json = (body: unknown, status = 200, headers: Record<string, string> = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } });
24
+
25
+ /** Only this website's own pages may use the route, so nobody else can spend the site's AI key through it. */
26
+ export function sameOrigin(req: Request): boolean {
27
+ const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
28
+ if (!host) return false;
29
+ const origin = req.headers.get("origin");
30
+ if (!origin) return req.headers.get("sec-fetch-site") === "same-origin";
31
+ try {
32
+ return new URL(origin).host === host;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function visitorIpHash(req: Request): string | undefined {
39
+ const ip = req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
40
+ return ip ? createHash("sha256").update(ip).digest("hex") : undefined;
41
+ }
42
+
43
+ async function isBot(): Promise<boolean> {
44
+ try {
45
+ const { checkBotId } = await import("botid/server");
46
+ const r = await checkBotId();
47
+ return r.isBot && !r.isVerifiedBot;
48
+ } catch {
49
+ // BotID isn't set up on this site. Site Manager's limits and the honeypot still apply.
50
+ return false;
51
+ }
52
+ }
53
+
54
+ async function upstream(path: string, init: RequestInit): Promise<Response> {
55
+ const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
56
+ return fetch(`${base}/api/v1${path}`, {
57
+ cache: "no-store",
58
+ ...init,
59
+ headers: { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}`, ...(init.body ? { "content-type": "application/json" } : {}) },
60
+ });
61
+ }
62
+
63
+ const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : undefined);
64
+
65
+ export async function POST(req: Request): Promise<Response> {
66
+ if (!sameOrigin(req)) return json({ error: "forbidden" }, 403);
67
+ const raw = await req.text();
68
+ if (raw.length > MAX_BODY) return json({ error: "too_large" }, 413);
69
+ let body: Record<string, unknown>;
70
+ try {
71
+ body = JSON.parse(raw) as Record<string, unknown>;
72
+ } catch {
73
+ return json({ error: "bad_request" }, 400);
74
+ }
75
+ const common = {
76
+ conversationId: str(body.conversationId, 64),
77
+ visitorId: str(body.visitorId, 100),
78
+ ipHash: visitorIpHash(req),
79
+ userAgent: req.headers.get("user-agent")?.slice(0, 500) ?? undefined,
80
+ hp: str(body.hp, 200),
81
+ };
82
+
83
+ if (body.type === "message") {
84
+ if (await isBot()) return json({ error: "forbidden" }, 403);
85
+ const res = await upstream("/chat", {
86
+ method: "POST",
87
+ body: JSON.stringify({ ...common, message: str(body.message, 5000) ?? "", pageUrl: str(body.pageUrl, 2000) ?? req.headers.get("referer") ?? undefined }),
88
+ });
89
+ if (!res.ok || !res.body) {
90
+ const err = (await res.json().catch(() => null)) as { error?: { code?: string; reason?: string } } | null;
91
+ const retry = res.headers.get("retry-after");
92
+ const status = res.status === 429 || res.status === 422 || res.status === 404 || res.status === 400 ? res.status : 502;
93
+ return json({ error: err?.error?.reason ?? err?.error?.code ?? "unavailable" }, status, retry ? { "retry-after": retry } : {});
94
+ }
95
+ // Passed through as it streams, so the visitor sees the reply being written.
96
+ return new Response(res.body, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" } });
97
+ }
98
+
99
+ if (body.type === "lead") {
100
+ const attribution = body.attribution && typeof body.attribution === "object" ? (body.attribution as Record<string, unknown>) : {};
101
+ const res = await upstream("/chat/lead", {
102
+ method: "POST",
103
+ body: JSON.stringify({
104
+ ...common,
105
+ name: str(body.name, 200) ?? "",
106
+ email: str(body.email, 320) ?? "",
107
+ phone: str(body.phone, 40) ?? "",
108
+ message: str(body.message, 2000) ?? "",
109
+ slot: str(body.slot, 40),
110
+ // A bot gets the usual thanks and nothing is stored, as with forms.
111
+ ...((await isBot()) ? { hp: "kit:bot" } : {}),
112
+ attribution: { landingPath: str(attribution.landingPath, 2000), referrer: str(attribution.referrer, 2000) },
113
+ }),
114
+ });
115
+ const out = (await res.json().catch(() => null)) as { data?: { successMessage: string }; error?: { fields?: Record<string, string> } } | null;
116
+ if (res.status === 201 && out?.data) return json({ ok: true, successMessage: out.data.successMessage });
117
+ if (res.status === 422) return json({ ok: false, error: "invalid", fields: out?.error?.fields ?? {} });
118
+ if (res.status === 409) return json({ ok: false, error: "slot_taken", fields: out?.error?.fields ?? {} });
119
+ if (res.status === 429) return json({ ok: false, error: "rate_limited" });
120
+ return json({ ok: false, error: "unavailable" });
121
+ }
122
+
123
+ return json({ error: "bad_request" }, 400);
124
+ }
125
+
126
+ export async function GET(req: Request): Promise<Response> {
127
+ if (!sameOrigin(req)) return json({ error: "forbidden" }, 403);
128
+ const url = new URL(req.url);
129
+ if (!url.searchParams.has("slots")) return json({ error: "bad_request" }, 400);
130
+ const q = new URLSearchParams({ days: "7" });
131
+ const from = url.searchParams.get("from");
132
+ if (from && /^\d{4}-\d{2}-\d{2}$/.test(from)) q.set("from", from);
133
+ const res = await upstream(`/chat/slots?${q}`, { method: "GET" });
134
+ const out = (await res.json().catch(() => null)) as { data?: unknown } | null;
135
+ return res.ok && out?.data ? json(out.data) : json({ error: "unavailable" }, 404);
136
+ }
@@ -0,0 +1,76 @@
1
+ // Website Chat shapes and the reader for its streamed replies. Plain code, safe in the browser and on the server.
2
+
3
+ /** GET /api/v1/chat. `live` false: the site shows no chat. */
4
+ export type ChatConfig = {
5
+ live: boolean;
6
+ name: string;
7
+ greeting: string;
8
+ suggestedQuestions: string[];
9
+ /** The chat can offer open times and book one. */
10
+ canBook: boolean;
11
+ /** The site's accent color from Site Manager, or null. The component's own `accent` prop wins. */
12
+ accent: string | null;
13
+ retentionDays: number;
14
+ };
15
+
16
+ /** One line of a streamed reply. */
17
+ export type ChatEvent =
18
+ | { type: "start"; conversationId: string }
19
+ | { type: "delta"; text: string }
20
+ | { type: "done"; unanswered: boolean; handoff: boolean; booking: boolean }
21
+ | { type: "error"; code: string };
22
+
23
+ export type ChatSlotDay = { date: string; slots: { start: string; end: string; label: string }[] };
24
+ export type ChatSlots = { timezone: string; slotMinutes: number; days: ChatSlotDay[] };
25
+
26
+ export type ChatLeadResult = { ok: true; successMessage: string } | { ok: false; error: "invalid" | "slot_taken" | "rate_limited" | "unavailable"; fields?: Record<string, string> };
27
+
28
+ /** Splits streamed NDJSON into events, keeping a partial last line for the next chunk. Unknown lines are skipped. */
29
+ export function parseChatLines(buffer: string): { events: ChatEvent[]; rest: string } {
30
+ const events: ChatEvent[] = [];
31
+ const lines = buffer.split("\n");
32
+ const rest = lines.pop() ?? "";
33
+ for (const line of lines) {
34
+ if (!line.trim()) continue;
35
+ try {
36
+ const e = JSON.parse(line) as ChatEvent;
37
+ if (e && typeof e === "object" && typeof e.type === "string") events.push(e);
38
+ } catch {
39
+ // A broken line: skip it rather than lose the rest of the reply.
40
+ }
41
+ }
42
+ return { events, rest };
43
+ }
44
+
45
+ /** Reads a streamed reply, calling `onEvent` for each event as it arrives. */
46
+ export async function readChatStream(body: ReadableStream<Uint8Array>, onEvent: (e: ChatEvent) => void): Promise<void> {
47
+ const reader = body.getReader();
48
+ const decoder = new TextDecoder();
49
+ let buf = "";
50
+ for (;;) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ buf += decoder.decode(value, { stream: true });
54
+ const { events, rest } = parseChatLines(buf);
55
+ buf = rest;
56
+ for (const e of events) onEvent(e);
57
+ }
58
+ for (const e of parseChatLines(`${buf}\n`).events) onEvent(e);
59
+ }
60
+
61
+ /**
62
+ * Text color on the accent: near-black on a light accent (a lime or yellow), white on a dark one, so the button and
63
+ * the visitor's messages stay readable whatever the site's color. Colors it can't read get white.
64
+ */
65
+ export function textOn(color: string): string {
66
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
67
+ if (!m) return "#ffffff";
68
+ const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
69
+ const [r, g, b] = [0, 2, 4].map((i) => {
70
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
71
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
72
+ });
73
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
74
+ // White wins below this luminance (the point where both have equal contrast).
75
+ return lum > 0.179 ? "#111318" : "#ffffff";
76
+ }
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
+ }