@dloizides/marketing-astro-kit 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Initial release. `PricingComparison.astro` (Free-vs-Pro comparison table with
6
+ tier columns, grouped feature rows, optional footnote + one-time callout card)
7
+ and `TrustSection.astro` (security cards with inline icons, customer-logo row
8
+ with placeholders, testimonial placeholders, discreet built-by line).
9
+ - Framework-agnostic data schema (`schema.ts`) + inline icon set (`icons.ts`).
10
+ - Themed via host CSS custom properties with fallbacks; no image assets.
11
+ - Extracted from `kefi-marketing` as the 2nd genuine use of the pricing/trust
12
+ sections across the Astro marketing sites.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @dloizides/marketing-astro-kit
2
+
3
+ Two reusable, **themeable** Astro marketing sections for product sites — the
4
+ credibility markers an executive scans for before they trust a SaaS:
5
+
6
+ | Component | What it is |
7
+ |---|---|
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. |
10
+
11
+ Both are **data-driven** (one typed object per section, see [`src/schema.ts`](./src/schema.ts))
12
+ and **themed entirely through the host site's CSS custom properties** — drop them
13
+ into any Astro marketing site and they inherit its palette automatically. No
14
+ images, no runtime JS beyond the host's existing scroll-reveal + contact-reveal
15
+ hooks → Lighthouse-light.
16
+
17
+ Extracted from `kefi-marketing` as the 2nd genuine use (per the "extract on the
18
+ second use" rule); the Astro marketing sites otherwise share no code.
19
+
20
+ ## Theming contract
21
+
22
+ The components read these CSS vars off the host `:root` (each has a sensible
23
+ fallback, so a site missing a token still renders):
24
+
25
+ `--brand`, `--brand-deep`, `--accent`, `--ink`, `--paper`, `--bg`, `--bg-tint`,
26
+ `--muted`, `--hairline`, `--radius-md`, `--radius-lg`, `--shadow`, plus the
27
+ `Oswald` (headings) and `Inter` (body) font families. This is the same token set
28
+ `kefi-marketing`, `kefi-landings` and the `scaffold-landing` output already
29
+ define.
30
+
31
+ Reveal-on-scroll: the components tag elements with `data-reveal` (matching the
32
+ kefi convention). If the host has a `data-reveal` observer, the sections animate
33
+ in; if not, they're just visible. Spam-safe contact CTAs (`kind: 'contact'`)
34
+ render `data-contact`/`data-local`/`data-domain` and rely on the host page's
35
+ existing `[data-contact]` mailto-reveal handler.
36
+
37
+ ## Adopt in an Astro marketing site (katalogos, erevna, …)
38
+
39
+ Because these sites aren't a formal npm workspace, wire the kit in with a **Vite
40
+ alias** (single-source, no publish needed). Once the package is published to npm
41
+ you can instead `npm i @dloizides/marketing-astro-kit` and import by its real
42
+ name — the import specifiers are identical.
43
+
44
+ **1. Alias the kit** in `astro.config.mjs`:
45
+
46
+ ```js
47
+ import { fileURLToPath } from 'node:url';
48
+
49
+ export default defineConfig({
50
+ // …
51
+ vite: {
52
+ resolve: {
53
+ alias: {
54
+ '@marketing-kit': fileURLToPath(
55
+ new URL('../NpmPackages/packages/marketing-astro-kit/src', import.meta.url),
56
+ ),
57
+ },
58
+ },
59
+ },
60
+ });
61
+ ```
62
+
63
+ **2. Add your product's data** in `src/data/marketing.ts` (copy the shape from
64
+ [`kefi-marketing/src/data/marketing.ts`](../../../kefi-marketing/src/data/marketing.ts)):
65
+
66
+ ```ts
67
+ import type { PricingComparisonData, TrustSectionData } from '@marketing-kit/schema';
68
+
69
+ export const pricing: PricingComparisonData = { /* your real tiers + gates */ };
70
+ export const trust: TrustSectionData = { /* your compliance + placeholders */ };
71
+ ```
72
+
73
+ Use **your product's actual plan gates** — don't invent features. Cells are
74
+ `true`/`false` (check/cross) or a short string (`"Unlimited"`, `"€0"`).
75
+
76
+ **3. Render** in your page, replacing any hand-rolled pricing/trust markup:
77
+
78
+ ```astro
79
+ ---
80
+ import PricingComparison from '@marketing-kit/PricingComparison.astro';
81
+ import TrustSection from '@marketing-kit/TrustSection.astro';
82
+ import { pricing, trust } from '../data/marketing';
83
+ ---
84
+ <TrustSection data={trust} id="trust" />
85
+ <PricingComparison data={pricing} id="pricing" />
86
+ ```
87
+
88
+ **4. Ensure the theming contract** — confirm your site's `:root` defines the
89
+ tokens above (or accept the fallbacks), and that the page has a `[data-contact]`
90
+ handler if you use a contact CTA.
91
+
92
+ That's the whole adoption: alias + one data file + two imports. No copy-paste of
93
+ the markup or styles.
94
+
95
+ ## TypeScript editor support (optional)
96
+
97
+ For in-editor types, mirror the alias in `tsconfig.json`:
98
+
99
+ ```json
100
+ { "compilerOptions": { "paths": { "@marketing-kit/*": ["../NpmPackages/packages/marketing-astro-kit/src/*"] } } }
101
+ ```
102
+
103
+ The Astro build (Rollup) only needs the Vite alias — types are not required for
104
+ `astro build` to succeed.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
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.",
5
+ "keywords": [
6
+ "astro",
7
+ "marketing",
8
+ "pricing",
9
+ "comparison-table",
10
+ "trust",
11
+ "landing-page",
12
+ "saas"
13
+ ],
14
+ "author": "dloizides",
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/openmindednewby/marketing-astro-kit.git"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "CHANGELOG.md"
25
+ ],
26
+ "exports": {
27
+ "./PricingComparison.astro": "./src/PricingComparison.astro",
28
+ "./TrustSection.astro": "./src/TrustSection.astro",
29
+ "./schema": "./src/schema.ts",
30
+ "./icons": "./src/icons.ts"
31
+ },
32
+ "peerDependencies": {
33
+ "astro": ">=4"
34
+ },
35
+ "sideEffects": false,
36
+ "engines": {
37
+ "node": ">=18.0.0"
38
+ }
39
+ }
@@ -0,0 +1,442 @@
1
+ ---
2
+ // Reusable "Free-vs-Pro" pricing comparison table for product marketing sites.
3
+ // Data-driven (see schema.ts → PricingComparisonData); themed purely through
4
+ // the host site's CSS custom properties. Drop-in for any Astro marketing site.
5
+ //
6
+ // <PricingComparison data={pricing} id="pricing" />
7
+ //
8
+ // The host page must provide the `[data-contact]` mailto-reveal handler for any
9
+ // CTA with `kind: 'contact'` (the scaffold + kefi-marketing already do).
10
+ import type { MarketingCta, PricingComparisonData } from './schema';
11
+
12
+ interface Props {
13
+ data: PricingComparisonData;
14
+ id?: string;
15
+ }
16
+
17
+ const { data, id = 'pricing' } = Astro.props as Props;
18
+
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
+ const { tiers, groups } = data;
38
+ ---
39
+
40
+ <section class={`pmk-pricing`} id={id} aria-labelledby={`${id}-title`}>
41
+ <div class="pmk-container">
42
+ <p class="pmk-eyebrow" data-reveal>{data.eyebrow}</p>
43
+ <h2 class="pmk-title" id={`${id}-title`} data-reveal>{data.title}</h2>
44
+ {data.subtitle && <p class="pmk-sub" data-reveal>{data.subtitle}</p>}
45
+
46
+ <div class="pmk-tablewrap" data-reveal>
47
+ <table class="pmk-table">
48
+ <caption class="pmk-visually-hidden">Plan feature comparison</caption>
49
+ <thead>
50
+ <tr>
51
+ <th scope="col" class="pmk-th-corner"><span class="pmk-visually-hidden">Feature</span></th>
52
+ {tiers.map((t) => (
53
+ <th scope="col" class={`pmk-th${t.featured ? ' is-featured' : ''}`}>
54
+ {t.badge && <span class="pmk-ribbon">{t.badge}</span>}
55
+ <span class="pmk-tier-name">{t.name}</span>
56
+ <span class="pmk-tier-price">
57
+ <b>{t.price}</b><i>{t.priceUnit}</i>
58
+ </span>
59
+ {t.tagline && <span class="pmk-tier-tag">{t.tagline}</span>}
60
+ <a class={`pmk-cta${t.featured ? ' pmk-cta--brand' : ' pmk-cta--ink'}`} {...ctaAttrs(t.cta)}>
61
+ {t.cta.label}
62
+ </a>
63
+ </th>
64
+ ))}
65
+ </tr>
66
+ </thead>
67
+ <tbody>
68
+ {groups.map((group) => (
69
+ <>
70
+ {group.heading && (
71
+ <tr class="pmk-grouprow">
72
+ <th scope="colgroup" colspan={tiers.length + 1}>{group.heading}</th>
73
+ </tr>
74
+ )}
75
+ {group.rows.map((row) => (
76
+ <tr class="pmk-row">
77
+ <th scope="row" class="pmk-rowlabel">
78
+ <span class="pmk-rowlabel-t">{row.label}</span>
79
+ {row.hint && <span class="pmk-rowlabel-h">{row.hint}</span>}
80
+ </th>
81
+ {row.cells.map((cell, ci) => (
82
+ <td
83
+ class={`pmk-cell${tiers[ci]?.featured ? ' is-featured' : ''}`}
84
+ data-label={tiers[ci]?.name}
85
+ >
86
+ {typeof cell === 'boolean' ? (
87
+ cell ? (
88
+ <span class="pmk-yes" aria-label="Included">
89
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7" /></svg>
90
+ </span>
91
+ ) : (
92
+ <span class="pmk-no" aria-label="Not included">
93
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
94
+ </span>
95
+ )
96
+ ) : (
97
+ <span class="pmk-val">{cell}</span>
98
+ )}
99
+ </td>
100
+ ))}
101
+ </tr>
102
+ ))}
103
+ </>
104
+ ))}
105
+ </tbody>
106
+ </table>
107
+ </div>
108
+
109
+ {data.footnote && <p class="pmk-footnote" data-reveal>{data.footnote}</p>}
110
+
111
+ {data.callout && (
112
+ <aside class="pmk-callout" data-reveal>
113
+ <div class="pmk-callout-body">
114
+ {data.callout.badge && <span class="pmk-callout-badge">{data.callout.badge}</span>}
115
+ <h3 class="pmk-callout-name">{data.callout.name}</h3>
116
+ <p class="pmk-callout-price"><b>{data.callout.price}</b> <i>{data.callout.priceUnit}</i></p>
117
+ {data.callout.tagline && <p class="pmk-callout-tag">{data.callout.tagline}</p>}
118
+ <ul class="pmk-callout-list">
119
+ {data.callout.bullets.map((b) => <li>{b}</li>)}
120
+ </ul>
121
+ </div>
122
+ <a class="pmk-cta pmk-cta--ink pmk-callout-cta" {...ctaAttrs(data.callout.cta)}>
123
+ {data.callout.cta.label}
124
+ </a>
125
+ </aside>
126
+ )}
127
+ </div>
128
+ </section>
129
+
130
+ <style>
131
+ /* Themed via host CSS vars (fallbacks keep it rendering standalone). */
132
+ .pmk-pricing {
133
+ --_brand: var(--brand, #d8332a);
134
+ --_brand-deep: var(--brand-deep, #a8231c);
135
+ --_accent: var(--accent, #f5a300);
136
+ --_ink: var(--ink, #0a0a1f);
137
+ --_paper: var(--paper, #fff);
138
+ --_bg: var(--bg, #fbf9f4);
139
+ --_bg-tint: var(--bg-tint, #f1ede1);
140
+ --_muted: var(--muted, rgba(10, 10, 31, 0.62));
141
+ --_hairline: var(--hairline, rgba(10, 10, 31, 0.1));
142
+ --_r-lg: var(--radius-lg, 24px);
143
+ --_r-md: var(--radius-md, 14px);
144
+ --_shadow: var(--shadow, 0 30px 60px -30px rgba(10, 10, 31, 0.4));
145
+ padding: 96px 0;
146
+ }
147
+ .pmk-container {
148
+ width: min(1200px, 100% - 48px);
149
+ margin-inline: auto;
150
+ }
151
+ .pmk-eyebrow {
152
+ font-family: 'Oswald', sans-serif;
153
+ font-size: 12px;
154
+ font-weight: 600;
155
+ letter-spacing: 0.22em;
156
+ text-transform: uppercase;
157
+ color: var(--_brand);
158
+ margin: 0;
159
+ }
160
+ .pmk-title {
161
+ font-family: 'Oswald', 'Inter', sans-serif;
162
+ font-size: clamp(30px, 3.8vw, 46px);
163
+ font-weight: 700;
164
+ line-height: 1.08;
165
+ letter-spacing: -0.01em;
166
+ margin: 12px 0 14px;
167
+ max-width: 22ch;
168
+ color: var(--_ink);
169
+ }
170
+ .pmk-sub {
171
+ color: var(--_muted);
172
+ max-width: 60ch;
173
+ margin: 0 0 40px;
174
+ }
175
+
176
+ /* Table --------------------------------------------------------------- */
177
+ .pmk-tablewrap {
178
+ overflow-x: auto;
179
+ border-radius: var(--_r-lg);
180
+ -webkit-overflow-scrolling: touch;
181
+ }
182
+ .pmk-table {
183
+ width: 100%;
184
+ min-width: 640px;
185
+ border-collapse: separate;
186
+ border-spacing: 0;
187
+ table-layout: fixed;
188
+ }
189
+ .pmk-table caption { text-align: left; }
190
+ .pmk-th-corner,
191
+ .pmk-rowlabel {
192
+ width: 34%;
193
+ text-align: left;
194
+ }
195
+ .pmk-th {
196
+ position: relative;
197
+ vertical-align: bottom;
198
+ padding: 22px 18px 20px;
199
+ text-align: center;
200
+ background: var(--_paper);
201
+ border-top: 1px solid var(--_hairline);
202
+ border-bottom: 2px solid var(--_hairline);
203
+ }
204
+ .pmk-th.is-featured {
205
+ background: var(--_ink);
206
+ border-radius: var(--_r-md) var(--_r-md) 0 0;
207
+ border-color: var(--_ink);
208
+ }
209
+ .pmk-th-corner {
210
+ background: transparent;
211
+ border-bottom: 2px solid var(--_hairline);
212
+ }
213
+ .pmk-ribbon {
214
+ position: absolute;
215
+ top: -11px;
216
+ left: 50%;
217
+ transform: translateX(-50%);
218
+ background: var(--_brand);
219
+ color: var(--_paper);
220
+ padding: 4px 12px;
221
+ border-radius: 999px;
222
+ font-family: 'Oswald', sans-serif;
223
+ font-size: 10.5px;
224
+ font-weight: 600;
225
+ letter-spacing: 0.16em;
226
+ text-transform: uppercase;
227
+ white-space: nowrap;
228
+ }
229
+ .pmk-tier-name {
230
+ display: block;
231
+ font-family: 'Oswald', sans-serif;
232
+ font-size: 16px;
233
+ font-weight: 600;
234
+ letter-spacing: 0.06em;
235
+ text-transform: uppercase;
236
+ color: var(--_ink);
237
+ }
238
+ .is-featured .pmk-tier-name { color: var(--_paper); }
239
+ .pmk-tier-price {
240
+ display: block;
241
+ margin: 8px 0 2px;
242
+ color: var(--_ink);
243
+ }
244
+ .pmk-tier-price b {
245
+ font-family: 'Oswald', sans-serif;
246
+ font-size: 34px;
247
+ font-weight: 700;
248
+ line-height: 1;
249
+ }
250
+ .pmk-tier-price i {
251
+ font-style: normal;
252
+ font-size: 13px;
253
+ color: var(--_muted);
254
+ margin-left: 4px;
255
+ }
256
+ .is-featured .pmk-tier-price,
257
+ .is-featured .pmk-tier-price b { color: var(--_paper); }
258
+ .is-featured .pmk-tier-price i { color: rgba(255, 255, 255, 0.7); }
259
+ .pmk-tier-tag {
260
+ display: block;
261
+ font-size: 12.5px;
262
+ color: var(--_muted);
263
+ margin-bottom: 14px;
264
+ min-height: 1.4em;
265
+ }
266
+ .is-featured .pmk-tier-tag { color: rgba(255, 255, 255, 0.72); }
267
+
268
+ /* CTA (self-contained — does not depend on host .btn) ----------------- */
269
+ .pmk-cta {
270
+ display: inline-flex;
271
+ align-items: center;
272
+ justify-content: center;
273
+ gap: 8px;
274
+ padding: 11px 18px;
275
+ border-radius: 999px;
276
+ font-family: 'Inter', system-ui, sans-serif;
277
+ font-weight: 600;
278
+ font-size: 13.5px;
279
+ letter-spacing: 0.02em;
280
+ border: 1px solid transparent;
281
+ cursor: pointer;
282
+ transition: transform 0.12s ease, background 0.12s ease, box-shadow 0.12s ease;
283
+ text-decoration: none;
284
+ }
285
+ .pmk-cta:hover { transform: translateY(-1px); }
286
+ .pmk-cta--ink { background: var(--_ink); color: var(--_paper); }
287
+ .pmk-cta--ink:hover { background: var(--_brand); }
288
+ .pmk-cta--brand { background: var(--_brand); color: var(--_paper); }
289
+ .pmk-cta--brand:hover { background: var(--_brand-deep); }
290
+ .is-featured .pmk-cta--brand { box-shadow: 0 10px 24px -10px rgba(0, 0, 0, 0.5); }
291
+
292
+ /* Group + rows -------------------------------------------------------- */
293
+ .pmk-grouprow th {
294
+ text-align: left;
295
+ padding: 22px 18px 8px;
296
+ font-family: 'Oswald', sans-serif;
297
+ font-size: 11px;
298
+ font-weight: 600;
299
+ letter-spacing: 0.18em;
300
+ text-transform: uppercase;
301
+ color: var(--_brand);
302
+ }
303
+ .pmk-row:hover .pmk-rowlabel,
304
+ .pmk-row:hover .pmk-cell:not(.is-featured) {
305
+ background: var(--_bg-tint);
306
+ }
307
+ .pmk-rowlabel {
308
+ padding: 15px 18px;
309
+ vertical-align: middle;
310
+ border-bottom: 1px solid var(--_hairline);
311
+ font-weight: 500;
312
+ }
313
+ .pmk-rowlabel-t { display: block; color: var(--_ink); font-size: 15px; }
314
+ .pmk-rowlabel-h { display: block; color: var(--_muted); font-size: 12.5px; margin-top: 2px; }
315
+ .pmk-cell {
316
+ padding: 15px 18px;
317
+ text-align: center;
318
+ vertical-align: middle;
319
+ border-bottom: 1px solid var(--_hairline);
320
+ color: var(--_ink);
321
+ }
322
+ .pmk-cell.is-featured {
323
+ background: color-mix(in srgb, var(--_ink) 5%, transparent);
324
+ border-left: 1px solid var(--_hairline);
325
+ border-right: 1px solid var(--_hairline);
326
+ }
327
+ .pmk-val {
328
+ font-family: 'Oswald', sans-serif;
329
+ font-weight: 600;
330
+ font-size: 14px;
331
+ letter-spacing: 0.01em;
332
+ }
333
+ .pmk-yes,
334
+ .pmk-no {
335
+ display: inline-grid;
336
+ place-items: center;
337
+ width: 24px;
338
+ height: 24px;
339
+ border-radius: 50%;
340
+ }
341
+ .pmk-yes {
342
+ background: color-mix(in srgb, var(--_accent) 20%, transparent);
343
+ color: var(--_brand-deep);
344
+ }
345
+ .pmk-yes svg { width: 15px; height: 15px; }
346
+ .pmk-no { color: var(--_muted); opacity: 0.5; }
347
+ .pmk-no svg { width: 15px; height: 15px; }
348
+ /* last row of the featured column gets a rounded bottom */
349
+ .pmk-table tbody tr:last-child .pmk-cell.is-featured {
350
+ border-radius: 0 0 var(--_r-md) var(--_r-md);
351
+ }
352
+
353
+ .pmk-footnote {
354
+ color: var(--_muted);
355
+ font-size: 13.5px;
356
+ margin: 18px 2px 0;
357
+ }
358
+
359
+ /* Concierge / one-time callout --------------------------------------- */
360
+ .pmk-callout {
361
+ margin-top: 26px;
362
+ display: flex;
363
+ align-items: center;
364
+ justify-content: space-between;
365
+ gap: 28px;
366
+ flex-wrap: wrap;
367
+ padding: 26px 30px;
368
+ border-radius: var(--_r-lg);
369
+ background: var(--_bg-tint);
370
+ border: 1px solid var(--_hairline);
371
+ }
372
+ .pmk-callout-badge {
373
+ display: inline-block;
374
+ font-family: 'Oswald', sans-serif;
375
+ font-size: 11px;
376
+ font-weight: 600;
377
+ letter-spacing: 0.16em;
378
+ text-transform: uppercase;
379
+ color: var(--_ink);
380
+ background: var(--_accent);
381
+ padding: 4px 10px;
382
+ border-radius: 999px;
383
+ margin-bottom: 12px;
384
+ }
385
+ .pmk-callout-name {
386
+ font-family: 'Oswald', sans-serif;
387
+ font-size: 22px;
388
+ font-weight: 700;
389
+ margin: 0;
390
+ display: inline-block;
391
+ }
392
+ .pmk-callout-price {
393
+ margin: 4px 0 8px;
394
+ color: var(--_ink);
395
+ }
396
+ .pmk-callout-price b { font-family: 'Oswald', sans-serif; font-size: 26px; }
397
+ .pmk-callout-price i { font-style: normal; font-size: 13px; color: var(--_muted); }
398
+ .pmk-callout-tag { color: var(--_muted); margin: 0 0 10px; font-size: 14.5px; }
399
+ .pmk-callout-list {
400
+ list-style: none;
401
+ padding: 0;
402
+ margin: 0;
403
+ display: flex;
404
+ flex-wrap: wrap;
405
+ gap: 6px 22px;
406
+ max-width: 62ch;
407
+ }
408
+ .pmk-callout-list li {
409
+ position: relative;
410
+ padding-left: 18px;
411
+ color: var(--_muted);
412
+ font-size: 13.5px;
413
+ }
414
+ .pmk-callout-list li::before {
415
+ content: '';
416
+ position: absolute;
417
+ left: 0;
418
+ top: 8px;
419
+ width: 7px;
420
+ height: 7px;
421
+ border-radius: 50%;
422
+ background: var(--_brand);
423
+ }
424
+ .pmk-callout-cta { white-space: nowrap; flex-shrink: 0; }
425
+
426
+ .pmk-visually-hidden {
427
+ position: absolute;
428
+ width: 1px;
429
+ height: 1px;
430
+ padding: 0;
431
+ margin: -1px;
432
+ overflow: hidden;
433
+ clip: rect(0 0 0 0);
434
+ white-space: nowrap;
435
+ border: 0;
436
+ }
437
+
438
+ @media (max-width: 640px) {
439
+ .pmk-pricing { padding: 64px 0; }
440
+ .pmk-callout { flex-direction: column; align-items: flex-start; }
441
+ }
442
+ </style>
@@ -0,0 +1,280 @@
1
+ ---
2
+ // Reusable trust / credibility section for product marketing sites — the
3
+ // markers an executive scans for: security & compliance cards, a discreet
4
+ // "built by" line, plus clearly-labeled PLACEHOLDER slots for customer logos
5
+ // and testimonials the site owner fills in with real ones.
6
+ //
7
+ // Data-driven (schema.ts → TrustSectionData); themed via host CSS vars.
8
+ //
9
+ // <TrustSection data={trust} id="trust" />
10
+ import type { TrustSectionData } from './schema';
11
+ import { TRUST_ICONS } from './icons';
12
+
13
+ interface Props {
14
+ data: TrustSectionData;
15
+ id?: string;
16
+ }
17
+
18
+ const { data, id = 'trust' } = Astro.props as Props;
19
+ ---
20
+
21
+ <section class="tmk-trust" id={id} aria-labelledby={`${id}-title`}>
22
+ <div class="tmk-container">
23
+ <p class="tmk-eyebrow" data-reveal>{data.eyebrow}</p>
24
+ <h2 class="tmk-title" id={`${id}-title`} data-reveal>{data.title}</h2>
25
+ {data.subtitle && <p class="tmk-sub" data-reveal>{data.subtitle}</p>}
26
+
27
+ <div class="tmk-grid">
28
+ {data.items.map((item, i) => (
29
+ <article class="tmk-card" data-reveal style={`--d:${(i % 4) + 1}`}>
30
+ <span class="tmk-ico" set:html={TRUST_ICONS[item.icon]} />
31
+ <h3 class="tmk-card-t">{item.title}</h3>
32
+ <p class="tmk-card-b">{item.body}</p>
33
+ </article>
34
+ ))}
35
+ </div>
36
+
37
+ {data.logos && (
38
+ <div class="tmk-logos" data-reveal>
39
+ <p class="tmk-logos-label">{data.logos.label}</p>
40
+ <div class="tmk-logos-row">
41
+ {data.logos.items.map((logo) =>
42
+ logo.placeholder ? (
43
+ <span class="tmk-logo tmk-logo--ph" aria-hidden="true">Your logo</span>
44
+ ) : logo.href ? (
45
+ <a class="tmk-logo" href={logo.href} target="_blank" rel="noopener">{logo.name}</a>
46
+ ) : (
47
+ <span class="tmk-logo">{logo.name}</span>
48
+ ),
49
+ )}
50
+ </div>
51
+ {data.logos.note && <p class="tmk-ph-note">{data.logos.note}</p>}
52
+ </div>
53
+ )}
54
+
55
+ {data.testimonials && (
56
+ <div class="tmk-quotes" data-reveal>
57
+ <p class="tmk-logos-label">{data.testimonials.label}</p>
58
+ <div class="tmk-quotes-row">
59
+ {data.testimonials.items.map((t) => (
60
+ <figure class={`tmk-quote${t.placeholder ? ' tmk-quote--ph' : ''}`}>
61
+ {t.placeholder && <span class="tmk-ph-tag">Placeholder</span>}
62
+ <blockquote class="tmk-quote-b">{t.quote}</blockquote>
63
+ <figcaption class="tmk-quote-cite">
64
+ <span class="tmk-quote-name">{t.authorName}</span>
65
+ {t.authorRole && <span class="tmk-quote-role">{t.authorRole}</span>}
66
+ </figcaption>
67
+ </figure>
68
+ ))}
69
+ </div>
70
+ {data.testimonials.note && <p class="tmk-ph-note">{data.testimonials.note}</p>}
71
+ </div>
72
+ )}
73
+
74
+ {data.builtBy && (
75
+ <p class="tmk-builtby" data-reveal>
76
+ {data.builtBy.label}
77
+ <a href={data.builtBy.href} target="_blank" rel="noopener">{data.builtBy.name}</a>
78
+ </p>
79
+ )}
80
+ </div>
81
+ </section>
82
+
83
+ <style>
84
+ .tmk-trust {
85
+ --_brand: var(--brand, #d8332a);
86
+ --_accent: var(--accent, #f5a300);
87
+ --_ink: var(--ink, #0a0a1f);
88
+ --_paper: var(--paper, #fff);
89
+ --_bg: var(--bg, #fbf9f4);
90
+ --_bg-tint: var(--bg-tint, #f1ede1);
91
+ --_muted: var(--muted, rgba(10, 10, 31, 0.62));
92
+ --_hairline: var(--hairline, rgba(10, 10, 31, 0.1));
93
+ --_r-md: var(--radius-md, 14px);
94
+ --_r-lg: var(--radius-lg, 24px);
95
+ --_shadow: var(--shadow, 0 30px 60px -30px rgba(10, 10, 31, 0.4));
96
+ padding: 96px 0;
97
+ background: var(--_paper);
98
+ border-top: 1px solid var(--_hairline);
99
+ border-bottom: 1px solid var(--_hairline);
100
+ }
101
+ .tmk-container {
102
+ width: min(1200px, 100% - 48px);
103
+ margin-inline: auto;
104
+ }
105
+ .tmk-eyebrow {
106
+ font-family: 'Oswald', sans-serif;
107
+ font-size: 12px;
108
+ font-weight: 600;
109
+ letter-spacing: 0.22em;
110
+ text-transform: uppercase;
111
+ color: var(--_brand);
112
+ margin: 0;
113
+ }
114
+ .tmk-title {
115
+ font-family: 'Oswald', 'Inter', sans-serif;
116
+ font-size: clamp(30px, 3.8vw, 46px);
117
+ font-weight: 700;
118
+ line-height: 1.08;
119
+ letter-spacing: -0.01em;
120
+ margin: 12px 0 14px;
121
+ max-width: 22ch;
122
+ color: var(--_ink);
123
+ }
124
+ .tmk-sub {
125
+ color: var(--_muted);
126
+ max-width: 60ch;
127
+ margin: 0 0 44px;
128
+ }
129
+
130
+ /* Cards --------------------------------------------------------------- */
131
+ .tmk-grid {
132
+ display: grid;
133
+ grid-template-columns: repeat(4, 1fr);
134
+ gap: 18px;
135
+ }
136
+ @media (max-width: 1024px) { .tmk-grid { grid-template-columns: repeat(2, 1fr); } }
137
+ @media (max-width: 560px) { .tmk-grid { grid-template-columns: 1fr; } }
138
+ .tmk-card {
139
+ position: relative;
140
+ padding: 26px 22px;
141
+ border-radius: var(--_r-md);
142
+ background: var(--_bg);
143
+ border: 1px solid var(--_hairline);
144
+ transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
145
+ }
146
+ .tmk-card:hover {
147
+ transform: translateY(-4px);
148
+ box-shadow: var(--_shadow);
149
+ border-color: transparent;
150
+ }
151
+ .tmk-ico {
152
+ display: inline-grid;
153
+ place-items: center;
154
+ width: 44px;
155
+ height: 44px;
156
+ border-radius: 12px;
157
+ background: color-mix(in srgb, var(--_brand) 10%, transparent);
158
+ color: var(--_brand);
159
+ margin-bottom: 16px;
160
+ }
161
+ .tmk-ico :global(svg) { width: 22px; height: 22px; }
162
+ .tmk-card-t {
163
+ font-family: 'Oswald', 'Inter', sans-serif;
164
+ font-size: 18px;
165
+ font-weight: 600;
166
+ margin: 0 0 8px;
167
+ color: var(--_ink);
168
+ }
169
+ .tmk-card-b { color: var(--_muted); margin: 0; font-size: 14.5px; }
170
+
171
+ /* Logos --------------------------------------------------------------- */
172
+ .tmk-logos { margin-top: 52px; }
173
+ .tmk-logos-label {
174
+ font-family: 'Oswald', sans-serif;
175
+ font-size: 12px;
176
+ letter-spacing: 0.2em;
177
+ text-transform: uppercase;
178
+ color: var(--_muted);
179
+ margin: 0 0 18px;
180
+ text-align: center;
181
+ }
182
+ .tmk-logos-row {
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ flex-wrap: wrap;
187
+ gap: 14px 18px;
188
+ }
189
+ .tmk-logo {
190
+ font-family: 'Oswald', sans-serif;
191
+ font-weight: 600;
192
+ font-size: 17px;
193
+ letter-spacing: 0.02em;
194
+ color: var(--_ink);
195
+ padding: 12px 22px;
196
+ border-radius: var(--_r-md);
197
+ background: var(--_bg-tint);
198
+ border: 1px solid var(--_hairline);
199
+ transition: color 0.14s ease, border-color 0.14s ease;
200
+ }
201
+ a.tmk-logo:hover { color: var(--_brand); border-color: var(--_brand); }
202
+ .tmk-logo--ph {
203
+ color: var(--_muted);
204
+ background: transparent;
205
+ border: 1px dashed var(--_hairline);
206
+ opacity: 0.75;
207
+ }
208
+
209
+ /* Testimonials -------------------------------------------------------- */
210
+ .tmk-quotes { margin-top: 52px; }
211
+ .tmk-quotes-row {
212
+ display: grid;
213
+ grid-template-columns: repeat(3, 1fr);
214
+ gap: 18px;
215
+ }
216
+ @media (max-width: 900px) { .tmk-quotes-row { grid-template-columns: 1fr; } }
217
+ .tmk-quote {
218
+ position: relative;
219
+ padding: 26px 24px 22px;
220
+ border-radius: var(--_r-md);
221
+ background: var(--_bg);
222
+ border: 1px solid var(--_hairline);
223
+ }
224
+ .tmk-quote--ph { border-style: dashed; background: transparent; }
225
+ .tmk-ph-tag {
226
+ position: absolute;
227
+ top: 14px;
228
+ right: 14px;
229
+ font-family: 'Oswald', sans-serif;
230
+ font-size: 9.5px;
231
+ font-weight: 600;
232
+ letter-spacing: 0.14em;
233
+ text-transform: uppercase;
234
+ color: var(--_muted);
235
+ background: var(--_bg-tint);
236
+ padding: 3px 8px;
237
+ border-radius: 999px;
238
+ }
239
+ .tmk-quote-b {
240
+ margin: 0 0 16px;
241
+ font-size: 16px;
242
+ line-height: 1.5;
243
+ color: var(--_ink);
244
+ }
245
+ .tmk-quote--ph .tmk-quote-b { color: var(--_muted); }
246
+ .tmk-quote-b::before { content: '“'; color: var(--_brand); font-weight: 700; }
247
+ .tmk-quote-cite { font-style: normal; }
248
+ .tmk-quote-name {
249
+ display: block;
250
+ font-family: 'Oswald', sans-serif;
251
+ font-weight: 600;
252
+ font-size: 14.5px;
253
+ color: var(--_ink);
254
+ }
255
+ .tmk-quote-role { display: block; font-size: 12.5px; color: var(--_muted); }
256
+
257
+ .tmk-ph-note {
258
+ text-align: center;
259
+ color: var(--_muted);
260
+ font-size: 12.5px;
261
+ margin: 16px 0 0;
262
+ font-style: italic;
263
+ }
264
+
265
+ /* Built by ------------------------------------------------------------ */
266
+ .tmk-builtby {
267
+ margin: 48px 0 0;
268
+ text-align: center;
269
+ color: var(--_muted);
270
+ font-size: 13px;
271
+ }
272
+ .tmk-builtby a {
273
+ color: var(--_ink);
274
+ border-bottom: 1px dotted var(--_muted);
275
+ margin-left: 5px;
276
+ }
277
+ .tmk-builtby a:hover { color: var(--_brand); }
278
+
279
+ @media (max-width: 560px) { .tmk-trust { padding: 64px 0; } }
280
+ </style>
package/src/icons.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Inline 24×24 stroke icons for the trust section. Text-light (no image
2
+ // requests → Lighthouse-friendly). Each is `currentColor`-stroked so it
3
+ // inherits the host palette. Keyed by TrustIconName in schema.ts.
4
+ import type { TrustIconName } from './schema';
5
+
6
+ export const TRUST_ICONS: Record<TrustIconName, string> = {
7
+ // GDPR / privacy — shield with check
8
+ shield:
9
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6l7-3z"/><path d="M9 12l2 2 4-4"/></svg>',
10
+ // Secure sign-in — key
11
+ key: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="4"/><path d="M11 11l8 8"/><path d="M16 16l2-2"/><path d="M18.5 18.5l1.5-1.5"/></svg>',
12
+ // Your domain — globe
13
+ globe:
14
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.5 3.8 5.7 3.8 9S14.5 18.5 12 21c-2.5-2.5-3.8-5.7-3.8-9S9.5 5.5 12 3z"/></svg>',
15
+ // Your money — wallet
16
+ wallet:
17
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2.5"/><path d="M3 9h18"/><circle cx="16.5" cy="13" r="1.3" fill="currentColor" stroke="none"/></svg>',
18
+ // Access control — lock
19
+ lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4.5" y="10.5" width="15" height="10" rx="2.5"/><path d="M8 10.5V8a4 4 0 018 0v2.5"/></svg>',
20
+ // Quality / polish — spark
21
+ spark:
22
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"/></svg>',
23
+ };
package/src/schema.ts ADDED
@@ -0,0 +1,131 @@
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.
5
+ //
6
+ // Themeing is done entirely through CSS custom properties on the host site's
7
+ // `:root` (--brand, --accent, --ink, --paper, --muted, --hairline, radii,
8
+ // --shadow, and the Oswald/Inter font families). The components reference
9
+ // those vars with sensible fallbacks, so a site drops them in and they inherit
10
+ // its palette automatically.
11
+
12
+ // ── Pricing comparison ─────────────────────────────────────────────────────
13
+
14
+ /** A comparison-table cell: `true`/`false` renders a check/cross; a string
15
+ * renders a short value (e.g. "Unlimited", "€0", "White-label"). */
16
+ export type ComparisonCell = boolean | string;
17
+
18
+ /** A call-to-action button. `kind: 'contact'` renders a spam-safe mailto that
19
+ * the host page assembles in JS from `contact.local`/`contact.domain` (the
20
+ * existing `[data-contact]` reveal handler) — never a plaintext email. */
21
+ export interface MarketingCta {
22
+ label: string;
23
+ href: string;
24
+ kind?: 'link' | 'contact';
25
+ contact?: { local: string; domain: string };
26
+ /** Umami custom-event name fired on click (optional). */
27
+ analyticsEvent?: string;
28
+ /** Extra Umami `data-umami-event-*` pairs, e.g. { location: 'pricing' }. */
29
+ analyticsData?: Record<string, string>;
30
+ }
31
+
32
+ /** One tier column in the comparison table header. */
33
+ export interface PricingTierColumn {
34
+ /** Stable code (free | pro | business …). Used for live-price patching and
35
+ * analytics — NOT shown to users. */
36
+ code: string;
37
+ name: string;
38
+ /** Already-formatted headline price, e.g. "€19" or "€0". */
39
+ price: string;
40
+ /** Unit under the price, e.g. "/ month" or "forever". */
41
+ priceUnit: string;
42
+ tagline?: string;
43
+ /** Visually lift this column as the recommended tier. */
44
+ featured?: boolean;
45
+ /** Small ribbon over a featured column, e.g. "Most popular". */
46
+ badge?: string;
47
+ cta: MarketingCta;
48
+ }
49
+
50
+ /** A single feature row spanning every tier column. */
51
+ export interface ComparisonRow {
52
+ label: string;
53
+ /** Optional muted helper under the label. */
54
+ hint?: string;
55
+ /** One cell per tier, in the SAME order as `PricingComparisonData.tiers`. */
56
+ cells: ComparisonCell[];
57
+ }
58
+
59
+ /** Rows grouped under an optional subheading (e.g. "Payments", "Team"). */
60
+ export interface ComparisonGroup {
61
+ heading?: string;
62
+ rows: ComparisonRow[];
63
+ }
64
+
65
+ /** An optional one-time / service tier rendered as a card beneath the table
66
+ * (Kefi uses it for the Concierge "we build it for you" offer). */
67
+ export interface PricingCallout {
68
+ name: string;
69
+ price: string;
70
+ priceUnit: string;
71
+ tagline?: string;
72
+ bullets: string[];
73
+ cta: MarketingCta;
74
+ badge?: string;
75
+ }
76
+
77
+ export interface PricingComparisonData {
78
+ eyebrow: string;
79
+ title: string;
80
+ subtitle?: string;
81
+ tiers: PricingTierColumn[];
82
+ groups: ComparisonGroup[];
83
+ footnote?: string;
84
+ callout?: PricingCallout;
85
+ }
86
+
87
+ // ── Trust / credibility ────────────────────────────────────────────────────
88
+
89
+ /** Built-in inline-SVG icon keys (see icons.ts). Adding one is a kit change. */
90
+ export type TrustIconName = 'shield' | 'key' | 'globe' | 'wallet' | 'lock' | 'spark';
91
+
92
+ export interface TrustItem {
93
+ icon: TrustIconName;
94
+ title: string;
95
+ body: string;
96
+ }
97
+
98
+ /** A customer/logo chip. Text wordmark only — no image asset, so the section
99
+ * stays Lighthouse-light. `placeholder: true` renders a clearly-labeled empty
100
+ * slot the site owner fills with a real customer later. */
101
+ export interface LogoItem {
102
+ name: string;
103
+ href?: string;
104
+ placeholder?: boolean;
105
+ }
106
+
107
+ /** A testimonial card. `placeholder: true` renders a clearly-labeled stub so
108
+ * the page reads as "credible, quotes coming" without faking a quote. */
109
+ export interface Testimonial {
110
+ quote: string;
111
+ authorName: string;
112
+ authorRole?: string;
113
+ placeholder?: boolean;
114
+ }
115
+
116
+ /** The discreet "built by …" attribution line. */
117
+ export interface BuiltByCredit {
118
+ label: string;
119
+ name: string;
120
+ href: string;
121
+ }
122
+
123
+ export interface TrustSectionData {
124
+ eyebrow: string;
125
+ title: string;
126
+ subtitle?: string;
127
+ items: TrustItem[];
128
+ logos?: { label: string; note?: string; items: LogoItem[] };
129
+ testimonials?: { label: string; note?: string; items: Testimonial[] };
130
+ builtBy?: BuiltByCredit;
131
+ }