@sumaq/site-kit 0.2.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 (60) hide show
  1. package/README.md +93 -0
  2. package/package.json +57 -0
  3. package/src/astro-modules.d.ts +11 -0
  4. package/src/blocks/About.astro +94 -0
  5. package/src/blocks/Audience.astro +66 -0
  6. package/src/blocks/BlockRenderer.astro +105 -0
  7. package/src/blocks/Contact.astro +58 -0
  8. package/src/blocks/ContactDetails.astro +91 -0
  9. package/src/blocks/CtaBand.astro +53 -0
  10. package/src/blocks/EmergencyGrid.astro +47 -0
  11. package/src/blocks/EmergencyHelplines.astro +109 -0
  12. package/src/blocks/EmergencyPrimary.astro +51 -0
  13. package/src/blocks/Faq.astro +56 -0
  14. package/src/blocks/FeatureList.astro +73 -0
  15. package/src/blocks/Gallery.astro +95 -0
  16. package/src/blocks/Generic.astro +30 -0
  17. package/src/blocks/Hero.astro +82 -0
  18. package/src/blocks/IntroCta.astro +41 -0
  19. package/src/blocks/LegalDocument.astro +83 -0
  20. package/src/blocks/LocationsOverview.astro +161 -0
  21. package/src/blocks/NotFound.astro +33 -0
  22. package/src/blocks/Notice.astro +49 -0
  23. package/src/blocks/PageBanner.astro +57 -0
  24. package/src/blocks/Pricing.astro +97 -0
  25. package/src/blocks/ProfileDetail.astro +202 -0
  26. package/src/blocks/Projects.astro +91 -0
  27. package/src/blocks/Quote.astro +53 -0
  28. package/src/blocks/Services.astro +46 -0
  29. package/src/blocks/Skills.astro +65 -0
  30. package/src/blocks/Stats.astro +54 -0
  31. package/src/blocks/Steps.astro +76 -0
  32. package/src/blocks/TeamDirectory.astro +164 -0
  33. package/src/blocks/TeamTeaser.astro +84 -0
  34. package/src/blocks/Testimonials.astro +75 -0
  35. package/src/blocks/Timeline.astro +65 -0
  36. package/src/components/Actions.astro +67 -0
  37. package/src/components/Card.astro +104 -0
  38. package/src/components/Logo.astro +57 -0
  39. package/src/components/Media.astro +83 -0
  40. package/src/components/Prose.astro +29 -0
  41. package/src/components/ScrollToTop.astro +49 -0
  42. package/src/components/Section.astro +75 -0
  43. package/src/components/SectionHeader.astro +58 -0
  44. package/src/components/SectionHeading.astro +76 -0
  45. package/src/components/Seo.astro +55 -0
  46. package/src/components/SiteFooter.astro +90 -0
  47. package/src/components/SiteHeader.astro +97 -0
  48. package/src/config.ts +59 -0
  49. package/src/index.ts +75 -0
  50. package/src/layouts/Base.astro +57 -0
  51. package/src/lib/collections.ts +101 -0
  52. package/src/lib/content.ts +51 -0
  53. package/src/lib/format.ts +17 -0
  54. package/src/lib/markdown.ts +5 -0
  55. package/src/profiles/clinic.yaml +35 -0
  56. package/src/profiles/portfolio.yaml +27 -0
  57. package/src/profiles/therapist.yaml +31 -0
  58. package/src/scripts/enhancements.js +459 -0
  59. package/src/tokens/class-inventory.json +284 -0
  60. package/src/tokens/index.css +83 -0
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # `@sumaq/site-kit`
2
+
3
+ Layouts, blocks, helpers and Astro config factory for Sumaq client sites.
4
+
5
+ ```bash
6
+ pnpm add @sumaq/site-kit@0.2.0
7
+ ```
8
+
9
+ Public on [npmjs.com](https://www.npmjs.com/package/@sumaq/site-kit). Pin an exact version in each `www-*` site — never a range.
10
+
11
+ The kit owns the **markup**. Colours, sizes and typography stay in the client's `src/styles/site.css`; content stays in `content/*.json`. See [`block-contract.md`](../../amauta/packages/block-contract.md) for the class contract and [`inventario-componentes.md`](../../amauta/packages/inventario-componentes.md) for where each component came from.
12
+
13
+ ## The rendering rule
14
+
15
+ **If a field has content the element renders; if it does not, nothing is emitted.** No empty `<p>`, no empty `<ul>`, no section header with three blank lines — and no empty `<section>` either: `Section` renders its children first and skips the whole band when the result is blank. Because of that, kit blocks are server-only — do not put a `client:*` component inside a `Section`.
16
+
17
+ The checks live in `lib/content.ts` (`hasText`, `list`, `hasItems`, `hasImage`, `hasAny`, `cmsKey`) so every block decides the same way.
18
+
19
+ ## Catalogue
20
+
21
+ ### Layout
22
+
23
+ `Base` — `<html>` shell with SEO, header, footer, skip link, optional floating back-to-top, and a `head` slot for per-site fonts.
24
+
25
+ ### Chrome and primitives — `@sumaq/site-kit/components/*`
26
+
27
+ | Component | What it is |
28
+ |---|---|
29
+ | `Seo` | title, description, canonical, Open Graph, Twitter, optional JSON-LD |
30
+ | `SiteHeader` / `SiteFooter` | brand, navigation, footer columns, copyright |
31
+ | `Section` | the `<section class="sq-section sq-{name}">` + `sq-container` skeleton |
32
+ | `SectionHeader` / `SectionHeading` | label, title (+ accent), lede, note — with and without the `<header>` wrapper |
33
+ | `Actions` | a row of CTAs; honours `external` |
34
+ | `Card` | media, icon, badge, title, text, bullets; becomes an `<a>` when given `href` |
35
+ | `Logo` | brand link, falls back to the brand name when there is no image |
36
+ | `Media` | `<img>` for CMS strings, `astro:assets` `<Image>` for imported assets |
37
+ | `Prose` | markdown or HTML into `sq-prose` |
38
+ | `ScrollToTop` | floating back-to-top control |
39
+
40
+ ### Blocks — `@sumaq/site-kit/blocks/*`
41
+
42
+ | Group | Blocks |
43
+ |---|---|
44
+ | Openers | `Hero`, `PageBanner` |
45
+ | Content | `About`, `Audience`, `Faq`, `FeatureList`, `Gallery`, `Generic`, `Notice`, `Pricing`, `Projects`, `Quote`, `Services`, `Skills`, `Stats`, `Steps`, `Testimonials`, `Timeline` |
46
+ | Conversion | `Contact` (people), `ContactDetails` (the practice), `CtaBand`, `IntroCta` |
47
+ | Collections | `LocationsOverview`, `ProfileDetail`, `TeamDirectory`, `TeamTeaser` |
48
+ | Standalone pages | `LegalDocument`, `NotFound` |
49
+ | Therapist profile | `EmergencyPrimary`, `EmergencyGrid`, `EmergencyHelplines` |
50
+ | Dispatcher | `BlockRenderer` — maps `block.type` to a component, `Generic` as fallback |
51
+
52
+ Every block accepts `section` (the `data-cms` base key), `variant` (`light` / `muted` / …) and `id`, so the same component can appear twice on a page under different CMS keys.
53
+
54
+ ## Site-local components
55
+
56
+ The kit is where a site **starts**, not where it is confined — a client paying for something of their own gets it in their own repo. Two seams make that work without forking:
57
+
58
+ ```astro
59
+ ---
60
+ import { Base, BlockRenderer } from "@sumaq/site-kit";
61
+ import Rooms from "../components/Rooms.astro"; // only this client
62
+ ---
63
+ <BlockRenderer blocks={blocks} components={{ rooms: Rooms }} />
64
+ ```
65
+
66
+ `components` merges into the catalogue **and wins over it**, so a site can add a block the kit does not have or replace one it does. An unmatched `type` still falls back to `Generic`, and warns in dev so a custom block never fails silently. Data-side there is nothing to register: `@sumaq/cms-schema` validates each block against the site's own `cms/*.yaml`, never against a central list.
67
+
68
+ **The namespace rule:** `sq-*` belongs to the kit and is inventoried in `class-inventory.json`. Site-local components use their own prefix. That is what keeps the shared markup verifiable without taxing what makes a client's site theirs.
69
+
70
+ Promotion: if a local block shows up in three clients, it earns a place in the catalogue — see the checklist below.
71
+
72
+ ## Profiles
73
+
74
+ `profiles/{therapist,clinic,portfolio}.yaml` — a curated block subset, the required/optional pages, and a default composition per niche. Adding a niche is a profile plus whatever blocks it is missing, not a site from scratch.
75
+
76
+ ## Working on the kit
77
+
78
+ ```bash
79
+ pnpm typecheck # tsc over the TS surface
80
+ pnpm check:catalogue # every block is exported, reachable and in the profiles
81
+ pnpm classes # regenerate src/tokens/class-inventory.json
82
+ pnpm test # typecheck + catalogue
83
+ ```
84
+
85
+ `class-inventory.json` is derived from the source — never hand-edit it; run `pnpm classes` after touching markup. To check the data contract too:
86
+
87
+ ```bash
88
+ node scripts/check-catalogue.mjs --registry ../../../sumaq-cli/skills/sumaq-site-builder/blocks-registry.yaml
89
+ ```
90
+
91
+ ## Adding a block
92
+
93
+ Follow the checklist in [`block-contract.md` §8](../../amauta/packages/block-contract.md): the regular skeleton, `data-cms` on every editable node, no `<style>`, no colours, no measurements, and no hardcoded copy — a label the client can read is content, not markup.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@sumaq/site-kit",
3
+ "version": "0.2.0",
4
+ "description": "Layouts, blocks, helpers and Astro config factory for Sumaq client sites.",
5
+ "license": "UNLICENSED",
6
+ "author": "elingan",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/elingan/sumaq-packages.git",
10
+ "directory": "packages/site-kit"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./config": "./src/config.ts",
16
+ "./blocks/*": "./src/blocks/*.astro",
17
+ "./components/*": "./src/components/*.astro",
18
+ "./layouts/*": "./src/layouts/*.astro",
19
+ "./collections": "./src/lib/collections.ts",
20
+ "./content": "./src/lib/content.ts",
21
+ "./format": "./src/lib/format.ts",
22
+ "./markdown": "./src/lib/markdown.ts",
23
+ "./tokens.css": "./src/tokens/index.css",
24
+ "./class-inventory.json": "./src/tokens/class-inventory.json",
25
+ "./profiles/*": "./src/profiles/*.yaml",
26
+ "./scripts/*": "./src/scripts/*"
27
+ },
28
+ "peerDependencies": {
29
+ "astro": "^7.1.0",
30
+ "sharp": "^0.35.0"
31
+ },
32
+ "dependencies": {
33
+ "@astrojs/sitemap": "3.7.3",
34
+ "marked": "15.0.7",
35
+ "@sumaq/cms-schema": "0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "astro": "7.1.4",
39
+ "typescript": "5.8.3"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "registry": "https://registry.npmjs.org/"
44
+ },
45
+ "files": [
46
+ "src"
47
+ ],
48
+ "engines": {
49
+ "node": ">=22.12.0"
50
+ },
51
+ "scripts": {
52
+ "typecheck": "tsc -p tsconfig.json",
53
+ "classes": "node scripts/build-class-inventory.mjs",
54
+ "check:catalogue": "node scripts/check-catalogue.mjs",
55
+ "test": "pnpm run typecheck && pnpm run check:catalogue"
56
+ }
57
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Lets plain `tsc` resolve the `.astro` re-exports in `index.ts`.
3
+ *
4
+ * It does not typecheck the components themselves — that needs `@astrojs/check`,
5
+ * which the package does not depend on yet. When it does, this shim goes away
6
+ * and the real prop types take over.
7
+ */
8
+ declare module "*.astro" {
9
+ const component: (props: Record<string, any>) => any;
10
+ export default component;
11
+ }
@@ -0,0 +1,94 @@
1
+ ---
2
+ /**
3
+ * Practitioner / company introduction with a portrait.
4
+ *
5
+ * Extracted from `AboutMe.astro` (www-utehofer-at), `About.astro`
6
+ * (www-bettinageidl-at) and `AboutSection.astro` (www-juliaschaffer-at): the
7
+ * three are the same split — copy on one side, portrait on the other, plus an
8
+ * identity line (name + qualifications).
9
+ */
10
+ import Section from "../components/Section.astro";
11
+ import SectionHeader from "../components/SectionHeader.astro";
12
+ import Media from "../components/Media.astro";
13
+ import Prose from "../components/Prose.astro";
14
+ import Actions from "../components/Actions.astro";
15
+ import { cmsKey, hasAny, hasImage, hasText, list } from "../lib/content";
16
+
17
+ const {
18
+ sectionLabel,
19
+ sectionTitle,
20
+ sectionTitleAccent,
21
+ sectionSubtitle,
22
+ body,
23
+ image,
24
+ name,
25
+ role,
26
+ qualifications = [],
27
+ ctas = [],
28
+ /** Portrait first instead of copy first. */
29
+ reverse = false,
30
+ section = "about",
31
+ variant = "light",
32
+ id = "about",
33
+ } = Astro.props;
34
+
35
+ const cms = section;
36
+ const badges = list<any>(qualifications)
37
+ .map((item) => (typeof item === "string" ? item : item.text))
38
+ .filter(hasText);
39
+ const hasCopy = hasAny(body, name, role, badges, ctas);
40
+ ---
41
+
42
+ <Section name="about" variant={variant} id={id} cms={cms}>
43
+ <SectionHeader
44
+ label={sectionLabel}
45
+ title={sectionTitle}
46
+ titleAccent={sectionTitleAccent}
47
+ lede={sectionSubtitle}
48
+ cms={cms}
49
+ />
50
+ {
51
+ (hasCopy || hasImage(image)) && (
52
+ <div class:list={["sq-about__body", reverse && "sq-about__body--reverse"]}>
53
+ {hasCopy && (
54
+ <div class="sq-about__copy">
55
+ {hasAny(name, role, badges) && (
56
+ <div class="sq-about__identity">
57
+ {hasText(name) && (
58
+ <p class="sq-about__name" data-cms={cmsKey(cms, "name")}>
59
+ {name}
60
+ </p>
61
+ )}
62
+ {hasText(role) && (
63
+ <p class="sq-about__role" data-cms={cmsKey(cms, "role")}>
64
+ {role}
65
+ </p>
66
+ )}
67
+ {badges.length > 0 && (
68
+ <ul class="sq-about__qualifications">
69
+ {badges.map((badge, i) => (
70
+ <li data-cms={cmsKey(cms, "qualifications", i, "text")}>{badge}</li>
71
+ ))}
72
+ </ul>
73
+ )}
74
+ </div>
75
+ )}
76
+ <Prose body={body} cms={cmsKey(cms, "body")} />
77
+ <Actions ctas={ctas} cms={cmsKey(cms, "ctas")} />
78
+ </div>
79
+ )}
80
+ {hasImage(image) && (
81
+ <figure class="sq-about__media">
82
+ <Media
83
+ src={image}
84
+ alt={image?.alt || name}
85
+ width={720}
86
+ height={900}
87
+ cms={cmsKey(cms, "image", "src")}
88
+ />
89
+ </figure>
90
+ )}
91
+ </div>
92
+ )
93
+ }
94
+ </Section>
@@ -0,0 +1,66 @@
1
+ ---
2
+ import Section from "../components/Section.astro";
3
+ import SectionHeader from "../components/SectionHeader.astro";
4
+ import Media from "../components/Media.astro";
5
+ import { cmsKey, hasImage, hasText, list } from "../lib/content";
6
+
7
+ const {
8
+ sectionLabel,
9
+ sectionTitle,
10
+ sectionTitleAccent,
11
+ sectionSubtitle,
12
+ sectionNote,
13
+ items = [],
14
+ section = "audience",
15
+ variant = "light",
16
+ id = "audience",
17
+ } = Astro.props;
18
+
19
+ const cms = section;
20
+ const cards = list<any>(items).filter(
21
+ (item) => hasText(item.title) || hasImage(item.image) || list(item.bullets).length > 0,
22
+ );
23
+ ---
24
+
25
+ <Section name="audience" variant={variant} id={id} cms={cms}>
26
+ <SectionHeader
27
+ label={sectionLabel}
28
+ title={sectionTitle}
29
+ titleAccent={sectionTitleAccent}
30
+ lede={sectionSubtitle}
31
+ note={sectionNote}
32
+ cms={cms}
33
+ />
34
+ {
35
+ cards.length > 0 && (
36
+ <div class="sq-audience__grid">
37
+ {cards.map((item: any, i: number) => {
38
+ const bullets = list<any>(item.bullets).filter((bullet) => hasText(bullet.text));
39
+ return (
40
+ <article class="sq-audience__card">
41
+ <Media
42
+ src={item.image}
43
+ alt={item.image?.alt}
44
+ cms={cmsKey(cms, "items", i, "image", "src")}
45
+ />
46
+ <div class="sq-audience__body">
47
+ {hasText(item.title) && (
48
+ <h3 data-cms={cmsKey(cms, "items", i, "title")}>{item.title}</h3>
49
+ )}
50
+ {bullets.length > 0 && (
51
+ <ul>
52
+ {bullets.map((bullet: any, bi: number) => (
53
+ <li data-cms={cmsKey(cms, "items", i, "bullets", bi, "text")}>
54
+ {bullet.text}
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ )}
59
+ </div>
60
+ </article>
61
+ );
62
+ })}
63
+ </div>
64
+ )
65
+ }
66
+ </Section>
@@ -0,0 +1,105 @@
1
+ ---
2
+ import About from "./About.astro";
3
+ import Audience from "./Audience.astro";
4
+ import Contact from "./Contact.astro";
5
+ import ContactDetails from "./ContactDetails.astro";
6
+ import CtaBand from "./CtaBand.astro";
7
+ import EmergencyGrid from "./EmergencyGrid.astro";
8
+ import EmergencyHelplines from "./EmergencyHelplines.astro";
9
+ import EmergencyPrimary from "./EmergencyPrimary.astro";
10
+ import Faq from "./Faq.astro";
11
+ import FeatureList from "./FeatureList.astro";
12
+ import Gallery from "./Gallery.astro";
13
+ import Generic from "./Generic.astro";
14
+ import Hero from "./Hero.astro";
15
+ import IntroCta from "./IntroCta.astro";
16
+ import LegalDocument from "./LegalDocument.astro";
17
+ import LocationsOverview from "./LocationsOverview.astro";
18
+ import NotFound from "./NotFound.astro";
19
+ import Notice from "./Notice.astro";
20
+ import PageBanner from "./PageBanner.astro";
21
+ import Pricing from "./Pricing.astro";
22
+ import ProfileDetail from "./ProfileDetail.astro";
23
+ import Projects from "./Projects.astro";
24
+ import Quote from "./Quote.astro";
25
+ import Services from "./Services.astro";
26
+ import Skills from "./Skills.astro";
27
+ import Stats from "./Stats.astro";
28
+ import Steps from "./Steps.astro";
29
+ import TeamDirectory from "./TeamDirectory.astro";
30
+ import TeamTeaser from "./TeamTeaser.astro";
31
+ import Testimonials from "./Testimonials.astro";
32
+ import Timeline from "./Timeline.astro";
33
+ import { list } from "../lib/content";
34
+
35
+ const BLOCK_COMPONENTS: Record<string, any> = {
36
+ about: About,
37
+ audience: Audience,
38
+ contact: Contact,
39
+ contactDetails: ContactDetails,
40
+ cta: CtaBand,
41
+ emergencyGrid: EmergencyGrid,
42
+ emergencyHelplines: EmergencyHelplines,
43
+ emergencyPrimary: EmergencyPrimary,
44
+ faq: Faq,
45
+ featureList: FeatureList,
46
+ gallery: Gallery,
47
+ generic: Generic,
48
+ hero: Hero,
49
+ introCta: IntroCta,
50
+ legal: LegalDocument,
51
+ locationsOverview: LocationsOverview,
52
+ notFound: NotFound,
53
+ notice: Notice,
54
+ pageBanner: PageBanner,
55
+ pricing: Pricing,
56
+ profileDetail: ProfileDetail,
57
+ projects: Projects,
58
+ quote: Quote,
59
+ services: Services,
60
+ skills: Skills,
61
+ stats: Stats,
62
+ steps: Steps,
63
+ teamDirectory: TeamDirectory,
64
+ teamTeaser: TeamTeaser,
65
+ testimonials: Testimonials,
66
+ timeline: Timeline,
67
+ };
68
+
69
+ /**
70
+ * Renders CMS blocks. Collection-backed blocks must already carry their items
71
+ * (members / locations) — resolve them in the page with @sumaq/site-kit/collections.
72
+ *
73
+ * `components` merges the site's own blocks into the map and wins over the
74
+ * catalogue, so a client repo can add a block the kit does not have — or
75
+ * replace one it does — without forking (block-contract §7):
76
+ *
77
+ * import Rooms from "../components/Rooms.astro";
78
+ * <BlockRenderer blocks={blocks} components={{ rooms: Rooms }} />
79
+ *
80
+ * A `type` that matches nothing falls back to `Generic` so a schema that runs
81
+ * ahead of the kit still builds; in dev it also warns, because silently
82
+ * rendering the wrong markup is how a custom block goes unnoticed.
83
+ */
84
+ const { blocks = [], components = {} } = Astro.props;
85
+ const registry: Record<string, any> = { ...BLOCK_COMPONENTS, ...components };
86
+ const entries = list<any>(blocks).filter((block) => block && typeof block === "object");
87
+
88
+ if (import.meta.env.DEV) {
89
+ for (const block of entries) {
90
+ if (block.type && !registry[block.type]) {
91
+ console.warn(
92
+ `[site-kit] Unknown block type "${block.type}" — falling back to Generic. ` +
93
+ `Pass it in <BlockRenderer components={{ ${block.type}: … }} /> if it is a site-local block.`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+ ---
99
+
100
+ {
101
+ entries.map((block: any) => {
102
+ const Comp = registry[block.type] ?? Generic;
103
+ return <Comp {...block} />;
104
+ })
105
+ }
@@ -0,0 +1,58 @@
1
+ ---
2
+ import Section from "../components/Section.astro";
3
+ import SectionHeader from "../components/SectionHeader.astro";
4
+ import { telHref } from "../lib/format";
5
+ import { cmsKey, hasText, list } from "../lib/content";
6
+
7
+ const {
8
+ sectionLabel,
9
+ sectionTitle,
10
+ sectionTitleAccent,
11
+ sectionSubtitle,
12
+ cards = [],
13
+ section = "contact",
14
+ variant = "muted",
15
+ id = "contact",
16
+ } = Astro.props;
17
+
18
+ const cms = section;
19
+ const people = list<any>(cards).filter(
20
+ (card) => hasText(card.name) || hasText(card.email) || hasText(card.phone),
21
+ );
22
+ ---
23
+
24
+ <Section name="contact" variant={variant} id={id} cms={cms}>
25
+ <SectionHeader
26
+ label={sectionLabel}
27
+ title={sectionTitle}
28
+ titleAccent={sectionTitleAccent}
29
+ lede={sectionSubtitle}
30
+ cms={cms}
31
+ />
32
+ {
33
+ people.length > 0 && (
34
+ <div class:list={["sq-contact__grid", people.length === 1 && "sq-contact__grid--single"]}>
35
+ {people.map((card: any, i: number) => (
36
+ <article class="sq-contact__card">
37
+ {hasText(card.name) && <h3 data-cms={cmsKey(cms, "cards", i, "name")}>{card.name}</h3>}
38
+ {hasText(card.role) && (
39
+ <p class="sq-contact__role" data-cms={cmsKey(cms, "cards", i, "role")}>
40
+ {card.role}
41
+ </p>
42
+ )}
43
+ {hasText(card.email) && (
44
+ <a href={`mailto:${card.email}`} data-cms={cmsKey(cms, "cards", i, "email")}>
45
+ {card.email}
46
+ </a>
47
+ )}
48
+ {hasText(card.phone) && (
49
+ <a href={telHref(card.phone)} data-cms={cmsKey(cms, "cards", i, "phone")}>
50
+ {card.phone}
51
+ </a>
52
+ )}
53
+ </article>
54
+ ))}
55
+ </div>
56
+ )
57
+ }
58
+ </Section>
@@ -0,0 +1,91 @@
1
+ ---
2
+ /**
3
+ * Address / phone / email / hours as a definition list.
4
+ *
5
+ * The kit's `Contact` block lists *people* (name + role + reachability); this
6
+ * one lists the *practice*, which is what `ContactSection.astro`
7
+ * (www-juliaschaffer-at), `Contact.astro` (www-utehofer-at) and
8
+ * `Contact.astro` (www-bettinageidl-at) each solved separately.
9
+ *
10
+ * `type` derives the href, so the CMS stores a value, never a `mailto:`.
11
+ */
12
+ import Section from "../components/Section.astro";
13
+ import SectionHeader from "../components/SectionHeader.astro";
14
+ import Actions from "../components/Actions.astro";
15
+ import Media from "../components/Media.astro";
16
+ import { telHref } from "../lib/format";
17
+ import { cmsKey, hasImage, hasText, list } from "../lib/content";
18
+
19
+ const {
20
+ sectionLabel,
21
+ sectionTitle,
22
+ sectionTitleAccent,
23
+ sectionSubtitle,
24
+ items = [],
25
+ ctas = [],
26
+ section = "contactDetails",
27
+ variant = "muted",
28
+ id = "contact",
29
+ } = Astro.props;
30
+
31
+ const cms = section;
32
+
33
+ const hrefFor = (item: any): string | undefined => {
34
+ if (hasText(item.href)) return item.href;
35
+ if (!hasText(item.value)) return undefined;
36
+ if (item.type === "email") return `mailto:${item.value}`;
37
+ if (item.type === "phone") return telHref(item.value);
38
+ return undefined;
39
+ };
40
+
41
+ const rows = list<any>(items)
42
+ .filter((item) => hasText(item.value) || hasText(item.label))
43
+ .map((item) => ({ ...item, resolvedHref: hrefFor(item) }));
44
+ ---
45
+
46
+ <Section name="contactDetails" variant={variant} id={id} cms={cms}>
47
+ <SectionHeader
48
+ label={sectionLabel}
49
+ title={sectionTitle}
50
+ titleAccent={sectionTitleAccent}
51
+ lede={sectionSubtitle}
52
+ cms={cms}
53
+ />
54
+ {
55
+ rows.length > 0 && (
56
+ <dl class="sq-contact-details__body">
57
+ {rows.map((item: any, i: number) => (
58
+ <div class="sq-contact-details__row">
59
+ {hasImage(item.icon) && (
60
+ <span class="sq-contact-details__icon" aria-hidden="true">
61
+ <Media src={item.icon} alt="" cms={cmsKey(cms, "items", i, "icon")} />
62
+ </span>
63
+ )}
64
+ {hasText(item.label) && (
65
+ <dt class="sq-contact-details__label" data-cms={cmsKey(cms, "items", i, "label")}>
66
+ {item.label}
67
+ </dt>
68
+ )}
69
+ {hasText(item.value) && (
70
+ <dd class="sq-contact-details__value">
71
+ {item.resolvedHref ? (
72
+ <a
73
+ href={item.resolvedHref}
74
+ target={item.external ? "_blank" : undefined}
75
+ rel={item.external ? "noopener" : undefined}
76
+ data-cms={cmsKey(cms, "items", i, "value")}
77
+ >
78
+ {item.value}
79
+ </a>
80
+ ) : (
81
+ <span data-cms={cmsKey(cms, "items", i, "value")}>{item.value}</span>
82
+ )}
83
+ </dd>
84
+ )}
85
+ </div>
86
+ ))}
87
+ </dl>
88
+ )
89
+ }
90
+ <Actions ctas={ctas} cms={cmsKey(cms, "ctas")} />
91
+ </Section>
@@ -0,0 +1,53 @@
1
+ ---
2
+ import Actions from "../components/Actions.astro";
3
+ import { cmsKey, hasAny, hasText } from "../lib/content";
4
+
5
+ const {
6
+ sectionTitle,
7
+ sectionTitleAccent,
8
+ sectionSubtitle,
9
+ ctas = [],
10
+ section = "cta",
11
+ } = Astro.props;
12
+
13
+ const cms = section;
14
+ const hasTitle = hasText(sectionTitle) || hasText(sectionTitleAccent);
15
+ const hasCopy = hasAny(sectionTitle, sectionTitleAccent, sectionSubtitle);
16
+ ---
17
+
18
+ {
19
+ hasAny(sectionTitle, sectionTitleAccent, sectionSubtitle, ctas) && (
20
+ <section
21
+ class="sq-cta"
22
+ aria-labelledby={hasTitle ? "cta-title" : undefined}
23
+ data-cms={cms}
24
+ >
25
+ <div class="sq-container sq-cta__inner">
26
+ {hasCopy && (
27
+ <div>
28
+ {hasTitle && (
29
+ <h2 id="cta-title" class="sq-cta__title" data-cms={cmsKey(cms, "sectionTitle")}>
30
+ {sectionTitle}
31
+ {hasText(sectionTitleAccent) && (
32
+ <span class="sq-accent" data-cms={cmsKey(cms, "sectionTitleAccent")}>
33
+ {sectionTitleAccent}
34
+ </span>
35
+ )}
36
+ </h2>
37
+ )}
38
+ {hasText(sectionSubtitle) && (
39
+ <p data-cms={cmsKey(cms, "sectionSubtitle")}>{sectionSubtitle}</p>
40
+ )}
41
+ </div>
42
+ )}
43
+ <Actions
44
+ ctas={ctas}
45
+ class="sq-cta__actions"
46
+ cms={cmsKey(cms, "ctas")}
47
+ secondaryClass="sq-btn--outline-light"
48
+ primaryFirst
49
+ />
50
+ </div>
51
+ </section>
52
+ )
53
+ }
@@ -0,0 +1,47 @@
1
+ ---
2
+ import { cmsKey, hasText, list } from "../lib/content";
3
+
4
+ const { sectionTitle, items = [], section = "emergencyGrid", callLabel } = Astro.props;
5
+ const cms = section;
6
+ const entries = list<any>(items).filter((item) => hasText(item.name) || hasText(item.number));
7
+ ---
8
+
9
+ {
10
+ hasText(sectionTitle) && (
11
+ <h2
12
+ class="sq-section__title sq-center sq-section__title--compact"
13
+ data-cms={cmsKey(cms, "sectionTitle")}
14
+ >
15
+ {sectionTitle}
16
+ </h2>
17
+ )
18
+ }
19
+ {
20
+ entries.length > 0 && (
21
+ <div class="sq-emergency__grid" data-cms={cms}>
22
+ {entries.map((item: any, i: number) => (
23
+ <a
24
+ class="sq-emergency__item"
25
+ href={item.href}
26
+ aria-label={hasText(callLabel) ? `${item.name} — ${callLabel}` : item.name}
27
+ >
28
+ {hasText(item.name) && (
29
+ <span class="sq-emergency__item-name" data-cms={cmsKey(cms, "items", i, "name")}>
30
+ {item.name}
31
+ </span>
32
+ )}
33
+ {hasText(item.desc) && (
34
+ <span class="sq-emergency__item-desc" data-cms={cmsKey(cms, "items", i, "desc")}>
35
+ {item.desc}
36
+ </span>
37
+ )}
38
+ {hasText(item.number) && (
39
+ <span class="sq-emergency__item-number" data-cms={cmsKey(cms, "items", i, "number")}>
40
+ {item.number}
41
+ </span>
42
+ )}
43
+ </a>
44
+ ))}
45
+ </div>
46
+ )
47
+ }