@atelic-action/ui 0.17.2 → 1.0.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.
- package/README.md +428 -44
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Writeup.tsx +7 -7
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +472 -399
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
3
|
+
import { Chip } from "../primitives/Chip";
|
|
4
|
+
import { ImageFrame } from "../primitives/ImageFrame";
|
|
5
|
+
import { TickList } from "../primitives/TickList";
|
|
6
|
+
|
|
7
|
+
export interface FeatureDetailProps {
|
|
8
|
+
/** Anchor id for jump links, e.g. "dry-needling". */
|
|
9
|
+
id?: string;
|
|
10
|
+
/** Optional mono index label, e.g. "01". Rendered above the eyebrow; omit for no number. */
|
|
11
|
+
num?: string;
|
|
12
|
+
eyebrow?: string;
|
|
13
|
+
title: ReactNode;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
lead?: ReactNode;
|
|
16
|
+
ticks?: ReactNode[];
|
|
17
|
+
image?: { src: string; alt: string };
|
|
18
|
+
/** Placeholder label rendered in the media frame until a real image exists. */
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
/** Media on the left on wide screens (alternating rows). */
|
|
21
|
+
flip?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Alternating copy-and-media feature row (the services page pattern). */
|
|
25
|
+
export function FeatureDetail({
|
|
26
|
+
id,
|
|
27
|
+
num,
|
|
28
|
+
eyebrow,
|
|
29
|
+
title,
|
|
30
|
+
tags,
|
|
31
|
+
lead,
|
|
32
|
+
ticks,
|
|
33
|
+
image,
|
|
34
|
+
placeholder,
|
|
35
|
+
flip = false,
|
|
36
|
+
}: FeatureDetailProps) {
|
|
37
|
+
return (
|
|
38
|
+
<article className={["feature-detail", flip && "flip"].filter(Boolean).join(" ")} id={id}>
|
|
39
|
+
<div>
|
|
40
|
+
{num && <span className="feature-num">{num}</span>}
|
|
41
|
+
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
|
42
|
+
<h2>{title}</h2>
|
|
43
|
+
{tags && tags.length > 0 && (
|
|
44
|
+
<div className="chip-row tag-row">
|
|
45
|
+
{tags.map((tag) => (
|
|
46
|
+
<Chip key={tag} dot>
|
|
47
|
+
{tag}
|
|
48
|
+
</Chip>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
{lead && <Lead>{lead}</Lead>}
|
|
53
|
+
{ticks && ticks.length > 0 && <TickList items={ticks} className="detail-ticks" />}
|
|
54
|
+
</div>
|
|
55
|
+
<div className="feature-detail-media">
|
|
56
|
+
<ImageFrame src={image?.src} alt={image?.alt} placeholder={placeholder} aspectRatio="4/3" />
|
|
57
|
+
</div>
|
|
58
|
+
</article>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { SectionHeading } from "../primitives";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
|
|
5
|
+
interface FeatureCardBase {
|
|
6
|
+
/** Small mono index label, e.g. "01". */
|
|
7
|
+
num?: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A card is either a link or a card that contains one, never both. The linked
|
|
13
|
+
* variant wraps the whole card in an anchor, and an anchor inside an anchor is
|
|
14
|
+
* invalid markup that browsers silently unnest, so a linked card's description
|
|
15
|
+
* stays plain text. The unlinked variant takes a node, which is how a word
|
|
16
|
+
* inside a card links to its definition.
|
|
17
|
+
*
|
|
18
|
+
* That node is inline content only: the description renders inside the card's
|
|
19
|
+
* `<p>`, so a block element (`p`, `div`, `ul`) nested there is invalid markup.
|
|
20
|
+
* TypeScript has no phrasing content type to say so, which leaves it a rule
|
|
21
|
+
* rather than a constraint.
|
|
22
|
+
*/
|
|
23
|
+
export type FeatureCard =
|
|
24
|
+
| (FeatureCardBase & { description: ReactNode; href?: never; linkLabel?: never })
|
|
25
|
+
| (FeatureCardBase & { description: string; href: string; linkLabel?: string });
|
|
26
|
+
|
|
27
|
+
export interface FeatureGridProps {
|
|
28
|
+
eyebrow?: string;
|
|
29
|
+
/** Deprecated no-op: the eyebrow's trailing fade is now its only rendering. */
|
|
30
|
+
eyebrowRun?: boolean;
|
|
31
|
+
title: ReactNode;
|
|
32
|
+
items: FeatureCard[];
|
|
33
|
+
/** Cards per row at desktop width (default 4). */
|
|
34
|
+
columns?: 2 | 3 | 4;
|
|
35
|
+
surface?: "default" | "alt";
|
|
36
|
+
id?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Numbered feature/service card grid with hover lift. */
|
|
40
|
+
export function FeatureGrid({
|
|
41
|
+
eyebrow,
|
|
42
|
+
title,
|
|
43
|
+
items,
|
|
44
|
+
columns = 4,
|
|
45
|
+
surface = "alt",
|
|
46
|
+
id,
|
|
47
|
+
}: FeatureGridProps) {
|
|
48
|
+
const withBase = useWithBase();
|
|
49
|
+
const sectionClasses = ["section", surface === "alt" && "surface-alt"].filter(Boolean).join(" ");
|
|
50
|
+
const gridStyle = { "--fg-cols": columns } as CSSProperties;
|
|
51
|
+
return (
|
|
52
|
+
<section className={sectionClasses} id={id}>
|
|
53
|
+
<div className="wrap">
|
|
54
|
+
<SectionHeading eyebrow={eyebrow} title={title} />
|
|
55
|
+
<div className="feature-grid" style={gridStyle}>
|
|
56
|
+
{items.map((item) => {
|
|
57
|
+
const inner = (
|
|
58
|
+
<>
|
|
59
|
+
{item.num && <span className="feature-num">{item.num}</span>}
|
|
60
|
+
<h3>{item.title}</h3>
|
|
61
|
+
<p>{item.description}</p>
|
|
62
|
+
{item.href && item.linkLabel && (
|
|
63
|
+
<span className="feature-link">
|
|
64
|
+
{item.linkLabel} <span className="arrow">→</span>
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
return item.href ? (
|
|
70
|
+
<a key={item.title} className="feature-card" href={withBase(item.href)}>
|
|
71
|
+
{inner}
|
|
72
|
+
</a>
|
|
73
|
+
) : (
|
|
74
|
+
<div key={item.title} className="feature-card">
|
|
75
|
+
{inner}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
|
|
5
|
+
export interface GalleryPhoto {
|
|
6
|
+
src?: string;
|
|
7
|
+
/** Alt text: describe the work in the frame, never the brand. */
|
|
8
|
+
alt?: string;
|
|
9
|
+
/** Placeholder shot brief rendered when no src exists yet. */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Short caption rendered under the photo. Keep it to one honest line. */
|
|
12
|
+
caption?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GalleryProps {
|
|
16
|
+
id?: string;
|
|
17
|
+
eyebrow?: string;
|
|
18
|
+
title: ReactNode;
|
|
19
|
+
lead?: ReactNode;
|
|
20
|
+
photos: GalleryPhoto[];
|
|
21
|
+
/** Tiles per row at desktop width (default 3). */
|
|
22
|
+
columns?: 2 | 3;
|
|
23
|
+
surface?: "default" | "alt";
|
|
24
|
+
/** Small note under the grid, e.g. photo provenance. */
|
|
25
|
+
footnote?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A captioned photo grid for a business's own work. Built for trade and
|
|
30
|
+
* service clients whose proof is the job itself: every tile is a real
|
|
31
|
+
* photograph with a caption that says what it is, never a stock frame.
|
|
32
|
+
*
|
|
33
|
+
* Tiles crop to a fixed aspect so a mixed bag of phone photos still reads
|
|
34
|
+
* as one wall. Photos without a `src` render their shot brief as a labeled
|
|
35
|
+
* placeholder, so the wall can be composed before the photography exists.
|
|
36
|
+
*/
|
|
37
|
+
export function Gallery({
|
|
38
|
+
id,
|
|
39
|
+
eyebrow,
|
|
40
|
+
title,
|
|
41
|
+
lead,
|
|
42
|
+
photos,
|
|
43
|
+
columns = 3,
|
|
44
|
+
surface = "default",
|
|
45
|
+
footnote,
|
|
46
|
+
}: GalleryProps) {
|
|
47
|
+
const withBase = useWithBase();
|
|
48
|
+
return (
|
|
49
|
+
<section
|
|
50
|
+
id={id}
|
|
51
|
+
className={["section gallery-section", surface === "alt" && "surface-alt"]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join(" ")}
|
|
54
|
+
>
|
|
55
|
+
<div className="wrap">
|
|
56
|
+
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
|
57
|
+
<h2>{title}</h2>
|
|
58
|
+
{lead && <Lead style={{ maxWidth: "60ch" }}>{lead}</Lead>}
|
|
59
|
+
<ul className={`gallery-grid cols-${columns}`}>
|
|
60
|
+
{photos.map((photo) => (
|
|
61
|
+
<li className="gallery-item" key={photo.src ?? photo.placeholder ?? photo.caption}>
|
|
62
|
+
<figure>
|
|
63
|
+
{photo.src ? (
|
|
64
|
+
<div className="gallery-frame">
|
|
65
|
+
<img
|
|
66
|
+
src={withBase(photo.src)}
|
|
67
|
+
alt={photo.alt ?? ""}
|
|
68
|
+
loading="lazy"
|
|
69
|
+
decoding="async"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
) : (
|
|
73
|
+
<div className="gallery-frame ph">{photo.placeholder ?? "Photo"}</div>
|
|
74
|
+
)}
|
|
75
|
+
{photo.caption && <figcaption>{photo.caption}</figcaption>}
|
|
76
|
+
</figure>
|
|
77
|
+
</li>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
{footnote && <p className="muted gallery-footnote">{footnote}</p>}
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow } from "../primitives";
|
|
3
|
+
import { Button, type ButtonProps } from "../primitives/Button";
|
|
4
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
5
|
+
|
|
6
|
+
export interface HeroCTA {
|
|
7
|
+
label: string;
|
|
8
|
+
href: string;
|
|
9
|
+
newTab?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface HeroTrustItem {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HeroProps {
|
|
18
|
+
eyebrow?: string;
|
|
19
|
+
/** Headline; use <em className="serif"> spans for the serif accent. */
|
|
20
|
+
title: ReactNode;
|
|
21
|
+
subtitle?: ReactNode;
|
|
22
|
+
/** Full-bleed hero image; omit to render the token-gradient fallback. */
|
|
23
|
+
image?: {
|
|
24
|
+
src: string;
|
|
25
|
+
alt: string;
|
|
26
|
+
/**
|
|
27
|
+
* CSS object-position anchoring the photo's subject, e.g. "27% 35%".
|
|
28
|
+
* Narrow viewports crop the sides of a cover-fit hero; without an
|
|
29
|
+
* anchor the default center crop can push the subject out of frame.
|
|
30
|
+
*/
|
|
31
|
+
position?: string;
|
|
32
|
+
};
|
|
33
|
+
primaryCta?: HeroCTA;
|
|
34
|
+
secondaryCta?: HeroCTA;
|
|
35
|
+
/** Trust indicators rendered under the CTAs. */
|
|
36
|
+
trust?: HeroTrustItem[];
|
|
37
|
+
/** Anchor target for the scroll-down affordance, e.g. "#pain". */
|
|
38
|
+
scrollTarget?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Full-bleed image hero with scrim, headline, CTA pair, and trust stats. */
|
|
42
|
+
export function Hero({
|
|
43
|
+
eyebrow,
|
|
44
|
+
title,
|
|
45
|
+
subtitle,
|
|
46
|
+
image,
|
|
47
|
+
primaryCta,
|
|
48
|
+
secondaryCta,
|
|
49
|
+
trust,
|
|
50
|
+
scrollTarget,
|
|
51
|
+
}: HeroProps) {
|
|
52
|
+
const withBase = useWithBase();
|
|
53
|
+
const ctaProps = (cta: HeroCTA, extra: Partial<ButtonProps>) => ({
|
|
54
|
+
href: cta.href,
|
|
55
|
+
newTab: cta.newTab,
|
|
56
|
+
...extra,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<section className="hero">
|
|
61
|
+
<div className="hero-media">
|
|
62
|
+
{image ? (
|
|
63
|
+
<img
|
|
64
|
+
src={withBase(image.src)}
|
|
65
|
+
alt={image.alt}
|
|
66
|
+
fetchPriority="high"
|
|
67
|
+
{...(image.position ? { style: { objectPosition: image.position } } : {})}
|
|
68
|
+
/>
|
|
69
|
+
) : (
|
|
70
|
+
<div className="hero-media-fallback" />
|
|
71
|
+
)}
|
|
72
|
+
<div className="hero-scrim" />
|
|
73
|
+
</div>
|
|
74
|
+
<div className="wrap hero-content">
|
|
75
|
+
{eyebrow && <Eyebrow onDark>{eyebrow}</Eyebrow>}
|
|
76
|
+
<h1>{title}</h1>
|
|
77
|
+
{subtitle && <p className="hero-sub">{subtitle}</p>}
|
|
78
|
+
{(primaryCta || secondaryCta) && (
|
|
79
|
+
<div className="cta-row">
|
|
80
|
+
{primaryCta && (
|
|
81
|
+
<Button {...ctaProps(primaryCta, { size: "lg", arrow: true })}>
|
|
82
|
+
{primaryCta.label}
|
|
83
|
+
</Button>
|
|
84
|
+
)}
|
|
85
|
+
{secondaryCta && (
|
|
86
|
+
<Button {...ctaProps(secondaryCta, { variant: "ghost", size: "lg", onDark: true })}>
|
|
87
|
+
{secondaryCta.label}
|
|
88
|
+
</Button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
{trust && trust.length > 0 && (
|
|
93
|
+
<ul className="hero-trust">
|
|
94
|
+
{trust.map((item) => (
|
|
95
|
+
<li key={item.label}>
|
|
96
|
+
<strong>{item.value}</strong>
|
|
97
|
+
<span>{item.label}</span>
|
|
98
|
+
</li>
|
|
99
|
+
))}
|
|
100
|
+
</ul>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
{scrollTarget && (
|
|
104
|
+
<a className="hero-scroll" href={withBase(scrollTarget)} aria-label="Scroll down">
|
|
105
|
+
↓
|
|
106
|
+
</a>
|
|
107
|
+
)}
|
|
108
|
+
</section>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { formatHoursRange, isClosed } from "../lib/hours";
|
|
2
|
+
import type { OpeningHours } from "./siteConfig";
|
|
3
|
+
|
|
4
|
+
export interface HoursTableProps {
|
|
5
|
+
hours: OpeningHours[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Day-by-day hours rows with dashed rules and mono times. */
|
|
9
|
+
export function HoursTable({ hours }: HoursTableProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div>
|
|
12
|
+
{hours.map((row) => (
|
|
13
|
+
<div
|
|
14
|
+
key={row.label}
|
|
15
|
+
className={["hours-row", isClosed(row) && "closed"].filter(Boolean).join(" ")}
|
|
16
|
+
>
|
|
17
|
+
<span className="day">{row.label}</span>
|
|
18
|
+
<span className="time">{formatHoursRange(row)}</span>
|
|
19
|
+
</div>
|
|
20
|
+
))}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type PageStop, useScrollSpy } from "../hooks";
|
|
2
|
+
|
|
3
|
+
/** One tab on the rail: an anchor already on the page. */
|
|
4
|
+
export type JumpRailItem = PageStop;
|
|
5
|
+
|
|
6
|
+
export interface JumpRailProps {
|
|
7
|
+
items: JumpRailItem[];
|
|
8
|
+
ariaLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sticky in page navigation: a pill tab per section anchor, highlighted by
|
|
13
|
+
* scroll spy as its section enters the reading band. Extracted from the FAQ
|
|
14
|
+
* browser's category tabs so any long page can share it. The scroll spy
|
|
15
|
+
* comes from @atelic-action/ui, the same hook the header's page stops use.
|
|
16
|
+
*/
|
|
17
|
+
export function JumpRail({ items, ariaLabel = "Page sections" }: JumpRailProps) {
|
|
18
|
+
const activeId = useScrollSpy(items.map((item) => item.id));
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<nav className="jump-rail" aria-label={ariaLabel}>
|
|
22
|
+
<div className="wrap jump-rail-inner">
|
|
23
|
+
{items.map((item) => (
|
|
24
|
+
<a
|
|
25
|
+
key={item.id}
|
|
26
|
+
href={`#${item.id}`}
|
|
27
|
+
className={activeId === item.id ? "active" : undefined}
|
|
28
|
+
>
|
|
29
|
+
{item.label}
|
|
30
|
+
</a>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
</nav>
|
|
34
|
+
);
|
|
35
|
+
}
|