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
43 changes: 43 additions & 0 deletions nuxt/components/ActionButtons.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
// A row of buttons given as data, for components whose buttons come from content (the
// use-case sections) or vary per caller (<ClosingCta>). Each entry names one of the
// fixed CTA destinations, so copy, href and event stay owned by the Cta* components; an
// entry with `href` instead is a plain in-page link (it-ot-middleware's "Read
// Whitepaper" jumps to its own #whitepapers list), which is navigation, not a tracked CTA.
type Cta =
| { cta: 'book-demo' | 'contact-us' | 'sign-up' | 'pricing', variant?: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'ghost', color?: 'primary' | 'highlight' | 'white', icon?: string }
| { label: string, href: string }

defineProps<{
ctas: Cta[]
position: string
}>()

const COMPONENTS = {
'book-demo': resolveComponent('CtaBookDemo'),
'contact-us': resolveComponent('CtaContactUs'),
'sign-up': resolveComponent('CtaSignUp'),
'pricing': resolveComponent('CtaPricing'),
}

// The first button leads, every later one follows, so a page only names a variant when it
// wants something other than that.
function variantFor(cta: { variant?: string }, index: number) {
return cta.variant || (index === 0 ? 'highlight' : 'primary-outlined')
}
</script>

<template>
<template v-for="(cta, i) in ctas" :key="i">
<a v-if="'href' in cta" :href="cta.href" class="inline-block ff-btn ff-btn--primary-outlined shadow uppercase">{{ cta.label }}</a>
<component
:is="COMPONENTS[cta.cta]"
v-else
:variant="variantFor(cta, i)"
:color="cta.color"
:icon="cta.icon"
:position="position"
class="min-h-[40px]"
/>
</template>
</template>
21 changes: 6 additions & 15 deletions nuxt/components/BlogFaq.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,11 @@
// paragraphs, which the 11ty pages expressed with raw <i> and <p> tags under `| safe`.
import { isListBlock, renderFaqAnswer } from '../lib/faq-answer.mjs'

withDefaults(defineProps<{
// Inside an article it sits under the post's own heading; on a marketing page
// <FaqSection> supplies the heading, the band and the FAQPage structured data.
defineProps<{
faq: Array<{ question: string, answer: string }>
/**
* How much room the block takes above the first question.
*
* faq.njk had exactly these two modes: `post` inside an article, `page` everywhere a
* marketing page pairs it with its own <h2>. Those pages set a negative bottom margin
* on that heading, which only reads correctly against the taller spacing, so a page
* rendering this in post mode pulls its first question up over its own heading.
*/
variant?: 'post' | 'page'
}>(), {
variant: 'post',
})
}>()

const openIndex = ref<number | null>(null)
function toggle(i: number) {
Expand All @@ -26,8 +17,8 @@ function toggle(i: number) {
</script>

<template>
<div id="faqs" class="w-full" :class="variant === 'page' ? 'py-16' : 'py-4'">
<div class="m-auto w-full ff-prose" :class="{ 'mt-12': variant === 'page' }">
<div id="faqs" class="w-full py-4">
<div class="m-auto w-full ff-prose">
<div class="prose max-w-none">
<div v-for="(item, i) in faq" :key="i" class="w-full py-4" :class="{ 'border-b': i !== faq.length - 1 }">
<h3 class="not-prose m-0">
Expand Down
48 changes: 48 additions & 0 deletions nuxt/components/ClosingCta.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
// The band a marketing page ends on. Before this there were three copies: one for the
// use cases, one for the industry pages and one for the legacy industry pages.
// - card: the light blue `ff-blue-card` (use cases, legacy industry pages, /use-cases/).
// - banner: the dark `ff-get-started-bg` gradient (industry pages,
// remote-device-management).
// Buttons are a list of fixed CTA destinations (see <ActionButtons>), a demo request plus
// sign-up unless the caller lists its own. `position` is the PostHog placement name, kept
// per caller so each page's existing series carries on.

withDefaults(defineProps<{
heading: string
description?: string
layout?: 'card' | 'banner'
ctas?: Array<Record<string, unknown>>
position?: string
}>(), {
description: undefined,
layout: 'card',
ctas: () => [{ cta: 'book-demo' }, { cta: 'sign-up' }],
position: 'footer',
})
</script>

<template>
<!-- eslint-disable vue/no-v-html -->
<section v-if="layout === 'banner'" class="w-full px-6 py-20">
<div class="max-w-screen-lg mx-auto">
<div class="rounded-xl px-9 py-12 flex flex-col items-center gap-8 text-center ff-get-started-bg">
<p class="text-white text-4xl sm:text-5xl font-medium m-0" v-html="heading" />
<p v-if="description" class="text-indigo-50 font-light text-xl max-w-3xl m-0" v-html="description" />
<div class="flex flex-col sm:flex-row gap-4 items-center">
<ActionButtons :ctas="ctas" :position="position" />
</div>
</div>
</div>
</section>

<section v-else class="w-full px-6 py-20">
<div class="ff-blue-card max-md:max-w-xl md:max-w-screen-lg mx-auto pt-12 pb-10 text-center">
<h3 class="mb-4 w-full text-center" v-html="heading" />
<p v-if="description" class="text-gray-600 mb-8 max-w-2xl mx-auto text-center" v-html="description" />
<div class="flex flex-wrap gap-4 justify-center" :class="{ 'mt-8': !description }">
<ActionButtons :ctas="ctas" :position="position" />
</div>
</div>
</section>
</template>
41 changes: 41 additions & 0 deletions nuxt/components/FaqSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
// A page's FAQ band: a heading over the <BlogFaq> accordion, plus the FAQPage structured
// data for the same questions. Every marketing page used to write all three by hand, with
// its own negative-margin trick to pull the accordion up under its heading.
//
// `schemaItems` is for a page whose displayed copy is altered for layout only, so search
// engines still get the plain text: device-agent shows "Node‑RED" with a non-breaking
// hyphen. `schema: false` is for a page that emits its FAQPage some other way.

const props = withDefaults(defineProps<{
items: Array<{ question: string, answer: string }>
heading?: string
background?: 'white' | 'gray' | 'indigo'
schema?: boolean
schemaItems?: Array<{ question: string, answer: string }>
}>(), {
heading: 'Frequently Asked <span class="text-indigo-600">Questions</span>',
background: 'white',
schema: true,
schemaItems: undefined,
})

const BACKGROUNDS = { white: '', gray: 'bg-gray-50', indigo: 'bg-indigo-50/50' }

if (props.schema && props.items.length) {
useSchemaOrg([
defineWebPage({ '@type': 'FAQPage' }),
...(props.schemaItems || props.items).map(item => defineQuestion({ question: item.question, answer: item.answer })),
])
}
</script>

<template>
<section class="w-full px-6 py-16" :class="BACKGROUNDS[background]">
<div class="max-w-screen-lg mx-auto">
<!-- eslint-disable-next-line vue/no-v-html -->
<h2 class="mb-4 max-md:text-center" v-html="heading" />
<BlogFaq :faq="items" />
</div>
</section>
</template>
35 changes: 35 additions & 0 deletions nuxt/components/ResourceList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
// Thumbnail link cards in the `ff-nodered-resources` style: a cover image on the left,
// the title on the right. /landing/plc/, /integrations/opcua/ and the UNS and IT/OT use
// cases each carried a copy of this markup. The caller keeps its own headings and
// "see all" links; this is only the list.
//
// `columns` is 2 for a grid of cards, or 1 for a single stacked column (the use-case
// resources band sets two of those side by side).
withDefaults(defineProps<{
items: Array<{
title: string
href: string
image: string
alt: string
// A bold run before the title, e.g. "Free whitepaper:".
lead?: string
}>
columns?: 1 | 2
}>(), { columns: 2 })
</script>

<template>
<div class="ff-nodered-resources grid grid-cols-1 gap-4" :class="{ 'md:grid-cols-2': columns === 2 }">
<NuxtLink v-for="item in items" :key="item.href" :to="item.href" class="h-full">
<li class="h-full">
<div class="w-2/5 max-md:aspect-video ff-image-cover ff-image-left-rounded h-full">
<img :src="item.image" :alt="item.alt" width="208" loading="lazy" class="w-full h-auto">
</div>
<label class="w-3/5 font-light text-left">
<span v-if="item.lead" class="font-semibold">{{ item.lead }} </span>{{ item.title }}
</label>
</li>
</NuxtLink>
</div>
</template>
61 changes: 61 additions & 0 deletions nuxt/components/StepList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
// A numbered vertical step list beside a sticky heading. It started as the "From OPC UA
// to insight, step by step" band on /integrations/opcua/, and /platform/device-agent/
// and /use-cases/remote-device-management/ each carried a copy before this.
//
// Each step's `label` defaults to "Step <n>". Headings, intro and step text are HTML
// strings (links, accent spans), rendered with v-html: they come from this repo's own
// pages and YAML, never from a visitor.
//
// On mobile the icon sits between two short horizontal lines; from sm up there is a
// single vertical line below it, offset so the icon lines up with the title rather than
// the label above it.

defineProps<{
heading: string
eyebrow?: string
intro?: string
link?: { label: string, href: string }
steps: Array<{ icon: string, title: string, text: string | string[], label?: string }>
}>()

const paragraphs = (text: string | string[]) => (Array.isArray(text) ? text : [text])
</script>

<template>
<!-- eslint-disable vue/no-v-html -->
<section class="w-full px-6 py-20 md:py-24">
<div class="max-w-screen-lg mx-auto md:flex md:gap-12 md:items-start">
<div class="mb-12 md:mb-0 md:w-[373px] md:shrink-0 md:sticky! md:top-24 md:self-start max-md:text-center">
<p v-if="eyebrow" class="text-gray-500 text-sm font-semibold uppercase mt-0 mb-3">{{ eyebrow }}</p>
<h2 class="mt-0 mb-0" v-html="heading" />
<p v-if="intro" class="font-light text-gray-700 leading-relaxed mt-6 mb-0" v-html="intro" />
<NuxtLink v-if="link" :to="link.href" class="mt-3 inline-flex items-center gap-1.5">
{{ link.label }}
<UIcon name="i-heroicons-arrow-long-right-20-solid" class="w-5 h-5 shrink-0" />
</NuxtLink>
</div>
<div class="max-w-screen-md mx-auto md:mx-0">
<div v-for="(step, index) in steps" :key="step.title" class="flex flex-col sm:flex-row gap-3 sm:gap-6">
<div class="flex items-center gap-3 sm:flex-col sm:items-center sm:gap-0">
<div class="ff-line h-px flex-1 sm:hidden" />
<UIcon :name="step.icon" class="w-6 h-6 text-indigo-600 shrink-0 sm:mt-7" />
<div class="ff-line h-px flex-1 sm:hidden" />
<div v-if="index !== steps.length - 1" class="ff-line hidden sm:block w-px flex-1 my-4 bg-gray-300" />
</div>
<div :class="index !== steps.length - 1 ? 'pb-10 sm:pb-12' : ''">
<span class="block text-sm font-semibold text-gray-500 mb-1 text-center sm:text-left">{{ step.label || `Step ${index + 1}` }}</span>
<h3 class="mt-0 mb-4 sm:mb-2 text-xl font-semibold text-indigo-600 text-center sm:text-left">{{ step.title }}</h3>
<p
v-for="(paragraph, p) in paragraphs(step.text)"
:key="p"
class="font-light text-gray-600"
:class="p === paragraphs(step.text).length - 1 ? 'mb-0' : ''"
v-html="paragraph"
/>
</div>
</div>
</div>
</div>
</section>
</template>
18 changes: 0 additions & 18 deletions nuxt/components/industry-page/Cta.vue

This file was deleted.

19 changes: 8 additions & 11 deletions nuxt/components/industry-page/Template.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ useSeoMeta({
twitterSite: '@FlowFuseinc',
})

useSchemaOrg([
defineWebPage({ '@type': 'FAQPage' }),
...props.page.faqs.map(item => defineQuestion(item)),
])
</script>

<template>
Expand Down Expand Up @@ -76,13 +72,14 @@ useSchemaOrg([
slug shows up here. Shared with the industriesLegacy [slug].vue's own band. -->
<IndustryPageUseCases :slug="page.slug" :display-name="page.industryName" />

<div class="w-full px-6 pt-20 pb-10">
<div class="max-w-screen-lg mx-auto">
<h2 class="mb-1 text-center md:text-left">Frequently Asked <span class="text-indigo-600">Questions</span></h2>
<BlogFaq :faq="page.faqs" />
</div>
</div>
<FaqSection :items="page.faqs" />

<IndustryPageCta :heading="page.closingCta.heading" :description="page.closingCta.description" />
<ClosingCta
layout="banner"
:heading="page.closingCta.heading"
:description="page.closingCta.description"
:ctas="[{ cta: 'book-demo' }]"
position="final-cta"
/>
</div>
</template>
17 changes: 0 additions & 17 deletions nuxt/components/industry/Cta.vue

This file was deleted.

4 changes: 3 additions & 1 deletion nuxt/components/use-case/AiBuildLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// <a class="ff-btn"> with an inline capture() call; this keeps the same event name and
// props but routes it through useCapture like every other Nuxt CTA.
const props = defineProps<{
// Its position among the numbered sections, matching the section nav ("01").
number: string
block: {
heading?: string
intro?: string
Expand All @@ -22,7 +24,7 @@ function ordinal(index: number) {
<template>
<div id="ai-build-layer" class="w-full py-16 sm:py-24 px-6 bg-gray-50 border-y border-gray-100 scroll-mt-16">
<div class="max-w-screen-lg mx-auto">
<p class="uppercase text-xs font-semibold text-indigo-400 mb-2">06 · Build it with AI</p>
<p class="uppercase text-xs font-semibold text-indigo-400 mb-2">{{ number }} · Build it with AI</p>
<h2 class="max-md:text-center">{{ block.heading || 'From described to deployed, with the FlowFuse Expert' }}</h2>
<p v-if="block.intro" class="mt-4 max-w-3xl text-gray-600">{{ block.intro }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
Expand Down
29 changes: 0 additions & 29 deletions nuxt/components/use-case/ClosingCta.vue

This file was deleted.

Loading
Loading