@dloizides/marketing-astro-kit 1.0.1 → 1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ - New `DemoFrame.astro` — a themeable, **client-side "try it" demo** shell for
6
+ static marketing sites (no backend): an eyebrow/title/intro, a row of clickable
7
+ example chips, a per-example result panel (bespoke product body injected via a
8
+ named slot `result-<example.id>`), a clearly-visible "sample data" disclaimer,
9
+ and a primary CTA (reuses `PrimaryCta`). Tiny inline vanilla JS toggles which
10
+ result shows on chip click (+ arrow-key roving tablist); the first example is
11
+ visible without JS (progressive enhancement). New `DemoExample` / `DemoFrameData`
12
+ types in `schema.ts`.
13
+ - New `GuideCard.astro` — a themeable card for a **guides / SEO content-hub**
14
+ listing, rendering one guide's tags, title, description and date. New
15
+ `GuideCardData` type.
16
+ - New `guideFrontmatterSchema` (Zod, built on `astro/zod`) + `GuideFrontmatter`
17
+ type in `schema.ts` — plug straight into an Astro content collection
18
+ (`defineCollection({ loader: glob(...), schema: guideFrontmatterSchema })`) so
19
+ every product's guides validate the same shape (title, description, slug?,
20
+ publishDate, updatedDate?, tags, ogImage?, canonical?, draft?). The article
21
+ page + routes + JSON-LD stay product-owned.
22
+ - `schema.ts` now imports `z` from `astro/zod` (Astro is already a peer dep) —
23
+ the ONE runtime import; every other export stays plain types.
24
+
25
+ ## 1.1.0
26
+
27
+ - New `PrimaryCta.astro` — a themeable primary call-to-action (link OR spam-safe
28
+ contact) with an optional secondary CTA and a `tone: 'light' | 'dark'` for
29
+ dark bands. Renders a `MarketingCta` so link-vs-lead-capture + analytics wiring
30
+ is no longer hand-coded per page.
31
+ - New `buildPrimaryCta(opts)` helper + `PrimaryCtaOptions` in `schema.ts`: drive
32
+ a primary CTA from ONE `SIGNUP_URL` constant — a real signup link when set,
33
+ spam-safe lead-capture when `null` (flip at GA with no template change).
34
+ - `ctaAttrs()` promoted from `PricingComparison.astro` into `schema.ts` and now
35
+ shared by every CTA-rendering component (no duplication).
36
+ - `TrustSection` gains three optional, guarded fields (existing sites
37
+ unaffected): `foundingPartner` ("first N free during beta" panel),
38
+ `founder` (honest one-line credibility) and `sampleDeliverable` (a "here's the
39
+ actual report you get" showcase — image, or a `slot="sample-deliverable"` for
40
+ bespoke mock markup while the section chrome stays shared).
41
+
3
42
  ## 1.0.0
4
43
 
5
44
  - Initial release. `PricingComparison.astro` (Free-vs-Pro comparison table with
package/README.md CHANGED
@@ -1,12 +1,69 @@
1
1
  # @dloizides/marketing-astro-kit
2
2
 
3
- Two reusable, **themeable** Astro marketing sections for product sites — the
4
- credibility markers an executive scans for before they trust a SaaS:
3
+ Reusable, **themeable** Astro marketing sections for product sites — the
4
+ credibility + conversion markers an executive scans for before they trust a SaaS:
5
5
 
6
6
  | Component | What it is |
7
7
  |---|---|
8
8
  | `PricingComparison.astro` | A clean **Free-vs-Pro(-vs-Business…) comparison table** — tier columns with price + CTA header, grouped feature rows (check / cross / short value), an optional footnote and an optional one-time "callout" card (e.g. a concierge/service tier). |
9
- | `TrustSection.astro` | A **trust / credibility** block — security & compliance cards with inline icons, a customer-logo row (real + clearly-labeled **placeholders**), testimonial cards (clearly-labeled **placeholders**), and a discreet **built-by** line. |
9
+ | `TrustSection.astro` | A **trust / credibility** block — security & compliance cards with inline icons, a customer-logo row (real + clearly-labeled **placeholders**), testimonial cards (clearly-labeled **placeholders**), an optional **founding-partner** panel, an honest **founder** line, a **sample-deliverable** showcase, and a discreet **built-by** line. |
10
+ | `PrimaryCta.astro` | A themeable **primary call-to-action** (link OR spam-safe contact) with an optional secondary CTA. Pair with `buildPrimaryCta` so ONE `SIGNUP_URL` constant drives whether it's a real signup link or lead-capture. |
11
+ | `DemoFrame.astro` | A themeable, **client-side "try it" demo** shell for static sites (no backend): eyebrow/title/intro, a row of clickable example chips, a per-example result panel (bespoke product body via a named `result-<id>` slot), a "sample data" disclaimer, and a primary CTA. Tiny inline vanilla JS toggles which result shows; first example visible without JS. |
12
+ | `GuideCard.astro` | A themeable **card for a guides / SEO content-hub** listing — one guide's tags, title, description and date. Pair with `guideFrontmatterSchema` on an Astro content collection. |
13
+
14
+ ## Config-driven primary CTA (`PrimaryCta` + `buildPrimaryCta`)
15
+
16
+ Instead of hand-coding "is this a link to the app or a lead-capture mailto?" per
17
+ page (with "flip to APP_URL at GA" TODO comments), hold ONE `SIGNUP_URL`
18
+ constant and let the CTA switch itself:
19
+
20
+ ```ts
21
+ // src/data/site.ts
22
+ export const SIGNUP_URL: string | null = null; // null → app not public yet → lead-capture
23
+ ```
24
+
25
+ ```ts
26
+ // src/data/marketing.ts
27
+ import { buildPrimaryCta } from '@marketing-kit/schema';
28
+ import { SIGNUP_URL } from './site';
29
+
30
+ export const primaryCta = buildPrimaryCta({
31
+ signupUrl: SIGNUP_URL, // set → real link ("Start free"); null → contact
32
+ linkLabel: 'Start free',
33
+ contactLabel: 'Request early access',
34
+ contact: { local: 'hello', domain: 'example.com' },
35
+ analyticsEvent: 'cta_primary',
36
+ analyticsData: { location: 'hero' },
37
+ });
38
+ ```
39
+
40
+ ```astro
41
+ ---
42
+ import PrimaryCta from '@marketing-kit/PrimaryCta.astro';
43
+ import { primaryCta, secondaryCta } from '../data/marketing';
44
+ ---
45
+ <PrimaryCta cta={primaryCta} secondary={secondaryCta} /> <!-- on light -->
46
+ <PrimaryCta cta={primaryCta} secondary={secondaryCta} tone="dark" align="center" /> <!-- on a dark band -->
47
+ ```
48
+
49
+ Flip `SIGNUP_URL` from `null` to a real URL (e.g. at GA) and every primary CTA
50
+ becomes a real "Start free" link — no template change. Contact CTAs still rely on
51
+ the host page's `[data-contact]` reveal handler.
52
+
53
+ ## Founding-partner / founder / sample-deliverable (`TrustSection` extras)
54
+
55
+ All three `TrustSectionData` fields are optional and render behind guards —
56
+ omitting them = the previous behaviour. The sample-deliverable accepts an
57
+ `image`, OR a product can inject bespoke mock markup via a named slot while the
58
+ eyebrow/title/caption chrome stays shared:
59
+
60
+ ```astro
61
+ <TrustSection data={trust} id="trust">
62
+ <Fragment slot="sample-deliverable">
63
+ <!-- your bespoke mock report / screenshot markup -->
64
+ </Fragment>
65
+ </TrustSection>
66
+ ```
10
67
 
11
68
  Both are **data-driven** (one typed object per section, see [`src/schema.ts`](./src/schema.ts))
12
69
  and **themed entirely through the host site's CSS custom properties** — drop them
@@ -17,6 +74,93 @@ hooks → Lighthouse-light.
17
74
  Extracted from `kefi-marketing` as the 2nd genuine use (per the "extract on the
18
75
  second use" rule); the Astro marketing sites otherwise share no code.
19
76
 
77
+ ## Client-side "try it" demo (`DemoFrame`)
78
+
79
+ Static marketing sites (nginx, no backend) can still offer an interactive demo —
80
+ `DemoFrame` renders the shared chrome around **curated, clearly-labelled sample
81
+ data** that runs entirely in the browser. The frame owns the eyebrow/title/intro,
82
+ the clickable example chips, a "sample data" disclaimer and the primary CTA; each
83
+ product injects its own bespoke **result body per example** through a named slot
84
+ (`result-<example.id>`), because that markup mirrors the product's real output:
85
+
86
+ ```ts
87
+ // src/data/marketing.ts
88
+ import type { DemoFrameData } from '@marketing-kit/schema';
89
+ export const demo: DemoFrameData = {
90
+ eyebrow: 'See it in action',
91
+ title: 'Screen a wallet — watch the verdict',
92
+ examples: [
93
+ { id: 'flagged', label: '0x…illustrative', sublabel: 'Sanctioned' },
94
+ { id: 'clean', label: 'Clean wallet', sublabel: 'No matches' },
95
+ ],
96
+ disclaimer: 'Sample results on example wallets. Create a free account to screen any address.',
97
+ cta: primaryCta, // a MarketingCta driving to signup
98
+ };
99
+ ```
100
+
101
+ ```astro
102
+ ---
103
+ import DemoFrame from '@marketing-kit/DemoFrame.astro';
104
+ import { demo } from '../data/marketing';
105
+ ---
106
+ <DemoFrame data={demo} id="demo">
107
+ <!-- One panel per example, keyed by data-demo-result (matching an example id).
108
+ Mark the FIRST panel data-active so it shows without JS. -->
109
+ <div data-demo-result="flagged" data-active
110
+ id="demo-panel-flagged" role="tabpanel" aria-labelledby="demo-chip-flagged">
111
+ <!-- bespoke high-risk result mock -->
112
+ </div>
113
+ <div data-demo-result="clean"
114
+ id="demo-panel-clean" role="tabpanel" aria-labelledby="demo-chip-clean">
115
+ <!-- bespoke low-risk result mock -->
116
+ </div>
117
+ </DemoFrame>
118
+ ```
119
+
120
+ Astro forbids a dynamic `slot[name]`, so panels are routed through the default
121
+ slot and keyed by a `data-demo-result` attribute (matching an example `id`)
122
+ rather than named slots. The frame's chip ids are `<id>-chip-<exampleId>` and it
123
+ wires `aria-controls` to `<id>-panel-<exampleId>`, so give each host panel
124
+ `id="<id>-panel-<exampleId>"` + `aria-labelledby="<id>-chip-<exampleId>"`. The
125
+ **first panel (`data-active`) is visible without JS** — the inline script only
126
+ moves the active panel on chip click (and arrow-key roving for the tablist). Keep
127
+ the result copy honest: real, public example data or an obviously-illustrative
128
+ placeholder, never a fabricated claim.
129
+
130
+ ## SEO guides / content hub (`GuideCard` + `guideFrontmatterSchema`)
131
+
132
+ A shared pattern for a product's guides flywheel: the kit supplies the **Zod
133
+ frontmatter schema** (plug into an Astro content collection) and the **card** for
134
+ the hub; the routes, article layout and JSON-LD stay product-owned.
135
+
136
+ ```ts
137
+ // src/content.config.ts
138
+ import { defineCollection } from 'astro:content';
139
+ import { glob } from 'astro/loaders';
140
+ import { guideFrontmatterSchema } from '@marketing-kit/schema';
141
+
142
+ const guides = defineCollection({
143
+ loader: glob({ pattern: '**/*.md', base: './src/content/guides' }),
144
+ schema: guideFrontmatterSchema,
145
+ });
146
+ export const collections = { guides };
147
+ ```
148
+
149
+ ```astro
150
+ ---
151
+ // src/pages/guides/index.astro
152
+ import { getCollection } from 'astro:content';
153
+ import GuideCard from '@marketing-kit/GuideCard.astro';
154
+ const guides = (await getCollection('guides', ({ data }) => !data.draft))
155
+ .sort((a, b) => +b.data.publishDate - +a.data.publishDate);
156
+ ---
157
+ {guides.map((g) => <GuideCard data={{ ...g.data, href: `/guides/${g.id}/` }} />)}
158
+ ```
159
+
160
+ `guideFrontmatterSchema` fields: `title`, `description`, `slug?`, `publishDate`,
161
+ `updatedDate?`, `tags`, `ogImage?`, `canonical?`, `draft?`. It is built on
162
+ `astro/zod`, so it is the same zod instance Astro's content layer uses.
163
+
20
164
  ## Theming contract
21
165
 
22
166
  The components read these CSS vars off the host `:root` (each has a sensible
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@dloizides/marketing-astro-kit",
3
- "version": "1.0.1",
4
- "description": "Reusable, themeable Astro marketing sections for product sites — a Free-vs-Pro pricing comparison table and a trust / credibility section (security cards, customer-logo + testimonial placeholders, discreet built-by). Data-driven via a shared TypeScript schema; themed entirely through the host site's CSS custom properties.",
3
+ "version": "1.2.0",
4
+ "description": "Reusable, themeable Astro marketing sections for product sites — a Free-vs-Pro pricing comparison table, a trust / credibility section (security cards, customer-logo + testimonial placeholders, discreet built-by), a client-side \"try it\" demo shell, and an SEO guides/content-hub card + frontmatter schema. Data-driven via a shared TypeScript schema; themed entirely through the host site's CSS custom properties.",
5
5
  "keywords": [
6
6
  "astro",
7
7
  "marketing",
8
8
  "pricing",
9
9
  "comparison-table",
10
10
  "trust",
11
+ "demo",
12
+ "guides",
13
+ "content-hub",
14
+ "seo",
11
15
  "landing-page",
12
16
  "saas"
13
17
  ],
@@ -26,6 +30,9 @@
26
30
  "exports": {
27
31
  "./PricingComparison.astro": "./src/PricingComparison.astro",
28
32
  "./TrustSection.astro": "./src/TrustSection.astro",
33
+ "./PrimaryCta.astro": "./src/PrimaryCta.astro",
34
+ "./DemoFrame.astro": "./src/DemoFrame.astro",
35
+ "./GuideCard.astro": "./src/GuideCard.astro",
29
36
  "./schema": "./src/schema.ts",
30
37
  "./icons": "./src/icons.ts"
31
38
  },
@@ -0,0 +1,264 @@
1
+ ---
2
+ // Reusable interactive "try it" demo shell for product marketing sites — a
3
+ // themeable chrome around client-side, curated example data. The marketing
4
+ // sites are static (nginx, no backend), so the demo runs ENTIRELY in the
5
+ // browser over honest, clearly-labelled sample data. This component owns the
6
+ // shared parts:
7
+ //
8
+ // • eyebrow / title / intro
9
+ // • a row of clickable example "chips"
10
+ // • a result-panel area — the host supplies one bespoke, product-owned panel
11
+ // per example in the default slot, each tagged `data-demo-result="<id>"`
12
+ // (matching an example id) and the FIRST one also `data-active`
13
+ // • a clearly-visible "sample data" disclaimer line
14
+ // • a primary CTA (reuses PrimaryCta) driving to signup
15
+ //
16
+ // <DemoFrame data={demo}>
17
+ // <div data-demo-result="flagged" data-active
18
+ // id="demo-panel-flagged" role="tabpanel" aria-labelledby="demo-chip-flagged">
19
+ // …your bespoke high-risk result markup…
20
+ // </div>
21
+ // <div data-demo-result="clean"
22
+ // id="demo-panel-clean" role="tabpanel" aria-labelledby="demo-chip-clean">…</div>
23
+ // </DemoFrame>
24
+ //
25
+ // The per-example RESULT markup is inherently product-specific (it mirrors the
26
+ // product's real output), so it comes in via the default slot (Astro forbids a
27
+ // dynamic `slot[name]`, so panels are keyed by a `data-demo-result` attribute
28
+ // instead) while the frame stays shared. The frame's chip ids are
29
+ // `<id>-chip-<exampleId>` and it wires `aria-controls` to `<id>-panel-<exampleId>`,
30
+ // so a host panel should use `id="<id>-panel-<exampleId>"` +
31
+ // `aria-labelledby="<id>-chip-<exampleId>"`. Minimal inline vanilla JS toggles
32
+ // which panel shows on chip click — no framework. Progressive enhancement: the
33
+ // FIRST panel (`data-active`) is visible without JS; the script just moves it.
34
+ import type { DemoFrameData } from './schema';
35
+ import PrimaryCta from './PrimaryCta.astro';
36
+
37
+ interface Props {
38
+ data: DemoFrameData;
39
+ id?: string;
40
+ }
41
+
42
+ const { data, id = 'demo' } = Astro.props as Props;
43
+ const { examples } = data;
44
+ ---
45
+
46
+ <section class="dmk-demo" id={id} aria-labelledby={`${id}-title`} data-demo-frame>
47
+ <div class="dmk-container">
48
+ <p class="dmk-eyebrow" data-reveal>{data.eyebrow}</p>
49
+ <h2 class="dmk-title" id={`${id}-title`} data-reveal>{data.title}</h2>
50
+ {data.intro && <p class="dmk-intro" data-reveal>{data.intro}</p>}
51
+
52
+ <div class="dmk-panel" data-reveal>
53
+ <div class="dmk-chips" role="tablist" aria-label="Example inputs">
54
+ {examples.map((ex, i) => (
55
+ <button
56
+ type="button"
57
+ class="dmk-chip"
58
+ role="tab"
59
+ id={`${id}-chip-${ex.id}`}
60
+ aria-controls={`${id}-panel-${ex.id}`}
61
+ aria-selected={i === 0 ? 'true' : 'false'}
62
+ tabindex={i === 0 ? '0' : '-1'}
63
+ data-demo-chip={ex.id}
64
+ data-active={i === 0 ? '' : undefined}
65
+ data-umami-event={ex.analyticsEvent}
66
+ >
67
+ <span class="dmk-chip__label">{ex.label}</span>
68
+ {ex.sublabel && <span class="dmk-chip__sub">{ex.sublabel}</span>}
69
+ </button>
70
+ ))}
71
+ </div>
72
+
73
+ <div class="dmk-results">
74
+ <slot />
75
+ </div>
76
+
77
+ <p class="dmk-disclaimer" data-reveal>
78
+ <span class="dmk-disclaimer__dot" aria-hidden="true"></span>
79
+ {data.disclaimer}
80
+ </p>
81
+
82
+ <div class="dmk-cta" data-reveal>
83
+ <PrimaryCta cta={data.cta} secondary={data.secondaryCta} size="md" />
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </section>
88
+
89
+ <script>
90
+ // Chip → result toggle for every DemoFrame on the page. Progressive
91
+ // enhancement: without JS the CSS already shows the first ([data-active])
92
+ // result; this just moves `data-active` on click (and arrow-key roving so the
93
+ // tablist is keyboard-navigable).
94
+ document.querySelectorAll('[data-demo-frame]').forEach((frame) => {
95
+ const chips = Array.from(frame.querySelectorAll('[data-demo-chip]'));
96
+ const results = Array.from(frame.querySelectorAll('[data-demo-result]'));
97
+
98
+ const activate = (key) => {
99
+ chips.forEach((chip) => {
100
+ const on = chip.getAttribute('data-demo-chip') === key;
101
+ chip.toggleAttribute('data-active', on);
102
+ chip.setAttribute('aria-selected', on ? 'true' : 'false');
103
+ chip.setAttribute('tabindex', on ? '0' : '-1');
104
+ });
105
+ results.forEach((result) => {
106
+ result.toggleAttribute('data-active', result.getAttribute('data-demo-result') === key);
107
+ });
108
+ };
109
+
110
+ chips.forEach((chip, index) => {
111
+ chip.addEventListener('click', () => activate(chip.getAttribute('data-demo-chip')));
112
+ chip.addEventListener('keydown', (event) => {
113
+ const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
114
+ if (!delta) return;
115
+ event.preventDefault();
116
+ const next = chips[(index + delta + chips.length) % chips.length];
117
+ activate(next.getAttribute('data-demo-chip'));
118
+ next.focus();
119
+ });
120
+ });
121
+ });
122
+ </script>
123
+
124
+ <style>
125
+ .dmk-demo {
126
+ --_brand: var(--brand, #d8332a);
127
+ --_brand-deep: var(--brand-deep, #a8231c);
128
+ --_accent: var(--accent, #f5a300);
129
+ --_ink: var(--ink, #0a0a1f);
130
+ --_paper: var(--paper, #fff);
131
+ --_bg: var(--bg, #fbf9f4);
132
+ --_bg-tint: var(--bg-tint, #f1ede1);
133
+ --_muted: var(--muted, rgba(10, 10, 31, 0.62));
134
+ --_hairline: var(--hairline, rgba(10, 10, 31, 0.1));
135
+ --_r-md: var(--radius-md, 14px);
136
+ --_r-lg: var(--radius-lg, 24px);
137
+ --_shadow: var(--shadow, 0 30px 60px -30px rgba(10, 10, 31, 0.4));
138
+ padding: 96px 0;
139
+ background: var(--_paper);
140
+ border-top: 1px solid var(--_hairline);
141
+ border-bottom: 1px solid var(--_hairline);
142
+ }
143
+ .dmk-container {
144
+ width: min(1200px, 100% - 48px);
145
+ margin-inline: auto;
146
+ }
147
+ .dmk-eyebrow {
148
+ font-family: 'Oswald', sans-serif;
149
+ font-size: 12px;
150
+ font-weight: 600;
151
+ letter-spacing: 0.22em;
152
+ text-transform: uppercase;
153
+ color: var(--_brand);
154
+ margin: 0;
155
+ }
156
+ .dmk-title {
157
+ font-family: 'Oswald', 'Inter', sans-serif;
158
+ font-size: clamp(30px, 3.8vw, 46px);
159
+ font-weight: 700;
160
+ line-height: 1.08;
161
+ letter-spacing: -0.01em;
162
+ margin: 12px 0 14px;
163
+ max-width: 24ch;
164
+ color: var(--_ink);
165
+ }
166
+ .dmk-intro {
167
+ color: var(--_muted);
168
+ max-width: 62ch;
169
+ margin: 0 0 36px;
170
+ }
171
+
172
+ /* Panel --------------------------------------------------------------- */
173
+ .dmk-panel {
174
+ padding: 26px;
175
+ border-radius: var(--_r-lg);
176
+ background: var(--_bg);
177
+ border: 1px solid var(--_hairline);
178
+ }
179
+ @media (max-width: 560px) {
180
+ .dmk-panel { padding: 18px; }
181
+ .dmk-demo { padding: 64px 0; }
182
+ }
183
+
184
+ /* Chips (tablist) ----------------------------------------------------- */
185
+ .dmk-chips {
186
+ display: flex;
187
+ flex-wrap: wrap;
188
+ gap: 10px;
189
+ margin-bottom: 22px;
190
+ }
191
+ .dmk-chip {
192
+ display: flex;
193
+ flex-direction: column;
194
+ align-items: flex-start;
195
+ gap: 2px;
196
+ padding: 11px 16px;
197
+ border-radius: var(--_r-md);
198
+ background: var(--_paper);
199
+ border: 1px solid var(--_hairline);
200
+ cursor: pointer;
201
+ text-align: left;
202
+ font-family: inherit;
203
+ transition: border-color 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease,
204
+ background 0.14s ease;
205
+ }
206
+ .dmk-chip:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--_brand) 40%, var(--_hairline)); }
207
+ .dmk-chip:focus-visible { outline: 2px solid var(--_brand); outline-offset: 2px; }
208
+ .dmk-chip[data-active] {
209
+ border-color: var(--_brand);
210
+ background: color-mix(in srgb, var(--_brand) 8%, var(--_paper));
211
+ box-shadow: 0 8px 22px -14px color-mix(in srgb, var(--_brand) 80%, transparent);
212
+ }
213
+ .dmk-chip__label {
214
+ font-family: 'Oswald', 'Inter', sans-serif;
215
+ font-weight: 600;
216
+ font-size: 14px;
217
+ letter-spacing: 0.01em;
218
+ color: var(--_ink);
219
+ }
220
+ .dmk-chip__sub {
221
+ font-size: 11.5px;
222
+ color: var(--_muted);
223
+ }
224
+
225
+ /* Results ------------------------------------------------------------- */
226
+ /* The panels are host-supplied slot content, so the show/hide toggle is
227
+ :global (keyed by data attributes) to reach across the slot boundary; it
228
+ stays scoped under .dmk-results so it can't leak to the wider page. */
229
+ .dmk-results { min-height: 10px; }
230
+ .dmk-results :global([data-demo-result]) { display: none; }
231
+ .dmk-results :global([data-demo-result][data-active]) {
232
+ display: block;
233
+ animation: dmk-fade 0.28s cubic-bezier(0.2, 0.7, 0.2, 1);
234
+ }
235
+ @keyframes dmk-fade {
236
+ from { opacity: 0; transform: translateY(6px); }
237
+ to { opacity: 1; transform: none; }
238
+ }
239
+ @media (prefers-reduced-motion: reduce) {
240
+ .dmk-results :global([data-demo-result][data-active]) { animation: none; }
241
+ }
242
+
243
+ /* Disclaimer ---------------------------------------------------------- */
244
+ .dmk-disclaimer {
245
+ display: flex;
246
+ align-items: baseline;
247
+ gap: 9px;
248
+ margin: 22px 0 0;
249
+ font-size: 12.5px;
250
+ line-height: 1.5;
251
+ color: var(--_muted);
252
+ font-style: italic;
253
+ }
254
+ .dmk-disclaimer__dot {
255
+ flex-shrink: 0;
256
+ width: 8px;
257
+ height: 8px;
258
+ border-radius: 50%;
259
+ background: var(--_accent);
260
+ transform: translateY(1px);
261
+ }
262
+
263
+ .dmk-cta { margin-top: 22px; }
264
+ </style>
@@ -0,0 +1,132 @@
1
+ ---
2
+ // Reusable card for a guides / content-hub listing on a product marketing site.
3
+ // Renders ONE guide's title, description, tags and date as a themeable link
4
+ // card. Data-driven (schema.ts → GuideCardData); themed via host CSS vars.
5
+ // Framework-free, no images → Lighthouse-light.
6
+ //
7
+ // {guides.map((g) => <GuideCard data={{ ...g.data, href: `/guides/${g.id}/` }} />)}
8
+ //
9
+ // Pair with `guideFrontmatterSchema` (schema.ts) on the host's Astro content
10
+ // collection so every product's hub validates + renders alike; the article
11
+ // page + routes stay product-owned.
12
+ import type { GuideCardData } from './schema';
13
+
14
+ interface Props {
15
+ data: GuideCardData;
16
+ }
17
+
18
+ const { data } = Astro.props as Props;
19
+
20
+ // Accept a Date (from the content collection) or a preformatted string. When a
21
+ // Date, format it to a stable, locale-neutral "14 Jul 2026" and emit a machine
22
+ // <time datetime> for the ISO value (good for SEO + a11y).
23
+ const toDate = (value: Date | string): Date | null => {
24
+ const d = value instanceof Date ? value : new Date(value);
25
+ return Number.isNaN(d.getTime()) ? null : d;
26
+ };
27
+ const published = toDate(data.publishDate);
28
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
29
+ const dateLabel = published
30
+ ? `${published.getUTCDate()} ${MONTHS[published.getUTCMonth()]} ${published.getUTCFullYear()}`
31
+ : typeof data.publishDate === 'string'
32
+ ? data.publishDate
33
+ : '';
34
+ const dateIso = published ? published.toISOString().slice(0, 10) : undefined;
35
+ const tags = data.tags ?? [];
36
+ ---
37
+
38
+ <a class="gmk-card" href={data.href}>
39
+ {tags.length > 0 && (
40
+ <div class="gmk-tags">
41
+ {tags.slice(0, 3).map((tag) => <span class="gmk-tag">{tag}</span>)}
42
+ </div>
43
+ )}
44
+ <h3 class="gmk-card-t">{data.title}</h3>
45
+ <p class="gmk-card-d">{data.description}</p>
46
+ <div class="gmk-card-foot">
47
+ {dateLabel && <time class="gmk-date" datetime={dateIso}>{dateLabel}</time>}
48
+ <span class="gmk-go" aria-hidden="true">Read →</span>
49
+ </div>
50
+ </a>
51
+
52
+ <style>
53
+ .gmk-card {
54
+ --_brand: var(--brand, #d8332a);
55
+ --_brand-deep: var(--brand-deep, #a8231c);
56
+ --_ink: var(--ink, #0a0a1f);
57
+ --_paper: var(--paper, #fff);
58
+ --_bg: var(--bg, #fbf9f4);
59
+ --_bg-tint: var(--bg-tint, #f1ede1);
60
+ --_muted: var(--muted, rgba(10, 10, 31, 0.62));
61
+ --_hairline: var(--hairline, rgba(10, 10, 31, 0.1));
62
+ --_r-md: var(--radius-md, 14px);
63
+ --_shadow: var(--shadow, 0 30px 60px -30px rgba(10, 10, 31, 0.4));
64
+ position: relative;
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 10px;
68
+ padding: 24px 22px;
69
+ border-radius: var(--_r-md);
70
+ background: var(--_paper);
71
+ border: 1px solid var(--_hairline);
72
+ text-decoration: none;
73
+ color: inherit;
74
+ transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
75
+ }
76
+ .gmk-card:hover {
77
+ transform: translateY(-4px);
78
+ box-shadow: var(--_shadow);
79
+ border-color: transparent;
80
+ }
81
+ .gmk-tags {
82
+ display: flex;
83
+ flex-wrap: wrap;
84
+ gap: 6px;
85
+ }
86
+ .gmk-tag {
87
+ font-family: 'Oswald', sans-serif;
88
+ font-size: 10.5px;
89
+ font-weight: 600;
90
+ letter-spacing: 0.12em;
91
+ text-transform: uppercase;
92
+ color: var(--_brand-deep);
93
+ background: color-mix(in srgb, var(--_brand) 10%, transparent);
94
+ padding: 4px 9px;
95
+ border-radius: 999px;
96
+ }
97
+ .gmk-card-t {
98
+ font-family: 'Oswald', 'Inter', sans-serif;
99
+ font-size: 19px;
100
+ font-weight: 600;
101
+ line-height: 1.2;
102
+ margin: 2px 0 0;
103
+ color: var(--_ink);
104
+ }
105
+ .gmk-card-d {
106
+ color: var(--_muted);
107
+ font-size: 14.5px;
108
+ line-height: 1.5;
109
+ margin: 0;
110
+ flex-grow: 1;
111
+ }
112
+ .gmk-card-foot {
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: space-between;
116
+ gap: 12px;
117
+ margin-top: 6px;
118
+ padding-top: 12px;
119
+ border-top: 1px solid var(--_hairline);
120
+ }
121
+ .gmk-date {
122
+ font-size: 12.5px;
123
+ color: var(--_muted);
124
+ }
125
+ .gmk-go {
126
+ font-family: 'Oswald', sans-serif;
127
+ font-size: 13px;
128
+ font-weight: 600;
129
+ color: var(--_brand-deep);
130
+ }
131
+ .gmk-card:hover .gmk-go { color: var(--_brand); }
132
+ </style>
@@ -7,7 +7,11 @@
7
7
  //
8
8
  // The host page must provide the `[data-contact]` mailto-reveal handler for any
9
9
  // CTA with `kind: 'contact'` (the scaffold + kefi-marketing already do).
10
- import type { MarketingCta, PricingComparisonData } from './schema';
10
+ //
11
+ // `ctaAttrs` (link vs spam-safe contact + analytics wiring) is shared from
12
+ // schema.ts so PrimaryCta.astro renders CTAs identically.
13
+ import type { PricingComparisonData } from './schema';
14
+ import { ctaAttrs } from './schema';
11
15
 
12
16
  interface Props {
13
17
  data: PricingComparisonData;
@@ -16,24 +20,6 @@ interface Props {
16
20
 
17
21
  const { data, id = 'pricing' } = Astro.props as Props;
18
22
 
19
- /** Build the html attributes for a CTA anchor (link or spam-safe contact). */
20
- function ctaAttrs(cta: MarketingCta): Record<string, string> {
21
- const attrs: Record<string, string> = {};
22
- if (cta.kind === 'contact' && cta.contact) {
23
- attrs.href = '#';
24
- attrs['data-contact'] = '';
25
- attrs['data-local'] = cta.contact.local;
26
- attrs['data-domain'] = cta.contact.domain;
27
- } else {
28
- attrs.href = cta.href;
29
- }
30
- if (cta.analyticsEvent) attrs['data-umami-event'] = cta.analyticsEvent;
31
- for (const [k, v] of Object.entries(cta.analyticsData ?? {})) {
32
- attrs[`data-umami-event-${k}`] = v;
33
- }
34
- return attrs;
35
- }
36
-
37
23
  const { tiers, groups } = data;
38
24
  ---
39
25
 
@@ -0,0 +1,138 @@
1
+ ---
2
+ // Reusable primary call-to-action for product marketing sites — renders a
3
+ // `MarketingCta` (link OR spam-safe contact) as a themeable button, with an
4
+ // optional secondary CTA beside it. One component for every "primary action"
5
+ // on a site (hero, closing band …) so the link-vs-lead-capture + analytics
6
+ // wiring is never hand-coded per page.
7
+ //
8
+ // <PrimaryCta cta={primary} secondary={secondary} />
9
+ //
10
+ // Pair it with `buildPrimaryCta` (schema.ts): the site holds ONE `SIGNUP_URL`
11
+ // constant and the CTA auto-switches between a real signup link (app is live)
12
+ // and lead-capture contact (app not public yet) — no template change.
13
+ //
14
+ // Zero runtime JS of its own: `kind: 'contact'` CTAs render the same
15
+ // `[data-contact]`/`data-local`/`data-domain` pattern the host page's existing
16
+ // contact-reveal handler assembles on click. Themed via host CSS custom props.
17
+ import type { MarketingCta } from './schema';
18
+ import { ctaAttrs } from './schema';
19
+
20
+ interface Props {
21
+ /** Primary CTA (link or contact). */
22
+ cta: MarketingCta;
23
+ /** Optional secondary CTA rendered beside the primary. */
24
+ secondary?: MarketingCta;
25
+ /** Button size. */
26
+ size?: 'md' | 'lg';
27
+ /** Horizontal alignment of the button group. */
28
+ align?: 'start' | 'center';
29
+ /** Set 'dark' when the CTA sits on a dark band so the ghost secondary stays
30
+ * legible (light text + translucent border). Defaults to 'light'. */
31
+ tone?: 'light' | 'dark';
32
+ id?: string;
33
+ }
34
+
35
+ const {
36
+ cta,
37
+ secondary,
38
+ size = 'lg',
39
+ align = 'start',
40
+ tone = 'light',
41
+ id,
42
+ } = Astro.props as Props;
43
+ ---
44
+
45
+ <div
46
+ class={`tmk-cta-group tmk-cta-group--${align} tmk-cta-group--${tone}`}
47
+ id={id}
48
+ data-reveal
49
+ >
50
+ <a class={`tmk-btn tmk-btn--primary tmk-btn--${size}`} {...ctaAttrs(cta)}>
51
+ {cta.label} <span class="tmk-btn__arrow" aria-hidden="true">→</span>
52
+ </a>
53
+ {secondary && (
54
+ <a class={`tmk-btn tmk-btn--ghost tmk-btn--${size}`} {...ctaAttrs(secondary)}>
55
+ {secondary.label}
56
+ </a>
57
+ )}
58
+ </div>
59
+
60
+ <style>
61
+ .tmk-cta-group {
62
+ --_brand: var(--brand, #d8332a);
63
+ --_brand-deep: var(--brand-deep, #a8231c);
64
+ --_ink: var(--ink, #0a0a1f);
65
+ --_paper: var(--paper, #fff);
66
+ --_hairline: var(--hairline, rgba(10, 10, 31, 0.1));
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ gap: 12px;
70
+ }
71
+ .tmk-cta-group--center {
72
+ justify-content: center;
73
+ }
74
+
75
+ .tmk-btn {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ gap: 8px;
80
+ padding: 14px 22px;
81
+ border-radius: 999px;
82
+ font-family: 'Inter', system-ui, sans-serif;
83
+ font-weight: 600;
84
+ font-size: 15px;
85
+ letter-spacing: 0.01em;
86
+ border: 1px solid transparent;
87
+ cursor: pointer;
88
+ text-decoration: none;
89
+ transition: transform 0.12s ease, background 0.12s ease, box-shadow 0.12s ease,
90
+ color 0.12s ease, border-color 0.12s ease;
91
+ }
92
+ .tmk-btn:hover {
93
+ transform: translateY(-1px);
94
+ }
95
+ .tmk-btn--lg {
96
+ padding: 16px 28px;
97
+ font-size: 16px;
98
+ }
99
+ .tmk-btn__arrow {
100
+ transition: transform 0.12s ease;
101
+ }
102
+ .tmk-btn:hover .tmk-btn__arrow {
103
+ transform: translateX(3px);
104
+ }
105
+
106
+ /* Primary — solid brand. */
107
+ .tmk-btn--primary {
108
+ background: var(--_brand-deep);
109
+ color: var(--_paper);
110
+ box-shadow: 0 8px 24px -10px color-mix(in srgb, var(--_brand) 70%, transparent);
111
+ }
112
+ .tmk-btn--primary:hover {
113
+ background: var(--_brand);
114
+ }
115
+
116
+ /* Ghost secondary — on light. */
117
+ .tmk-btn--ghost {
118
+ background: transparent;
119
+ color: var(--_ink);
120
+ border-color: color-mix(in srgb, var(--_ink) 18%, transparent);
121
+ }
122
+ .tmk-btn--ghost:hover {
123
+ background: var(--_ink);
124
+ color: var(--_paper);
125
+ border-color: var(--_ink);
126
+ }
127
+
128
+ /* Ghost secondary — on a dark band. */
129
+ .tmk-cta-group--dark .tmk-btn--ghost {
130
+ color: var(--_paper);
131
+ border-color: rgba(255, 255, 255, 0.32);
132
+ }
133
+ .tmk-cta-group--dark .tmk-btn--ghost:hover {
134
+ background: var(--_paper);
135
+ color: var(--_ink);
136
+ border-color: var(--_paper);
137
+ }
138
+ </style>
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // <TrustSection data={trust} id="trust" />
10
10
  import type { TrustSectionData } from './schema';
11
+ import { ctaAttrs } from './schema';
11
12
  import { TRUST_ICONS } from './icons';
12
13
 
13
14
  interface Props {
@@ -16,6 +17,11 @@ interface Props {
16
17
  }
17
18
 
18
19
  const { data, id = 'trust' } = Astro.props as Props;
20
+
21
+ // A product can inject bespoke mock markup for the sample-deliverable showcase
22
+ // via <TrustSection><Fragment slot="sample-deliverable">…</Fragment></TrustSection>
23
+ // while the eyebrow/title/caption chrome stays shared.
24
+ const hasSampleSlot = Astro.slots.has('sample-deliverable');
19
25
  ---
20
26
 
21
27
  <section class="tmk-trust" id={id} aria-labelledby={`${id}-title`}>
@@ -34,6 +40,23 @@ const { data, id = 'trust' } = Astro.props as Props;
34
40
  ))}
35
41
  </div>
36
42
 
43
+ {data.sampleDeliverable && (
44
+ <div class="tmk-sample" data-reveal>
45
+ <div class="tmk-sample-copy">
46
+ <p class="tmk-sample-eyebrow">{data.sampleDeliverable.eyebrow}</p>
47
+ <h3 class="tmk-sample-t">{data.sampleDeliverable.title}</h3>
48
+ <p class="tmk-sample-cap">{data.sampleDeliverable.caption}</p>
49
+ </div>
50
+ <div class="tmk-sample-media">
51
+ {hasSampleSlot ? (
52
+ <slot name="sample-deliverable" />
53
+ ) : data.sampleDeliverable.image ? (
54
+ <img class="tmk-sample-img" src={data.sampleDeliverable.image} alt={data.sampleDeliverable.title} loading="lazy" />
55
+ ) : null}
56
+ </div>
57
+ </div>
58
+ )}
59
+
37
60
  {data.logos && (
38
61
  <div class="tmk-logos" data-reveal>
39
62
  <p class="tmk-logos-label">{data.logos.label}</p>
@@ -71,6 +94,45 @@ const { data, id = 'trust' } = Astro.props as Props;
71
94
  </div>
72
95
  )}
73
96
 
97
+ {data.foundingPartner && (
98
+ <aside class="tmk-founding" data-reveal>
99
+ <div class="tmk-founding-body">
100
+ <p class="tmk-founding-eyebrow">{data.foundingPartner.eyebrow}</p>
101
+ <h3 class="tmk-founding-t">{data.foundingPartner.title}</h3>
102
+ <p class="tmk-founding-b">{data.foundingPartner.body}</p>
103
+ <ul class="tmk-founding-perks">
104
+ {data.foundingPartner.perks.map((perk) => <li>{perk}</li>)}
105
+ </ul>
106
+ </div>
107
+ {data.foundingPartner.cta && (
108
+ <a class="tmk-founding-cta" {...ctaAttrs(data.foundingPartner.cta)}>
109
+ {data.foundingPartner.cta.label}
110
+ </a>
111
+ )}
112
+ </aside>
113
+ )}
114
+
115
+ {data.founder && (
116
+ <figure class="tmk-founder" data-reveal>
117
+ {data.founder.avatar ? (
118
+ <img class="tmk-founder-av" src={data.founder.avatar} alt={data.founder.name ?? 'Founder'} loading="lazy" />
119
+ ) : (
120
+ <span class="tmk-founder-av tmk-founder-av--ph" aria-hidden="true">
121
+ {(data.founder.name ?? '·').trim().charAt(0)}
122
+ </span>
123
+ )}
124
+ <figcaption class="tmk-founder-cap">
125
+ <p class="tmk-founder-line">{data.founder.line}</p>
126
+ {(data.founder.name || data.founder.role) && (
127
+ <p class="tmk-founder-cite">
128
+ {data.founder.name && <span class="tmk-founder-name">{data.founder.name}</span>}
129
+ {data.founder.role && <span class="tmk-founder-role">{data.founder.role}</span>}
130
+ </p>
131
+ )}
132
+ </figcaption>
133
+ </figure>
134
+ )}
135
+
74
136
  {data.builtBy && (
75
137
  <p class="tmk-builtby" data-reveal>
76
138
  {data.builtBy.label}
@@ -262,6 +324,180 @@ const { data, id = 'trust' } = Astro.props as Props;
262
324
  font-style: italic;
263
325
  }
264
326
 
327
+ /* Sample deliverable -------------------------------------------------- */
328
+ .tmk-sample {
329
+ margin-top: 52px;
330
+ display: grid;
331
+ grid-template-columns: 0.92fr 1.08fr;
332
+ gap: 34px;
333
+ align-items: center;
334
+ padding: 30px;
335
+ border-radius: var(--_r-lg);
336
+ background: var(--_bg);
337
+ border: 1px solid var(--_hairline);
338
+ }
339
+ @media (max-width: 860px) {
340
+ .tmk-sample { grid-template-columns: 1fr; gap: 24px; padding: 24px; }
341
+ }
342
+ .tmk-sample-eyebrow {
343
+ font-family: 'Oswald', sans-serif;
344
+ font-size: 11px;
345
+ font-weight: 600;
346
+ letter-spacing: 0.2em;
347
+ text-transform: uppercase;
348
+ color: var(--_brand);
349
+ margin: 0 0 8px;
350
+ }
351
+ .tmk-sample-t {
352
+ font-family: 'Oswald', 'Inter', sans-serif;
353
+ font-size: clamp(20px, 2.4vw, 26px);
354
+ font-weight: 700;
355
+ line-height: 1.14;
356
+ margin: 0 0 10px;
357
+ color: var(--_ink);
358
+ }
359
+ .tmk-sample-cap { color: var(--_muted); margin: 0; font-size: 14.5px; }
360
+ .tmk-sample-media { min-width: 0; }
361
+ .tmk-sample-img {
362
+ display: block;
363
+ width: 100%;
364
+ height: auto;
365
+ border-radius: var(--_r-md);
366
+ border: 1px solid var(--_hairline);
367
+ }
368
+
369
+ /* Founding-partner panel ---------------------------------------------- */
370
+ .tmk-founding {
371
+ margin-top: 52px;
372
+ display: flex;
373
+ align-items: center;
374
+ justify-content: space-between;
375
+ gap: 30px;
376
+ flex-wrap: wrap;
377
+ padding: 30px 32px;
378
+ border-radius: var(--_r-lg);
379
+ background: var(--_ink);
380
+ color: var(--_paper);
381
+ border: 1px solid var(--_ink);
382
+ }
383
+ .tmk-founding-body { flex: 1 1 420px; }
384
+ .tmk-founding-eyebrow {
385
+ font-family: 'Oswald', sans-serif;
386
+ font-size: 11px;
387
+ font-weight: 600;
388
+ letter-spacing: 0.2em;
389
+ text-transform: uppercase;
390
+ color: var(--_accent);
391
+ margin: 0 0 8px;
392
+ }
393
+ .tmk-founding-t {
394
+ font-family: 'Oswald', 'Inter', sans-serif;
395
+ font-size: clamp(22px, 2.6vw, 28px);
396
+ font-weight: 700;
397
+ line-height: 1.12;
398
+ margin: 0 0 10px;
399
+ color: var(--_paper);
400
+ }
401
+ .tmk-founding-b {
402
+ color: rgba(255, 255, 255, 0.78);
403
+ margin: 0 0 16px;
404
+ font-size: 14.5px;
405
+ max-width: 60ch;
406
+ }
407
+ .tmk-founding-perks {
408
+ list-style: none;
409
+ padding: 0;
410
+ margin: 0;
411
+ display: grid;
412
+ grid-template-columns: repeat(2, minmax(0, 1fr));
413
+ gap: 8px 22px;
414
+ }
415
+ @media (max-width: 560px) { .tmk-founding-perks { grid-template-columns: 1fr; } }
416
+ .tmk-founding-perks li {
417
+ position: relative;
418
+ padding-left: 22px;
419
+ color: rgba(255, 255, 255, 0.9);
420
+ font-size: 13.8px;
421
+ }
422
+ .tmk-founding-perks li::before {
423
+ content: '';
424
+ position: absolute;
425
+ left: 0;
426
+ top: 7px;
427
+ width: 8px;
428
+ height: 8px;
429
+ border-radius: 50%;
430
+ background: var(--_accent);
431
+ }
432
+ .tmk-founding-cta {
433
+ display: inline-flex;
434
+ align-items: center;
435
+ justify-content: center;
436
+ flex-shrink: 0;
437
+ padding: 14px 24px;
438
+ border-radius: 999px;
439
+ background: var(--_accent);
440
+ color: var(--_ink);
441
+ font-family: 'Inter', system-ui, sans-serif;
442
+ font-weight: 700;
443
+ font-size: 14.5px;
444
+ letter-spacing: 0.01em;
445
+ text-decoration: none;
446
+ border: 1px solid transparent;
447
+ transition: transform 0.12s ease, box-shadow 0.12s ease;
448
+ cursor: pointer;
449
+ }
450
+ .tmk-founding-cta:hover {
451
+ transform: translateY(-1px);
452
+ box-shadow: 0 12px 26px -12px rgba(0, 0, 0, 0.6);
453
+ }
454
+
455
+ /* Founder line -------------------------------------------------------- */
456
+ .tmk-founder {
457
+ margin: 40px 0 0;
458
+ display: flex;
459
+ align-items: center;
460
+ gap: 16px;
461
+ max-width: 78ch;
462
+ }
463
+ .tmk-founder-av {
464
+ flex-shrink: 0;
465
+ width: 52px;
466
+ height: 52px;
467
+ border-radius: 50%;
468
+ object-fit: cover;
469
+ border: 1px solid var(--_hairline);
470
+ }
471
+ .tmk-founder-av--ph {
472
+ display: inline-grid;
473
+ place-items: center;
474
+ background: color-mix(in srgb, var(--_brand) 12%, transparent);
475
+ color: var(--_brand);
476
+ font-family: 'Oswald', sans-serif;
477
+ font-weight: 700;
478
+ font-size: 20px;
479
+ text-transform: uppercase;
480
+ }
481
+ .tmk-founder-cap { margin: 0; }
482
+ .tmk-founder-line {
483
+ margin: 0;
484
+ color: var(--_ink);
485
+ font-size: 15.5px;
486
+ line-height: 1.5;
487
+ }
488
+ .tmk-founder-cite { margin: 4px 0 0; }
489
+ .tmk-founder-name {
490
+ font-family: 'Oswald', sans-serif;
491
+ font-weight: 600;
492
+ font-size: 13.5px;
493
+ color: var(--_ink);
494
+ }
495
+ .tmk-founder-role {
496
+ font-size: 12.5px;
497
+ color: var(--_muted);
498
+ margin-left: 8px;
499
+ }
500
+
265
501
  /* Built by ------------------------------------------------------------ */
266
502
  .tmk-builtby {
267
503
  margin: 48px 0 0;
package/src/schema.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  // Data schema for the marketing "pricing comparison" + "trust / credibility"
2
- // Astro sections. Framework-agnostic types — a product supplies its own typed
3
- // data object (see kefi-marketing/src/data/marketing.ts) and the two .astro
4
- // components render it. No product-specific values live here.
2
+ // + "try-it demo" + "guides/content-hub" Astro sections. Framework-agnostic
3
+ // types a product supplies its own typed data object (see
4
+ // kefi-marketing/src/data/marketing.ts) and the .astro components render it.
5
+ // No product-specific values live here.
5
6
  //
6
7
  // Themeing is done entirely through CSS custom properties on the host site's
7
8
  // `:root` (--brand, --accent, --ink, --paper, --muted, --hairline, radii,
8
9
  // --shadow, and the Oswald/Inter font families). The components reference
9
10
  // those vars with sensible fallbacks, so a site drops them in and they inherit
10
11
  // its palette automatically.
12
+ //
13
+ // The `guideFrontmatterSchema` (bottom of file) is the ONE Zod import — built
14
+ // on Astro's own bundled zod (`astro/zod`, an `astro` peer-dep subpath), so the
15
+ // content collection a host site defines validates guide frontmatter with the
16
+ // SAME zod instance Astro's content layer uses. Everything else is plain types.
17
+
18
+ import { z } from 'astro/zod';
11
19
 
12
20
  // ── Pricing comparison ─────────────────────────────────────────────────────
13
21
 
@@ -29,6 +37,70 @@ export interface MarketingCta {
29
37
  analyticsData?: Record<string, string>;
30
38
  }
31
39
 
40
+ /** Build the html attributes for a CTA anchor — a normal link, or the spam-safe
41
+ * `[data-contact]` reveal pattern for `kind: 'contact'` (the host page's
42
+ * existing handler assembles the mailto on click). Shared by every kit
43
+ * component that renders a `MarketingCta` (PricingComparison, PrimaryCta …) so
44
+ * the link/contact + analytics wiring lives in exactly one place. */
45
+ export function ctaAttrs(cta: MarketingCta): Record<string, string> {
46
+ const attrs: Record<string, string> = {};
47
+ if (cta.kind === 'contact' && cta.contact) {
48
+ attrs.href = '#';
49
+ attrs['data-contact'] = '';
50
+ attrs['data-local'] = cta.contact.local;
51
+ attrs['data-domain'] = cta.contact.domain;
52
+ } else {
53
+ attrs.href = cta.href;
54
+ }
55
+ if (cta.analyticsEvent) attrs['data-umami-event'] = cta.analyticsEvent;
56
+ for (const [k, v] of Object.entries(cta.analyticsData ?? {})) {
57
+ attrs[`data-umami-event-${k}`] = v;
58
+ }
59
+ return attrs;
60
+ }
61
+
62
+ /** Inputs for {@link buildPrimaryCta}: one config that auto-switches a primary
63
+ * CTA between a real signup/app link and spam-safe lead-capture, driven purely
64
+ * by whether the product's app is public yet. */
65
+ export interface PrimaryCtaOptions {
66
+ /** The public signup / app URL. `null` (or empty) = the app is not public yet
67
+ * → the CTA falls back to lead-capture contact. Flip this to a real URL (e.g.
68
+ * at GA) and the CTA auto-switches to a real link — no template change. */
69
+ signupUrl?: string | null;
70
+ /** Button label once a signup URL exists (app is live), e.g. "Start free". */
71
+ linkLabel: string;
72
+ /** Button label while there's no signup URL (lead-capture), e.g. "Request early access". */
73
+ contactLabel: string;
74
+ /** Spam-safe contact used for the lead-capture fallback. */
75
+ contact: { local: string; domain: string };
76
+ analyticsEvent?: string;
77
+ analyticsData?: Record<string, string>;
78
+ }
79
+
80
+ /** Produce a {@link MarketingCta} that is a real signup link when `signupUrl`
81
+ * is set, else a spam-safe lead-capture contact CTA. Lets a site model "flip to
82
+ * APP_URL at GA" as data (one `SIGNUP_URL` constant) instead of hand-coding the
83
+ * mode + leaving TODO comments in the markup. */
84
+ export function buildPrimaryCta(opts: PrimaryCtaOptions): MarketingCta {
85
+ if (opts.signupUrl) {
86
+ return {
87
+ label: opts.linkLabel,
88
+ href: opts.signupUrl,
89
+ kind: 'link',
90
+ analyticsEvent: opts.analyticsEvent,
91
+ analyticsData: opts.analyticsData,
92
+ };
93
+ }
94
+ return {
95
+ label: opts.contactLabel,
96
+ href: '#',
97
+ kind: 'contact',
98
+ contact: opts.contact,
99
+ analyticsEvent: opts.analyticsEvent,
100
+ analyticsData: opts.analyticsData,
101
+ };
102
+ }
103
+
32
104
  /** One tier column in the comparison table header. */
33
105
  export interface PricingTierColumn {
34
106
  /** Stable code (free | pro | business …). Used for live-price patching and
@@ -120,6 +192,38 @@ export interface BuiltByCredit {
120
192
  href: string;
121
193
  }
122
194
 
195
+ /** A "founding-partner program" panel — turns the no-customers-yet gap into
196
+ * exclusivity ("first N, free during beta"). Rendered only when supplied. */
197
+ export interface FoundingPartnerPanel {
198
+ eyebrow: string;
199
+ title: string;
200
+ body: string;
201
+ /** What a founding partner gets (bullet list). */
202
+ perks: string[];
203
+ cta?: MarketingCta;
204
+ }
205
+
206
+ /** A short, HONEST founder credibility line. Keep it truthful + generic — do
207
+ * NOT fabricate specific credentials, companies or certifications. */
208
+ export interface FounderCredit {
209
+ name?: string;
210
+ role?: string;
211
+ /** The one-line credibility statement. */
212
+ line: string;
213
+ /** Optional avatar image URL (omit for an initials/monogram fallback). */
214
+ avatar?: string;
215
+ }
216
+
217
+ /** A "here's the actual deliverable you get" showcase slot. Supply `image` for
218
+ * the fully-shared case; OR pass a `slot="sample-deliverable"` on the section
219
+ * to inject bespoke mock markup while the section chrome stays shared. */
220
+ export interface SampleDeliverable {
221
+ eyebrow: string;
222
+ title: string;
223
+ caption: string;
224
+ image?: string;
225
+ }
226
+
123
227
  export interface TrustSectionData {
124
228
  eyebrow: string;
125
229
  title: string;
@@ -128,4 +232,98 @@ export interface TrustSectionData {
128
232
  logos?: { label: string; note?: string; items: LogoItem[] };
129
233
  testimonials?: { label: string; note?: string; items: Testimonial[] };
130
234
  builtBy?: BuiltByCredit;
235
+ /** Optional "first N free during beta" founding-partner panel. */
236
+ foundingPartner?: FoundingPartnerPanel;
237
+ /** Optional honest founder credibility line. */
238
+ founder?: FounderCredit;
239
+ /** Optional "the actual report you get" showcase (image or named slot). */
240
+ sampleDeliverable?: SampleDeliverable;
241
+ }
242
+
243
+ // ── Interactive "try it" demo (DemoFrame.astro) ─────────────────────────────
244
+ // The marketing sites are static (nginx, no backend), so the demo is entirely
245
+ // CLIENT-SIDE over curated example data — honest, and clearly labelled "sample".
246
+ // DemoFrame renders the SHARED chrome (eyebrow/title/intro, a row of clickable
247
+ // example chips, a result panel, a "sample data" disclaimer, a primary CTA);
248
+ // each product injects its own bespoke RESULT body per example through a named
249
+ // slot (`slot="result-<example.id>"`) so the result markup stays product-owned
250
+ // while the shell is reused. Tiny inline vanilla JS toggles which result shows;
251
+ // the first example is visible without JS (progressive enhancement).
252
+
253
+ /** One selectable example in a {@link DemoFrameData}. `id` wires the chip to its
254
+ * result body: the host passes `<Fragment slot="result-<id>">…</Fragment>`. */
255
+ export interface DemoExample {
256
+ /** Stable id — must match the named slot `result-<id>` the host supplies, and
257
+ * is used for the chip↔result toggle + analytics. No spaces. */
258
+ id: string;
259
+ /** Chip label (e.g. a masked sample wallet or "Clean wallet"). */
260
+ label: string;
261
+ /** Optional muted sub-label under the chip (e.g. an expected-verdict hint). */
262
+ sublabel?: string;
263
+ /** Optional Umami custom-event name fired when this chip is picked. */
264
+ analyticsEvent?: string;
265
+ }
266
+
267
+ export interface DemoFrameData {
268
+ eyebrow: string;
269
+ title: string;
270
+ /** Optional intro paragraph under the title. */
271
+ intro?: string;
272
+ /** The clickable examples, in display order. The first is shown by default
273
+ * (and is the one visible when JS is off). */
274
+ examples: DemoExample[];
275
+ /** The clearly-visible "these are sample results" disclaimer line. */
276
+ disclaimer: string;
277
+ /** Primary CTA driving to signup (reuses the shared MarketingCta wiring). */
278
+ cta: MarketingCta;
279
+ /** Optional secondary CTA rendered beside the primary. */
280
+ secondaryCta?: MarketingCta;
281
+ }
282
+
283
+ // ── SEO guides / content-hub (GuideCard.astro + guideFrontmatterSchema) ──────
284
+ // A shared pattern for a product's guides/content flywheel: the Zod frontmatter
285
+ // schema a host site plugs into an Astro content collection, plus a themeable
286
+ // card that renders one guide in a hub listing. The article page + collection
287
+ // wiring stay product-owned (routes, layout, JSON-LD) — the kit supplies the
288
+ // validated shape + the card so every product's hub looks and validates alike.
289
+
290
+ /** Zod schema for a guide's frontmatter — plug straight into an Astro content
291
+ * collection: `defineCollection({ loader: glob(...), schema: guideFrontmatterSchema })`.
292
+ * Built on `astro/zod` so it is the same zod instance Astro's content layer
293
+ * uses. `publishDate`/`updatedDate` are coerced from ISO strings to `Date`. */
294
+ export const guideFrontmatterSchema = z.object({
295
+ /** Headline — also the <title>/OG title. */
296
+ title: z.string(),
297
+ /** Meta description + card blurb (~120–160 chars). */
298
+ description: z.string(),
299
+ /** Optional route slug override; when omitted the host uses the file id. */
300
+ slug: z.string().optional(),
301
+ /** Publication date (ISO string in frontmatter → Date). */
302
+ publishDate: z.coerce.date(),
303
+ /** Optional last-updated date. */
304
+ updatedDate: z.coerce.date().optional(),
305
+ /** Topic tags for the card + on-page chips. */
306
+ tags: z.array(z.string()).default([]),
307
+ /** Optional per-guide OG image URL (falls back to the site default). */
308
+ ogImage: z.string().optional(),
309
+ /** Optional canonical override (defaults to the guide's own URL). */
310
+ canonical: z.string().optional(),
311
+ /** Draft guides are excluded from the hub + routes. */
312
+ draft: z.boolean().default(false),
313
+ });
314
+
315
+ /** The inferred frontmatter type (a guide entry's `data`). */
316
+ export type GuideFrontmatter = z.infer<typeof guideFrontmatterSchema>;
317
+
318
+ /** Props for {@link GuideCard}: one guide's card fields + its resolved href.
319
+ * A superset-compatible subset of {@link GuideFrontmatter} plus `href` so the
320
+ * host maps `entry.data` + the route in one place. */
321
+ export interface GuideCardData {
322
+ title: string;
323
+ description: string;
324
+ href: string;
325
+ tags?: string[];
326
+ /** Accepts a `Date` (from the collection) or a preformatted string. */
327
+ publishDate: Date | string;
328
+ updatedDate?: Date | string;
131
329
  }