@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.
Files changed (184) hide show
  1. package/README.md +428 -44
  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/Writeup.tsx +7 -7
  104. package/src/chrome/SiteShell.tsx +141 -0
  105. package/src/chrome/index.ts +2 -0
  106. package/src/components/Disclosure.tsx +80 -14
  107. package/src/components/GateLogin.tsx +1 -1
  108. package/src/components/Watermark.tsx +1 -1
  109. package/src/components/index.ts +6 -2
  110. package/src/gate/index.ts +1 -1
  111. package/src/gate/middleware.ts +17 -6
  112. package/src/gate/wall.ts +60 -2
  113. package/src/lib/basePathAudit.ts +96 -0
  114. package/src/lib/displayDate.ts +25 -0
  115. package/src/lib/honeypot.ts +11 -0
  116. package/src/lib/hours.ts +28 -0
  117. package/src/lib/sitemap.ts +34 -0
  118. package/src/lib/trackEvent.ts +26 -0
  119. package/src/lib/useOpenHeight.ts +28 -0
  120. package/src/primitives/Button.tsx +3 -1
  121. package/src/primitives/Chip.tsx +3 -1
  122. package/src/primitives/ImageFrame.tsx +48 -0
  123. package/src/primitives/ProseBlocks.tsx +67 -0
  124. package/src/primitives/QuoteCard.tsx +20 -0
  125. package/src/primitives/StarRow.tsx +37 -0
  126. package/src/primitives/TickList.tsx +24 -0
  127. package/src/primitives/index.ts +5 -0
  128. package/src/routing/BasePathProvider.tsx +47 -0
  129. package/src/routing/basePath.ts +57 -0
  130. package/src/routing/index.ts +8 -0
  131. package/src/scripts/check-base-path.ts +130 -0
  132. package/src/scripts/clean-sitemap.ts +27 -0
  133. package/src/scripts/contact-canary.ts +98 -0
  134. package/src/scripts/serve-static.ts +29 -3
  135. package/src/sections/BlogIndex.tsx +57 -0
  136. package/src/sections/BlogPost.tsx +65 -0
  137. package/src/sections/CTABanner.tsx +93 -0
  138. package/src/sections/CTASection.tsx +60 -0
  139. package/src/sections/ComparisonTable.tsx +114 -0
  140. package/src/sections/ContactForm.tsx +163 -0
  141. package/src/sections/ContactInfoCard.tsx +92 -0
  142. package/src/sections/CredentialList.tsx +28 -0
  143. package/src/sections/DeviceShowcase.tsx +39 -0
  144. package/src/sections/ExpandList.tsx +58 -0
  145. package/src/sections/FAQBrowser.tsx +283 -0
  146. package/src/sections/FeatureDetail.tsx +60 -0
  147. package/src/sections/FeatureGrid.tsx +83 -0
  148. package/src/sections/Gallery.tsx +84 -0
  149. package/src/sections/Hero.tsx +110 -0
  150. package/src/sections/HoursTable.tsx +23 -0
  151. package/src/sections/JumpRail.tsx +35 -0
  152. package/src/sections/LegalPage.tsx +43 -0
  153. package/src/sections/LocationSection.tsx +66 -0
  154. package/src/sections/Locations.tsx +71 -0
  155. package/src/sections/LogoMarquee.tsx +27 -0
  156. package/src/sections/MapEmbed.tsx +75 -0
  157. package/src/sections/MediaBand.tsx +20 -0
  158. package/src/sections/MenuList.tsx +44 -0
  159. package/src/sections/PageHero.tsx +25 -0
  160. package/src/sections/PressQuotes.tsx +51 -0
  161. package/src/sections/PricingBand.tsx +43 -0
  162. package/src/sections/PricingCard.tsx +36 -0
  163. package/src/sections/ProcessSteps.tsx +45 -0
  164. package/src/sections/ProfileCards.tsx +73 -0
  165. package/src/sections/RevealSlider.tsx +156 -0
  166. package/src/sections/ReviewCarousel.tsx +223 -0
  167. package/src/sections/ReviewWall.tsx +220 -0
  168. package/src/sections/SocialStrip.tsx +83 -0
  169. package/src/sections/SplitSection.tsx +56 -0
  170. package/src/sections/index.ts +76 -0
  171. package/src/sections/siteConfig.ts +201 -0
  172. package/src/styles/app.css +673 -0
  173. package/src/styles/artifact.css +472 -399
  174. package/src/styles/base.css +49 -47
  175. package/src/styles/chrome.css +71 -71
  176. package/src/styles/components.css +150 -140
  177. package/src/styles/gate.css +29 -29
  178. package/src/styles/primitives.css +190 -25
  179. package/src/styles/sections.css +2652 -0
  180. package/src/styles/star-rating.css +2 -2
  181. package/src/vite/index.ts +9 -2
  182. package/src/vite/marketing-config.ts +128 -0
  183. package/src/components/Collapsible.tsx +0 -43
  184. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,156 @@
1
+ import { ChevronLeft, ChevronRight } from "lucide-react";
2
+ import {
3
+ type CSSProperties,
4
+ type KeyboardEvent,
5
+ type PointerEvent,
6
+ useCallback,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { useWithBase } from "../routing/BasePathProvider";
11
+
12
+ export interface RevealSlide {
13
+ /** The image. Omit it and pass `placeholder` until the real shot exists. */
14
+ src?: string;
15
+ alt?: string;
16
+ /**
17
+ * Shot brief rendered as a labeled block when there is no `src` yet, e.g.
18
+ * "Screenshot: the old home page on a phone". The two sides render in
19
+ * different tones, so dragging still moves something visible.
20
+ */
21
+ placeholder?: string;
22
+ /** Corner label, e.g. "Today" or "The rebuild". */
23
+ label: string;
24
+ }
25
+
26
+ export interface RevealSliderProps {
27
+ /** The current-state image, revealed on the left. */
28
+ before: RevealSlide;
29
+ /** The rebuilt-state image, the base layer on the right. */
30
+ after: RevealSlide;
31
+ /** Starting divider position, 0-100. Default 50. */
32
+ startAt?: number;
33
+ /** Frame aspect ratio; both images should match it. Default "16/10". */
34
+ aspectRatio?: string;
35
+ /**
36
+ * Accessible name for the drag handle. Defaults to a neutral before and
37
+ * after, because what the two states are is the page's business rather
38
+ * than the component's. Name them when the page can be specific.
39
+ */
40
+ ariaLabel?: string;
41
+ }
42
+
43
+ /** One side of the frame: the photograph, or its shot brief until one exists. */
44
+ function Layer({
45
+ slide,
46
+ side,
47
+ style,
48
+ }: {
49
+ slide: RevealSlide;
50
+ side: "before" | "after";
51
+ style?: CSSProperties;
52
+ }) {
53
+ const withBase = useWithBase();
54
+ const base = ["rslider-layer", side === "before" && "rslider-before"].filter(Boolean).join(" ");
55
+ if (!slide.src) {
56
+ return (
57
+ <div className={`${base} rslider-ph is-${side}`} style={style}>
58
+ <span>{slide.placeholder ?? slide.label}</span>
59
+ </div>
60
+ );
61
+ }
62
+ return (
63
+ <img
64
+ className={`${base} rslider-img`}
65
+ src={withBase(slide.src)}
66
+ alt={slide.alt ?? ""}
67
+ draggable={false}
68
+ style={style}
69
+ />
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Drag-to-compare before/after slider. The rebuilt image is the base layer;
75
+ * the current image is masked with clip-path so dragging never distorts either
76
+ * one. SSR-safe: prerenders at `startAt`, then hydrates the pointer + keyboard
77
+ * handles. Reusable across any before/after reveal.
78
+ *
79
+ * A side with no `src` renders its shot brief instead, so a case study or a
80
+ * project page can be composed before the photography exists.
81
+ */
82
+ export function RevealSlider({
83
+ before,
84
+ after,
85
+ startAt = 50,
86
+ aspectRatio = "16/10",
87
+ ariaLabel = "Drag to compare before and after",
88
+ }: RevealSliderProps) {
89
+ const [pos, setPos] = useState(startAt);
90
+ const frameRef = useRef<HTMLDivElement>(null);
91
+ const dragging = useRef(false);
92
+
93
+ const setFromClientX = useCallback((clientX: number) => {
94
+ const el = frameRef.current;
95
+ if (!el) return;
96
+ const rect = el.getBoundingClientRect();
97
+ const next = ((clientX - rect.left) / rect.width) * 100;
98
+ setPos(Math.max(0, Math.min(100, next)));
99
+ }, []);
100
+
101
+ const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
102
+ dragging.current = true;
103
+ e.currentTarget.setPointerCapture?.(e.pointerId);
104
+ setFromClientX(e.clientX);
105
+ };
106
+ const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
107
+ if (dragging.current) setFromClientX(e.clientX);
108
+ };
109
+ const endDrag = () => {
110
+ dragging.current = false;
111
+ };
112
+ const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
113
+ if (e.key === "ArrowLeft") {
114
+ setPos((p) => Math.max(0, p - 3));
115
+ e.preventDefault();
116
+ } else if (e.key === "ArrowRight") {
117
+ setPos((p) => Math.min(100, p + 3));
118
+ e.preventDefault();
119
+ }
120
+ };
121
+
122
+ return (
123
+ <div
124
+ className="rslider"
125
+ style={{ aspectRatio }}
126
+ ref={frameRef}
127
+ onPointerMove={onPointerMove}
128
+ onPointerUp={endDrag}
129
+ onPointerLeave={endDrag}
130
+ onPointerCancel={endDrag}
131
+ >
132
+ <Layer slide={after} side="after" />
133
+ <Layer slide={before} side="before" style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }} />
134
+ <span className="rslider-label rslider-label-before">{before.label}</span>
135
+ <span className="rslider-label rslider-label-after">{after.label}</span>
136
+ <div className="rslider-line" style={{ left: `${pos}%` }} aria-hidden />
137
+ <button
138
+ type="button"
139
+ className="rslider-handle"
140
+ style={{ left: `${pos}%` }}
141
+ role="slider"
142
+ aria-label={ariaLabel}
143
+ aria-valuemin={0}
144
+ aria-valuemax={100}
145
+ aria-valuenow={Math.round(pos)}
146
+ onPointerDown={onPointerDown}
147
+ onKeyDown={onKeyDown}
148
+ >
149
+ <span className="rslider-grip">
150
+ <ChevronLeft size={16} aria-hidden />
151
+ <ChevronRight size={16} aria-hidden />
152
+ </span>
153
+ </button>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,223 @@
1
+ import { ArrowUpRight, ChevronLeft, ChevronRight } from "lucide-react";
2
+ import { type KeyboardEvent, type ReactNode, useState } from "react";
3
+ import { Eyebrow } from "../primitives";
4
+ import { StarRow } from "../primitives/StarRow";
5
+ import type { ReviewItem } from "./ReviewWall";
6
+
7
+ export type { ReviewItem };
8
+
9
+ export interface ReviewStat {
10
+ value: string;
11
+ label: string;
12
+ }
13
+
14
+ export interface ReviewCarouselProps {
15
+ eyebrow?: string;
16
+ /** The aggregate figure as a string, so "5.0" keeps its zero. */
17
+ score: string;
18
+ /** The denominator beside the score; pass an empty string to drop it. */
19
+ outOf?: string;
20
+ /** Aggregate star row, 1 to 5. Omit when the source shows no stars. */
21
+ rating?: number;
22
+ /** Caption beside the stars, e.g. "from 100 verified Google reviews". */
23
+ caption?: string;
24
+ /**
25
+ * Permalink to the business's live review profile; turns the caption
26
+ * into a link out to it, the same idiom `ReviewWall` uses for a review's
27
+ * dated source link. Without a `caption` there is nothing to link, so
28
+ * the prop is inert. Prefer a count the profile can't outgrow ("100+"),
29
+ * since the live number climbs and the caption doesn't.
30
+ */
31
+ profileUrl?: string;
32
+ /** Short paragraph under the caption: where the reviews come from. */
33
+ lead?: ReactNode;
34
+ /** Optional value/label row (three reads well). Omit to hide entirely. */
35
+ stats?: ReviewStat[];
36
+ /** The reviews, shown one at a time. */
37
+ items: ReviewItem[];
38
+ /** Accessible name for the carousel region. */
39
+ ariaLabel?: string;
40
+ id?: string;
41
+ className?: string;
42
+ }
43
+
44
+ /**
45
+ * Real reviews run from a sentence to a paragraph, and every slide shares
46
+ * one card, so the quote's type size steps down as it gets longer: short
47
+ * quotes stay big and quotable, long ones stay inside a card the page can
48
+ * hold. Length, not layout, decides — the same quote always sets the same
49
+ * way, on the server and in the browser.
50
+ */
51
+ function quoteSize(quote: string): string {
52
+ if (quote.length > 420) return "is-compact";
53
+ if (quote.length > 260) return "is-medium";
54
+ return "";
55
+ }
56
+
57
+ /**
58
+ * The social-proof split: the aggregate rating on the left (eyebrow, big
59
+ * score, star row and caption, a short provenance paragraph, an optional
60
+ * stat row) and a single large review card on the right, stepped through
61
+ * with dot pagination and round prev/next arrows.
62
+ *
63
+ * Every quote ships in the static HTML — inactive slides are hidden by
64
+ * CSS, never conditionally rendered — so crawlers and answer engines read
65
+ * all of them. Slides stack in one grid cell, so the card is as tall as
66
+ * the longest quote and stepping never shifts the page.
67
+ *
68
+ * Per-review stars are opt-in per item (`rating`), like `ReviewWall`:
69
+ * render them only when the source actually shows them.
70
+ *
71
+ * `profileUrl` turns the caption into a link out to the live review
72
+ * profile, carrying the wall's dated-source idiom (quiet text, trailing
73
+ * arrow, new tab) so a visitor can check the aggregate at the source.
74
+ */
75
+ export function ReviewCarousel({
76
+ eyebrow,
77
+ score,
78
+ outOf = "5",
79
+ rating,
80
+ caption,
81
+ profileUrl,
82
+ lead,
83
+ stats,
84
+ items,
85
+ ariaLabel = "Customer reviews",
86
+ id,
87
+ className,
88
+ }: ReviewCarouselProps) {
89
+ const [rawIndex, setIndex] = useState(0);
90
+ const count = items.length;
91
+ const index = Math.min(rawIndex, Math.max(count - 1, 0));
92
+
93
+ function go(next: number) {
94
+ if (count === 0) return;
95
+ setIndex(((next % count) + count) % count);
96
+ }
97
+
98
+ // Left/right step the carousel whenever focus sits on one of its
99
+ // controls, the keyboard convention for a paged region.
100
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
101
+ if (event.key === "ArrowLeft") {
102
+ event.preventDefault();
103
+ go(index - 1);
104
+ } else if (event.key === "ArrowRight") {
105
+ event.preventDefault();
106
+ go(index + 1);
107
+ }
108
+ }
109
+
110
+ return (
111
+ <section
112
+ className={["section", "review-carousel", className].filter(Boolean).join(" ")}
113
+ id={id}
114
+ >
115
+ <div className="wrap rc-grid">
116
+ <div className="rc-aggregate">
117
+ {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
118
+ <p className="rc-score">
119
+ <span className="rc-score-num">{score}</span>
120
+ {outOf && <span className="rc-score-out">/ {outOf}</span>}
121
+ </p>
122
+ {(rating != null || caption) && (
123
+ <p className="rc-rating">
124
+ {rating != null && <StarRow rating={rating} size={19} />}
125
+ {caption &&
126
+ (profileUrl ? (
127
+ <a
128
+ className="rc-caption"
129
+ href={profileUrl}
130
+ target="_blank"
131
+ rel="noopener"
132
+ aria-label={`${caption}, opens the review profile in a new tab`}
133
+ >
134
+ {caption}
135
+ <ArrowUpRight size={14} strokeWidth={2} aria-hidden="true" />
136
+ </a>
137
+ ) : (
138
+ <span className="rc-caption">{caption}</span>
139
+ ))}
140
+ </p>
141
+ )}
142
+ {lead && <p className="rc-lead">{lead}</p>}
143
+ {stats && stats.length > 0 && (
144
+ <dl className="rc-stats">
145
+ {stats.map((stat) => (
146
+ <div className="rc-stat" key={stat.label}>
147
+ <dt className="rc-stat-value">{stat.value}</dt>
148
+ <dd className="rc-stat-label">{stat.label}</dd>
149
+ </div>
150
+ ))}
151
+ </dl>
152
+ )}
153
+ </div>
154
+
155
+ {/* A named <section> is an ARIA region, the carousel pattern's
156
+ wrapper — spelled semantically rather than role="group". */}
157
+ <section
158
+ className="rc-viewport"
159
+ aria-roledescription="carousel"
160
+ aria-label={ariaLabel}
161
+ onKeyDown={onKeyDown}
162
+ >
163
+ <div className="rc-stage" aria-live="polite">
164
+ {items.map((item, i) => (
165
+ <figure
166
+ className={["rc-slide", i === index && "is-active"].filter(Boolean).join(" ")}
167
+ key={item.by + item.quote.slice(0, 24)}
168
+ aria-hidden={i !== index}
169
+ aria-roledescription="slide"
170
+ aria-label={`Review ${i + 1} of ${count}`}
171
+ >
172
+ {item.rating != null && <StarRow rating={item.rating} size={17} />}
173
+ <blockquote
174
+ className={["rc-quote", quoteSize(item.quote)].filter(Boolean).join(" ")}
175
+ >
176
+ “{item.quote}”
177
+ </blockquote>
178
+ <figcaption className="rc-who">
179
+ <span className="rc-name">{item.by}</span>
180
+ {item.detail && <span className="rc-detail">{item.detail}</span>}
181
+ </figcaption>
182
+ </figure>
183
+ ))}
184
+ </div>
185
+
186
+ <div className="rc-controls">
187
+ <div className="rc-dots">
188
+ {items.map((_, i) => (
189
+ <button
190
+ type="button"
191
+ className={["rc-dot", i === index && "is-active"].filter(Boolean).join(" ")}
192
+ // biome-ignore lint/suspicious/noArrayIndexKey: a dot is a position, not an identity; review text can repeat
193
+ key={`dot-${i}`}
194
+ aria-label={`Go to review ${i + 1}`}
195
+ aria-current={i === index}
196
+ onClick={() => setIndex(i)}
197
+ />
198
+ ))}
199
+ </div>
200
+ <div className="rc-arrows">
201
+ <button
202
+ type="button"
203
+ className="rc-arrow"
204
+ aria-label="Previous review"
205
+ onClick={() => go(index - 1)}
206
+ >
207
+ <ChevronLeft size={18} strokeWidth={2} aria-hidden="true" />
208
+ </button>
209
+ <button
210
+ type="button"
211
+ className="rc-arrow"
212
+ aria-label="Next review"
213
+ onClick={() => go(index + 1)}
214
+ >
215
+ <ChevronRight size={18} strokeWidth={2} aria-hidden="true" />
216
+ </button>
217
+ </div>
218
+ </div>
219
+ </section>
220
+ </div>
221
+ </section>
222
+ );
223
+ }
@@ -0,0 +1,220 @@
1
+ import { ArrowUpRight } from "lucide-react";
2
+ import { type ReactNode, useMemo, useState } from "react";
3
+ import { SectionHeading } from "../primitives";
4
+ import { StarRow } from "../primitives/StarRow";
5
+
6
+ export interface ReviewItem {
7
+ /** The review text, verbatim. Rendered inside typographic quote marks. */
8
+ quote: string;
9
+ /** Attribution, exactly as the source attributes it (e.g. "Greg T."). */
10
+ by: string;
11
+ /** Optional context line under the name: a source, a service, a city. */
12
+ detail?: string;
13
+ /**
14
+ * Star count, 1 to 5. Render stars only when the source shows them —
15
+ * never invent a rating the reviewer didn't give.
16
+ */
17
+ rating?: number;
18
+ /**
19
+ * Review date as the source gives it. An ISO date (`2026-04-24`) sorts
20
+ * correctly and renders as-is; any other string renders but sorts
21
+ * lexically, so prefer ISO.
22
+ */
23
+ date?: string;
24
+ /** Permalink to the review at its source; turns the date into a link. */
25
+ sourceUrl?: string;
26
+ /** Short labels (service, program, location) that drive the filter. */
27
+ tags?: string[];
28
+ }
29
+
30
+ export interface ReviewWallProps {
31
+ items: ReviewItem[];
32
+ /** Optional header block above the grid. */
33
+ eyebrow?: string;
34
+ title?: ReactNode;
35
+ lead?: ReactNode;
36
+ /**
37
+ * Tag filter dropdown. Defaults to on whenever the data carries tags —
38
+ * pass `false` to suppress it; `true` is a no-op without tags.
39
+ */
40
+ filterable?: boolean;
41
+ /**
42
+ * Newest / oldest sort dropdown. Defaults to on whenever the data
43
+ * carries dates.
44
+ */
45
+ sortable?: boolean;
46
+ /** Label for the "no filter" option, e.g. "All services". */
47
+ allTagsLabel?: string;
48
+ /**
49
+ * Character budget above which a quote visually clamps behind a
50
+ * "Read more" toggle. The full text always ships in the static HTML
51
+ * (the clamp is CSS-only), so crawlers read every word.
52
+ */
53
+ clampAt?: number;
54
+ id?: string;
55
+ className?: string;
56
+ }
57
+
58
+ function initials(by: string): string {
59
+ return by
60
+ .split(/\s+/)
61
+ .slice(0, 2)
62
+ .map((word) => word[0] ?? "")
63
+ .join("")
64
+ .toUpperCase();
65
+ }
66
+
67
+ function ReviewCard({ item, clampAt }: { item: ReviewItem; clampAt: number }) {
68
+ const clampable = item.quote.length > clampAt;
69
+ const [expanded, setExpanded] = useState(false);
70
+ return (
71
+ <figure className="rw-card">
72
+ {(item.rating != null || item.date) && (
73
+ <div className="rw-top">
74
+ {item.rating != null && <StarRow rating={item.rating} />}
75
+ {item.date &&
76
+ (item.sourceUrl ? (
77
+ <a
78
+ className="rw-date"
79
+ href={item.sourceUrl}
80
+ target="_blank"
81
+ rel="noopener"
82
+ aria-label={`Read the ${item.date} review from ${item.by} at its source`}
83
+ >
84
+ {item.date}
85
+ <ArrowUpRight size={13} strokeWidth={2} aria-hidden="true" />
86
+ </a>
87
+ ) : (
88
+ <span className="rw-date">{item.date}</span>
89
+ ))}
90
+ </div>
91
+ )}
92
+ <blockquote
93
+ className={["rw-quote", clampable && !expanded && "clamped"].filter(Boolean).join(" ")}
94
+ >
95
+ “{item.quote}”
96
+ </blockquote>
97
+ {clampable && (
98
+ <button
99
+ type="button"
100
+ className="rw-more"
101
+ aria-expanded={expanded}
102
+ onClick={() => setExpanded((open) => !open)}
103
+ >
104
+ {expanded ? "Show less" : "Read more"}
105
+ </button>
106
+ )}
107
+ {item.tags && item.tags.length > 0 && (
108
+ <div className="rw-tags">
109
+ {item.tags.map((tag) => (
110
+ <span className="rw-tag" key={tag}>
111
+ {tag}
112
+ </span>
113
+ ))}
114
+ </div>
115
+ )}
116
+ <figcaption className="rw-who">
117
+ <span className="rw-avatar" aria-hidden="true">
118
+ {initials(item.by)}
119
+ </span>
120
+ <span className="rw-name-block">
121
+ <span className="rw-name">{item.by}</span>
122
+ {item.detail && <span className="rw-detail">{item.detail}</span>}
123
+ </span>
124
+ </figcaption>
125
+ </figure>
126
+ );
127
+ }
128
+
129
+ /**
130
+ * The reviews-page organism: an optional header block, optional tag and
131
+ * sort controls, and a responsive grid of uniform review cards. Each card
132
+ * carries an optional star row, an optional date that links out to the
133
+ * review at its source, a CSS-clamped quote behind a per-card "Read more"
134
+ * toggle, optional tag chips, and an initials-avatar attribution line
135
+ * pinned to the card floor.
136
+ *
137
+ * Everything above the grid is optional, so the bare `<ReviewWall items />`
138
+ * form still renders a plain wall under a page's own SectionHeading. The
139
+ * controls appear only when the data can drive them (tags for the filter,
140
+ * dates for the sort), and the unfiltered state is what prerenders, so
141
+ * every quote ships in the static HTML.
142
+ */
143
+ export function ReviewWall({
144
+ items,
145
+ eyebrow,
146
+ title,
147
+ lead,
148
+ filterable,
149
+ sortable,
150
+ allTagsLabel = "All tags",
151
+ clampAt = 220,
152
+ id,
153
+ className,
154
+ }: ReviewWallProps) {
155
+ const tags = useMemo(() => {
156
+ const seen = new Set<string>();
157
+ for (const item of items) for (const tag of item.tags ?? []) seen.add(tag);
158
+ return [...seen].sort((a, b) => a.localeCompare(b));
159
+ }, [items]);
160
+ const hasDates = useMemo(() => items.some((item) => item.date), [items]);
161
+
162
+ const showFilter = (filterable ?? true) && tags.length > 0;
163
+ const showSort = (sortable ?? true) && hasDates;
164
+
165
+ const [tag, setTag] = useState("");
166
+ const [order, setOrder] = useState<"newest" | "oldest">("newest");
167
+
168
+ const shown = useMemo(() => {
169
+ const kept = tag ? items.filter((item) => item.tags?.includes(tag)) : [...items];
170
+ if (!showSort) return kept;
171
+ return kept.sort((a, b) => {
172
+ const left = a.date ?? "";
173
+ const right = b.date ?? "";
174
+ if (left === right) return 0;
175
+ return order === "newest" ? right.localeCompare(left) : left.localeCompare(right);
176
+ });
177
+ }, [items, tag, order, showSort]);
178
+
179
+ return (
180
+ <div className={["review-wall-block", className].filter(Boolean).join(" ")} id={id}>
181
+ {(eyebrow || title) && <SectionHeading eyebrow={eyebrow} title={title ?? ""} lead={lead} />}
182
+
183
+ {(showFilter || showSort) && (
184
+ <div className="rw-controls">
185
+ {showFilter && (
186
+ <label className="rw-select">
187
+ <span className="rw-select-label">Filter reviews by tag</span>
188
+ <select value={tag} onChange={(event) => setTag(event.target.value)}>
189
+ <option value="">{allTagsLabel}</option>
190
+ {tags.map((option) => (
191
+ <option key={option} value={option}>
192
+ {option}
193
+ </option>
194
+ ))}
195
+ </select>
196
+ </label>
197
+ )}
198
+ {showSort && (
199
+ <label className="rw-select rw-select-end">
200
+ <span className="rw-select-label">Sort reviews by date</span>
201
+ <select
202
+ value={order}
203
+ onChange={(event) => setOrder(event.target.value as "newest" | "oldest")}
204
+ >
205
+ <option value="newest">Newest</option>
206
+ <option value="oldest">Oldest</option>
207
+ </select>
208
+ </label>
209
+ )}
210
+ </div>
211
+ )}
212
+
213
+ <div className="review-wall">
214
+ {shown.map((item) => (
215
+ <ReviewCard key={item.by + item.quote.slice(0, 24)} item={item} clampAt={clampAt} />
216
+ ))}
217
+ </div>
218
+ </div>
219
+ );
220
+ }
@@ -0,0 +1,83 @@
1
+ import { AtSign } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { SectionHeading } from "../primitives";
4
+ import { Button } from "../primitives/Button";
5
+ import { useWithBase } from "../routing/BasePathProvider";
6
+
7
+ export interface SocialPhoto {
8
+ src?: string;
9
+ alt?: string;
10
+ /** Placeholder shot brief rendered when no src exists yet. */
11
+ placeholder?: string;
12
+ }
13
+
14
+ export interface SocialStripProps {
15
+ eyebrow?: string;
16
+ /** Deprecated no-op: the eyebrow's trailing fade is now its only rendering. */
17
+ eyebrowRun?: boolean;
18
+ title: ReactNode;
19
+ lead?: string;
20
+ /** Display handle, e.g. "@platformstrength". */
21
+ handle: string;
22
+ /** The profile the strip links out to. */
23
+ profileUrl: string;
24
+ photos: SocialPhoto[];
25
+ /** Follow button label; defaults to "Follow {handle}". */
26
+ followLabel?: string;
27
+ surface?: "default" | "alt";
28
+ id?: string;
29
+ }
30
+
31
+ /**
32
+ * A static strip of the business's own social photos with a follow link out.
33
+ * Deliberately not a live embed: third-party embed scripts are heavy,
34
+ * render-blocking, and break silently on static exports. Photos ship as local
35
+ * assets; the platform link carries the relationship.
36
+ */
37
+ export function SocialStrip({
38
+ eyebrow,
39
+ title,
40
+ lead,
41
+ handle,
42
+ profileUrl,
43
+ photos,
44
+ followLabel,
45
+ surface = "default",
46
+ id,
47
+ }: SocialStripProps) {
48
+ const withBase = useWithBase();
49
+ const sectionClasses = ["section", surface === "alt" && "surface-alt"].filter(Boolean).join(" ");
50
+ return (
51
+ <section className={sectionClasses} id={id}>
52
+ <div className="wrap">
53
+ <SectionHeading eyebrow={eyebrow} title={title} lead={lead} />
54
+ <div className="social-strip">
55
+ {photos.map((photo) =>
56
+ photo.src ? (
57
+ <a
58
+ key={photo.src}
59
+ className="social-tile"
60
+ href={profileUrl}
61
+ target="_blank"
62
+ rel="noopener"
63
+ aria-label={photo.alt ? `${photo.alt} (opens ${handle})` : `Opens ${handle}`}
64
+ >
65
+ <img src={withBase(photo.src)} alt={photo.alt ?? ""} loading="lazy" />
66
+ </a>
67
+ ) : (
68
+ <div key={photo.placeholder} className="social-tile ph">
69
+ {photo.placeholder ?? "Photo"}
70
+ </div>
71
+ ),
72
+ )}
73
+ </div>
74
+ <div className="social-follow">
75
+ <Button href={profileUrl} newTab variant="dark">
76
+ <AtSign size={17} aria-hidden />
77
+ {followLabel ?? `Follow ${handle}`}
78
+ </Button>
79
+ </div>
80
+ </div>
81
+ </section>
82
+ );
83
+ }