@tideworthy/tidysites-platform 0.10.15 → 0.11.1

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 (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +13 -0
  3. package/bin/tidysites-seed.mjs +51 -0
  4. package/package.json +14 -2
  5. package/src/sections/Photo.astro +18 -0
  6. package/src/sections/Sections.astro +42 -0
  7. package/src/sections/defaults/_Head.astro +13 -0
  8. package/src/sections/defaults/areas.astro +20 -0
  9. package/src/sections/defaults/badges.astro +17 -0
  10. package/src/sections/defaults/brands.astro +14 -0
  11. package/src/sections/defaults/case_studies.astro +16 -0
  12. package/src/sections/defaults/cases.astro +22 -0
  13. package/src/sections/defaults/clients.astro +14 -0
  14. package/src/sections/defaults/conditions.astro +13 -0
  15. package/src/sections/defaults/contact.astro +46 -0
  16. package/src/sections/defaults/cta.astro +16 -0
  17. package/src/sections/defaults/custom.astro +16 -0
  18. package/src/sections/defaults/downloads.astro +14 -0
  19. package/src/sections/defaults/faq.astro +20 -0
  20. package/src/sections/defaults/feature.astro +36 -0
  21. package/src/sections/defaults/financing.astro +17 -0
  22. package/src/sections/defaults/gallery.astro +14 -0
  23. package/src/sections/defaults/hero.astro +66 -0
  24. package/src/sections/defaults/insurance.astro +22 -0
  25. package/src/sections/defaults/intro.astro +11 -0
  26. package/src/sections/defaults/location.astro +32 -0
  27. package/src/sections/defaults/locations.astro +14 -0
  28. package/src/sections/defaults/offer.astro +18 -0
  29. package/src/sections/defaults/options.astro +12 -0
  30. package/src/sections/defaults/plans.astro +16 -0
  31. package/src/sections/defaults/policies.astro +13 -0
  32. package/src/sections/defaults/posts.astro +14 -0
  33. package/src/sections/defaults/products.astro +17 -0
  34. package/src/sections/defaults/related.astro +17 -0
  35. package/src/sections/defaults/reviews.astro +44 -0
  36. package/src/sections/defaults/services.astro +49 -0
  37. package/src/sections/defaults/signs.astro +12 -0
  38. package/src/sections/defaults/stats.astro +13 -0
  39. package/src/sections/defaults/steps.astro +32 -0
  40. package/src/sections/defaults/team.astro +42 -0
  41. package/src/sections/defaults/technologies.astro +13 -0
  42. package/src/sections/defaults/video.astro +15 -0
  43. package/src/sections/defaults.css +112 -0
  44. package/src/sections/lib.ts +164 -0
  45. package/src/template/index.ts +75 -0
  46. package/src/version.ts +1 -1
  47. package/template/README.md +33 -0
  48. package/template/structure.json +4224 -0
@@ -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>
@@ -0,0 +1,22 @@
1
+ ---
2
+ /** Insurers accepted, as logos, names or one line, with network status and government programs. */
3
+ import { bool, entries, getBusiness, nodeOf, pic, str, mediaUrl } 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, "logos");
8
+ const items = variant === "one line" ? [] : await entries("insurers", { limit: 60 });
9
+ const inNet = items.filter((i) => !/out/i.test(str(i.data.network))), outNet = items.filter((i) => /out/i.test(str(i.data.network)));
10
+ const groups = bool(n.show_network) && outNet.length ? [{ title: "In network", list: inNet }, { title: "Out of network", list: outNet }] : [{ title: "", list: items }];
11
+ ---
12
+ {(items.length > 0 || b.insuranceLine) && (
13
+ <section class="ts" id="insurance"><div class="ts-wrap">
14
+ <Head n={n} action={str(n.button_label) ? { href: str(n.button_url, "/contact"), label: str(n.button_label) } : null} />
15
+ {variant === "one line" || !items.length ? <p class="ts-lede" style="margin:0">{b.insuranceLine}</p> : groups.map((g) => (
16
+ <div>{g.title && <p class="ts-group">{g.title}</p>}
17
+ {variant === "logos" ? <div class="ts-logos">{g.list.map((i) => { const p = pic(i.data.logo); return <div class="ts-logo" data-tidy-record="insurers" data-tidy-record-id={i.id} data-tidy-record-label="Insurer">{mediaUrl(p) && <img src={mediaUrl(p) ?? ""} alt="" />}<span>{str(i.data.title)}</span></div>; })}</div> : <div class="ts-chips">{g.list.map((i) => <span class="ts-chip" data-tidy-record="insurers" data-tidy-record-id={i.id} data-tidy-record-label="Insurer">{str(i.data.title)}</span>)}</div>}
18
+ </div>
19
+ ))}
20
+ {bool(n.show_programs, true) && b.governmentPrograms && <p class="ts-muted" style="margin-top:14px;font-size:14px">{b.governmentPrograms}</p>}
21
+ </div></section>
22
+ )}
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { nodeOf, pic, str } from "../lib";
3
+ import Photo from "../Photo.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const photo = pic(n.image);
6
+ const has = Boolean(photo.image || photo.url);
7
+ ---
8
+ <section class="ts"><div class={`ts-wrap ${has ? "ts-split" : ""}`}>
9
+ <div>{str(n.eyebrow) && <p class="ts-eyebrow">{str(n.eyebrow)}</p>}{str(n.headline) && <h2>{str(n.headline)}</h2>}{str(n.body) && <div class="ts-prose" style="margin-top:14px;white-space:pre-line">{str(n.body)}</div>}{str(n.button_label) && <div class="ts-actions"><a class="ts-btn" href={str(n.button_url, "#")}>{str(n.button_label)}</a></div>}</div>
10
+ {has && <Photo image={photo.image} url={photo.url} alt={str(n.headline)} />}
11
+ </div></section>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /** Address, hours (per day or grouped), parking and a map, for the main location or a named one. */
3
+ import { addressLine, bool, entries, getBusiness, hoursGrouped, hoursRows, nodeOf, str, todayKey, DAYS } from "../lib";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const b = await getBusiness(Astro.request);
7
+ const locs = await entries("locations", { limit: 20 });
8
+ const want = str(n.location);
9
+ const loc = (want ? locs.find((l) => (l.slug ?? l.id) === want) : locs.find((l) => bool(l.data.is_primary))) ?? locs[0] ?? null;
10
+ const d = loc?.data ?? {};
11
+ const addr = loc ? addressLine({ address: str(d.address), city: str(d.city), region: str(d.region), postal: str(d.postal), hideAddress: b.hideAddress }) : addressLine(b);
12
+ const phone = str(d.phone) || b.phone;
13
+ const grouped = str(n.hours_style, "per day") === "grouped";
14
+ const rows = loc ? hoursRows(d) : [];
15
+ const today = DAYS.find(([k]) => k === todayKey())?.[1];
16
+ const mapUrl = str(d.map_url);
17
+ const q = encodeURIComponent([b.title, addr].filter(Boolean).join(" "));
18
+ ---
19
+ <section class="ts ts-band" id="find"><div class="ts-wrap">
20
+ <Head n={n} />
21
+ <div class={bool(n.show_map, true) ? "ts-split" : ""} style="align-items:start">
22
+ {bool(n.show_map, true) && (b.hideAddress ? <div class="ts-map">{b.serviceAreaLine || "We come to you."}</div> : <div class="ts-map"><iframe title="Map" loading="lazy" referrerpolicy="no-referrer-when-downgrade" src={`https://www.google.com/maps?q=${q}&output=embed`}></iframe></div>)}
23
+ <div class="ts-card">
24
+ {addr && <><p class="ts-group" style="margin-top:0">{b.isServiceArea ? "Based in" : "Address"}</p><p>{addr}</p>{(mapUrl || !b.hideAddress) && <a href={mapUrl || `https://www.google.com/maps/search/?api=1&query=${q}`} style="font-weight:700;color:var(--ts-primary);font-size:14px">Get directions</a>}</>}
25
+ {(phone || b.email) && <><p class="ts-group">Reach us</p>{phone && <p><a href={b.phoneHref}>{phone}</a></p>}{b.email && <p><a href={`mailto:${b.email}`}>{b.email}</a></p>}</>}
26
+ <p class="ts-group">Hours</p>
27
+ {rows.length ? (grouped ? <table class="ts-table">{hoursGrouped(d).map((g) => <tr><td>{g.days}</td><td>{g.value}</td></tr>)}</table> : <table class="ts-table">{rows.map((r) => <tr class={r.day === today ? "ts-today" : ""}><td>{r.day}</td><td>{r.value}</td></tr>)}</table>) : <p>{b.hours || "By appointment"}</p>}
28
+ {(str(d.hours_note) || b.hoursNote) && <p class="ts-muted" style="font-size:13px;margin-top:6px">{str(d.hours_note) || b.hoursNote}</p>}
29
+ {bool(n.show_parking, true) && (str(d.parking_line) || b.parkingLine || str(d.directions_line) || b.directionsLine) && <><p class="ts-group">Getting here</p><p style="font-size:14px">{[str(d.parking_line) || b.parkingLine, str(d.directions_line) || b.directionsLine, str(d.transit_line)].filter(Boolean).join(" ")}</p></>}
30
+ </div>
31
+ </div>
32
+ </div></section>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { addressLine, bool, entries, getBusiness, hoursGrouped, 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 b = await getBusiness(Astro.request);
7
+ const items = await entries("locations", { limit: 20 });
8
+ ---
9
+ {items.length > 0 && (
10
+ <section class="ts" id="locations"><div class="ts-wrap">
11
+ <Head n={n} />
12
+ <div class="ts-grid" style={`--cols:${Math.min(3, items.length)}`}>{items.map((l) => { const p = pic(l.data.photo); const d = l.data; return <div class="ts-card" data-tidy-record="locations" data-tidy-record-id={l.id} data-tidy-record-label="Location">{(p.image || p.url) && <Photo image={p.image} url={p.url} alt={str(d.title)} ratio="wide" />}<h3>{str(d.title)}</h3><p>{addressLine({ address: str(d.address), city: str(d.city), region: str(d.region), postal: str(d.postal) })}</p>{str(d.phone) && <p><a href={`tel:${str(d.phone).replace(/[^\d+]/g, "")}`}>{str(d.phone)}</a></p>}{bool(n.show_hours, true) && hoursGrouped(d).length > 0 && <table class="ts-table" style="font-size:13px">{hoursGrouped(d).map((g) => <tr><td>{g.days}</td><td>{g.value}</td></tr>)}</table>}{bool(n.show_booking, true) && (str(d.booking_url) || b.bookingUrl) && <div class="ts-actions" style="margin-top:8px"><a class="ts-btn ts-btn-accent" href={str(d.booking_url) || b.bookingUrl}>{b.bookingLabel}</a></div>}</div>; })}</div>
13
+ </div></section>
14
+ )}
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { bool, entries, nodeOf, str } from "../lib";
3
+ const n = nodeOf(Astro.props);
4
+ const offers = await entries("offers", { limit: 20 });
5
+ const want = str(n.offer);
6
+ const o = (want ? offers.find((x) => (x.slug ?? x.id) === want) : offers.find((x) => bool(x.data.active, true))) ?? null;
7
+ const ends = o ? str(o.data.ends) : "";
8
+ const live = !o || !ends || new Date(ends).getTime() > Date.now();
9
+ const title = str(n.headline) || (o ? str(o.data.title) : "");
10
+ ---
11
+ {title && live && (
12
+ <section class="ts ts-tight"><div class="ts-wrap">
13
+ <div class="ts-offer" {...(o ? { "data-tidy-record": "offers", "data-tidy-record-id": o.id, "data-tidy-record-label": "Offer" } : {})}>
14
+ <div>{ends && <p class="ts-eyebrow">Until {new Date(ends).toLocaleDateString("en-CA", { month: "long", day: "numeric" })}</p>}<h3 style="font-size:24px">{title}{o && str(o.data.value) ? ` · ${str(o.data.value)}` : ""}</h3>{(str(n.body) || (o && str(o.data.summary))) && <p style="margin-top:6px;color:var(--ts-ink-2)">{str(n.body) || str(o!.data.summary)}</p>}{o && str(o.data.how_to_claim) && <p class="ts-muted" style="font-size:13px;margin-top:6px">{str(o.data.how_to_claim)}</p>}</div>
15
+ <a class="ts-btn ts-btn-accent ts-btn-lg" href={str(n.button_url) || (o && str(o.data.cta_url)) || "/contact"}>{str(n.button_label) || (o && str(o.data.cta_label)) || "Claim this offer"}</a>
16
+ </div>
17
+ </div></section>
18
+ )}
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { bool, facts, list, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const items = list<Record<string, unknown>>(n.items).filter((i) => str(i.title));
6
+ ---
7
+ {items.length > 0 && (
8
+ <section class="ts"><div class="ts-wrap">
9
+ <Head n={n} />
10
+ <div class="ts-grid" style={`--cols:${Math.min(3, items.length)}`}>{items.map((o) => <div class="ts-card" style={bool(o.featured) ? "border-color:var(--ts-primary)" : ""}>{str(o.tag) && <p class="ts-eyebrow" style="margin:0">{str(o.tag)}</p>}<h3>{str(o.title)}</h3>{str(o.price) && <p class="ts-price" style="font-size:30px">{str(o.price)}{str(o.price_note) && <small>{str(o.price_note)}</small>}</p>}{str(o.summary) && <p>{str(o.summary)}</p>}{facts(o.facts).length > 0 && <dl class="ts-kv" style="font-size:14px">{facts(o.facts).map(([k, v]) => <><dt>{k}</dt><dd>{v}</dd></>)}</dl>}{str(o.button_label) && <div class="ts-actions" style="margin-top:8px"><a class="ts-btn" href={str(o.button_url, "/contact")}>{str(o.button_label)}</a></div>}</div>)}</div>
11
+ </div></section>
12
+ )}
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { bool, entries, int, lines, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ let items = await entries("plans", { limit: int(n.limit, 6) });
6
+ if (str(n.kind)) items = items.filter((p) => str(p.data.kind) === str(n.kind));
7
+ if (bool(n.featured_first, true)) items = [...items].sort((a, b) => Number(bool(b.data.featured)) - Number(bool(a.data.featured)));
8
+ ---
9
+ {items.length > 0 && (
10
+ <section class="ts" id="plans"><div class="ts-wrap">
11
+ <Head n={n} />
12
+ <div class="ts-grid" style={`--cols:${Math.min(3, items.length)}`}>
13
+ {items.map((p) => <div class="ts-card" data-tidy-record="plans" data-tidy-record-id={p.id} data-tidy-record-label="Plan" style={bool(p.data.featured) ? "border-color:var(--ts-primary)" : ""}>{str(p.data.tag) && <p class="ts-eyebrow" style="margin:0">{str(p.data.tag)}</p>}<h3>{str(p.data.title)}</h3>{str(p.data.price) && <p class="ts-price">{str(p.data.price)}{str(p.data.cadence) && <small>/ {str(p.data.cadence)}</small>}</p>}{str(p.data.summary) && <p>{str(p.data.summary)}</p>}{lines(p.data.includes).length > 0 && <ul class="ts-list">{lines(p.data.includes).map((l) => <li>{l}</li>)}</ul>}{str(p.data.eligibility) && <p class="ts-muted" style="font-size:13px">{str(p.data.eligibility)}</p>}{str(p.data.cta_label) && <div class="ts-actions" style="margin-top:12px"><a class="ts-btn ts-btn-accent" href={str(p.data.cta_url, "/contact")}>{str(p.data.cta_label)}</a></div>}{str(p.data.note) && <p class="ts-muted" style="font-size:12px">{str(p.data.note)}</p>}</div>)}
14
+ </div>
15
+ </div></section>
16
+ )}
@@ -0,0 +1,13 @@
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("policies", { limit: 20 });
6
+ if (str(n.group)) items = items.filter((p) => str(p.data.group) === str(n.group));
7
+ ---
8
+ {items.length > 0 && (
9
+ <section class="ts ts-band" id="policies"><div class="ts-wrap">
10
+ <Head n={n} />
11
+ <div class="ts-grid" style="--cols:3">{items.map((p) => <div class="ts-card" data-tidy-record="policies" data-tidy-record-id={p.id} data-tidy-record-label="Policy"><h3>{str(p.data.title)}</h3><p style="white-space:pre-line">{str(p.data.body)}</p></div>)}</div>
12
+ </div></section>
13
+ )}
@@ -0,0 +1,14 @@
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
+ let items = await entries("posts", { limit: int(n.limit, 3), orderBy: { createdAt: "desc" } as never });
7
+ if (str(n.category)) items = items.filter((p) => str(p.data.category) === str(n.category));
8
+ ---
9
+ {items.length > 0 && (
10
+ <section class="ts"><div class="ts-wrap">
11
+ <Head n={n} action={bool(n.show_all, true) ? { href: "/blog", label: "All posts" } : null} />
12
+ <div class="ts-grid" style="--cols:3">{items.map((p) => { const ph = pic(p.data.featured_image); return <a class="ts-card" href={`/blog/${p.slug ?? p.id}`}><Photo image={ph.image} url={ph.url} alt={str(p.data.title)} ratio="wide" />{str(p.data.category) && <p class="ts-eyebrow" style="margin:0">{str(p.data.category)}</p>}<h3>{str(p.data.title)}</h3>{str(p.data.summary) && <p>{str(p.data.summary)}</p>}</a>; })}</div>
13
+ </div></section>
14
+ )}
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { bool, entries, getBusiness, 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 b = await getBusiness(Astro.request);
7
+ let items = await entries("products", { limit: int(n.limit, 8) });
8
+ if (str(n.category)) items = items.filter((p) => str(p.data.category) === str(n.category));
9
+ if (str(n.variant) === "featured") items = items.filter((p) => bool(p.data.featured));
10
+ const prices = bool(n.show_prices, true);
11
+ ---
12
+ {items.length > 0 && (
13
+ <section class="ts" id="products"><div class="ts-wrap">
14
+ <Head n={n} action={bool(n.show_store_button, true) && b.storeUrl ? { href: b.storeUrl, label: b.storeLabel } : null} />
15
+ <div class="ts-grid" style={`--cols:${str(n.variant) === "list" ? 1 : 4}`}>{items.map((p) => { const ph = pic(p.data.image); return <a class="ts-card" href={str(p.data.buy_url) || `/products/${p.slug ?? p.id}`} data-tidy-record="products" data-tidy-record-id={p.id} data-tidy-record-label="Product"><Photo image={ph.image} url={ph.url} alt={str(p.data.title)} ratio="square" /><h3 style="font-size:17px">{str(p.data.title)}</h3>{str(p.data.summary) && <p>{str(p.data.summary)}</p>}{prices && str(p.data.price) && <p style="font-weight:700">{str(p.data.price)}{str(p.data.unit) && <span class="ts-muted" style="font-weight:400"> / {str(p.data.unit)}</span>}{str(p.data.compare_price) && <s class="ts-muted" style="margin-left:8px;font-weight:400">{str(p.data.compare_price)}</s>}</p>}{str(p.data.availability_line) && <p class="ts-muted" style="font-size:12.5px">{str(p.data.availability_line)}</p>}</a>; })}</div>
16
+ </div></section>
17
+ )}
@@ -0,0 +1,17 @@
1
+ ---
2
+ /** Other services in the same category as the service on the page. */
3
+ import { entries, int, nodeOf, str } from "../lib";
4
+ const props = Astro.props as Record<string, unknown>;
5
+ const n = nodeOf(props);
6
+ const ctx = (props.context as Record<string, unknown> | null) ?? null;
7
+ const cat = ctx ? String(ctx.category ?? "") : "";
8
+ const selfId = ctx ? String(ctx.id ?? "") : "";
9
+ let items = await entries("services", { limit: 40 });
10
+ items = items.filter((s) => s.id !== selfId && (!cat || String(s.data.category ?? "") === cat)).slice(0, int(n.limit, 4));
11
+ ---
12
+ {items.length > 0 && (
13
+ <section class="ts ts-tight ts-band"><div class="ts-wrap">
14
+ <h3 style="margin-bottom:14px">{str(n.headline) || "Related services"}</h3>
15
+ <div class="ts-grid" style="--cols:4">{items.map((s) => <a class="ts-card" href={`/services/${s.slug ?? s.id}`}><h4>{str(s.data.title)}</h4></a>)}</div>
16
+ </div></section>
17
+ )}
@@ -0,0 +1,44 @@
1
+ ---
2
+ /** Reviews from the records, with the business's rating and count beside them. */
3
+ import { bool, entries, getBusiness, int, nodeOf, pic, str, mediaUrl } 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, "aggregate with quotes");
8
+ const where: Record<string, string> = {};
9
+ if (bool(n.featured_only)) where.featured = "1";
10
+ let items = await entries("reviews", { limit: int(n.limit, 3), where });
11
+ const svc = str(n.service), area = str(n.service_area);
12
+ if (svc) items = items.filter((r) => String(r.data.service ?? "") === svc || true);
13
+ if (variant === "video testimonials") items = items.filter((r) => str(r.data.video_url));
14
+ const rating = b.rating ? b.rating.toFixed(1) : null;
15
+ const stars = (v: unknown) => { const r = typeof v === "number" ? Math.max(1, Math.min(5, Math.round(v))) : 5; return "★".repeat(r); };
16
+ const embed = (u: string) => u.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/");
17
+ const who = (r: { data: Record<string, unknown> }) => str(r.data.display_name) || str(r.data.title);
18
+ const when = (r: { data: Record<string, unknown> }) => { const d = str(r.data.date); return d ? new Date(d).toLocaleDateString("en-CA", { month: "long", year: "numeric" }) : ""; };
19
+ ---
20
+ {(items.length > 0 || (bool(n.show_aggregate, true) && rating)) && (
21
+ <section class="ts" id="reviews"><div class="ts-wrap">
22
+ <Head n={n} action={bool(n.show_review_links) && b.reviewsUrl ? { href: b.reviewsUrl, label: "Read them all" } : null} />
23
+ {variant === "single quote band" && items[0] ? (
24
+ <div style="max-width:64ch"><p class="ts-stars">{stars(items[0].data.rating)}</p><p style="font-family:var(--ts-display);font-size:clamp(22px,2.4vw,30px);line-height:1.3;margin-top:12px">“{str(items[0].data.quote)}”</p><p class="ts-muted" style="margin-top:12px">{who(items[0])}{bool(n.show_source, true) && str(items[0].data.source) ? ` · ${str(items[0].data.source)}` : ""}</p></div>
25
+ ) : variant === "video testimonials" ? (
26
+ <div class="ts-grid" style="--cols:3">{items.map((r) => <div><div class="ts-video"><iframe src={embed(str(r.data.video_url))} title={who(r)} loading="lazy" allowfullscreen></iframe></div><p style="margin-top:8px;font-weight:600">{who(r)}</p></div>)}</div>
27
+ ) : (
28
+ <div class={variant === "aggregate with quotes" && rating ? "ts-split ts-rev" : ""} style={variant === "aggregate with quotes" && rating ? "align-items:start" : ""}>
29
+ {variant === "aggregate with quotes" && rating && (
30
+ <aside class="ts-card"><span style="font-family:var(--ts-display);font-size:64px;font-weight:800;letter-spacing:-.04em;line-height:1">{rating}</span><span class="ts-stars">★★★★★</span>{b.reviewCount && <p class="ts-muted">{b.reviewCount} {b.ratingSource} reviews</p>}{b.reviewsUrl && <div class="ts-chips" style="margin-top:12px"><a class="ts-chip" href={b.reviewsUrl}>Read them all</a><a class="ts-chip" href={b.reviewsUrl}>Leave a review</a></div>}</aside>
31
+ )}
32
+ <div class="ts-grid" style={`--cols:${variant === "aggregate with quotes" ? 1 : 3}`}>
33
+ {items.map((r) => { const ph = pic(r.data.photo); return (
34
+ <article class="ts-card ts-review" data-tidy-record="reviews" data-tidy-record-id={r.id} data-tidy-record-label="Review">
35
+ <span class="ts-stars" aria-label={`${stars(r.data.rating).length} stars`}>{stars(r.data.rating)}</span>
36
+ <blockquote>{str(r.data.quote)}</blockquote>
37
+ <div class="ts-who">{(ph.image || ph.url) && <span class="ts-avatar"><img src={mediaUrl(ph) ?? ""} alt="" /></span>}<div><b>{who(r)}</b><span>{[bool(n.show_source, true) ? str(r.data.source) : "", when(r)].filter(Boolean).join(" · ")}</span></div></div>
38
+ </article>
39
+ ); })}
40
+ </div>
41
+ </div>
42
+ )}
43
+ </div></section>
44
+ )}
@@ -0,0 +1,49 @@
1
+ ---
2
+ /** Services or categories from the records, in one of five layouts. */
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, "cards with summary");
8
+ const source = str(n.source, "services");
9
+ const limit = int(n.limit, 12);
10
+ let items: Array<{ id: string; slug?: string | null; data: Record<string, unknown>; href: string }> = [];
11
+ let groups: Array<{ title: string; items: typeof items }> = [];
12
+ const svc = (e: { id: string; slug?: string | null; data: Record<string, unknown> }) => ({ ...e, href: `/services/${e.slug ?? e.id}` });
13
+ if (source === "categories") items = (await entries("service_categories", { limit })).map((e) => ({ ...e, href: `/services/category/${e.slug ?? e.id}` }));
14
+ else if (source === "featured services") items = (await entries("services", { limit, where: { featured: "1" } })).map(svc);
15
+ else if (source === "services in one category" && str(n.category)) {
16
+ const cats = await entries("service_categories");
17
+ const cat = cats.find((c) => (c.slug ?? c.id) === str(n.category));
18
+ items = (cat ? await entries("services", { limit, where: { category: String(cat.id) } }) : []).map(svc);
19
+ } else items = (await entries("services", { limit })).map(svc);
20
+ if (variant === "grouped by category") {
21
+ const cats = await entries("service_categories");
22
+ const all = (await entries("services", { limit: 100 })).map(svc);
23
+ groups = cats.map((c) => ({ title: str(c.data.title), items: all.filter((s) => String(s.data.category ?? "") === String(c.id)) })).filter((g) => g.items.length);
24
+ if (!groups.length) groups = [{ title: "", items: all }];
25
+ }
26
+ const showAll = bool(n.show_all, true);
27
+ ---
28
+ {(items.length > 0 || groups.length > 0) && (
29
+ <section class="ts ts-band" id="services"><div class="ts-wrap">
30
+ <Head n={n} action={showAll ? { href: "/services", label: "All services" } : null} />
31
+ {variant === "checklist" ? (
32
+ <ul class="ts-list" style="columns:2;gap:28px">{items.map((s) => <li><a href={s.href}>{str(s.data.title)}</a></li>)}</ul>
33
+ ) : variant === "grouped by category" ? (
34
+ groups.map((g) => <div>{g.title && <p class="ts-group">{g.title}</p>}<div class="ts-chips">{g.items.map((s) => <a class="ts-chip" href={s.href} data-tidy-record="services" data-tidy-record-id={s.id} data-tidy-record-label="Service details">{str(s.data.title)}</a>)}</div></div>)
35
+ ) : (
36
+ <div class="ts-grid" style={`--cols:${variant === "image tiles" ? 3 : 3}`}>
37
+ {items.map((s) => (
38
+ <a class="ts-card" href={s.href} data-tidy-record={source === "categories" ? "service_categories" : "services"} data-tidy-record-id={s.id} data-tidy-record-label={source === "categories" ? "Category details" : "Service details"}>
39
+ {variant === "image tiles" && (() => { const p = pic(s.data.featured_image); return <Photo image={p.image} url={p.url} alt={str(s.data.title)} ratio="wide" />; })()}
40
+ {variant === "icon cards" && str(s.data.icon) && <span class="ts-eyebrow" style="margin:0">{str(s.data.icon)}</span>}
41
+ <h3>{str(s.data.title)}</h3>
42
+ {variant !== "image tiles" && str(s.data.summary) && <p>{str(s.data.summary)}</p>}
43
+ {str(s.data.price_from) && <p class="ts-muted" style="font-size:13px">From {str(s.data.price_from)}</p>}
44
+ </a>
45
+ ))}
46
+ </div>
47
+ )}
48
+ </div></section>
49
+ )}
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { list, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const items = list<Record<string, unknown>>(n.items).filter((i) => str(i.title));
6
+ ---
7
+ {items.length > 0 && (
8
+ <section class="ts ts-band"><div class="ts-wrap">
9
+ <Head n={n} />
10
+ <div class="ts-grid" style="--cols:3">{items.map((i) => <div class="ts-card">{str(i.icon) && <span class="ts-eyebrow" style="margin:0">{str(i.icon)}</span>}<h3>{str(i.title)}</h3>{str(i.text) && <p>{str(i.text)}</p>}</div>)}</div>
11
+ </div></section>
12
+ )}
@@ -0,0 +1,13 @@
1
+ ---
2
+ /** Value and label tiles. A tile with no value is left out, never shown as a zero. */
3
+ import { list, nodeOf, str } from "../lib";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const tiles = list<Record<string, unknown>>(n.tiles).filter((t) => str(t.value) && str(t.value) !== "0");
7
+ ---
8
+ {tiles.length > 0 && (
9
+ <section class="ts ts-tight"><div class="ts-wrap">
10
+ <Head n={n} />
11
+ <div class="ts-grid" style={`--cols:${Math.min(4, tiles.length)}`}>{tiles.map((t) => <div class="ts-card ts-stat"><b>{str(t.value)}</b><span>{str(t.label)}{str(t.source) ? ` · ${str(t.source)}` : ""}</span></div>)}</div>
12
+ </div></section>
13
+ )}
@@ -0,0 +1,32 @@
1
+ ---
2
+ /** A real sequence: a first visit, a treatment, aftercare. */
3
+ import { bool, entries, lines, list, 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 = list<Record<string, unknown>>(n.steps).filter((s) => str(s.title));
8
+ const prep = lines(n.prep);
9
+ const forms = bool(n.show_forms) ? await entries("downloads", { limit: 6 }) : [];
10
+ const aside = bool(n.show_prep) && (prep.length > 0 || forms.length > 0);
11
+ ---
12
+ {items.length > 0 && (
13
+ <section class="ts"><div class="ts-wrap">
14
+ <Head n={n} />
15
+ <div class={aside ? "ts-split" : ""}>
16
+ <div class="ts-steps">
17
+ {items.map((s, i) => (
18
+ <div class="ts-step">
19
+ <span class="ts-n">{str(s.duration) || String(i + 1).padStart(2, "0")}</span>
20
+ <div><h3>{str(s.title)}</h3>{str(s.body) && <p>{str(s.body)}</p>}{(() => { const p = pic(s.image); return p.image || p.url ? <Photo image={p.image} url={p.url} alt={str(s.title)} ratio="wide" class="ts-step-photo" /> : null; })()}</div>
21
+ </div>
22
+ ))}
23
+ </div>
24
+ {aside && (
25
+ <aside class="ts-card">
26
+ {prep.length > 0 && <><h3>Bring with you</h3><ul class="ts-list" style="margin-top:10px">{prep.map((p) => <li>{p}</li>)}</ul></>}
27
+ {forms.length > 0 && <div class="ts-chips" style="margin-top:14px">{forms.map((f) => <a class="ts-chip" href={str(f.data.file_url, "#")}>{str(f.data.title)}</a>)}</div>}
28
+ </aside>
29
+ )}
30
+ </div>
31
+ </div></section>
32
+ )}
@@ -0,0 +1,42 @@
1
+ ---
2
+ /** People from the records: cards, grouped by department, a photo grid, or one person featured. */
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, "cards");
8
+ const limit = int(n.limit, 24);
9
+ const group = str(n.group);
10
+ let people = await entries("people", { limit: 100 });
11
+ if (group) people = people.filter((p) => str(p.data.group) === group);
12
+ if (bool(n.featured_only)) people = people.filter((p) => bool(p.data.is_owner) || str(p.data.credentials));
13
+ people = people.slice(0, limit);
14
+ const showCred = bool(n.show_credentials, true), showBio = bool(n.show_bio, true);
15
+ const name = (p: { data: Record<string, unknown> }) => str(p.data.display_name) || str(p.data.title);
16
+ const groups = variant === "grouped by department" ? [...new Set(people.map((p) => str(p.data.group)))].map((g) => ({ title: g, items: people.filter((p) => str(p.data.group) === g) })) : [];
17
+ const href = (p: { slug?: string | null; id: string; data: Record<string, unknown> }) => (bool(p.data.has_profile) ? `/team/${p.slug ?? p.id}` : null);
18
+ ---
19
+ {people.length > 0 && (
20
+ <section class="ts ts-band" id="team"><div class="ts-wrap">
21
+ <Head n={n} action={bool(n.show_all, true) ? { href: "/team", label: "Meet the team" } : null} />
22
+ {variant === "one person featured" ? (() => { const p = people[0]!; const ph = pic(p.data.photo); return (
23
+ <div class="ts-split"><Photo image={ph.image} url={ph.url} alt={name(p)} ratio="tall" /><div data-tidy-record="people" data-tidy-record-id={p.id} data-tidy-record-label="Team member"><p class="ts-role">{str(p.data.role)}{showCred && str(p.data.credentials) ? ` · ${str(p.data.credentials)}` : ""}</p><h3 style="font-size:28px;margin-top:6px">{name(p)}</h3>{str(p.data.bio) && <p class="ts-prose" style="margin-top:12px">{str(p.data.bio)}</p>}{href(p) && <div class="ts-actions"><a class="ts-btn ts-btn-outline" href={href(p)!}>Full bio</a></div>}</div></div>
24
+ ); })() : variant === "photo grid" ? (
25
+ <div class="ts-gallery">{people.map((p) => { const ph = pic(p.data.photo); return <div data-tidy-record="people" data-tidy-record-id={p.id} data-tidy-record-label="Team member"><Photo image={ph.image} url={ph.url} alt={name(p)} ratio="square" /><p style="margin-top:6px;font-weight:600;font-size:14px">{name(p)}</p></div>; })}</div>
26
+ ) : (groups.length ? groups : [{ title: "", items: people }]).map((g) => (
27
+ <div>{g.title && <p class="ts-group">{g.title}</p>}
28
+ <div class="ts-grid" style="--cols:3">
29
+ {g.items.map((p) => { const ph = pic(p.data.photo); const h = href(p); return (
30
+ <div class="ts-card" data-tidy-record="people" data-tidy-record-id={p.id} data-tidy-record-label="Team member">
31
+ {(ph.image || ph.url) && <Photo image={ph.image} url={ph.url} alt={name(p)} ratio="tall" />}
32
+ <div><span class="ts-person-name">{name(p)}</span>{showCred && str(p.data.credentials) && <span class="ts-muted" style="font-size:13px"> · {str(p.data.credentials)}</span>}</div>
33
+ {str(p.data.role) && <p class="ts-role">{str(p.data.role)}</p>}
34
+ {showBio && str(p.data.bio) && <p>{str(p.data.bio)}</p>}
35
+ {str(p.data.languages) && <p class="ts-muted" style="font-size:13px">{str(p.data.languages)}</p>}
36
+ {h && <a href={h} style="font-weight:700;font-size:14px;color:var(--ts-primary)">Full bio</a>}
37
+ </div>
38
+ ); })}
39
+ </div></div>
40
+ ))}
41
+ </div></section>
42
+ )}
@@ -0,0 +1,13 @@
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("technologies", { limit: int(n.limit, 6) });
7
+ ---
8
+ {items.length > 0 && (
9
+ <section class="ts ts-band"><div class="ts-wrap">
10
+ <Head n={n} />
11
+ <div class="ts-grid" style="--cols:3">{items.map((t) => { const p = pic(t.data.image); return <div class="ts-card" data-tidy-record="technologies" data-tidy-record-id={t.id} data-tidy-record-label="Technology">{(p.image || p.url) && <Photo image={p.image} url={p.url} alt={str(t.data.title)} ratio="wide" />}<h3>{str(t.data.title)}{str(t.data.brand) && <span class="ts-muted" style="font-weight:400;font-size:14px"> · {str(t.data.brand)}</span>}</h3>{str(t.data.summary) && <p>{str(t.data.summary)}</p>}{bool(t.data.has_page) && <a href={`/technology/${t.slug ?? t.id}`} style="font-weight:700;color:var(--ts-primary);font-size:14px">Learn more</a>}</div>; })}</div>
12
+ </div></section>
13
+ )}
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const url = str(n.video_url);
6
+ const embed = url.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/").replace("vimeo.com/", "player.vimeo.com/video/");
7
+ const isFile = /\.(mp4|webm|mov)(\?|$)/i.test(url);
8
+ ---
9
+ {url && (
10
+ <section class="ts"><div class="ts-wrap">
11
+ <Head n={n} />
12
+ <div class="ts-video">{isFile ? <video src={url} controls playsinline></video> : <iframe src={embed} title={str(n.headline) || "Video"} loading="lazy" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>}</div>
13
+ {str(n.caption) && <p class="ts-muted" style="margin-top:10px;font-size:14px">{str(n.caption)}</p>}
14
+ </div></section>
15
+ )}