@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.
- package/README.md +428 -44
- 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/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 +472 -399
- 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,43 @@
|
|
|
1
|
+
import { type ProseBlock, ProseBlocks } from "../primitives/ProseBlocks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Legal documents are prose blocks like any other ported document, so the
|
|
5
|
+
* block vocabulary and the renderer are shared with the blog rather than
|
|
6
|
+
* duplicated. Re-exported because a client site importing `LegalDoc` should
|
|
7
|
+
* not need to know where the block type happens to live.
|
|
8
|
+
*/
|
|
9
|
+
export type LegalBlock = ProseBlock;
|
|
10
|
+
|
|
11
|
+
export interface LegalDoc {
|
|
12
|
+
/** The document's own title, as the source states it. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** The source's own revision date, verbatim. Null when it states none. */
|
|
15
|
+
lastUpdated?: string | null;
|
|
16
|
+
blocks: LegalBlock[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LegalPageProps {
|
|
20
|
+
doc: LegalDoc;
|
|
21
|
+
id?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders a legal document: terms, a privacy policy, a disclaimer. Content
|
|
27
|
+
* arrives entirely as data so a client's published wording can be ported
|
|
28
|
+
* without passing through anyone's judgment about what it should say.
|
|
29
|
+
*
|
|
30
|
+
* All-caps passages are left exactly as the source sets them. Legal drafting
|
|
31
|
+
* uses capitals to make a clause conspicuous, which carries actual legal
|
|
32
|
+
* weight in some jurisdictions, so it is never normalised away here.
|
|
33
|
+
*/
|
|
34
|
+
export function LegalPage({ doc, id, className }: LegalPageProps) {
|
|
35
|
+
return (
|
|
36
|
+
<section className={["section", "legal", className].filter(Boolean).join(" ")} id={id}>
|
|
37
|
+
<div className="wrap">
|
|
38
|
+
{doc.lastUpdated && <p className="legal-updated">Last updated: {doc.lastUpdated}</p>}
|
|
39
|
+
<ProseBlocks blocks={doc.blocks} />
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
3
|
+
import { Button } from "../primitives/Button";
|
|
4
|
+
import { HoursTable } from "./HoursTable";
|
|
5
|
+
import { MapEmbed, type MapEmbedSite } from "./MapEmbed";
|
|
6
|
+
import type { SiteConfig } from "./siteConfig";
|
|
7
|
+
|
|
8
|
+
/** The keys of the site config the section reads; a whole `SiteConfig` passes as it is. */
|
|
9
|
+
export type LocationSectionSite = MapEmbedSite &
|
|
10
|
+
Pick<SiteConfig, "serviceAreas" | "hours" | "phone">;
|
|
11
|
+
|
|
12
|
+
export interface LocationSectionProps {
|
|
13
|
+
site: LocationSectionSite;
|
|
14
|
+
eyebrow?: string;
|
|
15
|
+
title: ReactNode;
|
|
16
|
+
lead?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Joins service areas into readable prose: "A, B, and C". */
|
|
20
|
+
function areasSentence(areas: string[]): string {
|
|
21
|
+
if (areas.length <= 1) return areas[0] ?? "";
|
|
22
|
+
return `${areas.slice(0, -1).join(", ")}, and ${areas[areas.length - 1]}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Local SEO section: NAP copy, service areas, hours, directions, and the
|
|
27
|
+
* map embed. Identical on every page that uses it — the service areas
|
|
28
|
+
* render as plain prose (crawlable text) from the site config.
|
|
29
|
+
*/
|
|
30
|
+
export function LocationSection({ site, eyebrow = "Find us", title, lead }: LocationSectionProps) {
|
|
31
|
+
return (
|
|
32
|
+
<section className="section location">
|
|
33
|
+
<div className="wrap location-grid">
|
|
34
|
+
<div>
|
|
35
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
36
|
+
<h2>{title}</h2>
|
|
37
|
+
{lead && <Lead>{lead}</Lead>}
|
|
38
|
+
{site.serviceAreas.length > 0 && (
|
|
39
|
+
<p className="loc-areas">Proudly serving {areasSentence(site.serviceAreas)}.</p>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
<div className="loc-block">
|
|
43
|
+
<h4>Hours</h4>
|
|
44
|
+
<HoursTable hours={site.hours} />
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div className="loc-actions">
|
|
48
|
+
{site.mapLinkUrl && (
|
|
49
|
+
<Button variant="dark" href={site.mapLinkUrl} newTab arrow>
|
|
50
|
+
Get Directions
|
|
51
|
+
</Button>
|
|
52
|
+
)}
|
|
53
|
+
{site.phone && (
|
|
54
|
+
<Button variant="ghost" href={`tel:${site.phone.e164}`}>
|
|
55
|
+
{site.phone.display}
|
|
56
|
+
</Button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<MapEmbed site={site} />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Navigation, Phone } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
4
|
+
import { MapEmbed, type MapEmbedSite } from "./MapEmbed";
|
|
5
|
+
|
|
6
|
+
export interface LocationEntry {
|
|
7
|
+
name: string;
|
|
8
|
+
address: string;
|
|
9
|
+
/** Hours lines, already formatted, e.g. "Mon to Sat · 5am to 3pm". */
|
|
10
|
+
hours: string[];
|
|
11
|
+
phoneDisplay: string;
|
|
12
|
+
phoneE164: string;
|
|
13
|
+
mapLink: string;
|
|
14
|
+
/** Optional one-line note, e.g. "The original, since 1979". */
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LocationsProps {
|
|
19
|
+
/** Drives the static map (the primary/flagship location); a whole `SiteConfig` passes as it is. */
|
|
20
|
+
site: MapEmbedSite;
|
|
21
|
+
eyebrow?: string;
|
|
22
|
+
title: ReactNode;
|
|
23
|
+
lead?: ReactNode;
|
|
24
|
+
locations: LocationEntry[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Multi-location block: the flagship's static map beside a grid of location
|
|
29
|
+
* cards (address, hours, call, directions). Reusable for any business that
|
|
30
|
+
* runs more than one address.
|
|
31
|
+
*/
|
|
32
|
+
export function Locations({ site, eyebrow = "Find us", title, lead, locations }: LocationsProps) {
|
|
33
|
+
return (
|
|
34
|
+
<section className="section locs">
|
|
35
|
+
<div className="wrap">
|
|
36
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
37
|
+
<h2>{title}</h2>
|
|
38
|
+
{lead && <Lead style={{ maxWidth: "58ch" }}>{lead}</Lead>}
|
|
39
|
+
<div className="locs-layout">
|
|
40
|
+
<div className="locs-map">
|
|
41
|
+
<MapEmbed site={site} />
|
|
42
|
+
</div>
|
|
43
|
+
<div className="locs-cards">
|
|
44
|
+
{locations.map((loc) => (
|
|
45
|
+
<div className="loc-card" key={loc.name}>
|
|
46
|
+
<h3>{loc.name}</h3>
|
|
47
|
+
{loc.note && <p className="loc-note">{loc.note}</p>}
|
|
48
|
+
<p className="loc-addr">{loc.address}</p>
|
|
49
|
+
<div className="loc-hours">
|
|
50
|
+
{loc.hours.map((line) => (
|
|
51
|
+
<span key={line}>{line}</span>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
<div className="loc-links">
|
|
55
|
+
<a href={`tel:${loc.phoneE164}`}>
|
|
56
|
+
<Phone size={14} aria-hidden />
|
|
57
|
+
{loc.phoneDisplay}
|
|
58
|
+
</a>
|
|
59
|
+
<a href={loc.mapLink} target="_blank" rel="noopener">
|
|
60
|
+
<Navigation size={14} aria-hidden />
|
|
61
|
+
Directions
|
|
62
|
+
</a>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Chip } from "../primitives/Chip";
|
|
2
|
+
|
|
3
|
+
export interface LogoMarqueeProps {
|
|
4
|
+
/** Items scrolled across the band (credentials, client names, press). */
|
|
5
|
+
items: string[];
|
|
6
|
+
ariaLabel?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Dark band with an infinitely scrolling row of chips. */
|
|
10
|
+
export function LogoMarquee({ items, ariaLabel = "Credentials" }: LogoMarqueeProps) {
|
|
11
|
+
// The track is duplicated so the -50% translate loops seamlessly.
|
|
12
|
+
const track = [...items, ...items];
|
|
13
|
+
return (
|
|
14
|
+
<section className="creds" aria-label={ariaLabel}>
|
|
15
|
+
<div className="marquee">
|
|
16
|
+
<div className="marquee-track">
|
|
17
|
+
{track.map((item, i) => (
|
|
18
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: intentional duplicates for the loop
|
|
19
|
+
<Chip key={`${item}-${i}`} dot onDark>
|
|
20
|
+
{item}
|
|
21
|
+
</Chip>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { MapPin, Navigation } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
import type { SiteConfig } from "./siteConfig";
|
|
5
|
+
|
|
6
|
+
/** The keys of the site config the map reads; a whole `SiteConfig` passes as it is. */
|
|
7
|
+
export type MapEmbedSite = Pick<
|
|
8
|
+
SiteConfig,
|
|
9
|
+
"name" | "address" | "mapImage" | "mapLinkUrl" | "mapEmbedUrl"
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
export interface MapEmbedProps {
|
|
13
|
+
site: MapEmbedSite;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The location map. Preferred form is a static screenshot (`site.mapImage`):
|
|
18
|
+
* one image request, no third-party chain, and a real map on first paint,
|
|
19
|
+
* linking straight to directions. Falls back to a click-to-load facade over
|
|
20
|
+
* the live Google embed when no static image is provided (the embed's utility
|
|
21
|
+
* chain weighs ~450 KiB, so it never loads on its own).
|
|
22
|
+
*/
|
|
23
|
+
export function MapEmbed({ site }: MapEmbedProps) {
|
|
24
|
+
const withBase = useWithBase();
|
|
25
|
+
const [loaded, setLoaded] = useState(false);
|
|
26
|
+
|
|
27
|
+
const address = `${site.address.street}, ${site.address.locality} ${site.address.region} ${site.address.postalCode}`;
|
|
28
|
+
|
|
29
|
+
if (site.mapImage) {
|
|
30
|
+
return (
|
|
31
|
+
<a
|
|
32
|
+
className="map-frame map-static"
|
|
33
|
+
href={site.mapLinkUrl ?? "#"}
|
|
34
|
+
{...(site.mapLinkUrl ? { target: "_blank", rel: "noopener" } : {})}
|
|
35
|
+
aria-label={`Get directions to ${site.name}, ${address}`}
|
|
36
|
+
>
|
|
37
|
+
<img src={withBase(site.mapImage)} alt={`Map to ${site.name}, ${address}`} loading="lazy" />
|
|
38
|
+
<span className="map-directions">
|
|
39
|
+
<Navigation size={15} aria-hidden />
|
|
40
|
+
Get Directions
|
|
41
|
+
</span>
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!site.mapEmbedUrl) return null;
|
|
47
|
+
|
|
48
|
+
if (!loaded) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="map-frame">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
className="map-facade"
|
|
54
|
+
onClick={() => setLoaded(true)}
|
|
55
|
+
aria-label={`Load the map to ${site.name}, ${address}`}
|
|
56
|
+
>
|
|
57
|
+
<MapPin size={28} aria-hidden />
|
|
58
|
+
<span className="map-facade-address">{address}</span>
|
|
59
|
+
<span className="map-facade-hint">Tap to load the map</span>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="map-frame">
|
|
67
|
+
<iframe
|
|
68
|
+
title={`Map to ${site.name}, ${address}`}
|
|
69
|
+
src={site.mapEmbedUrl}
|
|
70
|
+
loading="lazy"
|
|
71
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
2
|
+
|
|
3
|
+
export interface MediaBandProps {
|
|
4
|
+
image: { src: string; alt: string };
|
|
5
|
+
/**
|
|
6
|
+
* CSS aspect-ratio for the band (viewport-width : height), e.g. "8/3" for
|
|
7
|
+
* a wide strip. The image covers the band and crops from center.
|
|
8
|
+
*/
|
|
9
|
+
aspectRatio?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Full-bleed image band: edge to edge, no frame, no radius. */
|
|
13
|
+
export function MediaBand({ image, aspectRatio = "8/3" }: MediaBandProps) {
|
|
14
|
+
const withBase = useWithBase();
|
|
15
|
+
return (
|
|
16
|
+
<div className="media-band" style={{ aspectRatio }}>
|
|
17
|
+
<img src={withBase(image.src)} alt={image.alt} loading="lazy" />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A priced menu group: uppercase heading over dotted rows of name, optional
|
|
5
|
+
* note, and price, with an optional fine-print foot line. Composes into a
|
|
6
|
+
* page column; pair with Menu structured data in the route's head().
|
|
7
|
+
* Styles live in menu-list.css beside it, with the `.menu-stack` and
|
|
8
|
+
* `.menu-cols` layouts a page stacks its groups in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface MenuListItem {
|
|
12
|
+
name: string;
|
|
13
|
+
/** Modifier or description line under the name. */
|
|
14
|
+
note?: string;
|
|
15
|
+
/** Rendered as text so ranges ("Small 5 / Large 6") stay expressible. */
|
|
16
|
+
price: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MenuListProps {
|
|
20
|
+
heading: string;
|
|
21
|
+
items: MenuListItem[];
|
|
22
|
+
/** Fine print after the rows, e.g. "All sandwiches available as a salad." */
|
|
23
|
+
foot?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MenuList({ heading, items, foot }: MenuListProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="menu-list">
|
|
29
|
+
<h3>{heading}</h3>
|
|
30
|
+
<ul>
|
|
31
|
+
{items.map((item) => (
|
|
32
|
+
<li key={item.name}>
|
|
33
|
+
<span className="ml-item">
|
|
34
|
+
<span className="ml-name">{item.name}</span>
|
|
35
|
+
{item.note && <span className="ml-note">{item.note}</span>}
|
|
36
|
+
</span>
|
|
37
|
+
<span className="ml-price">{item.price}</span>
|
|
38
|
+
</li>
|
|
39
|
+
))}
|
|
40
|
+
</ul>
|
|
41
|
+
{foot && <p className="ml-foot">{foot}</p>}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
3
|
+
|
|
4
|
+
export interface PageHeroProps {
|
|
5
|
+
eyebrow?: string;
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
lead?: ReactNode;
|
|
8
|
+
center?: boolean;
|
|
9
|
+
/** Extra content under the lead (e.g. jump-link chips). */
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Interior page opener on the alternate surface band. */
|
|
14
|
+
export function PageHero({ eyebrow, title, lead, center = false, children }: PageHeroProps) {
|
|
15
|
+
return (
|
|
16
|
+
<section className={["page-hero", center && "center"].filter(Boolean).join(" ")}>
|
|
17
|
+
<div className="wrap">
|
|
18
|
+
{eyebrow && <Eyebrow center={center}>{eyebrow}</Eyebrow>}
|
|
19
|
+
<h1>{title}</h1>
|
|
20
|
+
{lead && <Lead style={center ? { marginInline: "auto" } : undefined}>{lead}</Lead>}
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface PressItem {
|
|
2
|
+
/** The quote or award title, e.g. "Best Coffeehouse for Food". */
|
|
3
|
+
quote: string;
|
|
4
|
+
/** Publication name, e.g. "Westword". */
|
|
5
|
+
source: string;
|
|
6
|
+
year: string | number;
|
|
7
|
+
/** Link to the article; rows without one render unlinked. */
|
|
8
|
+
href?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PressQuotesProps {
|
|
12
|
+
items: PressItem[];
|
|
13
|
+
/** Display order by year; newest first unless told otherwise. */
|
|
14
|
+
order?: "newest" | "oldest";
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Sort press items by year; exported for tests. */
|
|
19
|
+
export function sortPress(items: PressItem[], order: "newest" | "oldest"): PressItem[] {
|
|
20
|
+
const dir = order === "newest" ? -1 : 1;
|
|
21
|
+
return [...items].sort((a, b) => dir * (Number(a.year) - Number(b.year)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Rule separated rows of press quotes and awards, each citing its publication and year and linking to the article. */
|
|
25
|
+
export function PressQuotes({ items, order = "newest", className }: PressQuotesProps) {
|
|
26
|
+
return (
|
|
27
|
+
<ul className={["press-quotes", className].filter(Boolean).join(" ")}>
|
|
28
|
+
{sortPress(items, order).map((item) => {
|
|
29
|
+
const body = (
|
|
30
|
+
<>
|
|
31
|
+
<span className="pq-quote">{item.quote}</span>
|
|
32
|
+
<span className="pq-src">
|
|
33
|
+
{item.source} · {item.year}
|
|
34
|
+
</span>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<li key={item.source + item.year + item.quote}>
|
|
39
|
+
{item.href ? (
|
|
40
|
+
<a className="pq-row" href={item.href} target="_blank" rel="noopener noreferrer">
|
|
41
|
+
{body}
|
|
42
|
+
</a>
|
|
43
|
+
) : (
|
|
44
|
+
<div className="pq-row">{body}</div>
|
|
45
|
+
)}
|
|
46
|
+
</li>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</ul>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow } from "../primitives";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
|
|
5
|
+
export interface PricingBandProps {
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
lead?: ReactNode;
|
|
9
|
+
price: string;
|
|
10
|
+
/** Unit line under the price, e.g. "/ 60 minutes". */
|
|
11
|
+
unit: string;
|
|
12
|
+
/** Detail link under the price, e.g. to the pricing page. */
|
|
13
|
+
detail?: { label: string; href: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Brand-colored pricing band with an inline price card. Informational by
|
|
18
|
+
* design — the page's booking action belongs to the closing CTASection,
|
|
19
|
+
* so pages never pitch the primary CTA twice.
|
|
20
|
+
*/
|
|
21
|
+
export function PricingBand({ eyebrow, title, lead, price, unit, detail }: PricingBandProps) {
|
|
22
|
+
const withBase = useWithBase();
|
|
23
|
+
return (
|
|
24
|
+
<section className="price-band">
|
|
25
|
+
<div className="wrap price-inner">
|
|
26
|
+
<div>
|
|
27
|
+
{eyebrow && <Eyebrow onDark>{eyebrow}</Eyebrow>}
|
|
28
|
+
<h2>{title}</h2>
|
|
29
|
+
{lead && <p className="lead price-lead">{lead}</p>}
|
|
30
|
+
</div>
|
|
31
|
+
<div className="price-card">
|
|
32
|
+
<span className="price-fig">{price}</span>
|
|
33
|
+
<span className="price-unit">{unit}</span>
|
|
34
|
+
{detail && (
|
|
35
|
+
<a className="price-detail" href={withBase(detail.href)}>
|
|
36
|
+
{detail.label} →
|
|
37
|
+
</a>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Button } from "../primitives/Button";
|
|
3
|
+
import { TickList } from "../primitives/TickList";
|
|
4
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
5
|
+
|
|
6
|
+
export interface PricingCardProps {
|
|
7
|
+
/** Small mono line above the price, e.g. "Every session · every service". */
|
|
8
|
+
ribbon?: string;
|
|
9
|
+
price: string;
|
|
10
|
+
/** Line under the price, e.g. "for a full 60 minutes". */
|
|
11
|
+
per: string;
|
|
12
|
+
ticks?: ReactNode[];
|
|
13
|
+
cta: { label: string; href: string; newTab?: boolean };
|
|
14
|
+
secondary?: { label: string; href: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Standalone detailed pricing card (the pricing page centerpiece). */
|
|
18
|
+
export function PricingCard({ ribbon, price, per, ticks, cta, secondary }: PricingCardProps) {
|
|
19
|
+
const withBase = useWithBase();
|
|
20
|
+
return (
|
|
21
|
+
<div className="cost-card">
|
|
22
|
+
{ribbon && <span className="ribbon">{ribbon}</span>}
|
|
23
|
+
<div className="big">{price}</div>
|
|
24
|
+
<div className="per">{per}</div>
|
|
25
|
+
{ticks && ticks.length > 0 && <TickList items={ticks} />}
|
|
26
|
+
<Button href={cta.href} newTab={cta.newTab} size="lg" arrow style={{ width: "100%" }}>
|
|
27
|
+
{cta.label}
|
|
28
|
+
</Button>
|
|
29
|
+
{secondary && (
|
|
30
|
+
<a className="cost-secondary" href={withBase(secondary.href)}>
|
|
31
|
+
{secondary.label} →
|
|
32
|
+
</a>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow } from "../primitives";
|
|
3
|
+
import { ImageFrame } from "../primitives/ImageFrame";
|
|
4
|
+
|
|
5
|
+
export interface ProcessStep {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProcessStepsProps {
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
steps: ProcessStep[];
|
|
14
|
+
image?: { src: string; alt: string };
|
|
15
|
+
/** Placeholder label rendered in the media frame until a real image exists. */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Dark band with numbered steps and supporting media. */
|
|
20
|
+
export function ProcessSteps({ eyebrow, title, steps, image, placeholder }: ProcessStepsProps) {
|
|
21
|
+
return (
|
|
22
|
+
<section className="section process">
|
|
23
|
+
<div className="wrap process-grid">
|
|
24
|
+
<div>
|
|
25
|
+
{eyebrow && <Eyebrow onDark>{eyebrow}</Eyebrow>}
|
|
26
|
+
<h2>{title}</h2>
|
|
27
|
+
<ol className="steps">
|
|
28
|
+
{steps.map((step, i) => (
|
|
29
|
+
<li key={step.title}>
|
|
30
|
+
<span className="step-n">{i + 1}</span>
|
|
31
|
+
<div>
|
|
32
|
+
<h4>{step.title}</h4>
|
|
33
|
+
<p>{step.description}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ol>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="process-media">
|
|
40
|
+
<ImageFrame src={image?.src} alt={image?.alt} placeholder={placeholder} />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { SectionHeading } from "../primitives";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
|
|
5
|
+
export interface ProfileItem {
|
|
6
|
+
name: string;
|
|
7
|
+
/** One or two sentence bio; keep it shorter than a FeatureDetail lead. */
|
|
8
|
+
blurb: ReactNode;
|
|
9
|
+
/** Example of their work or a portrait. */
|
|
10
|
+
image?: { src: string; alt: string };
|
|
11
|
+
/** Placeholder label rendered until a real image exists. */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Where the card links (e.g. the person's section on a roster page). */
|
|
14
|
+
href?: string;
|
|
15
|
+
linkLabel?: string;
|
|
16
|
+
/** Small mono line under the name (e.g. an Instagram handle). */
|
|
17
|
+
meta?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProfileCardsProps {
|
|
21
|
+
id?: string;
|
|
22
|
+
eyebrow?: string;
|
|
23
|
+
title: ReactNode;
|
|
24
|
+
lead?: ReactNode;
|
|
25
|
+
items: ProfileItem[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Equal-weight horizontal profile cards: media beside a short bio, no
|
|
30
|
+
* numbering. For rosters and teams where every member carries the same
|
|
31
|
+
* weight (contrast FeatureGrid, whose numbered cards imply an ordering).
|
|
32
|
+
*/
|
|
33
|
+
export function ProfileCards({ id, eyebrow, title, lead, items }: ProfileCardsProps) {
|
|
34
|
+
const withBase = useWithBase();
|
|
35
|
+
return (
|
|
36
|
+
<section className="section" id={id}>
|
|
37
|
+
<div className="wrap">
|
|
38
|
+
<SectionHeading eyebrow={eyebrow} title={title} lead={lead} />
|
|
39
|
+
<ul className="profile-cards">
|
|
40
|
+
{items.map((item) => {
|
|
41
|
+
const media = item.image ? (
|
|
42
|
+
<img src={withBase(item.image.src)} alt={item.image.alt} loading="lazy" />
|
|
43
|
+
) : (
|
|
44
|
+
<span className="profile-placeholder">{item.placeholder ?? "Photo"}</span>
|
|
45
|
+
);
|
|
46
|
+
const body = (
|
|
47
|
+
<>
|
|
48
|
+
<div className="profile-media">{media}</div>
|
|
49
|
+
<div className="profile-body">
|
|
50
|
+
<h3>{item.name}</h3>
|
|
51
|
+
{item.meta && <span className="profile-meta">{item.meta}</span>}
|
|
52
|
+
<p>{item.blurb}</p>
|
|
53
|
+
{item.href && <span className="profile-link">{item.linkLabel ?? "More"} →</span>}
|
|
54
|
+
</div>
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
return (
|
|
58
|
+
<li key={item.name}>
|
|
59
|
+
{item.href ? (
|
|
60
|
+
<a className="profile-card" href={withBase(item.href)}>
|
|
61
|
+
{body}
|
|
62
|
+
</a>
|
|
63
|
+
) : (
|
|
64
|
+
<div className="profile-card">{body}</div>
|
|
65
|
+
)}
|
|
66
|
+
</li>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</ul>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|