Skip to content
Merged
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
25 changes: 20 additions & 5 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Tag options are defined in `nuxt/data/blogTags.json`. Future-dated posts are exc

- `cta` is required on every instance, one of `sign-up` | `demo` | `contact` | `pricing` — no fallback to the post's frontmatter `cta.type` (that one only drives `BlogPostCta` at the end of the article). An invalid value throws a descriptive error at render time instead of a bare `Cannot read properties of undefined`.
- `position` isn't a prop — it's hardcoded to `'inline-image'`, since every instance shares the same placement semantics.
- Fires the same `blog-cta` event as `BlogPostCta` (not a dedicated `cta-sign-up`/`cta-book-demo`/etc. event) — `cta_type` alone distinguishes the destination, so both the end-of-article CTA and inline image CTAs land in one PostHog series, filterable by `cta_type`/`position`.
- Renders through `CtaLink` (see **Inline links** under Call-to-Action components), so its href and destination event (`cta-book-demo`, etc., with `variant: 'image'`) come from `CTA_DESTINATIONS`. It also fires `blog-cta` with `cta_type`, the same as `BlogPostCta` does alongside its button's event. Image CTAs therefore land in both the destination's `cta-*` series and the single blog series, filterable by `cta_type`/`position`.
- The article title can't be passed as a prop from markdown (MDC only forwards literal `{...}` attributes), so `nuxt/pages/blog/[...slug].vue` does `provide('blogPostTitle', pageTitle)` and the component `inject()`s it — the only reason this indirection exists here and not on `BlogPostCta`.

---
Expand Down Expand Up @@ -329,25 +329,40 @@ There are exactly five CTA destinations, each with its own component with **fixe

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* the button sits, e.g. `hero`, `footer`, `pricing-card` — never which page; PostHog already captures the page URL on every event, so encoding the page into `position` too would just duplicate that and make it look like a placement dimension when it isn't one), `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 — not a `→`/`←` character baked into `label` itself, which every fixed component's copy avoids), `uppercase`, `padded` (only for `variant="nav-text"` — whether it has the header-`<ul>` link padding or is true zero-padding inline text), `target` (passed straight through to the underlying link; none of the five fixed destinations need it, it exists for `CtaCustom` linking off-site), `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).
**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* the button sits, e.g. `hero`, `footer`, `pricing-card` — never which page; PostHog already captures the page URL on every event, so encoding the page into `position` too would just duplicate that and make it look like a placement dimension when it isn't one), `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 — not a `→`/`←` character baked into `label` itself, which every fixed component's copy avoids), `uppercase`, `padded` (only for `variant="nav-text"` — whether it has the header-`<ul>` link padding or is true zero-padding inline text), `target` (passed straight through to the underlying link; none of the five fixed destinations need it, it exists for `CtaCustom` linking off-site), `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). The five fixed components also take `query` (URL parameters appended to the fixed href, e.g. `{ subject: 'Certified Nodes' }` or utm properties; an array value repeats the parameter): the path, label and event stay fixed, and there's no way to change the path or add a hash.

### CtaCustom — one-off destinations

`nuxt/components/CtaCustom.vue` is the escape hatch for a CTA that doesn't fit one of the five fixed destinations above — it takes `label` and (usually) `href` directly from the caller. What it does NOT take is a free-form `event` string: the event (and, for a genuinely fixed URL, the `href` too) comes from a `destinationKey` prop that looks up an entry in `nuxt/lib/custom-cta-destinations.ts`.

This exists because a raw `event` prop would let two different `CtaCustom` instances pointed at the same URL drift onto two different PostHog events with nothing to catch it — exactly the fragmentation this whole system exists to prevent, just one level down from the five reserved destinations. Keying off a shared registry entry instead makes "this destination has exactly one event" structural: there's one place it's decided, not N call sites each typing a string by hand.

- **Fixed-URL destinations** (e.g. `hubspotMeeting`, `communityForum`, `homepage`) store both `href` and `event` in the registry; `CtaCustom` reads the href from there and rejects a caller-supplied `href` that conflicts with it. The registry file also self-checks on load for a second key reusing an href already claimed by another key — checked against both other entries in this same file and the five reserved destinations in `cta-destinations.ts` — so that mistake fails immediately at build/dev-start rather than only when (and if) some page actually renders the offending `<CtaCustom>`.
- **Fixed-URL destinations** (e.g. `hubspotMeeting`, `communityForum`, `homepage`) store both `href` and `event` in the registry; `CtaCustom` reads the href from there and rejects a caller-supplied `href` that conflicts with it. The registry file also self-checks on load for a second key reusing an href already claimed by another key — checked against both other entries in this same file and the five reserved destinations in `cta-destinations.ts` — and `nuxt/lib/cta-destinations.test.mjs` loads the registry, so that mistake fails `npm test` rather than only when (and if) some page actually renders the offending `<CtaCustom>`.
- **Dynamic-URL destinations** (e.g. `latestWebinar`, whose target is whichever webinar is currently most-recently-dated; `agentSetupClientOpen`, whose target varies per AI client) have no `href` in the registry — only the event is pinned there, and the caller still supplies `href` each time. This is a real, accepted limit: nothing can statically verify that two different call sites' *dynamic* hrefs never coincidentally collide, since the actual value only exists at render time and can change over time (this session's design discussion concluded that's fine — a coincidental runtime overlap between two conceptually different destinations, like "always show the newest webinar" vs. "a blog post links to this specific one," isn't the same kind of drift as two call sites hand-typing the same static URL with different event names).
- `destinationKey` is required and its TS type is `keyof typeof CUSTOM_CTA_DESTINATIONS` — there's no way to point `CtaCustom` at a URL without first adding an entry to that file, by design.
- Like the five fixed components, `CtaCustom` throws a descriptive error (same convention as `CtaImage.vue`'s invalid-`cta` check) if pointed at one of the five *reserved* destinations' hrefs — use the matching fixed component instead.
- Like the five fixed components, `CtaCustom` throws a descriptive error (same convention as `CtaImage.vue`'s invalid-`cta` check) if pointed at one of the five *reserved* destinations' hrefs — use the matching fixed component instead. Both checks match reserved destinations with `ctaDestinationKey()` (see **Inline links**), so a query string or absolute `https://flowfuse.com/...` URL counts as the same destination, while a hash (a section of the page, like `deviceAgentInstall`'s) counts as its own.
- A render-time throw doesn't fail the build: Nitro's `prerender.failOnError` is off (the `failOnError: true` in `nuxt.config.ts` is the link checker's), so the failed route is logged and the build stays green. That's why these guards are also checked in `npm test`, the required PR check.

`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.
`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. Inline links are `CtaLink` (see **Inline links** below), not a `CtaButton` variant.

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 `nuxt/server/assets/analytics/body.html` (a Nitro server asset injected by `nuxt/server/plugins/analytics.ts` in production builds only, so `capture()` is absent in dev and deploy previews; it no-ops without analytics consent). Event names: `cta-sign-up`, `cta-sign-in`, `cta-contact-us`, `cta-book-demo`, `cta-pricing`.

### Inline links

A link inside a sentence keeps free-form text (it has to fit the prose), so it isn't one of the five buttons. It is `nuxt/components/CtaLink.vue`: `destination` is a `CTA_DESTINATIONS` key, and the href and event come from there. Text goes in the slot. It fires `capture(event, { position, variant })` (`variant` is `text`, or `image` when `CtaImage` wraps an image in it) and renders a class-less link, so it takes the page's default link CSS unless the caller passes a class. `query` adds URL parameters the same way the buttons' `query` does.

```vue
<CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink>
```

Markdown needs no markup for this: `nuxt/components/content/ProseA.vue` renders every markdown link, and when `ctaDestinationKey()` (in `cta-destinations.ts`) matches the href, it renders `<CtaLink position="inline-link" prose>` instead of Nuxt UI's `ProseA`, passing the link's query string through as `query`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash and the query string, `https://flowfuse.com/...` counts the same as a relative path, and `http://` the same as `https://`. A hash points at a section of the page, so it doesn't match. So `[FlowFuse Cloud](site:appURL)`, `[try it for free](cta:signUp)` and a hand-typed `https://app.flowfuse.com/account/create?code=...` are all tracked. `cta:<key>` (resolved by `nuxt/utils/remark-site-links.ts`) is still the preferred way to write the href, and takes a query string too: `[contact us](cta:contactUs?subject=Certified%20Nodes)`.

These are the destination events, not `blog-cta`: inline links also appear on changelog, webinar, handbook and docs pages, where a `Blog: <title>` reference would be wrong, and PostHog already records the page URL. Hrefs from `CUSTOM_CTA_DESTINATIONS` aren't matched. FAQ answers (`nuxt/lib/faq-answer.mjs`, rendered with `v-html`) can't hold a component, so their links aren't tracked.

`nuxt/lib/cta-destinations.test.mjs` fails `npm test` if a `.vue` file hard-codes an `href`/`to` to one of the five destinations, so a link can't skip `CtaLink` or the matching button. It only sees literal values; a dynamic `:href` still relies on the caller.

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

- **Vue auto-defaults unspecified `boolean` props to `false`, not `undefined`.** Any prop typed as `boolean` in a type-only `defineProps<{...}>()` needs `withDefaults(defineProps<...>(), { theProp: undefined })` if the code distinguishes "not passed" from "explicitly false" (e.g. via `??`) — otherwise the `??` fallback never triggers, since `false ?? x` is `false`.
Expand Down
5 changes: 3 additions & 2 deletions nuxt/components/CtaBookDemo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 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'
import { CTA_DESTINATIONS } from '../lib/cta-destinations'
import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -13,6 +13,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
query?: CtaQuery
}>(), { uppercase: undefined })

const DEST = CTA_DESTINATIONS.bookDemo
Expand All @@ -22,5 +23,5 @@ const LABEL = DEST.label
</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" />
<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" :query="query" />
</template>
5 changes: 3 additions & 2 deletions nuxt/components/CtaContactUs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 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'
import { CTA_DESTINATIONS } from '../lib/cta-destinations'
import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -13,6 +13,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
query?: CtaQuery
}>(), { uppercase: undefined })

const DEST = CTA_DESTINATIONS.contactUs
Expand All @@ -22,5 +23,5 @@ const LABEL = DEST.label
</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" />
<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" :query="query" />
</template>
7 changes: 5 additions & 2 deletions nuxt/components/CtaCustom.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
// descriptive error" convention as CtaImage.vue's invalid-cta check (see
// CLAUDE.md).
import CtaButton from './cta/CtaButton.vue'
import { CTA_DESTINATIONS, normalizeHref } from '../lib/cta-destinations'
import { CTA_DESTINATIONS, ctaDestinationKey } from '../lib/cta-destinations'
import { CUSTOM_CTA_DESTINATIONS } from '../lib/custom-cta-destinations'

const props = withDefaults(defineProps<{
Expand Down Expand Up @@ -93,7 +93,10 @@ const HREF = computed(() => {

const EVENT = computed(() => destination.value.event)

const collision = computed(() => Object.values(CTA_DESTINATIONS).find(dest => normalizeHref(dest.href) === normalizeHref(HREF.value)))
const collision = computed(() => {
const key = ctaDestinationKey(HREF.value)
return key && CTA_DESTINATIONS[key]
})
if (collision.value) {
throw new Error(`CtaCustom cannot point at "${HREF.value}" - use <${collision.value.component}> instead, so PostHog keeps grouping this destination under one event name.`)
}
Expand Down
35 changes: 35 additions & 0 deletions nuxt/components/CtaLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
import UiProseA from '@nuxt/ui/components/prose/A.vue'
import { CTA_DESTINATIONS, withCtaQuery, type CtaQuery } from '../lib/cta-destinations'
import { useCapture } from '../composables/useCapture'

const props = withDefaults(defineProps<{
destination: keyof typeof CTA_DESTINATIONS
position: string
variant?: 'text' | 'image'
query?: CtaQuery
target?: string
prose?: boolean
}>(), { variant: 'text' })

const dest = computed(() => {
const match = CTA_DESTINATIONS[props.destination]
if (!match) throw new Error(`CtaLink: invalid destination "${props.destination}" - must be one of: ${Object.keys(CTA_DESTINATIONS).join(', ')}`)
return match
})
const linkHref = computed(() => withCtaQuery(dest.value.href, props.query))
const capture = useCapture()

function onClick () {
capture(dest.value.event, { position: props.position, variant: props.variant })
}
</script>

<template>
<UiProseA v-if="prose" :href="linkHref" :target="target" @click="onClick">
<slot />
</UiProseA>
<ULink v-else :href="linkHref" :target="target" raw @click="onClick">
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<slot />
</ULink>
</template>
5 changes: 3 additions & 2 deletions nuxt/components/CtaPricing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 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'
import { CTA_DESTINATIONS } from '../lib/cta-destinations'
import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -13,6 +13,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
query?: CtaQuery
}>(), { uppercase: undefined })

const DEST = CTA_DESTINATIONS.pricing
Expand All @@ -22,5 +23,5 @@ const LABEL = DEST.label
</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" />
<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" :query="query" />
</template>
5 changes: 3 additions & 2 deletions nuxt/components/CtaSignIn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// where it lives on the page (position) vary per insertion.
// First component to give the Sign In link any tracking at all.
import CtaButton from './cta/CtaButton.vue'
import { CTA_DESTINATIONS } from '../lib/cta-destinations'
import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'

withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
Expand All @@ -14,6 +14,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
query?: CtaQuery
}>(), { uppercase: undefined })

const DEST = CTA_DESTINATIONS.signIn
Expand All @@ -23,5 +24,5 @@ const LABEL = DEST.label
</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" />
<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" :query="query" />
</template>
Loading
Loading