@atelic-action/ui 0.17.2 → 1.1.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 (188) hide show
  1. package/README.md +432 -45
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Note.tsx +9 -39
  104. package/src/artifact/Paragraph.tsx +65 -0
  105. package/src/artifact/Read.tsx +406 -0
  106. package/src/artifact/Writeup.tsx +7 -7
  107. package/src/artifact/index.ts +1 -0
  108. package/src/chrome/SiteShell.tsx +141 -0
  109. package/src/chrome/index.ts +2 -0
  110. package/src/components/Disclosure.tsx +80 -14
  111. package/src/components/GateLogin.tsx +1 -1
  112. package/src/components/Watermark.tsx +1 -1
  113. package/src/components/index.ts +6 -2
  114. package/src/gate/index.ts +1 -1
  115. package/src/gate/middleware.ts +17 -6
  116. package/src/gate/wall.ts +60 -2
  117. package/src/lib/basePathAudit.ts +96 -0
  118. package/src/lib/displayDate.ts +25 -0
  119. package/src/lib/honeypot.ts +11 -0
  120. package/src/lib/hours.ts +28 -0
  121. package/src/lib/sitemap.ts +34 -0
  122. package/src/lib/trackEvent.ts +26 -0
  123. package/src/lib/useOpenHeight.ts +28 -0
  124. package/src/primitives/Button.tsx +3 -1
  125. package/src/primitives/Chip.tsx +3 -1
  126. package/src/primitives/ImageFrame.tsx +48 -0
  127. package/src/primitives/ProseBlocks.tsx +67 -0
  128. package/src/primitives/QuoteCard.tsx +20 -0
  129. package/src/primitives/StarRow.tsx +37 -0
  130. package/src/primitives/TickList.tsx +24 -0
  131. package/src/primitives/index.ts +5 -0
  132. package/src/routing/BasePathProvider.tsx +47 -0
  133. package/src/routing/basePath.ts +57 -0
  134. package/src/routing/index.ts +8 -0
  135. package/src/scripts/check-base-path.ts +130 -0
  136. package/src/scripts/clean-sitemap.ts +27 -0
  137. package/src/scripts/contact-canary.ts +98 -0
  138. package/src/scripts/serve-static.ts +29 -3
  139. package/src/sections/BlogIndex.tsx +57 -0
  140. package/src/sections/BlogPost.tsx +65 -0
  141. package/src/sections/CTABanner.tsx +93 -0
  142. package/src/sections/CTASection.tsx +60 -0
  143. package/src/sections/ComparisonTable.tsx +114 -0
  144. package/src/sections/ContactForm.tsx +163 -0
  145. package/src/sections/ContactInfoCard.tsx +92 -0
  146. package/src/sections/CredentialList.tsx +28 -0
  147. package/src/sections/DeviceShowcase.tsx +39 -0
  148. package/src/sections/ExpandList.tsx +58 -0
  149. package/src/sections/FAQBrowser.tsx +283 -0
  150. package/src/sections/FeatureDetail.tsx +60 -0
  151. package/src/sections/FeatureGrid.tsx +83 -0
  152. package/src/sections/Gallery.tsx +84 -0
  153. package/src/sections/Hero.tsx +110 -0
  154. package/src/sections/HoursTable.tsx +23 -0
  155. package/src/sections/JumpRail.tsx +35 -0
  156. package/src/sections/LegalPage.tsx +43 -0
  157. package/src/sections/LocationSection.tsx +66 -0
  158. package/src/sections/Locations.tsx +71 -0
  159. package/src/sections/LogoMarquee.tsx +27 -0
  160. package/src/sections/MapEmbed.tsx +75 -0
  161. package/src/sections/MediaBand.tsx +20 -0
  162. package/src/sections/MenuList.tsx +44 -0
  163. package/src/sections/PageHero.tsx +25 -0
  164. package/src/sections/PressQuotes.tsx +51 -0
  165. package/src/sections/PricingBand.tsx +43 -0
  166. package/src/sections/PricingCard.tsx +36 -0
  167. package/src/sections/ProcessSteps.tsx +45 -0
  168. package/src/sections/ProfileCards.tsx +73 -0
  169. package/src/sections/RevealSlider.tsx +156 -0
  170. package/src/sections/ReviewCarousel.tsx +223 -0
  171. package/src/sections/ReviewWall.tsx +220 -0
  172. package/src/sections/SocialStrip.tsx +83 -0
  173. package/src/sections/SplitSection.tsx +56 -0
  174. package/src/sections/index.ts +76 -0
  175. package/src/sections/siteConfig.ts +201 -0
  176. package/src/styles/app.css +673 -0
  177. package/src/styles/artifact.css +759 -401
  178. package/src/styles/base.css +49 -47
  179. package/src/styles/chrome.css +71 -71
  180. package/src/styles/components.css +150 -140
  181. package/src/styles/gate.css +29 -29
  182. package/src/styles/primitives.css +190 -25
  183. package/src/styles/sections.css +2652 -0
  184. package/src/styles/star-rating.css +2 -2
  185. package/src/vite/index.ts +9 -2
  186. package/src/vite/marketing-config.ts +128 -0
  187. package/src/components/Collapsible.tsx +0 -43
  188. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,163 @@
1
+ import { type FormEvent, type ReactNode, useState } from "react";
2
+ import { isHoneypotFilled } from "../lib/honeypot";
3
+ import { trackEvent } from "../lib/trackEvent";
4
+ import { Button } from "../primitives/Button";
5
+ import { useWithBase } from "../routing/BasePathProvider";
6
+
7
+ export interface ContactFormProps {
8
+ /**
9
+ * POST target for a real form backend (e.g. "/api/contact"). The body is
10
+ * JSON: { name, email, topic, message, company } where company is the
11
+ * honeypot and must be relayed verbatim. When omitted, the form falls
12
+ * back to a mailto handoff via toEmail.
13
+ */
14
+ endpoint?: string;
15
+ /** Destination inbox for the mailto fallback (required without endpoint). */
16
+ toEmail?: string;
17
+ /** Options for the topic select. */
18
+ topics: string[];
19
+ /** Message shown after a successful submit (or after the mail draft opens). */
20
+ successMessage: string;
21
+ /** Message shown when the endpoint submission fails. */
22
+ errorMessage?: string;
23
+ /** Small footer line under the submit button. */
24
+ footnote?: ReactNode;
25
+ /**
26
+ * Called once for each human submission the endpoint accepted: never on a
27
+ * press of the button, a failed send, a honeypot catch, or the mailto
28
+ * handoff. Defaults to the GA4 `generate_lead` event with
29
+ * `form_name: "contact"`, a silent no op on a page without the Google tag.
30
+ * It is passed nothing, so no personal data can ride along.
31
+ */
32
+ onLead?: () => void;
33
+ }
34
+
35
+ function sendLead() {
36
+ trackEvent("generate_lead", { form_name: "contact" });
37
+ }
38
+
39
+ interface FieldErrors {
40
+ name?: boolean;
41
+ email?: boolean;
42
+ message?: boolean;
43
+ }
44
+
45
+ type Status = "idle" | "sending" | "sent" | "failed";
46
+
47
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
48
+
49
+ /**
50
+ * Contact form with client validation and a honeypot. POSTs to `endpoint`
51
+ * when one is configured; otherwise hands off to the visitor's mail app.
52
+ */
53
+ export function ContactForm({
54
+ endpoint,
55
+ toEmail,
56
+ topics,
57
+ successMessage,
58
+ errorMessage = "Something went wrong sending your message. Please try again.",
59
+ footnote,
60
+ onLead = sendLead,
61
+ }: ContactFormProps) {
62
+ const withBase = useWithBase();
63
+ const [errors, setErrors] = useState<FieldErrors>({});
64
+ const [status, setStatus] = useState<Status>("idle");
65
+
66
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
67
+ event.preventDefault();
68
+ if (status === "sending") return;
69
+ const form = event.currentTarget;
70
+ const data = new FormData(form);
71
+ const name = String(data.get("name") ?? "").trim();
72
+ const email = String(data.get("email") ?? "").trim();
73
+ const topic = String(data.get("topic") ?? "");
74
+ const message = String(data.get("message") ?? "").trim();
75
+ const company = String(data.get("company") ?? "");
76
+
77
+ const nextErrors: FieldErrors = {
78
+ name: !name,
79
+ email: !EMAIL_PATTERN.test(email),
80
+ message: !message,
81
+ };
82
+ setErrors(nextErrors);
83
+ if (nextErrors.name || nextErrors.email || nextErrors.message) {
84
+ setStatus("idle");
85
+ return;
86
+ }
87
+
88
+ if (!endpoint) {
89
+ if (!toEmail) {
90
+ setStatus("failed");
91
+ return;
92
+ }
93
+ const subject = encodeURIComponent(`Website inquiry: ${topic}`);
94
+ const body = encodeURIComponent(`${name} (${email}) wrote:\n\n${message}`);
95
+ setStatus("sent");
96
+ window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
97
+ return;
98
+ }
99
+
100
+ setStatus("sending");
101
+ try {
102
+ const response = await fetch(withBase(endpoint), {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ name, email, topic, message, company }),
106
+ });
107
+ if (!response.ok) throw new Error(`status ${response.status}`);
108
+ setStatus("sent");
109
+ form.reset();
110
+ // The conversion: only a relay the endpoint accepted counts, never a
111
+ // press of the button, a failed send, or a honeypot catch (which the
112
+ // relay answers with the same success so the bot never learns). No
113
+ // personal data rides along.
114
+ if (!isHoneypotFilled(company)) onLead();
115
+ } catch {
116
+ setStatus("failed");
117
+ }
118
+ }
119
+
120
+ return (
121
+ <form onSubmit={handleSubmit} noValidate>
122
+ {status === "sent" && <div className="form-success">{successMessage}</div>}
123
+ {status === "failed" && <div className="form-error">{errorMessage}</div>}
124
+ <div className={["field", errors.name && "invalid"].filter(Boolean).join(" ")}>
125
+ <label htmlFor="contact-name">Your name</label>
126
+ <input id="contact-name" name="name" type="text" autoComplete="name" required />
127
+ <span className="err">Please enter your name.</span>
128
+ </div>
129
+ <div className={["field", errors.email && "invalid"].filter(Boolean).join(" ")}>
130
+ <label htmlFor="contact-email">Email</label>
131
+ <input id="contact-email" name="email" type="email" autoComplete="email" required />
132
+ <span className="err">Please enter a valid email.</span>
133
+ </div>
134
+ <div className="field">
135
+ <label htmlFor="contact-topic">What can we help with?</label>
136
+ <select id="contact-topic" name="topic">
137
+ {topics.map((topic) => (
138
+ <option key={topic}>{topic}</option>
139
+ ))}
140
+ </select>
141
+ </div>
142
+ <div className={["field", errors.message && "invalid"].filter(Boolean).join(" ")}>
143
+ <label htmlFor="contact-message">Message</label>
144
+ <textarea id="contact-message" name="message" required />
145
+ <span className="err">Please add a short message.</span>
146
+ </div>
147
+ {/* Honeypot: visually hidden and skipped by keyboard/screen readers;
148
+ bots that fill it are quietly dropped by the backend. */}
149
+ <div className="hp-field" aria-hidden="true">
150
+ <label htmlFor="contact-company">Company</label>
151
+ <input id="contact-company" name="company" type="text" tabIndex={-1} autoComplete="off" />
152
+ </div>
153
+ <Button type="submit" size="lg" arrow style={{ width: "100%" }}>
154
+ {status === "sending" ? "Sending…" : "Send Message"}
155
+ </Button>
156
+ {footnote && (
157
+ <p className="muted form-footnote" style={{ fontSize: "0.82rem", marginTop: 14 }}>
158
+ {footnote}
159
+ </p>
160
+ )}
161
+ </form>
162
+ );
163
+ }
@@ -0,0 +1,92 @@
1
+ import { newTabProps } from "../lib/newTabProps";
2
+ import { useWithBase } from "../routing/BasePathProvider";
3
+ import type { SiteConfig } from "./siteConfig";
4
+
5
+ export interface ContactPhone {
6
+ /** Who picks up, e.g. "Owner" or "Co Owner". */
7
+ role: string;
8
+ display: string;
9
+ e164: string;
10
+ }
11
+
12
+ /** The keys of the site config the card reads; a whole `SiteConfig` passes as it is. */
13
+ export type ContactInfoCardSite = Pick<SiteConfig, "phone" | "email" | "address" | "footerLinks">;
14
+
15
+ export interface ContactInfoCardProps {
16
+ site: ContactInfoCardSite;
17
+ /** Card heading. */
18
+ title?: string;
19
+ /**
20
+ * Labelled phone lines for a business that publishes more than one (a
21
+ * family shop with an owner and a co owner, say). When set, these replace
22
+ * the single `site.phone` row and each carries its own role label.
23
+ */
24
+ phones?: ContactPhone[];
25
+ /**
26
+ * Render the street address row. Off for businesses that publish a service
27
+ * area but no storefront: an address row with an empty street reads as a
28
+ * broken card, and inventing one is worse.
29
+ */
30
+ showAddress?: boolean;
31
+ }
32
+
33
+ /** Dark card with direct contact rows: call, email, visit, portal. */
34
+ export function ContactInfoCard({
35
+ site,
36
+ title = "Reach us directly",
37
+ phones,
38
+ showAddress = true,
39
+ }: ContactInfoCardProps) {
40
+ const withBase = useWithBase();
41
+ const portal = site.footerLinks?.find((link) => link.label.toLowerCase().includes("portal"));
42
+ return (
43
+ <div className="contact-info-card">
44
+ <h3>{title}</h3>
45
+ <div className="ci-rows">
46
+ {phones?.length
47
+ ? phones.map((phone) => (
48
+ <div className="ci-row" key={phone.e164}>
49
+ <span className="k">{phone.role}</span>
50
+ <span className="v">
51
+ <a href={`tel:${phone.e164}`}>{phone.display}</a>
52
+ </span>
53
+ </div>
54
+ ))
55
+ : site.phone && (
56
+ <div className="ci-row">
57
+ <span className="k">Call / Text</span>
58
+ <span className="v">
59
+ <a href={`tel:${site.phone.e164}`}>{site.phone.display}</a>
60
+ </span>
61
+ </div>
62
+ )}
63
+ <div className={["ci-row", !showAddress && !portal && "last"].filter(Boolean).join(" ")}>
64
+ <span className="k">Email</span>
65
+ <span className="v">
66
+ <a href={`mailto:${site.email}`}>{site.email}</a>
67
+ </span>
68
+ </div>
69
+ {showAddress && (
70
+ <div className="ci-row">
71
+ <span className="k">Visit</span>
72
+ <span className="v">
73
+ {site.address.street}
74
+ <br />
75
+ {site.address.locality}, {site.address.region} {site.address.postalCode}
76
+ </span>
77
+ </div>
78
+ )}
79
+ {portal && (
80
+ <div className="ci-row last">
81
+ <span className="k">Portal</span>
82
+ <span className="v">
83
+ <a href={withBase(portal.href)} {...newTabProps(portal.href, portal.external)}>
84
+ {portal.label} →
85
+ </a>
86
+ </span>
87
+ </div>
88
+ )}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,28 @@
1
+ export interface Credential {
2
+ /** Short mono code, e.g. "DPT", "OCS". */
3
+ code: string;
4
+ title: string;
5
+ detail?: string;
6
+ }
7
+
8
+ export interface CredentialListProps {
9
+ items: Credential[];
10
+ className?: string;
11
+ }
12
+
13
+ /** Card rows of credentials or certifications with mono code prefixes. */
14
+ export function CredentialList({ items, className }: CredentialListProps) {
15
+ return (
16
+ <ul className={["cert-list", className].filter(Boolean).join(" ")}>
17
+ {items.map((item) => (
18
+ <li key={item.code + item.title}>
19
+ <span className="ci">{item.code}</span>
20
+ <div>
21
+ <strong>{item.title}</strong>
22
+ {item.detail && <small>{item.detail}</small>}
23
+ </div>
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ );
28
+ }
@@ -0,0 +1,39 @@
1
+ import { useWithBase } from "../routing/BasePathProvider";
2
+
3
+ export interface DeviceImage {
4
+ src: string;
5
+ alt: string;
6
+ }
7
+
8
+ export interface DeviceShowcaseProps {
9
+ /** Desktop screenshot, shown inside a browser window frame. */
10
+ desktop: DeviceImage;
11
+ /** Phone screenshot, shown in a device frame overlapping the desktop. */
12
+ phone: DeviceImage;
13
+ }
14
+
15
+ /**
16
+ * A desktop browser mockup with a phone mockup overlapping its lower-left,
17
+ * for showing a rebuilt site the way it will actually look. Reusable; both
18
+ * images arrive via props.
19
+ */
20
+ export function DeviceShowcase({ desktop, phone }: DeviceShowcaseProps) {
21
+ const withBase = useWithBase();
22
+ return (
23
+ <div className="devices">
24
+ <div className="dv-desktop">
25
+ <div className="dv-bar">
26
+ <i />
27
+ <i />
28
+ <i />
29
+ </div>
30
+ <div className="dv-screen">
31
+ <img src={withBase(desktop.src)} alt={desktop.alt} loading="lazy" />
32
+ </div>
33
+ </div>
34
+ <div className="dv-phone">
35
+ <img src={withBase(phone.src)} alt={phone.alt} loading="lazy" />
36
+ </div>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,58 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import { type ReactNode, useState } from "react";
3
+ import { useOpenHeight } from "../lib/useOpenHeight";
4
+
5
+ export interface ExpandListItem {
6
+ title: string;
7
+ body: ReactNode;
8
+ }
9
+
10
+ export interface ExpandListProps {
11
+ items: ExpandListItem[];
12
+ className?: string;
13
+ }
14
+
15
+ function ExpandRow({ index, item }: { index: number; item: ExpandListItem }) {
16
+ const [open, setOpen] = useState(false);
17
+ const { ref, maxHeight } = useOpenHeight(open);
18
+ const num = String(index + 1).padStart(2, "0");
19
+
20
+ return (
21
+ <div className={["xl-row", open && "open"].filter(Boolean).join(" ")}>
22
+ <button
23
+ type="button"
24
+ className="xl-head"
25
+ aria-expanded={open}
26
+ onClick={() => setOpen((prev) => !prev)}
27
+ >
28
+ <span className="xl-num">{num}</span>
29
+ <span className="xl-title">{item.title}</span>
30
+ <ChevronDown className="xl-chevron" size={18} strokeWidth={2} aria-hidden="true" />
31
+ </button>
32
+ {/* Closed, the body stays in the HTML but is inert: out of the tab
33
+ order and a screen reader's reach while it is clipped to nothing. */}
34
+ <div className="xl-body" style={{ maxHeight }} inert={!open}>
35
+ <div className="xl-body-inner" ref={ref}>
36
+ {item.body}
37
+ </div>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ /**
44
+ * A light accordion of numbered fold-out rows: mono index, title, and an
45
+ * expandable body — for amenity lists, add-ons, and other compact
46
+ * disclosures that don't warrant the full FAQBrowser help center. Bodies
47
+ * ship in the static HTML (collapsed visually, present for crawlers),
48
+ * matching how FAQBrowser handles answers.
49
+ */
50
+ export function ExpandList({ items, className }: ExpandListProps) {
51
+ return (
52
+ <div className={["expand-list", className].filter(Boolean).join(" ")}>
53
+ {items.map((item, index) => (
54
+ <ExpandRow key={item.title} index={index} item={item} />
55
+ ))}
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,283 @@
1
+ import { Check, ChevronDown, Link2, Search, X } from "lucide-react";
2
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
3
+ import { newTabProps } from "../lib/newTabProps";
4
+ import { useOpenHeight } from "../lib/useOpenHeight";
5
+ import { useWithBase } from "../routing/BasePathProvider";
6
+ import { JumpRail } from "./JumpRail";
7
+ import { PageHero } from "./PageHero";
8
+
9
+ export interface FAQCta {
10
+ label: string;
11
+ href: string;
12
+ newTab?: boolean;
13
+ }
14
+
15
+ export interface FAQEntry {
16
+ /** Stable slug; doubles as the deep-link anchor, e.g. "do-you-take-insurance". */
17
+ id: string;
18
+ question: string;
19
+ answer: ReactNode;
20
+ /** Plain-text answer for search matching and FAQPage structured data. */
21
+ answerText: string;
22
+ /** Optional link rendered at the end of the open answer. */
23
+ cta?: FAQCta;
24
+ }
25
+
26
+ export interface FAQCategory {
27
+ /** Stable slug; doubles as the section anchor. */
28
+ id: string;
29
+ title: string;
30
+ /** Short label for the sticky tab bar; falls back to title. */
31
+ tabLabel?: string;
32
+ description?: string;
33
+ /** Optional link line rendered under the category's card group. */
34
+ cta?: FAQCta;
35
+ items: FAQEntry[];
36
+ }
37
+
38
+ export interface FAQBrowserProps {
39
+ hero: { eyebrow?: string; title: ReactNode; lead?: ReactNode };
40
+ categories: FAQCategory[];
41
+ searchPlaceholder?: string;
42
+ browseAllLabel?: string;
43
+ /** Where the no match line sends a reader to ask instead. Defaults to "/contact". */
44
+ contactHref?: string;
45
+ }
46
+
47
+ function matches(query: string, item: FAQEntry): boolean {
48
+ const q = query.trim().toLowerCase();
49
+ if (!q) return true;
50
+ return item.question.toLowerCase().includes(q) || item.answerText.toLowerCase().includes(q);
51
+ }
52
+
53
+ function CTALink({ cta, className }: { cta: FAQCta; className?: string }) {
54
+ const withBase = useWithBase();
55
+ return (
56
+ <a className={className} href={withBase(cta.href)} {...newTabProps(cta.href, cta.newTab)}>
57
+ {cta.label} <span className="arrow">→</span>
58
+ </a>
59
+ );
60
+ }
61
+
62
+ function FAQRow({
63
+ item,
64
+ open,
65
+ copied,
66
+ onToggle,
67
+ onCopy,
68
+ }: {
69
+ item: FAQEntry;
70
+ open: boolean;
71
+ copied: boolean;
72
+ onToggle: () => void;
73
+ onCopy: () => void;
74
+ }) {
75
+ const { ref, maxHeight } = useOpenHeight(open);
76
+
77
+ return (
78
+ <div className={["faq-row", open && "open"].filter(Boolean).join(" ")} id={item.id}>
79
+ <div className="faq-row-head">
80
+ <button type="button" className="faq-row-q" aria-expanded={open} onClick={onToggle}>
81
+ {item.question}
82
+ </button>
83
+ <div className="faq-row-icons">
84
+ <button
85
+ type="button"
86
+ className={["faq-icon-btn", "faq-link-btn", copied && "copied"]
87
+ .filter(Boolean)
88
+ .join(" ")}
89
+ aria-label={copied ? "Link copied" : "Copy link to this question"}
90
+ onClick={onCopy}
91
+ >
92
+ {copied ? (
93
+ <Check size={16} strokeWidth={2} aria-hidden="true" />
94
+ ) : (
95
+ <Link2 size={16} strokeWidth={1.6} aria-hidden="true" />
96
+ )}
97
+ </button>
98
+ <button
99
+ type="button"
100
+ className="faq-icon-btn faq-chevron"
101
+ aria-label={open ? "Collapse answer" : "Expand answer"}
102
+ aria-expanded={open}
103
+ onClick={onToggle}
104
+ >
105
+ <ChevronDown size={16} strokeWidth={2} aria-hidden="true" />
106
+ </button>
107
+ </div>
108
+ </div>
109
+ {/* Closed, the answer stays in the HTML for crawlers but is inert, so
110
+ its links are out of the tab order and out of a screen reader's
111
+ reach while it is clipped to nothing. */}
112
+ <div className="faq-row-a" style={{ maxHeight }} inert={!open}>
113
+ <div className="faq-row-a-inner" ref={ref}>
114
+ {item.answer}
115
+ {item.cta && <CTALink cta={item.cta} className="faq-answer-cta" />}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The full FAQ page organism: centered help hero with live search, a sticky
124
+ * category tab bar with scroll spy, and per-category card groups with
125
+ * expand-all, back-to-top, and per-question deep links. Search hides
126
+ * non-matching questions and empty categories.
127
+ */
128
+ export function FAQBrowser({
129
+ hero,
130
+ categories,
131
+ searchPlaceholder = "Search questions or keywords",
132
+ browseAllLabel = "Browse all questions ↓",
133
+ contactHref = "/contact",
134
+ }: FAQBrowserProps) {
135
+ const withBase = useWithBase();
136
+ const [query, setQuery] = useState("");
137
+ const [openIds, setOpenIds] = useState<Set<string>>(new Set());
138
+ const [copiedId, setCopiedId] = useState<string | null>(null);
139
+
140
+ const filtered = useMemo(() => {
141
+ const withMatches = categories.map((cat) => ({
142
+ ...cat,
143
+ items: cat.items.filter((item) => matches(query, item)),
144
+ }));
145
+ return query.trim() ? withMatches.filter((cat) => cat.items.length > 0) : withMatches;
146
+ }, [categories, query]);
147
+
148
+ // Deep link: #<item-id> opens and scrolls to that question on load.
149
+ useEffect(() => {
150
+ const id = window.location.hash.slice(1);
151
+ if (!id) return;
152
+ const target = categories.flatMap((c) => c.items).find((item) => item.id === id);
153
+ if (!target) return;
154
+ setOpenIds((prev) => new Set(prev).add(id));
155
+ requestAnimationFrame(() => {
156
+ document.getElementById(id)?.scrollIntoView({ block: "center" });
157
+ });
158
+ }, [categories]);
159
+
160
+ function toggle(id: string) {
161
+ setOpenIds((prev) => {
162
+ const next = new Set(prev);
163
+ if (next.has(id)) next.delete(id);
164
+ else next.add(id);
165
+ return next;
166
+ });
167
+ }
168
+
169
+ // Copy only — updating the URL here would trip the router's scroll handling.
170
+ function copyLink(id: string) {
171
+ const url = `${window.location.origin}${window.location.pathname}#${id}`;
172
+ navigator.clipboard?.writeText(url).catch(() => {});
173
+ setCopiedId(id);
174
+ window.setTimeout(() => setCopiedId((current) => (current === id ? null : current)), 1600);
175
+ }
176
+
177
+ function toggleAll(cat: FAQCategory) {
178
+ const allOpen = cat.items.every((item) => openIds.has(item.id));
179
+ setOpenIds((prev) => {
180
+ const next = new Set(prev);
181
+ for (const item of cat.items) {
182
+ if (allOpen) next.delete(item.id);
183
+ else next.add(item.id);
184
+ }
185
+ return next;
186
+ });
187
+ }
188
+
189
+ return (
190
+ <>
191
+ <PageHero eyebrow={hero.eyebrow} title={hero.title} lead={hero.lead}>
192
+ {/* The find block centers on its own reading width under the
193
+ left-aligned page header: search pill, browse-all beneath it. */}
194
+ <div className="faq-find">
195
+ <div className="faq-search">
196
+ <Search size={18} strokeWidth={1.6} aria-hidden="true" />
197
+ <input
198
+ type="search"
199
+ value={query}
200
+ onChange={(event) => setQuery(event.target.value)}
201
+ placeholder={searchPlaceholder}
202
+ aria-label="Search questions"
203
+ />
204
+ {query && (
205
+ <button
206
+ type="button"
207
+ className="faq-search-clear"
208
+ aria-label="Clear search"
209
+ onClick={() => setQuery("")}
210
+ >
211
+ <X size={16} strokeWidth={2} aria-hidden="true" />
212
+ </button>
213
+ )}
214
+ </div>
215
+ {/* The first category the page renders, which a search may have
216
+ hidden the first one of; with nothing matching, no link. */}
217
+ {browseAllLabel && filtered[0] && (
218
+ <a className="faq-browse-all" href={`#cat-${filtered[0].id}`}>
219
+ {browseAllLabel}
220
+ </a>
221
+ )}
222
+ </div>
223
+ </PageHero>
224
+
225
+ <JumpRail
226
+ ariaLabel="Question categories"
227
+ items={filtered.map((cat) => ({
228
+ id: `cat-${cat.id}`,
229
+ label: cat.tabLabel ?? cat.title,
230
+ }))}
231
+ />
232
+
233
+ <div className="faq-cats">
234
+ {filtered.map((cat) => {
235
+ const allOpen = cat.items.length > 0 && cat.items.every((item) => openIds.has(item.id));
236
+ return (
237
+ <section className="faq-cat" id={`cat-${cat.id}`} key={cat.id}>
238
+ <div className="wrap">
239
+ <h2>{cat.title}</h2>
240
+ <div className="faq-cat-head">
241
+ {cat.description && <p className="faq-cat-desc">{cat.description}</p>}
242
+ <div className="faq-utils">
243
+ <button type="button" className="faq-util" onClick={() => toggleAll(cat)}>
244
+ {allOpen ? "Collapse all" : "Expand all"}
245
+ </button>
246
+ <button
247
+ type="button"
248
+ className="faq-util"
249
+ onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
250
+ >
251
+ Back to top ↑
252
+ </button>
253
+ </div>
254
+ </div>
255
+ <div className="faq-group">
256
+ {cat.items.map((item) => (
257
+ <FAQRow
258
+ key={item.id}
259
+ item={item}
260
+ open={openIds.has(item.id)}
261
+ copied={copiedId === item.id}
262
+ onToggle={() => toggle(item.id)}
263
+ onCopy={() => copyLink(item.id)}
264
+ />
265
+ ))}
266
+ </div>
267
+ {cat.cta && <CTALink cta={cat.cta} className="faq-cat-cta" />}
268
+ </div>
269
+ </section>
270
+ );
271
+ })}
272
+ {query.trim() && filtered.length === 0 && (
273
+ <div className="wrap faq-empty">
274
+ <p>
275
+ No questions match "{query.trim()}". Try another word, or{" "}
276
+ <a href={withBase(contactHref)}>reach out directly</a>. We're happy to help.
277
+ </p>
278
+ </div>
279
+ )}
280
+ </div>
281
+ </>
282
+ );
283
+ }