@tideworthy/tidysites-platform 0.10.15 → 0.11.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/CHANGELOG.md +9 -0
- package/README.md +13 -0
- package/bin/tidysites-seed.mjs +51 -0
- package/package.json +14 -2
- package/src/sections/Photo.astro +18 -0
- package/src/sections/Sections.astro +42 -0
- package/src/sections/defaults/_Head.astro +13 -0
- package/src/sections/defaults/areas.astro +20 -0
- package/src/sections/defaults/badges.astro +17 -0
- package/src/sections/defaults/brands.astro +14 -0
- package/src/sections/defaults/case_studies.astro +16 -0
- package/src/sections/defaults/cases.astro +22 -0
- package/src/sections/defaults/clients.astro +14 -0
- package/src/sections/defaults/conditions.astro +13 -0
- package/src/sections/defaults/contact.astro +46 -0
- package/src/sections/defaults/cta.astro +16 -0
- package/src/sections/defaults/custom.astro +16 -0
- package/src/sections/defaults/downloads.astro +14 -0
- package/src/sections/defaults/faq.astro +20 -0
- package/src/sections/defaults/feature.astro +36 -0
- package/src/sections/defaults/financing.astro +17 -0
- package/src/sections/defaults/gallery.astro +14 -0
- package/src/sections/defaults/hero.astro +66 -0
- package/src/sections/defaults/insurance.astro +22 -0
- package/src/sections/defaults/intro.astro +11 -0
- package/src/sections/defaults/location.astro +32 -0
- package/src/sections/defaults/locations.astro +14 -0
- package/src/sections/defaults/offer.astro +18 -0
- package/src/sections/defaults/options.astro +12 -0
- package/src/sections/defaults/plans.astro +16 -0
- package/src/sections/defaults/policies.astro +13 -0
- package/src/sections/defaults/posts.astro +14 -0
- package/src/sections/defaults/products.astro +17 -0
- package/src/sections/defaults/related.astro +17 -0
- package/src/sections/defaults/reviews.astro +44 -0
- package/src/sections/defaults/services.astro +49 -0
- package/src/sections/defaults/signs.astro +12 -0
- package/src/sections/defaults/stats.astro +13 -0
- package/src/sections/defaults/steps.astro +32 -0
- package/src/sections/defaults/team.astro +42 -0
- package/src/sections/defaults/technologies.astro +13 -0
- package/src/sections/defaults/video.astro +15 -0
- package/src/sections/defaults.css +112 -0
- package/src/sections/lib.ts +157 -0
- package/src/template/index.ts +75 -0
- package/src/version.ts +1 -1
- package/template/README.md +33 -0
- package/template/structure.json +4224 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
The package carries the template.
|
|
6
|
+
|
|
7
|
+
- `@tideworthy/tidysites-platform/template`: the one data shape every theme renders (24 collections, 35 section types, menus), typed, with `mergeStructure()`; `template/structure.json` is the source and `template/README.md` the rules and the evidence.
|
|
8
|
+
- `npx tidysites-seed [seed.json] [--check]`: writes the template's structure into a theme's seed and keeps its menus, settings and content; refuses a seed whose section values use fields their block type does not declare.
|
|
9
|
+
- `@tideworthy/tidysites-platform/sections`: the section dispatcher. A site's `src/custom/blocks/<type>.astro`, then the theme's `src/sections/<type>.astro` (or its `components` prop), then the package default. Every block framed for the on-site editor.
|
|
10
|
+
- `@tideworthy/tidysites-platform/sections/defaults/<type>.astro`: a plain default rendering of every section type, driven by the brand tokens; `sections/lib` has the helpers and the normalized business record; `sections/photo` the photo slot.
|
|
11
|
+
|
|
3
12
|
## 0.10.14
|
|
4
13
|
|
|
5
14
|
- Editor bar: Edit, Done and Sign in to edit show a spinner and what they are doing until the page comes back.
|
package/README.md
CHANGED
|
@@ -12,6 +12,19 @@ npm install @tideworthy/tidysites-platform
|
|
|
12
12
|
|
|
13
13
|
Peer dependencies: `astro` 7, `emdash` and `@emdash-cms/auth` 1.x, `kysely`. Published on npm as `@tideworthy/tidysites-platform`; GitHub tags `vX.Y.Z` match each version.
|
|
14
14
|
|
|
15
|
+
## The template and the sections
|
|
16
|
+
|
|
17
|
+
Since 0.11.0 the package carries the Tidysites template: the collections, fields, section types and menus every theme renders (`template/README.md` has the rules). A theme installs the package, writes the template's structure into its seed with `npx tidysites-seed`, renders pages with `@tideworthy/tidysites-platform/sections`, and overrides the section types it has an opinion about in `src/sections/<type>.astro`. Everything else renders with the package default, so a section an owner adds later still shows up.
|
|
18
|
+
|
|
19
|
+
```astro
|
|
20
|
+
---
|
|
21
|
+
import Sections from "@tideworthy/tidysites-platform/sections";
|
|
22
|
+
import { sectionTypes } from "@tideworthy/tidysites-platform/template";
|
|
23
|
+
---
|
|
24
|
+
<Sections value={page.data.sections} />
|
|
25
|
+
<TidyEditor blocks={sectionTypes} ... />
|
|
26
|
+
```
|
|
27
|
+
|
|
15
28
|
## Wire it up
|
|
16
29
|
|
|
17
30
|
```js
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Put the template's structure into a theme's seed file.
|
|
4
|
+
* npx tidysites-seed (seed/seed.json, or the path in package.json "emdash.seed")
|
|
5
|
+
* npx tidysites-seed path/to/seed.json [--check]
|
|
6
|
+
* The seed keeps its menus, settings, meta and content; collections, block
|
|
7
|
+
* types and relations are replaced with the package's. --check exits 1 when
|
|
8
|
+
* the file is out of date instead of writing it, for CI.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const check = args.includes("--check");
|
|
16
|
+
let file = args.find((a) => !a.startsWith("--"));
|
|
17
|
+
if (!file) {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve("package.json"), "utf8"));
|
|
20
|
+
file = pkg?.emdash?.seed ?? "seed/seed.json";
|
|
21
|
+
} catch {
|
|
22
|
+
file = "seed/seed.json";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const seedPath = path.resolve(file);
|
|
26
|
+
const templatePath = fileURLToPath(new URL("../template/structure.json", import.meta.url));
|
|
27
|
+
const template = JSON.parse(fs.readFileSync(templatePath, "utf8"));
|
|
28
|
+
const seed = fs.existsSync(seedPath) ? JSON.parse(fs.readFileSync(seedPath, "utf8")) : {};
|
|
29
|
+
|
|
30
|
+
const { blockTypes: _b, collections: _c, relations: _r, ...rest } = seed;
|
|
31
|
+
const next = { $schema: template.$schema, version: template.version, meta: rest.meta ?? template.meta, settings: rest.settings ?? {}, blockTypes: template.blockTypes, collections: template.collections, menus: rest.menus ?? template.menus ?? [], content: rest.content ?? {} };
|
|
32
|
+
if (template.relations !== undefined) next.relations = template.relations;
|
|
33
|
+
// Every section value in the content must use only the fields its block type declares (EmDash refuses the seed otherwise).
|
|
34
|
+
const types = new Map(template.blockTypes.map((b) => [b.slug, new Set(["_type", "_key", "_version", ...((b.versions.find((v) => v.version === b.currentVersion) ?? b.versions[b.versions.length - 1]).fields.map((f) => f.slug))])]));
|
|
35
|
+
const problems = [];
|
|
36
|
+
for (const [collection, list] of Object.entries(next.content)) for (const e of list) for (const s of Array.isArray(e?.data?.sections) ? e.data.sections : []) {
|
|
37
|
+
const allowed = types.get(s._type);
|
|
38
|
+
if (!allowed) { problems.push(`${collection}/${e.id}: section type "${s._type}" is not in the template`); continue; }
|
|
39
|
+
for (const k of Object.keys(s)) if (!allowed.has(k)) problems.push(`${collection}/${e.id}: section "${s._type}" (${s._key}) uses "${k}", which its block type does not declare`);
|
|
40
|
+
}
|
|
41
|
+
if (problems.length) { console.error(problems.join("\n")); process.exit(1); }
|
|
42
|
+
const text = JSON.stringify(next, null, "\t") + "\n";
|
|
43
|
+
const current = fs.existsSync(seedPath) ? fs.readFileSync(seedPath, "utf8") : "";
|
|
44
|
+
if (check) {
|
|
45
|
+
if (current === text) { console.log(`${file} matches the template.`); process.exit(0); }
|
|
46
|
+
console.error(`${file} is out of date with the template. Run: npx tidysites-seed ${file}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
fs.mkdirSync(path.dirname(seedPath), { recursive: true });
|
|
50
|
+
fs.writeFileSync(seedPath, text);
|
|
51
|
+
console.log(`${file}: ${template.collections.length} collections, ${template.blockTypes.length} section types from the template; menus, settings and content kept.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tideworthy/tidysites-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The Tidysites platform integration for EmDash sites: bootstrap, sessions, maintenance, health, and publish hooks under /_tidy/*.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"homepage": "https://github.com/4sons/tidysites-platform#readme",
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
|
+
"template",
|
|
15
|
+
"bin",
|
|
14
16
|
"README.md",
|
|
15
17
|
"LICENSE",
|
|
16
18
|
"CHANGELOG.md"
|
|
@@ -32,7 +34,14 @@
|
|
|
32
34
|
"./routes/schema": "./src/routes/schema.ts",
|
|
33
35
|
"./routes/maintenance": "./src/routes/maintenance.ts",
|
|
34
36
|
"./routes/revisions": "./src/routes/revisions.ts",
|
|
35
|
-
"./routes/change": "./src/routes/change.ts"
|
|
37
|
+
"./routes/change": "./src/routes/change.ts",
|
|
38
|
+
"./template": "./src/template/index.ts",
|
|
39
|
+
"./template/structure.json": "./template/structure.json",
|
|
40
|
+
"./sections": "./src/sections/Sections.astro",
|
|
41
|
+
"./sections/lib": "./src/sections/lib.ts",
|
|
42
|
+
"./sections/photo": "./src/sections/Photo.astro",
|
|
43
|
+
"./sections/defaults.css": "./src/sections/defaults.css",
|
|
44
|
+
"./sections/defaults/*": "./src/sections/defaults/*"
|
|
36
45
|
},
|
|
37
46
|
"scripts": {
|
|
38
47
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.editor.json",
|
|
@@ -61,5 +70,8 @@
|
|
|
61
70
|
"publishConfig": {
|
|
62
71
|
"access": "public",
|
|
63
72
|
"provenance": true
|
|
73
|
+
},
|
|
74
|
+
"bin": {
|
|
75
|
+
"tidysites-seed": "./bin/tidysites-seed.mjs"
|
|
64
76
|
}
|
|
65
77
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** A photo slot: the CMS image, a plain URL, or a labelled empty slot. Never a broken image. */
|
|
3
|
+
import { Image } from "emdash/ui";
|
|
4
|
+
interface Props {
|
|
5
|
+
image?: { id?: string; src?: string; alt?: string; width?: number; height?: number } | null;
|
|
6
|
+
url?: string | null;
|
|
7
|
+
alt: string;
|
|
8
|
+
ratio?: "wide" | "tall" | "square" | "video";
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
const { image, url, alt, ratio = "wide", width = 1200, height = 750, class: cls = "" } = Astro.props;
|
|
14
|
+
const has = Boolean(image?.id || image?.src || url);
|
|
15
|
+
---
|
|
16
|
+
<div class={`ts-photo ts-photo-${ratio} ${has ? "ts-photo-has" : ""} ${cls}`.trim()}>
|
|
17
|
+
{image?.id || image?.src ? <Image image={image as never} width={width} height={height} alt={image?.alt ?? alt} /> : url ? <img src={url} alt={alt} loading="lazy" /> : <span>{alt}</span>}
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Renders a page's `sections` field. For each block, the component comes from
|
|
4
|
+
* the first of: the site's own folder (src/custom/blocks/<type>.astro), the
|
|
5
|
+
* theme (src/sections/<type>.astro, or the `components` prop for a theme that
|
|
6
|
+
* keeps its own map), then this package's default for the type. Every block is
|
|
7
|
+
* framed for the on-site editor (`data-tidy-block`, `data-tidy-type`).
|
|
8
|
+
*/
|
|
9
|
+
import type { Node } from "./lib";
|
|
10
|
+
import "./defaults.css";
|
|
11
|
+
import { SECTION_TYPES } from "../template/index";
|
|
12
|
+
|
|
13
|
+
export type Section = { _type: string; _key?: string; _version?: number } & Record<string, unknown>;
|
|
14
|
+
type Comp = (props: Record<string, unknown>) => unknown;
|
|
15
|
+
const byName = (mods: Record<string, unknown>): Record<string, Comp> =>
|
|
16
|
+
Object.fromEntries(Object.entries(mods).map(([path, mod]) => [path.split("/").pop()!.replace(/\.astro$/, ""), (mod as { default: Comp }).default]));
|
|
17
|
+
|
|
18
|
+
// Root-relative globs resolve against the consuming site, not this package.
|
|
19
|
+
const site = byName(import.meta.glob("/src/custom/blocks/*.astro", { eager: true }));
|
|
20
|
+
const theme = byName(import.meta.glob("/src/sections/*.astro", { eager: true }));
|
|
21
|
+
const defaults = byName(import.meta.glob("./defaults/*.astro", { eager: true }));
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
value?: Section[] | null;
|
|
25
|
+
/** A theme's own components by type, for themes that keep their own map. */
|
|
26
|
+
components?: Record<string, Comp>;
|
|
27
|
+
/** The entry the page shows (its data), for sections that depend on it, such as related services. */
|
|
28
|
+
context?: Node | null;
|
|
29
|
+
/** Set false to leave out this package's defaults and render only what the theme and site provide. */
|
|
30
|
+
fallback?: boolean;
|
|
31
|
+
}
|
|
32
|
+
const { value, components = {}, context = null, fallback = true } = Astro.props;
|
|
33
|
+
const sections = Array.isArray(value) ? value : [];
|
|
34
|
+
const pick = (type: string): Comp | undefined => site[type] ?? components[type] ?? theme[type] ?? (fallback ? defaults[type] : undefined);
|
|
35
|
+
---
|
|
36
|
+
<div data-tidy-blocks>
|
|
37
|
+
{sections.map((s, index) => {
|
|
38
|
+
const Component = pick(s._type);
|
|
39
|
+
if (!Component) return import.meta.env.DEV ? <div role="alert" class="ts-missing">No component for section type “{s._type}”{SECTION_TYPES.includes(s._type) ? "" : " (not a template section type)"}.</div> : null;
|
|
40
|
+
return <div data-tidy-block={String(s._key ?? "")} data-tidy-type={s._type}><Component value={s} index={index} blockKey={String(s._key ?? "")} context={context} /></div>;
|
|
41
|
+
})}
|
|
42
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The eyebrow, headline and optional text that open most sections, plus a right-side action. */
|
|
3
|
+
import { str } from "../lib";
|
|
4
|
+
interface Props { n: Record<string, unknown>; action?: { href: string; label: string } | null; class?: string }
|
|
5
|
+
const { n, action = null, class: cls = "" } = Astro.props;
|
|
6
|
+
const eyebrow = str(n.eyebrow), headline = str(n.headline), body = str(n.body) || str(n.text);
|
|
7
|
+
---
|
|
8
|
+
{(eyebrow || headline || body || action) && (
|
|
9
|
+
<div class={`ts-head ${cls}`}>
|
|
10
|
+
<div>{eyebrow && <p class="ts-eyebrow">{eyebrow}</p>}{headline && <h2>{headline}</h2>}{body && <p class="ts-lede">{body}</p>}</div>
|
|
11
|
+
{action && <a class="ts-btn ts-btn-outline" href={action.href}>{action.label}</a>}
|
|
12
|
+
</div>
|
|
13
|
+
)}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Places served from the service area records. */
|
|
3
|
+
import { bool, entries, int, nodeOf, pic, str } from "../lib";
|
|
4
|
+
import Photo from "../Photo.astro";
|
|
5
|
+
import Head from "./_Head.astro";
|
|
6
|
+
const n = nodeOf(Astro.props);
|
|
7
|
+
const variant = str(n.variant, "link list");
|
|
8
|
+
let items = await entries("service_areas", { limit: int(n.limit, 40) });
|
|
9
|
+
if (bool(n.featured_only)) items = items.filter((a) => bool(a.data.featured) || bool(a.data.is_home_base));
|
|
10
|
+
const href = (a: { slug?: string | null; id: string }) => `/service-areas/${a.slug ?? a.id}`;
|
|
11
|
+
const regions = variant === "grouped by region" ? [...new Set(items.map((a) => str(a.data.region)))].map((r) => ({ title: r, items: items.filter((a) => str(a.data.region) === r) })) : [];
|
|
12
|
+
---
|
|
13
|
+
{items.length > 0 && (
|
|
14
|
+
<section class="ts" id="areas"><div class="ts-wrap">
|
|
15
|
+
<Head n={n} action={bool(n.show_all, true) ? { href: "/service-areas", label: "All areas" } : null} />
|
|
16
|
+
{variant === "cards" ? <div class="ts-grid" style="--cols:3">{items.map((a) => { const p = pic(a.data.featured_image); return <a class="ts-card" href={href(a)} data-tidy-record="service_areas" data-tidy-record-id={a.id} data-tidy-record-label="Service area">{(p.image || p.url) && <Photo image={p.image} url={p.url} alt={str(a.data.title)} ratio="wide" />}<h3>{str(a.data.title)}</h3>{str(a.data.summary) && <p>{str(a.data.summary)}</p>}</a>; })}</div>
|
|
17
|
+
: variant === "grouped by region" ? regions.map((r) => <div>{r.title && <p class="ts-group">{r.title}</p>}<div class="ts-chips">{r.items.map((a) => <a class="ts-chip" href={href(a)}>{str(a.data.title)}</a>)}</div></div>)
|
|
18
|
+
: <div class="ts-chips">{items.map((a) => <a class="ts-chip" href={href(a)} data-tidy-record="service_areas" data-tidy-record-id={a.id} data-tidy-record-label="Service area">{str(a.data.title)}{bool(a.data.is_home_base) ? " · home base" : ""}</a>)}</div>}
|
|
19
|
+
</div></section>
|
|
20
|
+
)}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Associations, certifications, awards and seals: from the badge records or written into the section. */
|
|
3
|
+
import { entries, list, nodeOf, pic, str } from "../lib";
|
|
4
|
+
import Head from "./_Head.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const fromRecords = str(n.source, "badges from the badges records").startsWith("badges from");
|
|
7
|
+
const group = str(n.group);
|
|
8
|
+
const recs = fromRecords ? (await entries("badges", { limit: 24 })).filter((b) => !group || str(b.data.group) === group).map((b) => ({ title: str(b.data.title), issuer: str(b.data.issuer), image: pic(b.data.image), url: str(b.data.url), id: b.id })) : [];
|
|
9
|
+
const inline = list<Record<string, unknown>>(n.badges).map((b) => ({ title: str(b.title), issuer: str(b.issuer), image: pic(b.image), url: str(b.url), id: "" }));
|
|
10
|
+
const items = [...recs, ...inline].filter((b) => b.title);
|
|
11
|
+
---
|
|
12
|
+
{items.length > 0 && (
|
|
13
|
+
<section class="ts ts-tight"><div class="ts-wrap">
|
|
14
|
+
<Head n={n} />
|
|
15
|
+
<div class="ts-logos">{items.map((b) => { const Tag = b.url ? "a" : "div"; return <Tag class="ts-logo" href={b.url || undefined} {...(b.id ? { "data-tidy-record": "badges", "data-tidy-record-id": b.id, "data-tidy-record-label": "Badge" } : {})}>{(b.image.image?.src || b.image.url) && <img src={b.image.image?.src ?? b.image.url ?? ""} alt="" />}<span>{b.title}{b.issuer && <span class="ts-muted" style="font-weight:400"> · {b.issuer}</span>}</span></Tag>; })}</div>
|
|
16
|
+
</div></section>
|
|
17
|
+
)}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { entries, nodeOf, pic, str } from "../lib";
|
|
3
|
+
import Head from "./_Head.astro";
|
|
4
|
+
const n = nodeOf(Astro.props);
|
|
5
|
+
let items = await entries("brands", { limit: 40 });
|
|
6
|
+
if (str(n.group)) items = items.filter((b) => str(b.data.group) === str(n.group));
|
|
7
|
+
const names = str(n.variant) === "names";
|
|
8
|
+
---
|
|
9
|
+
{items.length > 0 && (
|
|
10
|
+
<section class="ts ts-tight"><div class="ts-wrap">
|
|
11
|
+
<Head n={n} />
|
|
12
|
+
{names ? <p class="ts-lede" style="margin:0">{items.map((b) => str(b.data.title)).join(", ")}</p> : <div class="ts-logos">{items.map((b) => { const p = pic(b.data.logo); const Tag = str(b.data.url) ? "a" : "div"; return <Tag class="ts-logo" href={str(b.data.url) || undefined} data-tidy-record="brands" data-tidy-record-id={b.id} data-tidy-record-label="Brand">{(p.image?.src || p.url) && <img src={p.image?.src ?? p.url ?? ""} alt="" />}<span>{str(b.data.title)}</span></Tag>; })}</div>}
|
|
13
|
+
</div></section>
|
|
14
|
+
)}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { bool, entries, int, nodeOf, pic, str } from "../lib";
|
|
3
|
+
import Photo from "../Photo.astro";
|
|
4
|
+
import Head from "./_Head.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const items = await entries("case_studies", { limit: int(n.limit, 3) });
|
|
7
|
+
const results = (d: Record<string, unknown>) => [1, 2, 3].map((i) => ({ v: str(d[`result_${i}_value`]), l: str(d[`result_${i}_label`]) })).filter((r) => r.v);
|
|
8
|
+
const variant = str(n.variant, "cards");
|
|
9
|
+
---
|
|
10
|
+
{items.length > 0 && (
|
|
11
|
+
<section class="ts" id="case-studies"><div class="ts-wrap">
|
|
12
|
+
<Head n={n} />
|
|
13
|
+
{variant === "results strip" ? <div class="ts-grid" style="--cols:3">{items.flatMap((c) => results(c.data).slice(0, 1).map((r) => <div class="ts-card ts-stat"><b>{r.v}</b><span>{r.l} · {str(c.data.client) || str(c.data.title)}</span></div>))}</div>
|
|
14
|
+
: <div class="ts-grid" style={`--cols:${variant === "one featured" ? 1 : 3}`}>{items.slice(0, variant === "one featured" ? 1 : undefined).map((c) => { const p = pic(c.data.featured_image); return <a class="ts-card" href={`/case-studies/${c.slug ?? c.id}`} data-tidy-record="case_studies" data-tidy-record-id={c.id} data-tidy-record-label="Case study">{(p.image || p.url) && <Photo image={p.image} url={p.url} alt={str(c.data.title)} ratio="wide" />}{(str(c.data.client) || str(c.data.industry)) && <p class="ts-eyebrow" style="margin:0">{[str(c.data.client), str(c.data.industry)].filter(Boolean).join(" · ")}</p>}<h3>{str(c.data.title)}</h3>{str(c.data.summary) && <p>{str(c.data.summary)}</p>}{bool(n.show_results, true) && results(c.data).length > 0 && <div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:6px">{results(c.data).map((r) => <div class="ts-stat"><b style="font-size:28px">{r.v}</b><span>{r.l}</span></div>)}</div>}</a>; })}</div>}
|
|
15
|
+
</div></section>
|
|
16
|
+
)}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Before and after cases from the records. */
|
|
3
|
+
import { bool, entries, int, nodeOf, pic, str } from "../lib";
|
|
4
|
+
import Photo from "../Photo.astro";
|
|
5
|
+
import Head from "./_Head.astro";
|
|
6
|
+
const n = nodeOf(Astro.props);
|
|
7
|
+
const items = await entries("cases", { limit: int(n.limit, 3) });
|
|
8
|
+
const names = bool(n.show_names);
|
|
9
|
+
---
|
|
10
|
+
{items.length > 0 && (
|
|
11
|
+
<section class="ts" id="results"><div class="ts-wrap">
|
|
12
|
+
<Head n={n} />
|
|
13
|
+
<div class="ts-grid" style={`--cols:${str(n.variant) === "slider" ? 1 : 3}`}>
|
|
14
|
+
{items.map((c) => { const bp = pic(c.data.before), ap = pic(c.data.after); return (
|
|
15
|
+
<article class="ts-card" data-tidy-record="cases" data-tidy-record-id={c.id} data-tidy-record-label="Case" style="padding:0;overflow:hidden">
|
|
16
|
+
<div class="ts-ba"><div style="position:relative"><span class="ts-tag">Before</span><Photo image={bp.image} url={bp.url} alt="Before" ratio="square" /></div><div style="position:relative"><span class="ts-tag">After</span><Photo image={ap.image} url={ap.url} alt="After" ratio="square" /></div></div>
|
|
17
|
+
<div style="padding:16px 18px 18px">{str(c.data.category) && <p class="ts-role">{str(c.data.category)}</p>}<h3 style="font-size:18px;margin-top:4px">{str(c.data.title)}{names && str(c.data.patient_name) ? ` · ${str(c.data.patient_name)}` : ""}</h3>{(str(c.data.story) || str(c.data.problem)) && <p style="margin-top:6px">{str(c.data.story) || [str(c.data.problem), str(c.data.treatment)].filter(Boolean).join(" ")}</p>}</div>
|
|
18
|
+
</article>
|
|
19
|
+
); })}
|
|
20
|
+
</div>
|
|
21
|
+
</div></section>
|
|
22
|
+
)}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { bool, entries, int, nodeOf, pic, str } from "../lib";
|
|
3
|
+
import Head from "./_Head.astro";
|
|
4
|
+
const n = nodeOf(Astro.props);
|
|
5
|
+
let items = await entries("clients", { limit: int(n.limit, 24) });
|
|
6
|
+
if (bool(n.featured_only)) items = items.filter((c) => bool(c.data.featured));
|
|
7
|
+
const industries = str(n.variant) === "with industries";
|
|
8
|
+
---
|
|
9
|
+
{items.length > 0 && (
|
|
10
|
+
<section class="ts ts-tight"><div class="ts-wrap">
|
|
11
|
+
<Head n={n} />
|
|
12
|
+
<div class="ts-logos" style={str(n.variant) === "logo strip" ? "justify-content:space-between" : ""}>{items.map((c) => { const p = pic(c.data.logo); const Tag = str(c.data.url) ? "a" : "div"; return <Tag class="ts-logo" href={str(c.data.url) || undefined} data-tidy-record="clients" data-tidy-record-id={c.id} data-tidy-record-label="Client">{(p.image?.src || p.url) ? <img src={p.image?.src ?? p.url ?? ""} alt={str(c.data.title)} /> : <span>{str(c.data.title)}</span>}{industries && str(c.data.industry) && <span class="ts-muted" style="font-weight:400">{str(c.data.industry)}</span>}</Tag>; })}</div>
|
|
13
|
+
</div></section>
|
|
14
|
+
)}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { entries, int, nodeOf, str } from "../lib";
|
|
3
|
+
import Head from "./_Head.astro";
|
|
4
|
+
const n = nodeOf(Astro.props);
|
|
5
|
+
const items = await entries("conditions", { limit: int(n.limit, 24) });
|
|
6
|
+
const cards = str(n.variant) === "cards";
|
|
7
|
+
---
|
|
8
|
+
{items.length > 0 && (
|
|
9
|
+
<section class="ts ts-band" id="conditions"><div class="ts-wrap">
|
|
10
|
+
<Head n={n} />
|
|
11
|
+
{cards ? <div class="ts-grid" style="--cols:3">{items.map((c) => <a class="ts-card" href={`/conditions/${c.slug ?? c.id}`} data-tidy-record="conditions" data-tidy-record-id={c.id} data-tidy-record-label="Condition"><h3>{str(c.data.title)}</h3>{str(c.data.summary) && <p>{str(c.data.summary)}</p>}</a>)}</div> : <div class="ts-chips">{items.map((c) => <a class="ts-chip" href={`/conditions/${c.slug ?? c.id}`} data-tidy-record="conditions" data-tidy-record-id={c.id} data-tidy-record-label="Condition">{str(c.data.title)}</a>)}</div>}
|
|
12
|
+
</div></section>
|
|
13
|
+
)}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** A request or contact form, each extra question a switch, beside the business details. */
|
|
3
|
+
import { bool, entries, getBusiness, nodeOf, str, addressLine } from "../lib";
|
|
4
|
+
import Head from "./_Head.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const b = await getBusiness(Astro.request);
|
|
7
|
+
const variant = str(n.variant, "appointment request");
|
|
8
|
+
const services = bool(n.ask_service, variant !== "question only") ? await entries("services", { limit: 40 }) : [];
|
|
9
|
+
const providers = bool(n.ask_provider) ? await entries("people", { limit: 20 }) : [];
|
|
10
|
+
let turnstile: string | undefined;
|
|
11
|
+
try { const m = (await import("cloudflare:workers")) as unknown as { env?: Record<string, unknown> }; turnstile = typeof m.env?.TURNSTILE_SITEKEY === "string" ? m.env.TURNSTILE_SITEKEY : undefined; } catch { turnstile = undefined; }
|
|
12
|
+
const details = bool(n.show_business_details, true);
|
|
13
|
+
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
14
|
+
---
|
|
15
|
+
<section class="ts" id="contact"><div class="ts-wrap">
|
|
16
|
+
<Head n={n} />
|
|
17
|
+
<div class={details ? "ts-split" : ""} style="align-items:start">
|
|
18
|
+
<form class="ts-form" action="/api/lead" method="post">
|
|
19
|
+
<label>Name<input name="name" autocomplete="name" required /></label>
|
|
20
|
+
<label>Phone<input name="phone" type="tel" autocomplete="tel" /></label>
|
|
21
|
+
<label class={variant === "short" ? "ts-full" : ""}>Email<input name="email" type="email" autocomplete="email" /></label>
|
|
22
|
+
{services.length > 0 && <label>What is it about?<select name="service"><option value="">Choose one</option>{services.map((s) => <option value={s.id}>{str(s.data.title)}</option>)}<option value="other">Something else</option></select></label>}
|
|
23
|
+
{providers.length > 0 && <label>Who would you like to see?<select name="provider"><option value="">No preference</option>{providers.map((p) => <option value={p.id}>{str(p.data.display_name) || str(p.data.title)}</option>)}</select></label>}
|
|
24
|
+
{bool(n.ask_patient_type) && <div class="ts-full"><span style="font-size:13px;font-weight:600;color:var(--ts-ink-2);display:block;margin-bottom:8px">Are you</span><div class="ts-choices"><label><input type="radio" name="patient_type" value="new" /> New</label><label><input type="radio" name="patient_type" value="existing" /> Existing</label></div></div>}
|
|
25
|
+
{bool(n.ask_day) && <div class="ts-full"><span style="font-size:13px;font-weight:600;color:var(--ts-ink-2);display:block;margin-bottom:8px">Days that work</span><div class="ts-choices">{DAYS.map((d) => <label><input type="checkbox" name="days" value={d} /> {d}</label>)}</div></div>}
|
|
26
|
+
{bool(n.ask_time) && <label>Preferred time<select name="time"><option>Any</option><option>Morning</option><option>Afternoon</option><option>Evening</option></select></label>}
|
|
27
|
+
{bool(n.ask_contact_method) && <label>How should we reply?<select name="contact_method"><option>Phone</option><option>Text</option><option>Email</option></select></label>}
|
|
28
|
+
{bool(n.ask_referrer) && <label class="ts-full">How did you hear about us?<input name="referrer" /></label>}
|
|
29
|
+
{variant !== "short" && <label class="ts-full">{variant === "question only" ? "Your question" : "Anything we should know?"}<textarea name="message"></textarea></label>}
|
|
30
|
+
<input class="ts-hp" type="text" name="website_hp" tabindex="-1" autocomplete="off" aria-hidden="true" />
|
|
31
|
+
{turnstile && <div class="cf-turnstile ts-full" data-sitekey={turnstile} data-size="flexible"></div>}
|
|
32
|
+
<div class="ts-full" style="display:flex;gap:14px;align-items:center;flex-wrap:wrap"><button class="ts-btn ts-btn-accent" type="submit">{str(n.submit_label) || (variant === "question only" ? "Send question" : "Send request")}</button>{str(n.promise) && <span class="ts-muted" style="font-size:13px">{str(n.promise)}</span>}</div>
|
|
33
|
+
</form>
|
|
34
|
+
{details && (
|
|
35
|
+
<dl class="ts-kv ts-card">
|
|
36
|
+
{b.phone && <><dt>Call or text</dt><dd><a href={b.phoneHref}>{b.phone}</a></dd></>}
|
|
37
|
+
{b.email && <><dt>Email</dt><dd><a href={`mailto:${b.email}`}>{b.email}</a></dd></>}
|
|
38
|
+
{b.hours && <><dt>Hours</dt><dd>{b.hours}</dd></>}
|
|
39
|
+
{addressLine(b) && <><dt>{b.isServiceArea ? "Based in" : "Address"}</dt><dd>{addressLine(b)}</dd></>}
|
|
40
|
+
{b.sameDayLine && <><dt>Same day</dt><dd>{b.sameDayLine}</dd></>}
|
|
41
|
+
{b.parkingLine && <><dt>Parking</dt><dd>{b.parkingLine}</dd></>}
|
|
42
|
+
</dl>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</div></section>
|
|
46
|
+
{turnstile && <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { bool, getBusiness, nodeOf, str } from "../lib";
|
|
3
|
+
const n = nodeOf(Astro.props);
|
|
4
|
+
const b = await getBusiness(Astro.request);
|
|
5
|
+
const variant = str(n.variant, "band");
|
|
6
|
+
const href = str(n.button_url) || b.bookingUrl || "/contact";
|
|
7
|
+
---
|
|
8
|
+
<section class="ts ts-ink ts-tight" id="book"><div class="ts-wrap" style={variant !== "band" ? "display:grid;grid-template-columns:1.2fr .8fr;gap:32px;align-items:center" : ""}>
|
|
9
|
+
<div>
|
|
10
|
+
<h2>{str(n.headline) || b.ctaLabel}</h2>
|
|
11
|
+
{str(n.body) && <p class="ts-lede">{str(n.body)}</p>}
|
|
12
|
+
<div class="ts-actions"><a class="ts-btn ts-btn-accent ts-btn-lg" href={href}>{str(n.button_label) || b.ctaLabel}</a>{str(n.button2_label) ? <a class="ts-btn ts-btn-outline ts-btn-lg" href={str(n.button2_url, "#")}>{str(n.button2_label)}</a> : bool(n.show_phone, true) && b.phone && <a class="ts-btn ts-btn-outline ts-btn-lg" href={b.phoneHref}>{b.phone}</a>}</div>
|
|
13
|
+
</div>
|
|
14
|
+
{variant === "band with facts" && <dl class="ts-kv ts-card">{b.hours && <><dt>Hours</dt><dd>{b.hours}</dd></>}{b.sameDayLine && <><dt>Same day</dt><dd>{b.sameDayLine}</dd></>}{b.newPatientsLine && <><dt></dt><dd>{b.newPatientsLine}</dd></>}</dl>}
|
|
15
|
+
{variant === "band with form" && <form class="ts-form ts-card" action="/api/lead" method="post"><label class="ts-full">Name<input name="name" required /></label><label class="ts-full">Phone<input name="phone" type="tel" /></label><input class="ts-hp" type="text" name="website_hp" tabindex="-1" autocomplete="off" aria-hidden="true" /><div class="ts-full"><button class="ts-btn ts-btn-accent" type="submit">{str(n.button_label) || b.ctaLabel}</button></div></form>}
|
|
16
|
+
</div></section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The site's own HTML, CSS and script, in a sandboxed frame sized to its content. */
|
|
3
|
+
import { int, nodeOf, str } from "../lib";
|
|
4
|
+
const n = nodeOf(Astro.props);
|
|
5
|
+
const html = str(n.html), css = str(n.css), js = str(n.js);
|
|
6
|
+
const height = int(n.height, int(n.min_height, 320));
|
|
7
|
+
const key = String((Astro.props as { blockKey?: string }).blockKey ?? "");
|
|
8
|
+
const doc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;font-family:system-ui,sans-serif}${css}</style></head><body>${html}<script>${js}<\/script><script>new ResizeObserver(()=>parent.postMessage({tidyFrame:${JSON.stringify(key)},h:document.documentElement.scrollHeight},"*")).observe(document.documentElement)<\/script></body></html>`;
|
|
9
|
+
---
|
|
10
|
+
{(html || css || js) && (
|
|
11
|
+
<section class="ts ts-tight"><div class="ts-wrap">
|
|
12
|
+
{str(n.title) && <h2 style="margin-bottom:14px">{str(n.title)}</h2>}
|
|
13
|
+
<iframe class="ts-frame" data-tidy-frame={key} srcdoc={doc} sandbox="allow-scripts allow-forms allow-popups" style={`height:${height}px`} title={str(n.title) || "Custom section"} loading="lazy"></iframe>
|
|
14
|
+
</div></section>
|
|
15
|
+
<script is:inline>window.addEventListener("message",function(e){var d=e.data;if(!d||!d.tidyFrame)return;var f=document.querySelector('iframe[data-tidy-frame="'+d.tidyFrame+'"]');if(f&&d.h>0)f.style.height=d.h+"px";});</script>
|
|
16
|
+
)}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { entries, nodeOf, str } from "../lib";
|
|
3
|
+
import Head from "./_Head.astro";
|
|
4
|
+
const n = nodeOf(Astro.props);
|
|
5
|
+
let items = await entries("downloads", { limit: 40 });
|
|
6
|
+
if (str(n.group)) items = items.filter((d) => str(d.data.group) === str(n.group));
|
|
7
|
+
const groups = [...new Set(items.map((d) => str(d.data.group)))].map((g) => ({ title: g, items: items.filter((d) => str(d.data.group) === g) }));
|
|
8
|
+
---
|
|
9
|
+
{items.length > 0 && (
|
|
10
|
+
<section class="ts" id="forms"><div class="ts-wrap">
|
|
11
|
+
<Head n={n} />
|
|
12
|
+
<div class="ts-grid" style={`--cols:${Math.min(2, groups.length)}`}>{groups.map((g) => <div>{g.title && <p class="ts-group" style="margin-top:0">{g.title}</p>}<div style="display:grid;gap:8px">{g.items.map((d) => <a class="ts-card" href={str(d.data.file_url, "#")} style="flex-direction:row;justify-content:space-between;align-items:center;padding:12px 14px" data-tidy-record="downloads" data-tidy-record-id={d.id} data-tidy-record-label="Form"><span style="font-weight:600">{str(d.data.title)}</span>{str(d.data.note) && <span class="ts-muted" style="font-size:12.5px">{str(d.data.note)}</span>}</a>)}</div></div>)}</div>
|
|
13
|
+
</div></section>
|
|
14
|
+
)}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Questions from the records, filtered by group or service, plus any written into the section. */
|
|
3
|
+
import { bool, entries, int, list, nodeOf, str } from "../lib";
|
|
4
|
+
import Head from "./_Head.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const group = str(n.group);
|
|
7
|
+
let items = await entries("faqs", { limit: int(n.limit, 40), ...(group ? { where: { group } } : {}) });
|
|
8
|
+
const extra = list<Record<string, unknown>>(n.items).filter((q) => str(q.question));
|
|
9
|
+
const grouped = bool(n.grouped) && !group;
|
|
10
|
+
const groups = grouped ? [...new Set(items.map((q) => str(q.data.group)))].map((g) => ({ title: g, items: items.filter((q) => str(q.data.group) === g) })) : [{ title: "", items }];
|
|
11
|
+
---
|
|
12
|
+
{(items.length > 0 || extra.length > 0) && (
|
|
13
|
+
<section class="ts ts-band" id="faq"><div class="ts-wrap">
|
|
14
|
+
<Head n={n} />
|
|
15
|
+
<div style="max-width:820px">
|
|
16
|
+
{groups.map((g) => <div>{g.title && <p class="ts-group">{g.title}</p>}{g.items.map((q) => <details data-tidy-record="faqs" data-tidy-record-id={q.id} data-tidy-record-label="Question"><summary>{str(q.data.title)}</summary><div class="ts-a">{str(q.data.answer)}</div></details>)}</div>)}
|
|
17
|
+
{extra.map((q) => <details><summary>{str(q.question)}</summary><div class="ts-a">{str(q.answer)}</div></details>)}
|
|
18
|
+
</div>
|
|
19
|
+
</div></section>
|
|
20
|
+
)}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Why choose us: points as cards, a checklist, beside an image, or one mission statement. */
|
|
3
|
+
import { lines, list, nodeOf, pic, str, steps } from "../lib";
|
|
4
|
+
import Photo from "../Photo.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const variant = str(n.variant, "cards");
|
|
7
|
+
const points = list<Record<string, unknown>>(n.points).filter((p) => str(p.title) || str(p.body));
|
|
8
|
+
const bullets = lines(n.bullets);
|
|
9
|
+
const stepList = steps(n.steps);
|
|
10
|
+
const photo = pic(n.image);
|
|
11
|
+
const hasPhoto = Boolean(photo.image || photo.url);
|
|
12
|
+
const side = str(n.image_side, "left");
|
|
13
|
+
---
|
|
14
|
+
<section class="ts"><div class="ts-wrap">
|
|
15
|
+
{variant === "mission statement" ? (
|
|
16
|
+
<div style="max-width:62ch">{str(n.eyebrow) && <p class="ts-eyebrow">{str(n.eyebrow)}</p>}<p style="font-family:var(--ts-display);font-size:clamp(22px,2.6vw,32px);line-height:1.25">{str(n.headline) || str(n.body)}</p>{str(n.headline) && str(n.body) && <p class="ts-muted" style="margin-top:12px">{str(n.body)}</p>}</div>
|
|
17
|
+
) : (
|
|
18
|
+
<div class={hasPhoto || variant === "image beside points" ? `ts-split ${side === "right" ? "ts-rev" : ""}` : ""}>
|
|
19
|
+
{hasPhoto && side === "left" && <Photo image={photo.image} url={photo.url} alt={str(n.headline)} ratio="wide" />}
|
|
20
|
+
<div>
|
|
21
|
+
{str(n.eyebrow) && <p class="ts-eyebrow">{str(n.eyebrow)}</p>}
|
|
22
|
+
{str(n.headline) && <h2>{str(n.headline)}</h2>}
|
|
23
|
+
{str(n.body) && <p class="ts-lede">{str(n.body)}</p>}
|
|
24
|
+
{variant === "cards" && points.length > 0 && !hasPhoto ? (
|
|
25
|
+
<div class="ts-grid" style="margin-top:24px;--cols:3">{points.map((p) => <div class="ts-card">{str(p.icon) && <span class="ts-eyebrow" style="margin:0">{str(p.icon)}</span>}<h3>{str(p.title)}</h3>{str(p.body) && <p>{str(p.body)}</p>}</div>)}</div>
|
|
26
|
+
) : points.length > 0 ? (
|
|
27
|
+
<ul class="ts-list" style="margin-top:18px">{points.map((p) => <li><span><b>{str(p.title)}</b>{str(p.body) && <> {str(p.body)}</>}</span></li>)}</ul>
|
|
28
|
+
) : null}
|
|
29
|
+
{bullets.length > 0 && <ul class="ts-list" style="margin-top:18px">{bullets.map((t) => <li>{t}</li>)}</ul>}
|
|
30
|
+
{stepList.length > 0 && <ol style="margin:18px 0 0;padding-left:20px;color:var(--ts-ink-2)">{stepList.map((s) => <li style="margin-bottom:8px"><b>{s.title}</b>{s.text && <> {s.text}</>}</li>)}</ol>}
|
|
31
|
+
{str(n.button_label) && <div class="ts-actions"><a class="ts-btn" href={str(n.button_url, "#")}>{str(n.button_label)}</a></div>}
|
|
32
|
+
</div>
|
|
33
|
+
{hasPhoto && side === "right" && <Photo image={photo.image} url={photo.url} alt={str(n.headline)} ratio="wide" />}
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
</div></section>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { bool, entries, getBusiness, lines, nodeOf, pic, str } from "../lib";
|
|
3
|
+
import Head from "./_Head.astro";
|
|
4
|
+
const n = nodeOf(Astro.props);
|
|
5
|
+
const b = await getBusiness(Astro.request);
|
|
6
|
+
const providers = bool(n.show_providers, true) ? await entries("financing_providers", { limit: 8 }) : [];
|
|
7
|
+
const methods = str(n.payment_methods).split(",").map((s) => s.trim()).filter(Boolean);
|
|
8
|
+
---
|
|
9
|
+
{(providers.length > 0 || methods.length > 0 || b.hsaFsaLine) && (
|
|
10
|
+
<section class="ts ts-band" id="financing"><div class="ts-wrap">
|
|
11
|
+
<Head n={n} action={bool(n.show_plans) ? { href: "/plans", label: "Membership plans" } : null} />
|
|
12
|
+
<div class="ts-grid" style="--cols:3">
|
|
13
|
+
{providers.map((p) => { const lg = pic(p.data.logo); return <div class="ts-card" data-tidy-record="financing_providers" data-tidy-record-id={p.id} data-tidy-record-label="Financing provider">{(lg.image?.src || lg.url) && <img src={lg.image?.src ?? lg.url ?? ""} alt="" style="max-height:28px;width:auto" />}<h3>{str(p.data.title)}</h3>{str(p.data.rate_line) && <p>{str(p.data.rate_line)}</p>}{str(p.data.note) && <p class="ts-muted" style="font-size:13px">{str(p.data.note)}</p>}{str(p.data.apply_url) && <a href={str(p.data.apply_url)} style="font-weight:700;color:var(--ts-primary);font-size:14px">Check your options</a>}</div>; })}
|
|
14
|
+
{(methods.length > 0 || (bool(n.show_hsa, true) && b.hsaFsaLine)) && <div class="ts-card"><h3>Paying directly</h3>{methods.length > 0 && <p>{methods.join(", ")}</p>}{bool(n.show_hsa, true) && b.hsaFsaLine && <p class="ts-muted" style="font-size:13px">{b.hsaFsaLine}</p>}</div>}
|
|
15
|
+
</div>
|
|
16
|
+
</div></section>
|
|
17
|
+
)}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { list, nodeOf, pic, str } from "../lib";
|
|
3
|
+
import Photo from "../Photo.astro";
|
|
4
|
+
import Head from "./_Head.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const images = list<Record<string, unknown>>(n.images).map((i) => ({ p: pic(i.image), caption: str(i.caption) })).filter((i) => i.p.image || i.p.url);
|
|
7
|
+
const captions = str(n.variant) === "with captions";
|
|
8
|
+
---
|
|
9
|
+
{images.length > 0 && (
|
|
10
|
+
<section class="ts"><div class="ts-wrap">
|
|
11
|
+
<Head n={n} />
|
|
12
|
+
<div class="ts-gallery" style={str(n.variant) === "strip" ? "grid-template-columns:repeat(6,1fr)" : ""}>{images.map((i) => <figure style="margin:0"><Photo image={i.p.image} url={i.p.url} alt={i.caption || ""} ratio="square" />{captions && i.caption && <figcaption class="ts-muted" style="font-size:13px;margin-top:6px">{i.caption}</figcaption>}</figure>)}</div>
|
|
13
|
+
</div></section>
|
|
14
|
+
)}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Hero: headline, subhead, buttons, and a photo, a booking card, a form, or a picker of services. */
|
|
3
|
+
import { bool, entries, getBusiness, nodeOf, pic, str } from "../lib";
|
|
4
|
+
import Photo from "../Photo.astro";
|
|
5
|
+
const n = nodeOf(Astro.props);
|
|
6
|
+
const b = await getBusiness(Astro.request);
|
|
7
|
+
const variant = str(n.variant, "headline and photo");
|
|
8
|
+
const photo = pic(n.image);
|
|
9
|
+
const button = { label: str(n.button_label) || b.ctaLabel, href: str(n.button_url) || b.bookingUrl || "/contact" };
|
|
10
|
+
const button2 = str(n.button2_label) ? { label: str(n.button2_label), href: str(n.button2_url) || "#" } : b.phone ? { label: b.phone, href: b.phoneHref } : null;
|
|
11
|
+
const services = variant === "headline and picker" ? await entries("services", { limit: 8 }) : [];
|
|
12
|
+
const rating = b.rating ? b.rating.toFixed(1) : null;
|
|
13
|
+
const showRating = bool(n.show_rating, true) && rating;
|
|
14
|
+
const over = variant === "photo with headline over";
|
|
15
|
+
---
|
|
16
|
+
<section class={`ts ts-hero ${over ? "ts-ink" : ""}`} style={over && photo.url ? `background-image:linear-gradient(rgba(0,0,0,.35),rgba(0,0,0,.55)),url(${photo.url});background-size:cover;background-position:center` : undefined}>
|
|
17
|
+
<div class={`ts-wrap ${over || variant === "headline and picker" ? "" : "ts-split"}`}>
|
|
18
|
+
<div>
|
|
19
|
+
{str(n.pill) && <p class="ts-chip" style="display:inline-flex;margin-bottom:16px">{str(n.pill)}</p>}
|
|
20
|
+
{str(n.eyebrow) && <p class="ts-eyebrow">{str(n.eyebrow)}</p>}
|
|
21
|
+
<h1>{str(n.headline) || b.tagline || b.title}</h1>
|
|
22
|
+
{str(n.subhead) && <p class="ts-lede">{str(n.subhead)}</p>}
|
|
23
|
+
<div class="ts-actions">
|
|
24
|
+
<a class="ts-btn ts-btn-accent ts-btn-lg" href={button.href}>{button.label}</a>
|
|
25
|
+
{button2 && <a class="ts-btn ts-btn-outline ts-btn-lg" href={button2.href}>{button2.label}</a>}
|
|
26
|
+
</div>
|
|
27
|
+
{(showRating || bool(n.show_hours) || bool(n.show_phone)) && (
|
|
28
|
+
<p class="ts-muted" style="margin-top:18px;font-size:14px">
|
|
29
|
+
{showRating && <span><span class="ts-stars">★★★★★</span> {rating}{b.reviewCount ? ` from ${b.reviewCount} ${b.ratingSource} reviews` : ""}</span>}
|
|
30
|
+
{bool(n.show_hours) && b.hours && <span> · {b.hours}</span>}
|
|
31
|
+
{bool(n.show_phone) && b.phone && <span> · <a href={b.phoneHref}>{b.phone}</a></span>}
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
{variant === "headline and photo" && <Photo image={photo.image} url={photo.url} alt={str(n.headline) || b.title} ratio="wide" />}
|
|
36
|
+
{variant === "headline and booking card" && (
|
|
37
|
+
<div class="ts-card" style="gap:0">
|
|
38
|
+
<Photo image={photo.image} url={photo.url} alt={b.title} ratio="wide" class="ts-hero-card-photo" />
|
|
39
|
+
<dl class="ts-kv" style="margin-top:16px">
|
|
40
|
+
{b.hours && <><dt>Hours</dt><dd>{b.hours}</dd></>}
|
|
41
|
+
{b.phone && <><dt>Call or text</dt><dd><a href={b.phoneHref}>{b.phone}</a></dd></>}
|
|
42
|
+
{b.insuranceLine && <><dt>Insurance</dt><dd>{b.insuranceLine}</dd></>}
|
|
43
|
+
{b.newPatientsLine && <><dt></dt><dd>{b.newPatientsLine}</dd></>}
|
|
44
|
+
</dl>
|
|
45
|
+
<div class="ts-actions" style="margin-top:16px"><a class="ts-btn ts-btn-accent" href={button.href}>{button.label}</a>{b.phone && <a class="ts-btn ts-btn-outline" href={b.phoneHref}>Call us</a>}</div>
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
{variant === "headline and form" && (
|
|
49
|
+
<form class="ts-card ts-form" action="/api/lead" method="post">
|
|
50
|
+
<label class="ts-full">Name<input name="name" autocomplete="name" required /></label>
|
|
51
|
+
<label>Phone<input name="phone" type="tel" autocomplete="tel" /></label>
|
|
52
|
+
<label>Email<input name="email" type="email" autocomplete="email" /></label>
|
|
53
|
+
<label class="ts-full">How can we help?<textarea name="message"></textarea></label>
|
|
54
|
+
<input class="ts-hp" type="text" name="website_hp" tabindex="-1" autocomplete="off" aria-hidden="true" />
|
|
55
|
+
<div class="ts-full"><button class="ts-btn ts-btn-accent" type="submit">{button.label}</button></div>
|
|
56
|
+
</form>
|
|
57
|
+
)}
|
|
58
|
+
{variant === "headline and picker" && services.length > 0 && (
|
|
59
|
+
<div class="ts-card" style="margin-top:28px">
|
|
60
|
+
<h3>{str(n.picker_title, "What do you need?")}</h3>
|
|
61
|
+
<div class="ts-chips" style="margin-top:10px">{services.map((s) => <a class="ts-chip" href={`/services/${s.slug ?? s.id}`}>{str(s.data.title)}</a>)}</div>
|
|
62
|
+
{str(n.picker_note) && <p class="ts-muted" style="margin-top:10px;font-size:14px">{str(n.picker_note)}</p>}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|