@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.
- package/README.md +605 -2
- package/audit/ts-hooks.mjs +68 -0
- package/audit/wiring-audit.d.mts +37 -0
- package/audit/wiring-audit.mjs +604 -0
- package/bin/site-manager-kit.mjs +10 -0
- package/package.json +70 -4
- package/src/analytics.tsx +23 -0
- package/src/announcements.ts +13 -0
- package/src/attribution-fields.ts +52 -0
- package/src/attribution.tsx +47 -0
- package/src/blog.ts +122 -0
- package/src/chat-route.ts +136 -0
- package/src/chat-types.ts +76 -0
- package/src/client.ts +116 -0
- package/src/config.ts +78 -0
- package/src/copy-keys-route.ts +21 -0
- package/src/copy-keys.ts +77 -0
- package/src/data.ts +189 -0
- package/src/downloads.ts +7 -0
- package/src/faq.ts +21 -0
- package/src/floor-plans.ts +45 -0
- package/src/form-guard.tsx +37 -0
- package/src/form-token.ts +33 -0
- package/src/form-types.ts +13 -0
- package/src/forms.ts +76 -0
- package/src/glossary.ts +19 -0
- package/src/health-route.ts +34 -0
- package/src/image.ts +12 -0
- package/src/index.ts +36 -0
- package/src/indexnow.ts +40 -0
- package/src/listings.ts +38 -0
- package/src/metadata.ts +55 -0
- package/src/money.ts +10 -0
- package/src/not-found-reporter.tsx +16 -0
- package/src/not-found.ts +53 -0
- package/src/payment.ts +70 -0
- package/src/phone-input.tsx +26 -0
- package/src/phone.ts +37 -0
- package/src/preview-banner.tsx +49 -0
- package/src/preview-route.ts +48 -0
- package/src/preview.ts +94 -0
- package/src/redirects.ts +57 -0
- package/src/residents.ts +23 -0
- package/src/revalidate-route.ts +34 -0
- package/src/robots.ts +26 -0
- package/src/rss-route.ts +26 -0
- package/src/services.ts +52 -0
- package/src/signature.ts +23 -0
- package/src/site-chat-panel.tsx +537 -0
- package/src/site-chat.tsx +26 -0
- package/src/sitemap.ts +26 -0
- package/src/structured-data.tsx +207 -0
- package/src/types.ts +580 -0
- package/src/version.ts +1 -0
- package/src/visual-accents.ts +126 -0
- package/src/visual-bridge-client.tsx +1070 -0
- package/src/visual-bridge.tsx +30 -0
- package/src/visual-clicks.ts +13 -0
- package/src/visual-lists.ts +37 -0
- package/src/visual-match.ts +77 -0
- package/src/visual-messages.ts +160 -0
- package/src/visual-mode.ts +18 -0
- package/src/visual-preview.ts +69 -0
- package/src/visual-tags.ts +90 -0
- package/src/visual.ts +59 -0
- package/src/waitlist-form.ts +39 -0
- package/src/waitlist.ts +48 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState, type CSSProperties, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
|
|
4
|
+
import { readChatStream, textOn, type ChatConfig, type ChatLeadResult, type ChatSlots } from "./chat-types";
|
|
5
|
+
|
|
6
|
+
export type SiteChatProps = {
|
|
7
|
+
config: ChatConfig;
|
|
8
|
+
/** A CSS color for the button, the visitor's messages and links. Defaults to the site's accent from Site Manager. */
|
|
9
|
+
accent?: string;
|
|
10
|
+
/** "auto" follows the visitor's device. */
|
|
11
|
+
theme?: "auto" | "light" | "dark";
|
|
12
|
+
position?: "right" | "left";
|
|
13
|
+
/** The site's chat route. */
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
/** Raise the button above something else fixed in that corner (a cookie bar), in pixels. */
|
|
16
|
+
offset?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Msg = { id: string; role: "visitor" | "assistant"; text: string; pending?: boolean };
|
|
20
|
+
type Stage = "chat" | "details" | "slots";
|
|
21
|
+
type Saved = { conversationId: string | null; messages: Msg[]; leadSent: boolean };
|
|
22
|
+
|
|
23
|
+
const VISITOR_KEY = "bp_chat_visitor";
|
|
24
|
+
const SAVED_KEY = "bp_chat";
|
|
25
|
+
const MAX_CHARS = 1000;
|
|
26
|
+
|
|
27
|
+
const rid = () => (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
28
|
+
|
|
29
|
+
// Storage can be blocked (private windows, strict settings). The chat still works, it just forgets on reload.
|
|
30
|
+
function read<T>(store: "local" | "session", key: string): T | null {
|
|
31
|
+
try {
|
|
32
|
+
const s = store === "local" ? window.localStorage : window.sessionStorage;
|
|
33
|
+
const v = s.getItem(key);
|
|
34
|
+
return v ? (JSON.parse(v) as T) : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function write(store: "local" | "session", key: string, value: unknown) {
|
|
40
|
+
try {
|
|
41
|
+
(store === "local" ? window.localStorage : window.sessionStorage).setItem(key, JSON.stringify(value));
|
|
42
|
+
} catch {
|
|
43
|
+
// Not kept: fine.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function visitorId(): string {
|
|
48
|
+
const have = read<string>("local", VISITOR_KEY);
|
|
49
|
+
if (have && /^[\w-]{8,100}$/.test(have)) return have;
|
|
50
|
+
const id = rid();
|
|
51
|
+
write("local", VISITOR_KEY, id);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Links in a reply become clickable: full https addresses and this site's own paths. Everything else stays text. */
|
|
56
|
+
function linkify(text: string): ReactNode[] {
|
|
57
|
+
const out: ReactNode[] = [];
|
|
58
|
+
const re = /(https?:\/\/[^\s<>()]+[^\s<>().,!?:;'"])|(^|\s)(\/[a-z0-9][\w\-/]*)/gi;
|
|
59
|
+
let last = 0;
|
|
60
|
+
for (const m of text.matchAll(re)) {
|
|
61
|
+
const start = m.index ?? 0;
|
|
62
|
+
const lead = m[2] ?? "";
|
|
63
|
+
const href = m[1] ?? m[3];
|
|
64
|
+
const at = start + (m[1] ? 0 : lead.length);
|
|
65
|
+
out.push(text.slice(last, at));
|
|
66
|
+
out.push(
|
|
67
|
+
<a key={at} href={href} target={m[1] ? "_blank" : undefined} rel={m[1] ? "noopener noreferrer" : undefined}>
|
|
68
|
+
{href}
|
|
69
|
+
</a>,
|
|
70
|
+
);
|
|
71
|
+
last = at + href.length;
|
|
72
|
+
}
|
|
73
|
+
out.push(text.slice(last));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function attribution(): { landingPath?: string; referrer?: string } {
|
|
78
|
+
const a = read<{ landingPath?: string; referrer?: string }>("session", "bp_attribution");
|
|
79
|
+
return a ? { landingPath: a.landingPath, referrer: a.referrer } : {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const CSS = `
|
|
83
|
+
.bpc{--bpc-bg:#ffffff;--bpc-surface:#f4f5f7;--bpc-text:#16181d;--bpc-muted:#5f6573;--bpc-border:rgba(20,22,28,.12);--bpc-shadow:0 24px 60px -18px rgba(15,18,25,.35),0 2px 6px rgba(15,18,25,.08);
|
|
84
|
+
position:fixed;z-index:2147483000;bottom:calc(20px + var(--bpc-offset,0px));font-size:15px;line-height:1.45;font-family:inherit;color:var(--bpc-text)}
|
|
85
|
+
.bpc[data-theme=dark]{--bpc-bg:#14161b;--bpc-surface:#1d2027;--bpc-text:#eceef3;--bpc-muted:#a0a6b4;--bpc-border:rgba(255,255,255,.1);--bpc-shadow:0 24px 60px -18px rgba(0,0,0,.7),0 2px 6px rgba(0,0,0,.4)}
|
|
86
|
+
@media (prefers-color-scheme:dark){.bpc[data-theme=auto]{--bpc-bg:#14161b;--bpc-surface:#1d2027;--bpc-text:#eceef3;--bpc-muted:#a0a6b4;--bpc-border:rgba(255,255,255,.1);--bpc-shadow:0 24px 60px -18px rgba(0,0,0,.7),0 2px 6px rgba(0,0,0,.4)}}
|
|
87
|
+
.bpc[data-pos=right]{right:20px}.bpc[data-pos=left]{left:20px}
|
|
88
|
+
.bpc *{box-sizing:border-box}
|
|
89
|
+
.bpc-launch{all:unset;cursor:pointer;display:grid;place-items:center;width:60px;height:60px;border-radius:50%;background:var(--bpc-accent);color:var(--bpc-on-accent);
|
|
90
|
+
box-shadow:0 12px 32px -8px color-mix(in srgb,var(--bpc-accent) 70%,transparent),0 0 0 1px color-mix(in srgb,var(--bpc-accent) 30%,transparent),inset 0 1px 0 rgba(255,255,255,.25);
|
|
91
|
+
transition:transform .2s cubic-bezier(.2,.8,.2,1),box-shadow .2s}
|
|
92
|
+
.bpc-launch:hover{transform:translateY(-2px) scale(1.04)}.bpc-launch:active{transform:scale(.97)}
|
|
93
|
+
.bpc-launch:focus-visible{outline:3px solid color-mix(in srgb,var(--bpc-accent) 45%,transparent);outline-offset:3px}
|
|
94
|
+
.bpc-launch svg{transition:transform .25s cubic-bezier(.2,.8,.2,1),opacity .2s}
|
|
95
|
+
.bpc-pulse{position:absolute;inset:0;border-radius:50%;pointer-events:none;box-shadow:0 0 0 0 color-mix(in srgb,var(--bpc-accent) 55%,transparent);animation:bpc-pulse 2.4s ease-out 1s 3}
|
|
96
|
+
@keyframes bpc-pulse{to{box-shadow:0 0 0 18px transparent}}
|
|
97
|
+
.bpc-panel{position:absolute;bottom:76px;width:min(392px,calc(100vw - 32px));height:min(620px,calc(100dvh - 120px));display:flex;flex-direction:column;overflow:hidden;
|
|
98
|
+
background:var(--bpc-bg);border:1px solid var(--bpc-border);border-radius:22px;box-shadow:var(--bpc-shadow);transform-origin:bottom right;animation:bpc-in .28s cubic-bezier(.2,.8,.2,1)}
|
|
99
|
+
.bpc[data-pos=right] .bpc-panel{right:0}.bpc[data-pos=left] .bpc-panel{left:0;transform-origin:bottom left}
|
|
100
|
+
@keyframes bpc-in{from{opacity:0;transform:translateY(12px) scale(.96)}}
|
|
101
|
+
.bpc-head{position:relative;display:flex;align-items:center;gap:12px;padding:16px 16px 14px;color:var(--bpc-on-accent);
|
|
102
|
+
background:radial-gradient(120% 140% at 0% 0%,color-mix(in srgb,var(--bpc-accent) 80%,#fff) 0%,var(--bpc-accent) 45%,color-mix(in srgb,var(--bpc-accent) 70%,#000) 100%)}
|
|
103
|
+
.bpc-head::after{content:"";position:absolute;inset:auto 0 0;height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent)}
|
|
104
|
+
.bpc-avatar{display:grid;place-items:center;width:38px;height:38px;flex:none;border-radius:12px;background:rgba(255,255,255,.18);box-shadow:inset 0 0 0 1px rgba(255,255,255,.3)}
|
|
105
|
+
.bpc-title{flex:1;min-width:0}.bpc-title b{display:block;font-size:16px;font-weight:650;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
106
|
+
.bpc-title span{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
|
|
107
|
+
.bpc-dot{width:7px;height:7px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 3px rgba(74,222,128,.25)}
|
|
108
|
+
.bpc-x{all:unset;cursor:pointer;display:grid;place-items:center;width:34px;height:34px;border-radius:10px;color:inherit;opacity:.85}
|
|
109
|
+
.bpc-x:hover{opacity:1;background:rgba(255,255,255,.15)}.bpc-x:focus-visible{outline:2px solid rgba(255,255,255,.8)}
|
|
110
|
+
.bpc-body{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px;scroll-behavior:smooth;overscroll-behavior:contain}
|
|
111
|
+
.bpc-msg{max-width:86%;padding:10px 14px;border-radius:18px;white-space:pre-wrap;word-wrap:break-word;animation:bpc-msg .22s ease-out}
|
|
112
|
+
@keyframes bpc-msg{from{opacity:0;transform:translateY(6px)}}
|
|
113
|
+
.bpc-msg a{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
|
114
|
+
.bpc-a{align-self:flex-start;background:var(--bpc-surface);border-bottom-left-radius:6px}
|
|
115
|
+
.bpc-a a{color:var(--bpc-text);text-decoration-color:var(--bpc-accent);text-decoration-thickness:2px}
|
|
116
|
+
.bpc-v{align-self:flex-end;background:var(--bpc-accent);color:var(--bpc-on-accent);border-bottom-right-radius:6px}
|
|
117
|
+
.bpc-typing{display:inline-flex;gap:4px;padding:4px 2px}.bpc-typing i{width:7px;height:7px;border-radius:50%;background:var(--bpc-muted);opacity:.5;animation:bpc-b 1.2s infinite}
|
|
118
|
+
.bpc-typing i:nth-child(2){animation-delay:.15s}.bpc-typing i:nth-child(3){animation-delay:.3s}
|
|
119
|
+
@keyframes bpc-b{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-4px);opacity:1}}
|
|
120
|
+
.bpc-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px}
|
|
121
|
+
.bpc-chip{all:unset;cursor:pointer;padding:7px 12px;border-radius:999px;font-size:13.5px;border:1px solid color-mix(in srgb,var(--bpc-accent) 40%,var(--bpc-border));color:var(--bpc-text);background:color-mix(in srgb,var(--bpc-accent) 7%,var(--bpc-bg));transition:background .15s,transform .15s}
|
|
122
|
+
.bpc-chip:hover{background:color-mix(in srgb,var(--bpc-accent) 15%,var(--bpc-bg));transform:translateY(-1px)}
|
|
123
|
+
.bpc-chip:focus-visible{outline:2px solid var(--bpc-accent);outline-offset:2px}.bpc-chip[aria-pressed=true]{background:var(--bpc-accent);color:var(--bpc-on-accent);border-color:var(--bpc-accent)}
|
|
124
|
+
.bpc-card{align-self:stretch;border:1px solid var(--bpc-border);background:var(--bpc-bg);border-radius:16px;padding:14px;display:grid;gap:10px;box-shadow:0 1px 0 var(--bpc-border);animation:bpc-msg .22s ease-out}
|
|
125
|
+
.bpc-card h3{margin:0;font-size:14.5px;font-weight:650}.bpc-card p{margin:0;color:var(--bpc-muted);font-size:13px}
|
|
126
|
+
.bpc-row{display:flex;gap:8px;flex-wrap:wrap}
|
|
127
|
+
.bpc-btn{all:unset;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:9px 14px;border-radius:12px;font-weight:600;font-size:14px;background:var(--bpc-accent);color:var(--bpc-on-accent);transition:filter .15s,transform .15s}
|
|
128
|
+
.bpc-btn:hover{filter:brightness(1.07)}.bpc-btn:active{transform:scale(.98)}.bpc-btn[disabled]{opacity:.5;cursor:default}
|
|
129
|
+
.bpc-btn:focus-visible{outline:3px solid color-mix(in srgb,var(--bpc-accent) 45%,transparent);outline-offset:2px}
|
|
130
|
+
.bpc-ghost{background:transparent;color:var(--bpc-text);box-shadow:inset 0 0 0 1px var(--bpc-border)}.bpc-ghost:hover{background:var(--bpc-surface);filter:none}
|
|
131
|
+
.bpc-field{display:grid;gap:4px;font-size:12.5px;color:var(--bpc-muted)}
|
|
132
|
+
.bpc-in{font:inherit;font-size:14.5px;color:var(--bpc-text);background:var(--bpc-surface);border:1px solid transparent;border-radius:11px;padding:9px 11px;width:100%;transition:border-color .15s,box-shadow .15s}
|
|
133
|
+
.bpc-in:focus{outline:none;border-color:var(--bpc-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--bpc-accent) 22%,transparent)}
|
|
134
|
+
.bpc-err{color:#dc2626;font-size:12px}
|
|
135
|
+
.bpc-hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
|
136
|
+
.bpc-foot{border-top:1px solid var(--bpc-border);padding:10px 12px 8px;background:var(--bpc-bg)}
|
|
137
|
+
.bpc-compose{display:flex;align-items:flex-end;gap:8px;background:var(--bpc-surface);border-radius:16px;padding:6px 6px 6px 14px;border:1px solid transparent;transition:border-color .15s,box-shadow .15s}
|
|
138
|
+
.bpc-compose:focus-within{border-color:color-mix(in srgb,var(--bpc-accent) 60%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--bpc-accent) 16%,transparent)}
|
|
139
|
+
.bpc-compose textarea{flex:1;resize:none;border:0;background:transparent;font:inherit;font-size:15px;color:var(--bpc-text);max-height:120px;padding:7px 0;outline:none}
|
|
140
|
+
.bpc-send{all:unset;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;flex:none;border-radius:12px;background:var(--bpc-accent);color:var(--bpc-on-accent);transition:transform .15s,opacity .15s}
|
|
141
|
+
.bpc-send[disabled]{opacity:.35;cursor:default}.bpc-send:not([disabled]):hover{transform:translateY(-1px)}
|
|
142
|
+
.bpc-send:focus-visible{outline:3px solid color-mix(in srgb,var(--bpc-accent) 45%,transparent)}
|
|
143
|
+
.bpc-note{margin:7px 4px 0;font-size:11px;color:var(--bpc-muted);text-align:center}
|
|
144
|
+
.bpc-days{display:flex;gap:6px;overflow-x:auto;padding-bottom:2px;scrollbar-width:thin}
|
|
145
|
+
@media (max-width:480px){.bpc[data-open=true]{inset:0;bottom:0}.bpc[data-open=true] .bpc-launch{display:none}
|
|
146
|
+
.bpc[data-open=true] .bpc-panel{position:fixed;inset:0;width:100%;height:100dvh;border-radius:0;bottom:0}}
|
|
147
|
+
@media (prefers-reduced-motion:reduce){.bpc *{animation:none!important;transition:none!important}}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const ChatIcon = () => (
|
|
151
|
+
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
152
|
+
<path d="M8 9h8M8 13h5" />
|
|
153
|
+
<path d="M12 20l-3-3H6a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3h-3l-3 3z" />
|
|
154
|
+
</svg>
|
|
155
|
+
);
|
|
156
|
+
const CloseIcon = ({ size = 22 }: { size?: number }) => (
|
|
157
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
|
158
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
159
|
+
</svg>
|
|
160
|
+
);
|
|
161
|
+
const SendIcon = () => (
|
|
162
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
163
|
+
<path d="M5 12h14M13 6l6 6-6 6" />
|
|
164
|
+
</svg>
|
|
165
|
+
);
|
|
166
|
+
const SparkIcon = () => (
|
|
167
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
168
|
+
<path d="M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3zM19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15z" />
|
|
169
|
+
</svg>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
export function SiteChatPanel({ config, accent, theme = "auto", position = "right", endpoint = "/api/blueprint/chat", offset = 0 }: SiteChatProps) {
|
|
173
|
+
const [open, setOpen] = useState(false);
|
|
174
|
+
const [messages, setMessages] = useState<Msg[]>([]);
|
|
175
|
+
const [conversationId, setConversationId] = useState<string | null>(null);
|
|
176
|
+
const [draft, setDraft] = useState("");
|
|
177
|
+
const [busy, setBusy] = useState(false);
|
|
178
|
+
const [offer, setOffer] = useState<{ booking: boolean } | null>(null);
|
|
179
|
+
const [stage, setStage] = useState<Stage>("chat");
|
|
180
|
+
const [leadSent, setLeadSent] = useState(false);
|
|
181
|
+
const [slots, setSlots] = useState<ChatSlots | null | "loading" | "none">(null);
|
|
182
|
+
const [day, setDay] = useState(0);
|
|
183
|
+
const [slot, setSlot] = useState<{ start: string; label: string; date: string } | null>(null);
|
|
184
|
+
const [details, setDetails] = useState({ name: "", email: "", phone: "", message: "", hp: "" });
|
|
185
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
186
|
+
const [sending, setSending] = useState(false);
|
|
187
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
188
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
189
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
190
|
+
const launchRef = useRef<HTMLButtonElement>(null);
|
|
191
|
+
const panelId = useId();
|
|
192
|
+
|
|
193
|
+
// Pick up where the visitor left off on another page of the site, for this tab.
|
|
194
|
+
// Saving waits for this, so the first render's empty chat never overwrites the one being restored.
|
|
195
|
+
const [restored, setRestored] = useState(false);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const s = read<Saved>("session", SAVED_KEY);
|
|
198
|
+
if (s) {
|
|
199
|
+
setMessages(s.messages.filter((m) => !m.pending));
|
|
200
|
+
setConversationId(s.conversationId);
|
|
201
|
+
setLeadSent(s.leadSent);
|
|
202
|
+
}
|
|
203
|
+
setRestored(true);
|
|
204
|
+
}, []);
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (restored && !busy) write("session", SAVED_KEY, { conversationId, messages: messages.slice(-60), leadSent } satisfies Saved);
|
|
207
|
+
}, [restored, messages, conversationId, leadSent, busy]);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
|
|
210
|
+
}, [messages, offer, stage, slots, notice]);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (open && stage === "chat") inputRef.current?.focus();
|
|
213
|
+
}, [open, stage]);
|
|
214
|
+
|
|
215
|
+
const close = useCallback(() => {
|
|
216
|
+
setOpen(false);
|
|
217
|
+
launchRef.current?.focus();
|
|
218
|
+
}, []);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!open) return;
|
|
221
|
+
const onKey = (e: globalThis.KeyboardEvent) => e.key === "Escape" && close();
|
|
222
|
+
window.addEventListener("keydown", onKey);
|
|
223
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
224
|
+
}, [open, close]);
|
|
225
|
+
|
|
226
|
+
async function ask(text: string) {
|
|
227
|
+
const message = text.replace(/\s+/g, " ").trim().slice(0, MAX_CHARS);
|
|
228
|
+
if (!message || busy) return;
|
|
229
|
+
setDraft("");
|
|
230
|
+
setNotice(null);
|
|
231
|
+
setOffer(null);
|
|
232
|
+
const replyId = rid();
|
|
233
|
+
setMessages((m) => [...m, { id: rid(), role: "visitor", text: message }, { id: replyId, role: "assistant", text: "", pending: true }]);
|
|
234
|
+
setBusy(true);
|
|
235
|
+
const update = (fn: (m: Msg) => Msg) => setMessages((list) => list.map((x) => (x.id === replyId ? fn(x) : x)));
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetch(endpoint, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "content-type": "application/json" },
|
|
240
|
+
body: JSON.stringify({ type: "message", conversationId, visitorId: visitorId(), message, pageUrl: window.location.href, hp: details.hp }),
|
|
241
|
+
});
|
|
242
|
+
if (!res.ok || !res.body) {
|
|
243
|
+
const err = ((await res.json().catch(() => null)) as { error?: string } | null)?.error;
|
|
244
|
+
const text =
|
|
245
|
+
res.status === 429
|
|
246
|
+
? "You're sending messages quickly. Give it a minute, then try again."
|
|
247
|
+
: err === "conversation_full"
|
|
248
|
+
? "This chat has reached its limit. Leave your details and the team will pick it up from here."
|
|
249
|
+
: "Sorry, the chat isn't available right now. Please try again in a moment.";
|
|
250
|
+
update((m) => ({ ...m, text, pending: false }));
|
|
251
|
+
if (err === "conversation_full") setOffer({ booking: config.canBook });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
await readChatStream(res.body, (e) => {
|
|
255
|
+
if (e.type === "start") setConversationId(e.conversationId);
|
|
256
|
+
else if (e.type === "delta") update((m) => ({ ...m, text: m.text + e.text, pending: false }));
|
|
257
|
+
else if (e.type === "done" && (e.handoff || e.unanswered) && !leadSent) setOffer({ booking: e.booking });
|
|
258
|
+
});
|
|
259
|
+
update((m) => ({ ...m, pending: false, text: m.text || "Sorry, I didn't get an answer. Please try again." }));
|
|
260
|
+
} catch {
|
|
261
|
+
update((m) => ({ ...m, pending: false, text: m.text || "Sorry, the connection dropped. Please try again." }));
|
|
262
|
+
} finally {
|
|
263
|
+
setBusy(false);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function loadSlots() {
|
|
268
|
+
setStage("slots");
|
|
269
|
+
setSlot(null);
|
|
270
|
+
setSlots("loading");
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(`${endpoint}?slots=1`);
|
|
273
|
+
const data = res.ok ? ((await res.json()) as ChatSlots) : null;
|
|
274
|
+
const days = data?.days.filter((d) => d.slots.length) ?? [];
|
|
275
|
+
setSlots(data && days.length ? { ...data, days } : "none");
|
|
276
|
+
setDay(0);
|
|
277
|
+
} catch {
|
|
278
|
+
setSlots("none");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function startDetails() {
|
|
283
|
+
const lastQuestion = [...messages].reverse().find((m) => m.role === "visitor")?.text ?? "";
|
|
284
|
+
setDetails((d) => ({ ...d, message: d.message || lastQuestion }));
|
|
285
|
+
setErrors({});
|
|
286
|
+
setStage("details");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function sendLead(e: FormEvent) {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
if (!conversationId) return;
|
|
292
|
+
const errs: Record<string, string> = {};
|
|
293
|
+
if (!details.name.trim()) errs.name = "Add your name";
|
|
294
|
+
if (!details.email.trim() && !details.phone.trim()) errs.email = "Add an email or phone so the team can reply";
|
|
295
|
+
if (details.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(details.email.trim())) errs.email = "Check the email address";
|
|
296
|
+
setErrors(errs);
|
|
297
|
+
if (Object.keys(errs).length) return;
|
|
298
|
+
setSending(true);
|
|
299
|
+
try {
|
|
300
|
+
const res = await fetch(endpoint, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { "content-type": "application/json" },
|
|
303
|
+
body: JSON.stringify({ type: "lead", conversationId, visitorId: visitorId(), ...details, slot: slot?.start, attribution: attribution() }),
|
|
304
|
+
});
|
|
305
|
+
const r = (await res.json().catch(() => ({ ok: false, error: "unavailable" }))) as ChatLeadResult;
|
|
306
|
+
if (r.ok) {
|
|
307
|
+
setLeadSent(true);
|
|
308
|
+
setOffer(null);
|
|
309
|
+
const when = slot ? ` You're booked for ${slot.label} on ${dayLabel(slot.date)}.` : "";
|
|
310
|
+
setMessages((m) => [...m, { id: rid(), role: "assistant", text: `${r.successMessage}${when}` }]);
|
|
311
|
+
setStage("chat");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (r.error === "slot_taken") {
|
|
315
|
+
setNotice("That time was just taken. Pick another.");
|
|
316
|
+
void loadSlots();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (r.error === "invalid" && r.fields) setErrors(Object.fromEntries(Object.entries(r.fields).map(([k, v]) => [k === "slot" ? "message" : k, v])));
|
|
320
|
+
else setErrors({ message: r.error === "rate_limited" ? "Too many requests. Try again in a little while." : "Couldn't send. Please try again." });
|
|
321
|
+
} finally {
|
|
322
|
+
setSending(false);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function onKey(e: KeyboardEvent<HTMLTextAreaElement>) {
|
|
327
|
+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
void ask(draft);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const color = accent ?? config.accent ?? "#2563eb";
|
|
334
|
+
const style = { "--bpc-accent": color, "--bpc-on-accent": textOn(color), "--bpc-offset": `${offset}px` } as CSSProperties;
|
|
335
|
+
const daysList = slots && typeof slots === "object" ? slots.days : [];
|
|
336
|
+
const showChips = !messages.length && config.suggestedQuestions.length > 0;
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="bpc" data-theme={theme} data-pos={position} data-open={open} style={style}>
|
|
340
|
+
<style>{CSS}</style>
|
|
341
|
+
{open ? (
|
|
342
|
+
<div className="bpc-panel" id={panelId} role="dialog" aria-label={`Chat with ${config.name}`}>
|
|
343
|
+
<div className="bpc-head">
|
|
344
|
+
<span className="bpc-avatar">
|
|
345
|
+
<SparkIcon />
|
|
346
|
+
</span>
|
|
347
|
+
<span className="bpc-title">
|
|
348
|
+
<b>{config.name}</b>
|
|
349
|
+
<span>
|
|
350
|
+
<i className="bpc-dot" aria-hidden /> AI assistant · answers from this website
|
|
351
|
+
</span>
|
|
352
|
+
</span>
|
|
353
|
+
<button type="button" className="bpc-x" onClick={close} aria-label="Close chat">
|
|
354
|
+
<CloseIcon />
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className="bpc-body" ref={bodyRef} aria-live="polite" aria-relevant="additions text">
|
|
359
|
+
<div className="bpc-msg bpc-a">{config.greeting}</div>
|
|
360
|
+
{showChips ? (
|
|
361
|
+
<div className="bpc-chips">
|
|
362
|
+
{config.suggestedQuestions.map((q) => (
|
|
363
|
+
<button key={q} type="button" className="bpc-chip" onClick={() => void ask(q)}>
|
|
364
|
+
{q}
|
|
365
|
+
</button>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
) : null}
|
|
369
|
+
{messages.map((m) => (
|
|
370
|
+
<div key={m.id} className={`bpc-msg ${m.role === "visitor" ? "bpc-v" : "bpc-a"}`}>
|
|
371
|
+
{m.pending && !m.text ? (
|
|
372
|
+
<span className="bpc-typing" aria-label={`${config.name} is writing`}>
|
|
373
|
+
<i />
|
|
374
|
+
<i />
|
|
375
|
+
<i />
|
|
376
|
+
</span>
|
|
377
|
+
) : m.role === "assistant" ? (
|
|
378
|
+
linkify(m.text)
|
|
379
|
+
) : (
|
|
380
|
+
m.text
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
))}
|
|
384
|
+
|
|
385
|
+
{offer && stage === "chat" && !busy ? (
|
|
386
|
+
<div className="bpc-card">
|
|
387
|
+
<h3>{offer.booking ? "Want to set up a time?" : "Want the team to reach out?"}</h3>
|
|
388
|
+
<p>
|
|
389
|
+
{offer.booking ? "Pick an open time, or leave your details and someone will get back to you." : "Leave your details and someone will get back to you."} This conversation goes with it, so
|
|
390
|
+
you won't have to repeat yourself.
|
|
391
|
+
</p>
|
|
392
|
+
<div className="bpc-row">
|
|
393
|
+
{offer.booking ? (
|
|
394
|
+
<button type="button" className="bpc-btn" onClick={() => void loadSlots()}>
|
|
395
|
+
Book a time
|
|
396
|
+
</button>
|
|
397
|
+
) : null}
|
|
398
|
+
<button type="button" className={`bpc-btn ${offer.booking ? "bpc-ghost" : ""}`} onClick={startDetails}>
|
|
399
|
+
Leave my details
|
|
400
|
+
</button>
|
|
401
|
+
<button type="button" className="bpc-btn bpc-ghost" onClick={() => setOffer(null)}>
|
|
402
|
+
Not now
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
) : null}
|
|
407
|
+
|
|
408
|
+
{stage === "slots" ? (
|
|
409
|
+
<div className="bpc-card">
|
|
410
|
+
<h3>Pick a time</h3>
|
|
411
|
+
{notice ? <p className="bpc-err">{notice}</p> : null}
|
|
412
|
+
{slots === "loading" ? (
|
|
413
|
+
<span className="bpc-typing" aria-label="Loading times">
|
|
414
|
+
<i />
|
|
415
|
+
<i />
|
|
416
|
+
<i />
|
|
417
|
+
</span>
|
|
418
|
+
) : slots === "none" || !daysList.length ? (
|
|
419
|
+
<>
|
|
420
|
+
<p>There are no open times in the next week. Leave your details and the team will find one with you.</p>
|
|
421
|
+
<div className="bpc-row">
|
|
422
|
+
<button type="button" className="bpc-btn" onClick={startDetails}>
|
|
423
|
+
Leave my details
|
|
424
|
+
</button>
|
|
425
|
+
</div>
|
|
426
|
+
</>
|
|
427
|
+
) : (
|
|
428
|
+
<>
|
|
429
|
+
<div className="bpc-days" role="tablist" aria-label="Days">
|
|
430
|
+
{daysList.map((d, i) => (
|
|
431
|
+
<button key={d.date} type="button" role="tab" aria-selected={i === day} aria-pressed={i === day} className="bpc-chip" onClick={() => setDay(i)}>
|
|
432
|
+
{dayLabel(d.date)}
|
|
433
|
+
</button>
|
|
434
|
+
))}
|
|
435
|
+
</div>
|
|
436
|
+
<div className="bpc-chips">
|
|
437
|
+
{daysList[day]?.slots.map((s) => (
|
|
438
|
+
<button key={s.start} type="button" className="bpc-chip" aria-pressed={slot?.start === s.start} onClick={() => setSlot({ start: s.start, label: s.label, date: daysList[day].date })}>
|
|
439
|
+
{s.label}
|
|
440
|
+
</button>
|
|
441
|
+
))}
|
|
442
|
+
</div>
|
|
443
|
+
<div className="bpc-row">
|
|
444
|
+
<button type="button" className="bpc-btn" disabled={!slot} onClick={startDetails}>
|
|
445
|
+
Continue
|
|
446
|
+
</button>
|
|
447
|
+
<button type="button" className="bpc-btn bpc-ghost" onClick={() => setStage("chat")}>
|
|
448
|
+
Back
|
|
449
|
+
</button>
|
|
450
|
+
</div>
|
|
451
|
+
</>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
) : null}
|
|
455
|
+
|
|
456
|
+
{stage === "details" ? (
|
|
457
|
+
<form className="bpc-card" onSubmit={(e) => void sendLead(e)} noValidate>
|
|
458
|
+
<h3>{slot ? `Book ${slot.label}, ${dayLabel(slot.date)}` : "Your details"}</h3>
|
|
459
|
+
<label className="bpc-field">
|
|
460
|
+
Name
|
|
461
|
+
<input className="bpc-in" autoComplete="name" value={details.name} onChange={(e) => setDetails({ ...details, name: e.target.value })} maxLength={200} autoFocus />
|
|
462
|
+
{errors.name ? <span className="bpc-err">{errors.name}</span> : null}
|
|
463
|
+
</label>
|
|
464
|
+
<label className="bpc-field">
|
|
465
|
+
Email
|
|
466
|
+
<input className="bpc-in" type="email" autoComplete="email" inputMode="email" value={details.email} onChange={(e) => setDetails({ ...details, email: e.target.value })} maxLength={320} />
|
|
467
|
+
{errors.email ? <span className="bpc-err">{errors.email}</span> : null}
|
|
468
|
+
</label>
|
|
469
|
+
<label className="bpc-field">
|
|
470
|
+
Phone
|
|
471
|
+
<input className="bpc-in" type="tel" autoComplete="tel" inputMode="tel" value={details.phone} onChange={(e) => setDetails({ ...details, phone: e.target.value })} maxLength={40} />
|
|
472
|
+
{errors.phone ? <span className="bpc-err">{errors.phone}</span> : null}
|
|
473
|
+
</label>
|
|
474
|
+
<label className="bpc-field">
|
|
475
|
+
Message
|
|
476
|
+
<textarea className="bpc-in" rows={3} value={details.message} onChange={(e) => setDetails({ ...details, message: e.target.value })} maxLength={2000} />
|
|
477
|
+
{errors.message ? <span className="bpc-err">{errors.message}</span> : null}
|
|
478
|
+
</label>
|
|
479
|
+
<label className="bpc-hp" aria-hidden>
|
|
480
|
+
Leave this empty
|
|
481
|
+
<input tabIndex={-1} autoComplete="off" value={details.hp} onChange={(e) => setDetails({ ...details, hp: e.target.value })} />
|
|
482
|
+
</label>
|
|
483
|
+
<div className="bpc-row">
|
|
484
|
+
<button type="submit" className="bpc-btn" disabled={sending}>
|
|
485
|
+
{sending ? "Sending..." : slot ? "Book it" : "Send"}
|
|
486
|
+
</button>
|
|
487
|
+
<button type="button" className="bpc-btn bpc-ghost" onClick={() => setStage(slot ? "slots" : "chat")}>
|
|
488
|
+
Back
|
|
489
|
+
</button>
|
|
490
|
+
</div>
|
|
491
|
+
</form>
|
|
492
|
+
) : null}
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
<div className="bpc-foot">
|
|
496
|
+
<form
|
|
497
|
+
className="bpc-compose"
|
|
498
|
+
onSubmit={(e) => {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
void ask(draft);
|
|
501
|
+
}}
|
|
502
|
+
>
|
|
503
|
+
<textarea
|
|
504
|
+
ref={inputRef}
|
|
505
|
+
rows={1}
|
|
506
|
+
value={draft}
|
|
507
|
+
maxLength={MAX_CHARS}
|
|
508
|
+
placeholder="Ask a question"
|
|
509
|
+
aria-label="Your message"
|
|
510
|
+
onKeyDown={onKey}
|
|
511
|
+
onChange={(e) => {
|
|
512
|
+
setDraft(e.target.value);
|
|
513
|
+
e.target.style.height = "auto";
|
|
514
|
+
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`;
|
|
515
|
+
}}
|
|
516
|
+
/>
|
|
517
|
+
<button type="submit" className="bpc-send" disabled={busy || !draft.trim()} aria-label="Send">
|
|
518
|
+
<SendIcon />
|
|
519
|
+
</button>
|
|
520
|
+
</form>
|
|
521
|
+
<p className="bpc-note">AI answers from this website's information. Chats are kept {config.retentionDays} days.</p>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
) : null}
|
|
525
|
+
<button ref={launchRef} type="button" className="bpc-launch" aria-expanded={open} aria-controls={open ? panelId : undefined} aria-label={open ? "Close chat" : `Chat with ${config.name}`} onClick={() => (open ? close() : setOpen(true))}>
|
|
526
|
+
{!open && !messages.length ? <span className="bpc-pulse" aria-hidden /> : null}
|
|
527
|
+
{open ? <CloseIcon size={24} /> : <ChatIcon />}
|
|
528
|
+
</button>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** "Thu, Nov 6" from "2026-11-06", read as a calendar day (no time zone shift). */
|
|
534
|
+
function dayLabel(date: string): string {
|
|
535
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
536
|
+
return new Date(Date.UTC(y, m - 1, d, 12)).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
|
|
537
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { bp, tagsFor } from "./client";
|
|
2
|
+
import type { ChatConfig } from "./chat-types";
|
|
3
|
+
import { SiteChatPanel, type SiteChatProps } from "./site-chat-panel";
|
|
4
|
+
|
|
5
|
+
/** The chat's settings, cached like other reads and refreshed when they change in Site Manager. Null when off. */
|
|
6
|
+
export async function getChatConfig(): Promise<ChatConfig | null> {
|
|
7
|
+
try {
|
|
8
|
+
return await bp<ChatConfig>("/chat", { tags: tagsFor("site_chat") });
|
|
9
|
+
} catch {
|
|
10
|
+
// Website Chat is off for the site (403), or Site Manager can't be reached: no chat rather than a broken page.
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Put once inside <body> in app/layout.tsx, with the chat route at app/api/blueprint/chat/route.ts. Shows a chat
|
|
17
|
+
* button on every page while Website Chat is live in Site Manager, and nothing otherwise.
|
|
18
|
+
*
|
|
19
|
+
* <SiteChat /> the site's accent from Site Manager, light or dark from the visitor's device
|
|
20
|
+
* <SiteChat accent="#0e7c66" theme="light" position="left" />
|
|
21
|
+
*/
|
|
22
|
+
export async function SiteChat(props: Omit<SiteChatProps, "config">) {
|
|
23
|
+
const config = await getChatConfig();
|
|
24
|
+
if (!config?.live) return null;
|
|
25
|
+
return <SiteChatPanel config={config} {...props} />;
|
|
26
|
+
}
|
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { bp, tagsFor } from "./client";
|
|
3
|
+
import type { SitemapEntry } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Pure: Site Manager's entries to Next's sitemap format, plus any extra entries the site adds. */
|
|
6
|
+
export function buildSitemap(entries: SitemapEntry[], extra: MetadataRoute.Sitemap = []): MetadataRoute.Sitemap {
|
|
7
|
+
const seen = new Set<string>();
|
|
8
|
+
const out: MetadataRoute.Sitemap = [];
|
|
9
|
+
for (const e of [...entries.map((x) => ({ url: x.url, lastModified: new Date(x.lastModified) })), ...extra]) {
|
|
10
|
+
// Absolute and without a trailing slash, the home page included.
|
|
11
|
+
const url = e.url.replace(/\/+$/, "");
|
|
12
|
+
if (seen.has(url)) continue;
|
|
13
|
+
seen.add(url);
|
|
14
|
+
out.push({ ...e, url });
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* For `app/sitemap.ts`: every page Site Manager lists (no noindex, no redirected paths, no pages of sections that
|
|
21
|
+
* are off), with real last-modified dates.
|
|
22
|
+
*/
|
|
23
|
+
export async function sitemap(extra: MetadataRoute.Sitemap = []): Promise<MetadataRoute.Sitemap> {
|
|
24
|
+
const entries = (await bp<SitemapEntry[]>("/sitemap", { tags: tagsFor("seo", "redirects") })) ?? [];
|
|
25
|
+
return buildSitemap(entries, extra);
|
|
26
|
+
}
|