@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,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
+ }