@atelic-action/ui 0.17.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +428 -44
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Writeup.tsx +7 -7
  104. package/src/chrome/SiteShell.tsx +141 -0
  105. package/src/chrome/index.ts +2 -0
  106. package/src/components/Disclosure.tsx +80 -14
  107. package/src/components/GateLogin.tsx +1 -1
  108. package/src/components/Watermark.tsx +1 -1
  109. package/src/components/index.ts +6 -2
  110. package/src/gate/index.ts +1 -1
  111. package/src/gate/middleware.ts +17 -6
  112. package/src/gate/wall.ts +60 -2
  113. package/src/lib/basePathAudit.ts +96 -0
  114. package/src/lib/displayDate.ts +25 -0
  115. package/src/lib/honeypot.ts +11 -0
  116. package/src/lib/hours.ts +28 -0
  117. package/src/lib/sitemap.ts +34 -0
  118. package/src/lib/trackEvent.ts +26 -0
  119. package/src/lib/useOpenHeight.ts +28 -0
  120. package/src/primitives/Button.tsx +3 -1
  121. package/src/primitives/Chip.tsx +3 -1
  122. package/src/primitives/ImageFrame.tsx +48 -0
  123. package/src/primitives/ProseBlocks.tsx +67 -0
  124. package/src/primitives/QuoteCard.tsx +20 -0
  125. package/src/primitives/StarRow.tsx +37 -0
  126. package/src/primitives/TickList.tsx +24 -0
  127. package/src/primitives/index.ts +5 -0
  128. package/src/routing/BasePathProvider.tsx +47 -0
  129. package/src/routing/basePath.ts +57 -0
  130. package/src/routing/index.ts +8 -0
  131. package/src/scripts/check-base-path.ts +130 -0
  132. package/src/scripts/clean-sitemap.ts +27 -0
  133. package/src/scripts/contact-canary.ts +98 -0
  134. package/src/scripts/serve-static.ts +29 -3
  135. package/src/sections/BlogIndex.tsx +57 -0
  136. package/src/sections/BlogPost.tsx +65 -0
  137. package/src/sections/CTABanner.tsx +93 -0
  138. package/src/sections/CTASection.tsx +60 -0
  139. package/src/sections/ComparisonTable.tsx +114 -0
  140. package/src/sections/ContactForm.tsx +163 -0
  141. package/src/sections/ContactInfoCard.tsx +92 -0
  142. package/src/sections/CredentialList.tsx +28 -0
  143. package/src/sections/DeviceShowcase.tsx +39 -0
  144. package/src/sections/ExpandList.tsx +58 -0
  145. package/src/sections/FAQBrowser.tsx +283 -0
  146. package/src/sections/FeatureDetail.tsx +60 -0
  147. package/src/sections/FeatureGrid.tsx +83 -0
  148. package/src/sections/Gallery.tsx +84 -0
  149. package/src/sections/Hero.tsx +110 -0
  150. package/src/sections/HoursTable.tsx +23 -0
  151. package/src/sections/JumpRail.tsx +35 -0
  152. package/src/sections/LegalPage.tsx +43 -0
  153. package/src/sections/LocationSection.tsx +66 -0
  154. package/src/sections/Locations.tsx +71 -0
  155. package/src/sections/LogoMarquee.tsx +27 -0
  156. package/src/sections/MapEmbed.tsx +75 -0
  157. package/src/sections/MediaBand.tsx +20 -0
  158. package/src/sections/MenuList.tsx +44 -0
  159. package/src/sections/PageHero.tsx +25 -0
  160. package/src/sections/PressQuotes.tsx +51 -0
  161. package/src/sections/PricingBand.tsx +43 -0
  162. package/src/sections/PricingCard.tsx +36 -0
  163. package/src/sections/ProcessSteps.tsx +45 -0
  164. package/src/sections/ProfileCards.tsx +73 -0
  165. package/src/sections/RevealSlider.tsx +156 -0
  166. package/src/sections/ReviewCarousel.tsx +223 -0
  167. package/src/sections/ReviewWall.tsx +220 -0
  168. package/src/sections/SocialStrip.tsx +83 -0
  169. package/src/sections/SplitSection.tsx +56 -0
  170. package/src/sections/index.ts +76 -0
  171. package/src/sections/siteConfig.ts +201 -0
  172. package/src/styles/app.css +673 -0
  173. package/src/styles/artifact.css +472 -399
  174. package/src/styles/base.css +49 -47
  175. package/src/styles/chrome.css +71 -71
  176. package/src/styles/components.css +150 -140
  177. package/src/styles/gate.css +29 -29
  178. package/src/styles/primitives.css +190 -25
  179. package/src/styles/sections.css +2652 -0
  180. package/src/styles/star-rating.css +2 -2
  181. package/src/vite/index.ts +9 -2
  182. package/src/vite/marketing-config.ts +128 -0
  183. package/src/components/Collapsible.tsx +0 -43
  184. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,114 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * A graded before and after: one row per graded item, each side carrying a grade and
5
+ * the points behind it. Built for case studies, where the reader's question is
6
+ * "what actually changed", and the answer has to survive being scanned.
7
+ *
8
+ * The grade sits beside its points rather than above them, and an "after"
9
+ * grade takes the primary color only when it differs from its "before", so
10
+ * running an eye down the last column finds every change without reading.
11
+ *
12
+ * Row labels, grades and copy all arrive as props: the component knows
13
+ * nothing about clients or scoring scales.
14
+ */
15
+
16
+ export interface ComparisonState {
17
+ /**
18
+ * A grade for this side. A single character renders as a large letter;
19
+ * anything longer renders as a small mono note, which is how an ungraded
20
+ * state ("not measured") says so without being handed a letter it did not
21
+ * earn.
22
+ */
23
+ grade: string;
24
+ /** What was true. One point renders as prose, several as a list. */
25
+ points: ReactNode[];
26
+ }
27
+
28
+ export interface ComparisonRow {
29
+ id: string;
30
+ /** The row label, e.g. the thing being graded. */
31
+ stage: string;
32
+ before: ComparisonState;
33
+ after: ComparisonState;
34
+ }
35
+
36
+ export interface ComparisonTableProps {
37
+ rows: ComparisonRow[];
38
+ /** Column headers. Defaults suit a before and after. */
39
+ columns?: { stage?: string; before?: string; after?: string };
40
+ /** Small print under the table, e.g. what the grades mean. */
41
+ footnote?: ReactNode;
42
+ className?: string;
43
+ }
44
+
45
+ function Cell({ state, highlight }: { state: ComparisonState; highlight: boolean }) {
46
+ const isLetter = state.grade.length === 1;
47
+ return (
48
+ <div className="cmp-cell">
49
+ <span
50
+ className={["cmp-grade", isLetter ? "is-letter" : "is-note", highlight ? "is-moved" : ""]
51
+ .filter(Boolean)
52
+ .join(" ")}
53
+ >
54
+ {state.grade}
55
+ </span>
56
+ <div className="cmp-points">
57
+ {state.points.length === 1 ? (
58
+ state.points[0]
59
+ ) : (
60
+ <ul>
61
+ {state.points.map((point, i) => (
62
+ // biome-ignore lint/suspicious/noArrayIndexKey: a row's points are static and never reorder.
63
+ <li key={i}>{point}</li>
64
+ ))}
65
+ </ul>
66
+ )}
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export function ComparisonTable({ rows, columns, footnote, className }: ComparisonTableProps) {
73
+ const heads = {
74
+ stage: columns?.stage ?? "Area",
75
+ before: columns?.before ?? "Before",
76
+ after: columns?.after ?? "After",
77
+ };
78
+
79
+ return (
80
+ <div className={["comparison", className].filter(Boolean).join(" ")}>
81
+ <div className="cmp-scroll">
82
+ <table className="cmp-table">
83
+ <colgroup>
84
+ <col className="cmp-col-stage" />
85
+ <col className="cmp-col-half" />
86
+ <col className="cmp-col-half" />
87
+ </colgroup>
88
+ <thead>
89
+ <tr>
90
+ <th>{heads.stage}</th>
91
+ <th>{heads.before}</th>
92
+ <th>{heads.after}</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody>
96
+ {rows.map((row) => (
97
+ <tr key={row.id}>
98
+ <th scope="row">{row.stage}</th>
99
+ <td>
100
+ <Cell state={row.before} highlight={false} />
101
+ </td>
102
+ <td className="is-after">
103
+ <Cell state={row.after} highlight={row.before.grade !== row.after.grade} />
104
+ </td>
105
+ </tr>
106
+ ))}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+
111
+ {footnote && <p className="cmp-footnote">{footnote}</p>}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,163 @@
1
+ import { type FormEvent, type ReactNode, useState } from "react";
2
+ import { isHoneypotFilled } from "../lib/honeypot";
3
+ import { trackEvent } from "../lib/trackEvent";
4
+ import { Button } from "../primitives/Button";
5
+ import { useWithBase } from "../routing/BasePathProvider";
6
+
7
+ export interface ContactFormProps {
8
+ /**
9
+ * POST target for a real form backend (e.g. "/api/contact"). The body is
10
+ * JSON: { name, email, topic, message, company } where company is the
11
+ * honeypot and must be relayed verbatim. When omitted, the form falls
12
+ * back to a mailto handoff via toEmail.
13
+ */
14
+ endpoint?: string;
15
+ /** Destination inbox for the mailto fallback (required without endpoint). */
16
+ toEmail?: string;
17
+ /** Options for the topic select. */
18
+ topics: string[];
19
+ /** Message shown after a successful submit (or after the mail draft opens). */
20
+ successMessage: string;
21
+ /** Message shown when the endpoint submission fails. */
22
+ errorMessage?: string;
23
+ /** Small footer line under the submit button. */
24
+ footnote?: ReactNode;
25
+ /**
26
+ * Called once for each human submission the endpoint accepted: never on a
27
+ * press of the button, a failed send, a honeypot catch, or the mailto
28
+ * handoff. Defaults to the GA4 `generate_lead` event with
29
+ * `form_name: "contact"`, a silent no op on a page without the Google tag.
30
+ * It is passed nothing, so no personal data can ride along.
31
+ */
32
+ onLead?: () => void;
33
+ }
34
+
35
+ function sendLead() {
36
+ trackEvent("generate_lead", { form_name: "contact" });
37
+ }
38
+
39
+ interface FieldErrors {
40
+ name?: boolean;
41
+ email?: boolean;
42
+ message?: boolean;
43
+ }
44
+
45
+ type Status = "idle" | "sending" | "sent" | "failed";
46
+
47
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
48
+
49
+ /**
50
+ * Contact form with client validation and a honeypot. POSTs to `endpoint`
51
+ * when one is configured; otherwise hands off to the visitor's mail app.
52
+ */
53
+ export function ContactForm({
54
+ endpoint,
55
+ toEmail,
56
+ topics,
57
+ successMessage,
58
+ errorMessage = "Something went wrong sending your message. Please try again.",
59
+ footnote,
60
+ onLead = sendLead,
61
+ }: ContactFormProps) {
62
+ const withBase = useWithBase();
63
+ const [errors, setErrors] = useState<FieldErrors>({});
64
+ const [status, setStatus] = useState<Status>("idle");
65
+
66
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
67
+ event.preventDefault();
68
+ if (status === "sending") return;
69
+ const form = event.currentTarget;
70
+ const data = new FormData(form);
71
+ const name = String(data.get("name") ?? "").trim();
72
+ const email = String(data.get("email") ?? "").trim();
73
+ const topic = String(data.get("topic") ?? "");
74
+ const message = String(data.get("message") ?? "").trim();
75
+ const company = String(data.get("company") ?? "");
76
+
77
+ const nextErrors: FieldErrors = {
78
+ name: !name,
79
+ email: !EMAIL_PATTERN.test(email),
80
+ message: !message,
81
+ };
82
+ setErrors(nextErrors);
83
+ if (nextErrors.name || nextErrors.email || nextErrors.message) {
84
+ setStatus("idle");
85
+ return;
86
+ }
87
+
88
+ if (!endpoint) {
89
+ if (!toEmail) {
90
+ setStatus("failed");
91
+ return;
92
+ }
93
+ const subject = encodeURIComponent(`Website inquiry: ${topic}`);
94
+ const body = encodeURIComponent(`${name} (${email}) wrote:\n\n${message}`);
95
+ setStatus("sent");
96
+ window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
97
+ return;
98
+ }
99
+
100
+ setStatus("sending");
101
+ try {
102
+ const response = await fetch(withBase(endpoint), {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ name, email, topic, message, company }),
106
+ });
107
+ if (!response.ok) throw new Error(`status ${response.status}`);
108
+ setStatus("sent");
109
+ form.reset();
110
+ // The conversion: only a relay the endpoint accepted counts, never a
111
+ // press of the button, a failed send, or a honeypot catch (which the
112
+ // relay answers with the same success so the bot never learns). No
113
+ // personal data rides along.
114
+ if (!isHoneypotFilled(company)) onLead();
115
+ } catch {
116
+ setStatus("failed");
117
+ }
118
+ }
119
+
120
+ return (
121
+ <form onSubmit={handleSubmit} noValidate>
122
+ {status === "sent" && <div className="form-success">{successMessage}</div>}
123
+ {status === "failed" && <div className="form-error">{errorMessage}</div>}
124
+ <div className={["field", errors.name && "invalid"].filter(Boolean).join(" ")}>
125
+ <label htmlFor="contact-name">Your name</label>
126
+ <input id="contact-name" name="name" type="text" autoComplete="name" required />
127
+ <span className="err">Please enter your name.</span>
128
+ </div>
129
+ <div className={["field", errors.email && "invalid"].filter(Boolean).join(" ")}>
130
+ <label htmlFor="contact-email">Email</label>
131
+ <input id="contact-email" name="email" type="email" autoComplete="email" required />
132
+ <span className="err">Please enter a valid email.</span>
133
+ </div>
134
+ <div className="field">
135
+ <label htmlFor="contact-topic">What can we help with?</label>
136
+ <select id="contact-topic" name="topic">
137
+ {topics.map((topic) => (
138
+ <option key={topic}>{topic}</option>
139
+ ))}
140
+ </select>
141
+ </div>
142
+ <div className={["field", errors.message && "invalid"].filter(Boolean).join(" ")}>
143
+ <label htmlFor="contact-message">Message</label>
144
+ <textarea id="contact-message" name="message" required />
145
+ <span className="err">Please add a short message.</span>
146
+ </div>
147
+ {/* Honeypot: visually hidden and skipped by keyboard/screen readers;
148
+ bots that fill it are quietly dropped by the backend. */}
149
+ <div className="hp-field" aria-hidden="true">
150
+ <label htmlFor="contact-company">Company</label>
151
+ <input id="contact-company" name="company" type="text" tabIndex={-1} autoComplete="off" />
152
+ </div>
153
+ <Button type="submit" size="lg" arrow style={{ width: "100%" }}>
154
+ {status === "sending" ? "Sending…" : "Send Message"}
155
+ </Button>
156
+ {footnote && (
157
+ <p className="muted form-footnote" style={{ fontSize: "0.82rem", marginTop: 14 }}>
158
+ {footnote}
159
+ </p>
160
+ )}
161
+ </form>
162
+ );
163
+ }
@@ -0,0 +1,92 @@
1
+ import { newTabProps } from "../lib/newTabProps";
2
+ import { useWithBase } from "../routing/BasePathProvider";
3
+ import type { SiteConfig } from "./siteConfig";
4
+
5
+ export interface ContactPhone {
6
+ /** Who picks up, e.g. "Owner" or "Co Owner". */
7
+ role: string;
8
+ display: string;
9
+ e164: string;
10
+ }
11
+
12
+ /** The keys of the site config the card reads; a whole `SiteConfig` passes as it is. */
13
+ export type ContactInfoCardSite = Pick<SiteConfig, "phone" | "email" | "address" | "footerLinks">;
14
+
15
+ export interface ContactInfoCardProps {
16
+ site: ContactInfoCardSite;
17
+ /** Card heading. */
18
+ title?: string;
19
+ /**
20
+ * Labelled phone lines for a business that publishes more than one (a
21
+ * family shop with an owner and a co owner, say). When set, these replace
22
+ * the single `site.phone` row and each carries its own role label.
23
+ */
24
+ phones?: ContactPhone[];
25
+ /**
26
+ * Render the street address row. Off for businesses that publish a service
27
+ * area but no storefront: an address row with an empty street reads as a
28
+ * broken card, and inventing one is worse.
29
+ */
30
+ showAddress?: boolean;
31
+ }
32
+
33
+ /** Dark card with direct contact rows: call, email, visit, portal. */
34
+ export function ContactInfoCard({
35
+ site,
36
+ title = "Reach us directly",
37
+ phones,
38
+ showAddress = true,
39
+ }: ContactInfoCardProps) {
40
+ const withBase = useWithBase();
41
+ const portal = site.footerLinks?.find((link) => link.label.toLowerCase().includes("portal"));
42
+ return (
43
+ <div className="contact-info-card">
44
+ <h3>{title}</h3>
45
+ <div className="ci-rows">
46
+ {phones?.length
47
+ ? phones.map((phone) => (
48
+ <div className="ci-row" key={phone.e164}>
49
+ <span className="k">{phone.role}</span>
50
+ <span className="v">
51
+ <a href={`tel:${phone.e164}`}>{phone.display}</a>
52
+ </span>
53
+ </div>
54
+ ))
55
+ : site.phone && (
56
+ <div className="ci-row">
57
+ <span className="k">Call / Text</span>
58
+ <span className="v">
59
+ <a href={`tel:${site.phone.e164}`}>{site.phone.display}</a>
60
+ </span>
61
+ </div>
62
+ )}
63
+ <div className={["ci-row", !showAddress && !portal && "last"].filter(Boolean).join(" ")}>
64
+ <span className="k">Email</span>
65
+ <span className="v">
66
+ <a href={`mailto:${site.email}`}>{site.email}</a>
67
+ </span>
68
+ </div>
69
+ {showAddress && (
70
+ <div className="ci-row">
71
+ <span className="k">Visit</span>
72
+ <span className="v">
73
+ {site.address.street}
74
+ <br />
75
+ {site.address.locality}, {site.address.region} {site.address.postalCode}
76
+ </span>
77
+ </div>
78
+ )}
79
+ {portal && (
80
+ <div className="ci-row last">
81
+ <span className="k">Portal</span>
82
+ <span className="v">
83
+ <a href={withBase(portal.href)} {...newTabProps(portal.href, portal.external)}>
84
+ {portal.label} →
85
+ </a>
86
+ </span>
87
+ </div>
88
+ )}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,28 @@
1
+ export interface Credential {
2
+ /** Short mono code, e.g. "DPT", "OCS". */
3
+ code: string;
4
+ title: string;
5
+ detail?: string;
6
+ }
7
+
8
+ export interface CredentialListProps {
9
+ items: Credential[];
10
+ className?: string;
11
+ }
12
+
13
+ /** Card rows of credentials or certifications with mono code prefixes. */
14
+ export function CredentialList({ items, className }: CredentialListProps) {
15
+ return (
16
+ <ul className={["cert-list", className].filter(Boolean).join(" ")}>
17
+ {items.map((item) => (
18
+ <li key={item.code + item.title}>
19
+ <span className="ci">{item.code}</span>
20
+ <div>
21
+ <strong>{item.title}</strong>
22
+ {item.detail && <small>{item.detail}</small>}
23
+ </div>
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ );
28
+ }
@@ -0,0 +1,39 @@
1
+ import { useWithBase } from "../routing/BasePathProvider";
2
+
3
+ export interface DeviceImage {
4
+ src: string;
5
+ alt: string;
6
+ }
7
+
8
+ export interface DeviceShowcaseProps {
9
+ /** Desktop screenshot, shown inside a browser window frame. */
10
+ desktop: DeviceImage;
11
+ /** Phone screenshot, shown in a device frame overlapping the desktop. */
12
+ phone: DeviceImage;
13
+ }
14
+
15
+ /**
16
+ * A desktop browser mockup with a phone mockup overlapping its lower-left,
17
+ * for showing a rebuilt site the way it will actually look. Reusable; both
18
+ * images arrive via props.
19
+ */
20
+ export function DeviceShowcase({ desktop, phone }: DeviceShowcaseProps) {
21
+ const withBase = useWithBase();
22
+ return (
23
+ <div className="devices">
24
+ <div className="dv-desktop">
25
+ <div className="dv-bar">
26
+ <i />
27
+ <i />
28
+ <i />
29
+ </div>
30
+ <div className="dv-screen">
31
+ <img src={withBase(desktop.src)} alt={desktop.alt} loading="lazy" />
32
+ </div>
33
+ </div>
34
+ <div className="dv-phone">
35
+ <img src={withBase(phone.src)} alt={phone.alt} loading="lazy" />
36
+ </div>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,58 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import { type ReactNode, useState } from "react";
3
+ import { useOpenHeight } from "../lib/useOpenHeight";
4
+
5
+ export interface ExpandListItem {
6
+ title: string;
7
+ body: ReactNode;
8
+ }
9
+
10
+ export interface ExpandListProps {
11
+ items: ExpandListItem[];
12
+ className?: string;
13
+ }
14
+
15
+ function ExpandRow({ index, item }: { index: number; item: ExpandListItem }) {
16
+ const [open, setOpen] = useState(false);
17
+ const { ref, maxHeight } = useOpenHeight(open);
18
+ const num = String(index + 1).padStart(2, "0");
19
+
20
+ return (
21
+ <div className={["xl-row", open && "open"].filter(Boolean).join(" ")}>
22
+ <button
23
+ type="button"
24
+ className="xl-head"
25
+ aria-expanded={open}
26
+ onClick={() => setOpen((prev) => !prev)}
27
+ >
28
+ <span className="xl-num">{num}</span>
29
+ <span className="xl-title">{item.title}</span>
30
+ <ChevronDown className="xl-chevron" size={18} strokeWidth={2} aria-hidden="true" />
31
+ </button>
32
+ {/* Closed, the body stays in the HTML but is inert: out of the tab
33
+ order and a screen reader's reach while it is clipped to nothing. */}
34
+ <div className="xl-body" style={{ maxHeight }} inert={!open}>
35
+ <div className="xl-body-inner" ref={ref}>
36
+ {item.body}
37
+ </div>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ /**
44
+ * A light accordion of numbered fold-out rows: mono index, title, and an
45
+ * expandable body — for amenity lists, add-ons, and other compact
46
+ * disclosures that don't warrant the full FAQBrowser help center. Bodies
47
+ * ship in the static HTML (collapsed visually, present for crawlers),
48
+ * matching how FAQBrowser handles answers.
49
+ */
50
+ export function ExpandList({ items, className }: ExpandListProps) {
51
+ return (
52
+ <div className={["expand-list", className].filter(Boolean).join(" ")}>
53
+ {items.map((item, index) => (
54
+ <ExpandRow key={item.title} index={index} item={item} />
55
+ ))}
56
+ </div>
57
+ );
58
+ }