@atelic-action/ui 0.17.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +429 -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 +1 -1
- package/src/artifact/Shot.tsx +8 -2
- package/src/artifact/Writeup.tsx +7 -7
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +583 -397
- 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,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
|
+
}
|