@dloizides/marketing-astro-kit 1.0.1 → 1.1.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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ - New `PrimaryCta.astro` — a themeable primary call-to-action (link OR spam-safe
6
+ contact) with an optional secondary CTA and a `tone: 'light' | 'dark'` for
7
+ dark bands. Renders a `MarketingCta` so link-vs-lead-capture + analytics wiring
8
+ is no longer hand-coded per page.
9
+ - New `buildPrimaryCta(opts)` helper + `PrimaryCtaOptions` in `schema.ts`: drive
10
+ a primary CTA from ONE `SIGNUP_URL` constant — a real signup link when set,
11
+ spam-safe lead-capture when `null` (flip at GA with no template change).
12
+ - `ctaAttrs()` promoted from `PricingComparison.astro` into `schema.ts` and now
13
+ shared by every CTA-rendering component (no duplication).
14
+ - `TrustSection` gains three optional, guarded fields (existing sites
15
+ unaffected): `foundingPartner` ("first N free during beta" panel),
16
+ `founder` (honest one-line credibility) and `sampleDeliverable` (a "here's the
17
+ actual report you get" showcase — image, or a `slot="sample-deliverable"` for
18
+ bespoke mock markup while the section chrome stays shared).
19
+
3
20
  ## 1.0.0
4
21
 
5
22
  - Initial release. `PricingComparison.astro` (Free-vs-Pro comparison table with
package/README.md CHANGED
@@ -6,7 +6,62 @@ credibility markers an executive scans for before they trust a SaaS:
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
+
12
+ ## Config-driven primary CTA (`PrimaryCta` + `buildPrimaryCta`)
13
+
14
+ Instead of hand-coding "is this a link to the app or a lead-capture mailto?" per
15
+ page (with "flip to APP_URL at GA" TODO comments), hold ONE `SIGNUP_URL`
16
+ constant and let the CTA switch itself:
17
+
18
+ ```ts
19
+ // src/data/site.ts
20
+ export const SIGNUP_URL: string | null = null; // null → app not public yet → lead-capture
21
+ ```
22
+
23
+ ```ts
24
+ // src/data/marketing.ts
25
+ import { buildPrimaryCta } from '@marketing-kit/schema';
26
+ import { SIGNUP_URL } from './site';
27
+
28
+ export const primaryCta = buildPrimaryCta({
29
+ signupUrl: SIGNUP_URL, // set → real link ("Start free"); null → contact
30
+ linkLabel: 'Start free',
31
+ contactLabel: 'Request early access',
32
+ contact: { local: 'hello', domain: 'example.com' },
33
+ analyticsEvent: 'cta_primary',
34
+ analyticsData: { location: 'hero' },
35
+ });
36
+ ```
37
+
38
+ ```astro
39
+ ---
40
+ import PrimaryCta from '@marketing-kit/PrimaryCta.astro';
41
+ import { primaryCta, secondaryCta } from '../data/marketing';
42
+ ---
43
+ <PrimaryCta cta={primaryCta} secondary={secondaryCta} /> <!-- on light -->
44
+ <PrimaryCta cta={primaryCta} secondary={secondaryCta} tone="dark" align="center" /> <!-- on a dark band -->
45
+ ```
46
+
47
+ Flip `SIGNUP_URL` from `null` to a real URL (e.g. at GA) and every primary CTA
48
+ becomes a real "Start free" link — no template change. Contact CTAs still rely on
49
+ the host page's `[data-contact]` reveal handler.
50
+
51
+ ## Founding-partner / founder / sample-deliverable (`TrustSection` extras)
52
+
53
+ All three `TrustSectionData` fields are optional and render behind guards —
54
+ omitting them = the previous behaviour. The sample-deliverable accepts an
55
+ `image`, OR a product can inject bespoke mock markup via a named slot while the
56
+ eyebrow/title/caption chrome stays shared:
57
+
58
+ ```astro
59
+ <TrustSection data={trust} id="trust">
60
+ <Fragment slot="sample-deliverable">
61
+ <!-- your bespoke mock report / screenshot markup -->
62
+ </Fragment>
63
+ </TrustSection>
64
+ ```
10
65
 
11
66
  Both are **data-driven** (one typed object per section, see [`src/schema.ts`](./src/schema.ts))
12
67
  and **themed entirely through the host site's CSS custom properties** — drop them
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dloizides/marketing-astro-kit",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
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
5
  "keywords": [
6
6
  "astro",
@@ -26,6 +26,7 @@
26
26
  "exports": {
27
27
  "./PricingComparison.astro": "./src/PricingComparison.astro",
28
28
  "./TrustSection.astro": "./src/TrustSection.astro",
29
+ "./PrimaryCta.astro": "./src/PrimaryCta.astro",
29
30
  "./schema": "./src/schema.ts",
30
31
  "./icons": "./src/icons.ts"
31
32
  },
@@ -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
@@ -29,6 +29,70 @@ export interface MarketingCta {
29
29
  analyticsData?: Record<string, string>;
30
30
  }
31
31
 
32
+ /** Build the html attributes for a CTA anchor — a normal link, or the spam-safe
33
+ * `[data-contact]` reveal pattern for `kind: 'contact'` (the host page's
34
+ * existing handler assembles the mailto on click). Shared by every kit
35
+ * component that renders a `MarketingCta` (PricingComparison, PrimaryCta …) so
36
+ * the link/contact + analytics wiring lives in exactly one place. */
37
+ export function ctaAttrs(cta: MarketingCta): Record<string, string> {
38
+ const attrs: Record<string, string> = {};
39
+ if (cta.kind === 'contact' && cta.contact) {
40
+ attrs.href = '#';
41
+ attrs['data-contact'] = '';
42
+ attrs['data-local'] = cta.contact.local;
43
+ attrs['data-domain'] = cta.contact.domain;
44
+ } else {
45
+ attrs.href = cta.href;
46
+ }
47
+ if (cta.analyticsEvent) attrs['data-umami-event'] = cta.analyticsEvent;
48
+ for (const [k, v] of Object.entries(cta.analyticsData ?? {})) {
49
+ attrs[`data-umami-event-${k}`] = v;
50
+ }
51
+ return attrs;
52
+ }
53
+
54
+ /** Inputs for {@link buildPrimaryCta}: one config that auto-switches a primary
55
+ * CTA between a real signup/app link and spam-safe lead-capture, driven purely
56
+ * by whether the product's app is public yet. */
57
+ export interface PrimaryCtaOptions {
58
+ /** The public signup / app URL. `null` (or empty) = the app is not public yet
59
+ * → the CTA falls back to lead-capture contact. Flip this to a real URL (e.g.
60
+ * at GA) and the CTA auto-switches to a real link — no template change. */
61
+ signupUrl?: string | null;
62
+ /** Button label once a signup URL exists (app is live), e.g. "Start free". */
63
+ linkLabel: string;
64
+ /** Button label while there's no signup URL (lead-capture), e.g. "Request early access". */
65
+ contactLabel: string;
66
+ /** Spam-safe contact used for the lead-capture fallback. */
67
+ contact: { local: string; domain: string };
68
+ analyticsEvent?: string;
69
+ analyticsData?: Record<string, string>;
70
+ }
71
+
72
+ /** Produce a {@link MarketingCta} that is a real signup link when `signupUrl`
73
+ * is set, else a spam-safe lead-capture contact CTA. Lets a site model "flip to
74
+ * APP_URL at GA" as data (one `SIGNUP_URL` constant) instead of hand-coding the
75
+ * mode + leaving TODO comments in the markup. */
76
+ export function buildPrimaryCta(opts: PrimaryCtaOptions): MarketingCta {
77
+ if (opts.signupUrl) {
78
+ return {
79
+ label: opts.linkLabel,
80
+ href: opts.signupUrl,
81
+ kind: 'link',
82
+ analyticsEvent: opts.analyticsEvent,
83
+ analyticsData: opts.analyticsData,
84
+ };
85
+ }
86
+ return {
87
+ label: opts.contactLabel,
88
+ href: '#',
89
+ kind: 'contact',
90
+ contact: opts.contact,
91
+ analyticsEvent: opts.analyticsEvent,
92
+ analyticsData: opts.analyticsData,
93
+ };
94
+ }
95
+
32
96
  /** One tier column in the comparison table header. */
33
97
  export interface PricingTierColumn {
34
98
  /** Stable code (free | pro | business …). Used for live-price patching and
@@ -120,6 +184,38 @@ export interface BuiltByCredit {
120
184
  href: string;
121
185
  }
122
186
 
187
+ /** A "founding-partner program" panel — turns the no-customers-yet gap into
188
+ * exclusivity ("first N, free during beta"). Rendered only when supplied. */
189
+ export interface FoundingPartnerPanel {
190
+ eyebrow: string;
191
+ title: string;
192
+ body: string;
193
+ /** What a founding partner gets (bullet list). */
194
+ perks: string[];
195
+ cta?: MarketingCta;
196
+ }
197
+
198
+ /** A short, HONEST founder credibility line. Keep it truthful + generic — do
199
+ * NOT fabricate specific credentials, companies or certifications. */
200
+ export interface FounderCredit {
201
+ name?: string;
202
+ role?: string;
203
+ /** The one-line credibility statement. */
204
+ line: string;
205
+ /** Optional avatar image URL (omit for an initials/monogram fallback). */
206
+ avatar?: string;
207
+ }
208
+
209
+ /** A "here's the actual deliverable you get" showcase slot. Supply `image` for
210
+ * the fully-shared case; OR pass a `slot="sample-deliverable"` on the section
211
+ * to inject bespoke mock markup while the section chrome stays shared. */
212
+ export interface SampleDeliverable {
213
+ eyebrow: string;
214
+ title: string;
215
+ caption: string;
216
+ image?: string;
217
+ }
218
+
123
219
  export interface TrustSectionData {
124
220
  eyebrow: string;
125
221
  title: string;
@@ -128,4 +224,10 @@ export interface TrustSectionData {
128
224
  logos?: { label: string; note?: string; items: LogoItem[] };
129
225
  testimonials?: { label: string; note?: string; items: Testimonial[] };
130
226
  builtBy?: BuiltByCredit;
227
+ /** Optional "first N free during beta" founding-partner panel. */
228
+ foundingPartner?: FoundingPartnerPanel;
229
+ /** Optional honest founder credibility line. */
230
+ founder?: FounderCredit;
231
+ /** Optional "the actual report you get" showcase (image or named slot). */
232
+ sampleDeliverable?: SampleDeliverable;
131
233
  }