Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,26 +268,27 @@ This also covers old `/docs/**` paths left behind by a rename in `flowfuse/flowf

## Call-to-Action components

**Nuxt only** — 11ty pages still use hand-written `<a class="ff-btn ...">` links; these components have no 11ty equivalent yet.
Both frameworks have their own implementation of the same system — Vue components on Nuxt, Nunjucks macros on 11ty (see **11ty equivalent** below). The rendering/styling code itself is kept in sync by hand (Vue and Nunjucks share nothing at that level), but the *data* — event name, href, and fixed label per destination — lives in one file, `src/_data/ctaDestinations.json`, imported by both the Vue components and the Nunjucks macros (via a Nunjucks global, see below) so the two can't drift apart on copy or event names without both sides being touched. `site.json`'s `appURL` is combined with `ctaDestinations`' `hrefSuffix` for `signUp`/`signIn`, since the base URL differs per environment and doesn't belong duplicated a third time in `ctaDestinations.json`.

There are exactly four CTA destinations, each with its own component with **fixed copy and href** (a PostHog audit found dozens of different button texts pointing at the same four URLs, which made it impossible to tell which copy converted best — see `/handbook/marketing/website#call-to-action-buttons` for the non-engineer-facing explanation and a live gallery of every variant):
There are exactly five CTA destinations, each with its own component with **fixed copy and href** (a PostHog audit found dozens of different button texts pointing at the same handful of URLs, which made it impossible to tell which copy converted best — see `/handbook/marketing/website#call-to-action-buttons` for the non-engineer-facing explanation and a live gallery of every variant):

| Component | href | Fixed label |
|---|---|---|
| `nuxt/components/CtaSignUp.vue` | `{site.appURL}/account/create` | "Free Trial" on `position="main-nav"`/`"mobile-cta-bar"`, else "Try it out" |
| `nuxt/components/CtaSignIn.vue` | `site.appURL` | "Sign In" |
| `nuxt/components/CtaContactUs.vue` | `/contact-us/` | "Contact Us" |
| `nuxt/components/CtaBookDemo.vue` | `/book-demo/` | "Book a Demo" |
| `nuxt/components/CtaPricing.vue` | `/pricing/` | "View Pricing" |

All four are thin wrappers around `nuxt/components/cta/CtaButton.vue`, which does the actual styling/tracking and isn't meant to be used directly. If a page needs different wording, that's a sign a fifth destination-specific component is needed — not a prop that lets callers override copy on these four.
All five are thin wrappers around `nuxt/components/cta/CtaButton.vue`, which does the actual styling/tracking and isn't meant to be used directly. If a page needs different wording, that's a sign a sixth destination-specific component is needed — not a prop that lets callers override copy on these five.

**Props** (all optional except `variant`/`position`): `variant` (`primary` | `primary-outlined` | `highlight` | `highlight-outlined` | `ghost` | `nav-text`), `position` (free string, sent to PostHog — describes where on the page, e.g. `hero`, `pricing-card`), `plan` (e.g. `edge`/`hub`/`fleet`, sent to PostHog), `color` (`primary`|`highlight`|`white`, only for `variant="ghost"`, which has no background of its own), `icon` (Nuxt Icon name for a trailing icon), `uppercase`, `padded` (only for `variant="nav-text"` — whether it has the header-`<ul>` link padding or is true zero-padding inline text), `preview` (renders identically but doesn't navigate or call `capture()` — used by the handbook's live example gallery so clicking a doc example can't send a real event or leave the page).

`nav-text` is deliberately not called `text` — it's the plain, no-underline treatment used for "Free Trial" (main nav) and "Sign In" (utility bar) specifically, not a general-purpose inline link. A future `text` variant (styled like a normal paragraph link — the site's blue-700, underline-on-hover convention) is reserved for that.

There's no `size` prop — every real-button variant's padding/font-size is hardcoded to match `.ff-btn` exactly (see the Computed-tab note in the gotchas below), so a size knob would only ever have affected icon dimensions. It was removed once confirmed nothing used a non-default value.

Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composables/useCapture.ts`, which wraps the global `window.capture()` from `src/_includes/analytics/body.html` (shared with 11ty, no-ops without analytics consent). Event names: `cta-sign-up`, `cta-sign-in`, `cta-contact-us`, `cta-book-demo`.
Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composables/useCapture.ts`, which wraps the global `window.capture()` from `src/_includes/analytics/body.html` (shared with 11ty, no-ops without analytics consent). Event names: `cta-sign-up`, `cta-sign-in`, `cta-contact-us`, `cta-book-demo`, `cta-pricing`.

### Gotchas already solved here (don't re-discover them)

Expand All @@ -299,6 +300,16 @@ Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composa
- **The `ui` prop override doesn't reach compoundVariants-driven classes.** The gotcha above (full-string replacement) only applies to the app.config-level base extension; UButton's own `variant`/`color`-driven classes (e.g. `ghost`'s `hover:bg-{color}/10`) are computed separately and still get merged in via `tv()` regardless of what `ui.base` says. `CtaButton.vue`'s ghost color classes explicitly add `hover:bg-transparent` to cancel that default hover background, since a ghost CTA should have none at all.
- **UButton's `to` prop treats any same-origin-looking path as a Nuxt route, even if Nuxt doesn't serve it.** `CtaContactUs`/`CtaBookDemo` point at `/contact-us/` and `/book-demo/`, which are still 11ty — without `external`, clicking does a client-side Vue Router navigation instead of a real page load, and 404s instead of reaching the server-side 11ty proxy. `CtaButton.vue` takes a fixed (non-caller-configurable) `external` prop per destination; `CtaContactUs`/`CtaBookDemo` set it `true`, `CtaSignUp`/`CtaSignIn` set it `false` (moot — those hrefs are already cross-origin). **When `/contact-us/` or `/book-demo/` actually migrates to Nuxt, flip that destination's `external` to `false`** — leaving it `true` would keep forcing a full page reload where a client-side nav would work fine.

### 11ty equivalent

`src/_includes/components/cta/` holds five Nunjucks macros (`ctaSignUp`, `ctaSignIn`, `ctaContactUs`, `ctaBookDemo`, `ctaPricing`) mirroring the Vue components above — same fixed copy/href/event per destination, same `{ position, variant }` capture payload (no `preview` or `external` prop: 11ty has no handbook gallery to guard against, and every `<a>` is a real page load already, no Vue Router to fight). All five delegate to a shared `ctaButton` macro in the same folder (`cta-button.njk`) for the actual class-building/tracking, same relationship as `CtaButton.vue` to its wrappers.

No `plan` param on the 11ty side — that's only used on the pricing table, which is Nuxt-only (there's no 11ty pricing page to call it from). If 11ty ever grows a caller that needs it, add it back rather than passing it unused today.

`ctaSignUp`/`ctaSignIn` take `site` as their first argument (`site.appURL` for the href) instead of reading it from the calling template's context — Nunjucks macros don't inherit the caller's context unless explicitly imported `with context`, and passing `site` explicitly avoids relying on that import mode everywhere the macro is used.

Every hand-written `<a class="ff-btn ...">` pointing at one of the five destinations has been migrated to these macros — there should be no new ones. A link to a URL outside the five fixed destinations (e.g. `/ai/`) is not part of this system and stays hand-written.

## Naming conventions

- All slugs: **kebab-case**
Expand Down
25 changes: 25 additions & 0 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ module.exports = function(eleventyConfig) {
watch: ["_site/**/*.css", "_site/**/*.js"],
})

// CTA event/href/label data (single source of truth shared with the Nuxt
// Cta* components, which import the same src/_data/ctaDestinations.json)
// registered as a Nunjucks *global* rather than page data: the five
// ctaSignUp/ctaSignIn/ctaContactUs/ctaBookDemo/ctaPricing macros need to
// read it, and Nunjucks macros don't see page-context data
// (addGlobalData) unless it's explicitly passed in as an argument -
// addNunjucksGlobal makes it visible everywhere, macros included, with no
// per-call-site plumbing.
// Registered once with a stable object reference, then refreshed IN PLACE
// on every build (not re-registered via a second addNunjucksGlobal call,
// which only affected later builds - templates already held a reference
// to the first, now-stale value, so mid-watch edits rendered `undefined`
// until the whole process restarted). Mutating the same object's keys
// means every template holding a reference to it sees the update too.
const ctaDestinationsPath = require.resolve('./src/_data/ctaDestinations.json')
const ctaDestinations = require(ctaDestinationsPath)
eleventyConfig.addNunjucksGlobal('ctaDestinations', ctaDestinations)
eleventyConfig.addWatchTarget(ctaDestinationsPath)
eleventyConfig.on('eleventy.before', () => {
delete require.cache[ctaDestinationsPath]
const fresh = require(ctaDestinationsPath)
Object.keys(ctaDestinations).forEach(key => delete ctaDestinations[key])
Object.assign(ctaDestinations, fresh)
})

// make global accessible in src/_includes/layouts/base.njk for loading of PH scripts
eleventyConfig.addGlobalData('POSTHOG_APIKEY', () => process.env.POSTHOG_APIKEY || '' )
eleventyConfig.addGlobalData('DEV_MODE', () => DEV_MODE || DEV_MODE_POSTS)
Expand Down
9 changes: 4 additions & 5 deletions nuxt/components/BlogPostCta.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ const props = defineProps<{
cta?: { type?: string, title?: string, description?: string } | null
}>()

// 'sign-up' / 'demo' / 'contact' now render one of the unified Cta* components
// (fixed copy/href/event) instead of a local buttonText/buttonUrl pair, so the
// blog CTA can't drift from the rest of the site's copy. 'pricing' isn't one
// of the four unified destinations, so it keeps its own link.
// All four types now render one of the unified Cta* components (fixed
// copy/href/event) instead of a local buttonText/buttonUrl pair, so the blog
// CTA can't drift from the rest of the site's copy.
const CTA_VARIANTS: Record<string, { title: string, description: string }> = {
'sign-up': {
title: 'Start building with your own industrial data',
Expand Down Expand Up @@ -60,7 +59,7 @@ function onCtaClick (event: MouseEvent) {
<CtaSignUp v-if="ctaType === 'sign-up'" variant="highlight" position="blog-post-cta" />
<CtaBookDemo v-else-if="ctaType === 'demo'" variant="highlight" position="blog-post-cta" />
<CtaContactUs v-else-if="ctaType === 'contact'" variant="highlight" position="blog-post-cta" />
<a v-else class="ff-btn ff-btn--highlight uppercase items-center text-base no-underline" href="/pricing">View Pricing</a>
<CtaPricing v-else variant="highlight" position="blog-post-cta" />
</div>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions nuxt/components/CtaBookDemo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
// Shared with the equivalent 11ty macro (cta-book-demo.njk) - see CtaSignUp.vue.
import ctaDestinations from '../../src/_data/ctaDestinations.json'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -14,9 +16,10 @@ withDefaults(defineProps<{
icon?: string
}>(), { uppercase: undefined })

const EVENT = 'cta-book-demo'
const HREF = '/book-demo/'
const LABEL = 'Book a Demo'
const dest = ctaDestinations.bookDemo
const EVENT = dest.event
const HREF = dest.href
const LABEL = dest.label
// /book-demo/ is still served by 11ty, not a Nuxt route - see CtaButton.vue's
// `external` prop.
</script>
Expand Down
9 changes: 6 additions & 3 deletions nuxt/components/CtaContactUs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
// Shared with the equivalent 11ty macro (cta-contact-us.njk) - see CtaSignUp.vue.
import ctaDestinations from '../../src/_data/ctaDestinations.json'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -14,9 +16,10 @@ withDefaults(defineProps<{
icon?: string
}>(), { uppercase: undefined })

const EVENT = 'cta-contact-us'
const HREF = '/contact-us/'
const LABEL = 'Contact Us'
const dest = ctaDestinations.contactUs
const EVENT = dest.event
const HREF = dest.href
const LABEL = dest.label
// /contact-us/ is still served by 11ty, not a Nuxt route - see CtaButton.vue's
// `external` prop.
</script>
Expand Down
29 changes: 29 additions & 0 deletions nuxt/components/CtaPricing.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
// Shared with the equivalent 11ty macro (cta-pricing.njk) - see CtaSignUp.vue.
import ctaDestinations from '../../src/_data/ctaDestinations.json'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
position: string
plan?: string
color?: 'primary' | 'highlight' | 'white'
uppercase?: boolean
padded?: boolean
preview?: boolean
icon?: string
}>(), { uppercase: undefined })

const dest = ctaDestinations.pricing
const EVENT = dest.event
const HREF = dest.href
const LABEL = dest.label
// /pricing/ is a Nuxt route (nuxt/pages/pricing/index.vue), not 11ty - see
// CtaButton.vue's `external` prop.
</script>

<template>
<CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" />
</template>
9 changes: 6 additions & 3 deletions nuxt/components/CtaSignIn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// First component to give the Sign In link any tracking at all.
import CtaButton from './cta/CtaButton.vue'
import site from '../../src/_data/site.json'
// Shared with the equivalent 11ty macro (cta-sign-in.njk) - see CtaSignUp.vue.
import ctaDestinations from '../../src/_data/ctaDestinations.json'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -16,9 +18,10 @@ withDefaults(defineProps<{
icon?: string
}>(), { uppercase: undefined })

const EVENT = 'cta-sign-in'
const HREF = site.appURL
const LABEL = 'Sign In'
const dest = ctaDestinations.signIn
const EVENT = dest.event
const HREF = `${site.appURL}${dest.hrefSuffix}`
const LABEL = dest.label
</script>

<template>
Expand Down
14 changes: 10 additions & 4 deletions nuxt/components/CtaSignUp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
import site from '../../src/_data/site.json'
// Event/href-suffix/label all come from this single JSON file, which the
// equivalent 11ty macro (src/_includes/components/cta/cta-sign-up.njk) also
// reads - the one place these can drift between the two frameworks if only
// one side gets updated.
import ctaDestinations from '../../src/_data/ctaDestinations.json'

const props = withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -15,16 +20,17 @@ const props = withDefaults(defineProps<{
icon?: string
}>(), { uppercase: undefined })

const EVENT = 'cta-sign-up'
const HREF = `${site.appURL}/account/create`
const dest = ctaDestinations.signUp
const EVENT = dest.event
const HREF = `${site.appURL}${dest.hrefSuffix}`

// Copy varies by placement, per the PostHog CTA analysis: the nav's
// sitewide "Free Trial" and everywhere else's "Try it out" were the two
// highest-volume variants for this destination, each proven in its own
// context. Kept as a fixed lookup (not a caller-supplied prop) so callers
// can't reintroduce arbitrary copy variants - only `position` decides.
const NAV_POSITIONS = new Set(['main-nav', 'mobile-cta-bar'])
const LABEL = computed(() => NAV_POSITIONS.has(props.position) ? 'Free Trial' : 'Try it out')
const NAV_POSITIONS = new Set(dest.navPositions)
const LABEL = computed(() => NAV_POSITIONS.has(props.position) ? dest.navLabel : dest.label)
</script>

<template>
Expand Down
Loading