@dloizides/marketing-astro-kit 1.1.0 → 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 +22 -0
- package/README.md +91 -2
- package/package.json +8 -2
- package/src/DemoFrame.astro +264 -0
- package/src/GuideCard.astro +132 -0
- package/src/schema.ts +99 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
3
25
|
## 1.1.0
|
|
4
26
|
|
|
5
27
|
- New `PrimaryCta.astro` — a themeable primary call-to-action (link OR spam-safe
|
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# @dloizides/marketing-astro-kit
|
|
2
2
|
|
|
3
|
-
|
|
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
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
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. |
|
|
11
13
|
|
|
12
14
|
## Config-driven primary CTA (`PrimaryCta` + `buildPrimaryCta`)
|
|
13
15
|
|
|
@@ -72,6 +74,93 @@ hooks → Lighthouse-light.
|
|
|
72
74
|
Extracted from `kefi-marketing` as the 2nd genuine use (per the "extract on the
|
|
73
75
|
second use" rule); the Astro marketing sites otherwise share no code.
|
|
74
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
|
+
|
|
75
164
|
## Theming contract
|
|
76
165
|
|
|
77
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.
|
|
4
|
-
"description": "Reusable, themeable Astro marketing sections for product sites — a Free-vs-Pro pricing comparison table
|
|
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
|
],
|
|
@@ -27,6 +31,8 @@
|
|
|
27
31
|
"./PricingComparison.astro": "./src/PricingComparison.astro",
|
|
28
32
|
"./TrustSection.astro": "./src/TrustSection.astro",
|
|
29
33
|
"./PrimaryCta.astro": "./src/PrimaryCta.astro",
|
|
34
|
+
"./DemoFrame.astro": "./src/DemoFrame.astro",
|
|
35
|
+
"./GuideCard.astro": "./src/GuideCard.astro",
|
|
30
36
|
"./schema": "./src/schema.ts",
|
|
31
37
|
"./icons": "./src/icons.ts"
|
|
32
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>
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
|
@@ -231,3 +239,91 @@ export interface TrustSectionData {
|
|
|
231
239
|
/** Optional "the actual report you get" showcase (image or named slot). */
|
|
232
240
|
sampleDeliverable?: SampleDeliverable;
|
|
233
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;
|
|
329
|
+
}
|