@atelic-action/ui 0.17.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +432 -45
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Note.tsx +9 -39
- package/src/artifact/Paragraph.tsx +65 -0
- package/src/artifact/Read.tsx +406 -0
- package/src/artifact/Writeup.tsx +7 -7
- package/src/artifact/index.ts +1 -0
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +759 -401
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface SplitSectionProps {
|
|
4
|
+
left: ReactNode;
|
|
5
|
+
right: ReactNode;
|
|
6
|
+
/**
|
|
7
|
+
* Column proportions: "even" (1/1), or "narrow-first" (0.9/1.1,
|
|
8
|
+
* the media-plus-copy pattern).
|
|
9
|
+
*/
|
|
10
|
+
ratio?: "even" | "narrow-first";
|
|
11
|
+
align?: "start" | "center";
|
|
12
|
+
/** Section background band. */
|
|
13
|
+
surface?: "default" | "alt";
|
|
14
|
+
/** Compact top padding for sections directly under a page hero. */
|
|
15
|
+
tightTop?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Compact bottom padding for when the next section continues this
|
|
18
|
+
* one's thought (e.g. a list that belongs to this section's copy).
|
|
19
|
+
* Pair with `tight-top` on the follower to close the gap.
|
|
20
|
+
*/
|
|
21
|
+
tightBottom?: boolean;
|
|
22
|
+
id?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The recurring two-column section: copy plus copy, or media plus copy. */
|
|
27
|
+
export function SplitSection({
|
|
28
|
+
left,
|
|
29
|
+
right,
|
|
30
|
+
ratio = "even",
|
|
31
|
+
align = "start",
|
|
32
|
+
surface = "default",
|
|
33
|
+
tightTop = false,
|
|
34
|
+
tightBottom = false,
|
|
35
|
+
id,
|
|
36
|
+
className,
|
|
37
|
+
}: SplitSectionProps) {
|
|
38
|
+
const sectionClasses = [
|
|
39
|
+
"section",
|
|
40
|
+
surface === "alt" && "surface-alt",
|
|
41
|
+
tightTop && "tight-top",
|
|
42
|
+
tightBottom && "tight-bottom",
|
|
43
|
+
className,
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(" ");
|
|
47
|
+
const gridClasses = ["split", `ratio-${ratio}`, `align-${align}`].join(" ");
|
|
48
|
+
return (
|
|
49
|
+
<section className={sectionClasses} id={id}>
|
|
50
|
+
<div className={`wrap ${gridClasses}`}>
|
|
51
|
+
<div>{left}</div>
|
|
52
|
+
<div>{right}</div>
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@atelic-action/ui/sections`: the page scale sections a marketing site
|
|
3
|
+
* composes, moved here from template-marketing in 0.19.0 (ATE-643), with the
|
|
4
|
+
* `SiteConfig` contract they and `SiteShell` read. Their styles are
|
|
5
|
+
* `@atelic-action/ui/styles/sections.css`; a section imports no stylesheet of
|
|
6
|
+
* its own.
|
|
7
|
+
*/
|
|
8
|
+
export { isHoneypotFilled } from "../lib/honeypot";
|
|
9
|
+
export { formatClock, formatHoursRange, isClosed } from "../lib/hours";
|
|
10
|
+
export { type EventParams, trackEvent } from "../lib/trackEvent";
|
|
11
|
+
export { BlogIndex, type BlogIndexItem, type BlogIndexProps } from "./BlogIndex";
|
|
12
|
+
export { BlogPost, type BlogPostDoc, type BlogPostProps } from "./BlogPost";
|
|
13
|
+
export {
|
|
14
|
+
type ComparisonRow,
|
|
15
|
+
type ComparisonState,
|
|
16
|
+
ComparisonTable,
|
|
17
|
+
type ComparisonTableProps,
|
|
18
|
+
} from "./ComparisonTable";
|
|
19
|
+
export { ContactForm, type ContactFormProps } from "./ContactForm";
|
|
20
|
+
export {
|
|
21
|
+
ContactInfoCard,
|
|
22
|
+
type ContactInfoCardProps,
|
|
23
|
+
type ContactInfoCardSite,
|
|
24
|
+
type ContactPhone,
|
|
25
|
+
} from "./ContactInfoCard";
|
|
26
|
+
export { type Credential, CredentialList, type CredentialListProps } from "./CredentialList";
|
|
27
|
+
export { CTABanner, type CTABannerCTA, type CTABannerProps } from "./CTABanner";
|
|
28
|
+
export { CTASection, type CTASectionProps } from "./CTASection";
|
|
29
|
+
export { type DeviceImage, DeviceShowcase, type DeviceShowcaseProps } from "./DeviceShowcase";
|
|
30
|
+
export { ExpandList, type ExpandListItem, type ExpandListProps } from "./ExpandList";
|
|
31
|
+
export {
|
|
32
|
+
FAQBrowser,
|
|
33
|
+
type FAQBrowserProps,
|
|
34
|
+
type FAQCategory,
|
|
35
|
+
type FAQCta,
|
|
36
|
+
type FAQEntry,
|
|
37
|
+
} from "./FAQBrowser";
|
|
38
|
+
export { FeatureDetail, type FeatureDetailProps } from "./FeatureDetail";
|
|
39
|
+
export { type FeatureCard, FeatureGrid, type FeatureGridProps } from "./FeatureGrid";
|
|
40
|
+
export { Gallery, type GalleryPhoto, type GalleryProps } from "./Gallery";
|
|
41
|
+
export { Hero, type HeroCTA, type HeroProps, type HeroTrustItem } from "./Hero";
|
|
42
|
+
export { HoursTable, type HoursTableProps } from "./HoursTable";
|
|
43
|
+
export { JumpRail, type JumpRailItem, type JumpRailProps } from "./JumpRail";
|
|
44
|
+
export { type LegalBlock, type LegalDoc, LegalPage, type LegalPageProps } from "./LegalPage";
|
|
45
|
+
export {
|
|
46
|
+
LocationSection,
|
|
47
|
+
type LocationSectionProps,
|
|
48
|
+
type LocationSectionSite,
|
|
49
|
+
} from "./LocationSection";
|
|
50
|
+
export { type LocationEntry, Locations, type LocationsProps } from "./Locations";
|
|
51
|
+
export { LogoMarquee, type LogoMarqueeProps } from "./LogoMarquee";
|
|
52
|
+
export { MapEmbed, type MapEmbedProps, type MapEmbedSite } from "./MapEmbed";
|
|
53
|
+
export { MediaBand, type MediaBandProps } from "./MediaBand";
|
|
54
|
+
export { MenuList, type MenuListItem, type MenuListProps } from "./MenuList";
|
|
55
|
+
export { PageHero, type PageHeroProps } from "./PageHero";
|
|
56
|
+
export { type PressItem, PressQuotes, type PressQuotesProps, sortPress } from "./PressQuotes";
|
|
57
|
+
export { PricingBand, type PricingBandProps } from "./PricingBand";
|
|
58
|
+
export { PricingCard, type PricingCardProps } from "./PricingCard";
|
|
59
|
+
export { type ProcessStep, ProcessSteps, type ProcessStepsProps } from "./ProcessSteps";
|
|
60
|
+
export { ProfileCards, type ProfileCardsProps, type ProfileItem } from "./ProfileCards";
|
|
61
|
+
export { type RevealSlide, RevealSlider, type RevealSliderProps } from "./RevealSlider";
|
|
62
|
+
export { ReviewCarousel, type ReviewCarouselProps, type ReviewStat } from "./ReviewCarousel";
|
|
63
|
+
export { type ReviewItem, ReviewWall, type ReviewWallProps } from "./ReviewWall";
|
|
64
|
+
export { type SocialPhoto, SocialStrip, type SocialStripProps } from "./SocialStrip";
|
|
65
|
+
export { SplitSection, type SplitSectionProps } from "./SplitSection";
|
|
66
|
+
export type {
|
|
67
|
+
Address,
|
|
68
|
+
CTA,
|
|
69
|
+
Founder,
|
|
70
|
+
GateConfig,
|
|
71
|
+
NavItem,
|
|
72
|
+
OpeningHours,
|
|
73
|
+
SiteConfig,
|
|
74
|
+
SiteLink,
|
|
75
|
+
SocialLink,
|
|
76
|
+
} from "./siteConfig";
|