@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.
- package/README.md +432 -45
- 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/Note.tsx +9 -39
- package/src/artifact/Paragraph.tsx +65 -0
- package/src/artifact/Read.tsx +406 -0
- package/src/artifact/Writeup.tsx +7 -7
- package/src/artifact/index.ts +1 -0
- 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 +759 -401
- 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,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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ProseBlock, ProseBlocks } from "../primitives/ProseBlocks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Legal documents are prose blocks like any other ported document, so the
|
|
5
|
+
* block vocabulary and the renderer are shared with the blog rather than
|
|
6
|
+
* duplicated. Re-exported because a client site importing `LegalDoc` should
|
|
7
|
+
* not need to know where the block type happens to live.
|
|
8
|
+
*/
|
|
9
|
+
export type LegalBlock = ProseBlock;
|
|
10
|
+
|
|
11
|
+
export interface LegalDoc {
|
|
12
|
+
/** The document's own title, as the source states it. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** The source's own revision date, verbatim. Null when it states none. */
|
|
15
|
+
lastUpdated?: string | null;
|
|
16
|
+
blocks: LegalBlock[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LegalPageProps {
|
|
20
|
+
doc: LegalDoc;
|
|
21
|
+
id?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders a legal document: terms, a privacy policy, a disclaimer. Content
|
|
27
|
+
* arrives entirely as data so a client's published wording can be ported
|
|
28
|
+
* without passing through anyone's judgment about what it should say.
|
|
29
|
+
*
|
|
30
|
+
* All-caps passages are left exactly as the source sets them. Legal drafting
|
|
31
|
+
* uses capitals to make a clause conspicuous, which carries actual legal
|
|
32
|
+
* weight in some jurisdictions, so it is never normalised away here.
|
|
33
|
+
*/
|
|
34
|
+
export function LegalPage({ doc, id, className }: LegalPageProps) {
|
|
35
|
+
return (
|
|
36
|
+
<section className={["section", "legal", className].filter(Boolean).join(" ")} id={id}>
|
|
37
|
+
<div className="wrap">
|
|
38
|
+
{doc.lastUpdated && <p className="legal-updated">Last updated: {doc.lastUpdated}</p>}
|
|
39
|
+
<ProseBlocks blocks={doc.blocks} />
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
3
|
+
import { Button } from "../primitives/Button";
|
|
4
|
+
import { HoursTable } from "./HoursTable";
|
|
5
|
+
import { MapEmbed, type MapEmbedSite } from "./MapEmbed";
|
|
6
|
+
import type { SiteConfig } from "./siteConfig";
|
|
7
|
+
|
|
8
|
+
/** The keys of the site config the section reads; a whole `SiteConfig` passes as it is. */
|
|
9
|
+
export type LocationSectionSite = MapEmbedSite &
|
|
10
|
+
Pick<SiteConfig, "serviceAreas" | "hours" | "phone">;
|
|
11
|
+
|
|
12
|
+
export interface LocationSectionProps {
|
|
13
|
+
site: LocationSectionSite;
|
|
14
|
+
eyebrow?: string;
|
|
15
|
+
title: ReactNode;
|
|
16
|
+
lead?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Joins service areas into readable prose: "A, B, and C". */
|
|
20
|
+
function areasSentence(areas: string[]): string {
|
|
21
|
+
if (areas.length <= 1) return areas[0] ?? "";
|
|
22
|
+
return `${areas.slice(0, -1).join(", ")}, and ${areas[areas.length - 1]}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Local SEO section: NAP copy, service areas, hours, directions, and the
|
|
27
|
+
* map embed. Identical on every page that uses it — the service areas
|
|
28
|
+
* render as plain prose (crawlable text) from the site config.
|
|
29
|
+
*/
|
|
30
|
+
export function LocationSection({ site, eyebrow = "Find us", title, lead }: LocationSectionProps) {
|
|
31
|
+
return (
|
|
32
|
+
<section className="section location">
|
|
33
|
+
<div className="wrap location-grid">
|
|
34
|
+
<div>
|
|
35
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
36
|
+
<h2>{title}</h2>
|
|
37
|
+
{lead && <Lead>{lead}</Lead>}
|
|
38
|
+
{site.serviceAreas.length > 0 && (
|
|
39
|
+
<p className="loc-areas">Proudly serving {areasSentence(site.serviceAreas)}.</p>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
<div className="loc-block">
|
|
43
|
+
<h4>Hours</h4>
|
|
44
|
+
<HoursTable hours={site.hours} />
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div className="loc-actions">
|
|
48
|
+
{site.mapLinkUrl && (
|
|
49
|
+
<Button variant="dark" href={site.mapLinkUrl} newTab arrow>
|
|
50
|
+
Get Directions
|
|
51
|
+
</Button>
|
|
52
|
+
)}
|
|
53
|
+
{site.phone && (
|
|
54
|
+
<Button variant="ghost" href={`tel:${site.phone.e164}`}>
|
|
55
|
+
{site.phone.display}
|
|
56
|
+
</Button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<MapEmbed site={site} />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Navigation, Phone } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
4
|
+
import { MapEmbed, type MapEmbedSite } from "./MapEmbed";
|
|
5
|
+
|
|
6
|
+
export interface LocationEntry {
|
|
7
|
+
name: string;
|
|
8
|
+
address: string;
|
|
9
|
+
/** Hours lines, already formatted, e.g. "Mon to Sat · 5am to 3pm". */
|
|
10
|
+
hours: string[];
|
|
11
|
+
phoneDisplay: string;
|
|
12
|
+
phoneE164: string;
|
|
13
|
+
mapLink: string;
|
|
14
|
+
/** Optional one-line note, e.g. "The original, since 1979". */
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LocationsProps {
|
|
19
|
+
/** Drives the static map (the primary/flagship location); a whole `SiteConfig` passes as it is. */
|
|
20
|
+
site: MapEmbedSite;
|
|
21
|
+
eyebrow?: string;
|
|
22
|
+
title: ReactNode;
|
|
23
|
+
lead?: ReactNode;
|
|
24
|
+
locations: LocationEntry[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Multi-location block: the flagship's static map beside a grid of location
|
|
29
|
+
* cards (address, hours, call, directions). Reusable for any business that
|
|
30
|
+
* runs more than one address.
|
|
31
|
+
*/
|
|
32
|
+
export function Locations({ site, eyebrow = "Find us", title, lead, locations }: LocationsProps) {
|
|
33
|
+
return (
|
|
34
|
+
<section className="section locs">
|
|
35
|
+
<div className="wrap">
|
|
36
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
37
|
+
<h2>{title}</h2>
|
|
38
|
+
{lead && <Lead style={{ maxWidth: "58ch" }}>{lead}</Lead>}
|
|
39
|
+
<div className="locs-layout">
|
|
40
|
+
<div className="locs-map">
|
|
41
|
+
<MapEmbed site={site} />
|
|
42
|
+
</div>
|
|
43
|
+
<div className="locs-cards">
|
|
44
|
+
{locations.map((loc) => (
|
|
45
|
+
<div className="loc-card" key={loc.name}>
|
|
46
|
+
<h3>{loc.name}</h3>
|
|
47
|
+
{loc.note && <p className="loc-note">{loc.note}</p>}
|
|
48
|
+
<p className="loc-addr">{loc.address}</p>
|
|
49
|
+
<div className="loc-hours">
|
|
50
|
+
{loc.hours.map((line) => (
|
|
51
|
+
<span key={line}>{line}</span>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
<div className="loc-links">
|
|
55
|
+
<a href={`tel:${loc.phoneE164}`}>
|
|
56
|
+
<Phone size={14} aria-hidden />
|
|
57
|
+
{loc.phoneDisplay}
|
|
58
|
+
</a>
|
|
59
|
+
<a href={loc.mapLink} target="_blank" rel="noopener">
|
|
60
|
+
<Navigation size={14} aria-hidden />
|
|
61
|
+
Directions
|
|
62
|
+
</a>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Chip } from "../primitives/Chip";
|
|
2
|
+
|
|
3
|
+
export interface LogoMarqueeProps {
|
|
4
|
+
/** Items scrolled across the band (credentials, client names, press). */
|
|
5
|
+
items: string[];
|
|
6
|
+
ariaLabel?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Dark band with an infinitely scrolling row of chips. */
|
|
10
|
+
export function LogoMarquee({ items, ariaLabel = "Credentials" }: LogoMarqueeProps) {
|
|
11
|
+
// The track is duplicated so the -50% translate loops seamlessly.
|
|
12
|
+
const track = [...items, ...items];
|
|
13
|
+
return (
|
|
14
|
+
<section className="creds" aria-label={ariaLabel}>
|
|
15
|
+
<div className="marquee">
|
|
16
|
+
<div className="marquee-track">
|
|
17
|
+
{track.map((item, i) => (
|
|
18
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: intentional duplicates for the loop
|
|
19
|
+
<Chip key={`${item}-${i}`} dot onDark>
|
|
20
|
+
{item}
|
|
21
|
+
</Chip>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { MapPin, Navigation } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
import type { SiteConfig } from "./siteConfig";
|
|
5
|
+
|
|
6
|
+
/** The keys of the site config the map reads; a whole `SiteConfig` passes as it is. */
|
|
7
|
+
export type MapEmbedSite = Pick<
|
|
8
|
+
SiteConfig,
|
|
9
|
+
"name" | "address" | "mapImage" | "mapLinkUrl" | "mapEmbedUrl"
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
export interface MapEmbedProps {
|
|
13
|
+
site: MapEmbedSite;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The location map. Preferred form is a static screenshot (`site.mapImage`):
|
|
18
|
+
* one image request, no third-party chain, and a real map on first paint,
|
|
19
|
+
* linking straight to directions. Falls back to a click-to-load facade over
|
|
20
|
+
* the live Google embed when no static image is provided (the embed's utility
|
|
21
|
+
* chain weighs ~450 KiB, so it never loads on its own).
|
|
22
|
+
*/
|
|
23
|
+
export function MapEmbed({ site }: MapEmbedProps) {
|
|
24
|
+
const withBase = useWithBase();
|
|
25
|
+
const [loaded, setLoaded] = useState(false);
|
|
26
|
+
|
|
27
|
+
const address = `${site.address.street}, ${site.address.locality} ${site.address.region} ${site.address.postalCode}`;
|
|
28
|
+
|
|
29
|
+
if (site.mapImage) {
|
|
30
|
+
return (
|
|
31
|
+
<a
|
|
32
|
+
className="map-frame map-static"
|
|
33
|
+
href={site.mapLinkUrl ?? "#"}
|
|
34
|
+
{...(site.mapLinkUrl ? { target: "_blank", rel: "noopener" } : {})}
|
|
35
|
+
aria-label={`Get directions to ${site.name}, ${address}`}
|
|
36
|
+
>
|
|
37
|
+
<img src={withBase(site.mapImage)} alt={`Map to ${site.name}, ${address}`} loading="lazy" />
|
|
38
|
+
<span className="map-directions">
|
|
39
|
+
<Navigation size={15} aria-hidden />
|
|
40
|
+
Get Directions
|
|
41
|
+
</span>
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!site.mapEmbedUrl) return null;
|
|
47
|
+
|
|
48
|
+
if (!loaded) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="map-frame">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
className="map-facade"
|
|
54
|
+
onClick={() => setLoaded(true)}
|
|
55
|
+
aria-label={`Load the map to ${site.name}, ${address}`}
|
|
56
|
+
>
|
|
57
|
+
<MapPin size={28} aria-hidden />
|
|
58
|
+
<span className="map-facade-address">{address}</span>
|
|
59
|
+
<span className="map-facade-hint">Tap to load the map</span>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="map-frame">
|
|
67
|
+
<iframe
|
|
68
|
+
title={`Map to ${site.name}, ${address}`}
|
|
69
|
+
src={site.mapEmbedUrl}
|
|
70
|
+
loading="lazy"
|
|
71
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|