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
49 changes: 27 additions & 22 deletions nuxt/components/BlogFaq.vue
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
<script setup lang="ts">
defineProps<{
// The answer renderer moved to nuxt/lib/faq-answer.mjs so it can be unit tested and so
// the escaping rules live in one place; it also gained *italic*, **bold** and blank-line
// paragraphs, which the 11ty pages expressed with raw <i> and <p> tags under `| safe`.
import { isListBlock, renderFaqAnswer } from '../lib/faq-answer.mjs'

withDefaults(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) {
openIndex.value = openIndex.value === i ? null : i
}

const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
function escapeHtml(text: string) {
return text.replace(/[&<>"]/g, char => ESCAPE_MAP[char])
}

// Answers are plain text (any literal "<...>" in existing FAQ content - e.g. "<ip>",
// "<img>" placeholders - must stay literal, not be parsed as markup), so escape first.
// The only markup this renders is [label](url) links, converted after escaping so a URL
// or label can't reintroduce raw HTML.
function renderAnswer(answer: string) {
return escapeHtml(answer).replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
'<a href="$2" class="text-indigo-600 hover:underline">$1</a>',
)
}
</script>

<template>
<div class="w-full py-4" id="faqs">
<div class="m-auto w-full ff-prose">
<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 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 All @@ -46,8 +46,13 @@ function renderAnswer(answer: string) {
</button>
</h3>
<div v-show="openIndex === i" class="px-6 mt-6">
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="renderAnswer(item.answer)" />
<template v-for="(block, b) in renderFaqAnswer(item.answer)" :key="b">
<!-- A <ul>/<ol> cannot sit inside a <p>, so a list block renders bare.
eslint-disable-next-line vue/no-v-html -->
<div v-if="isListBlock(block)" class="ff-faq-list" v-html="block" />
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-else v-html="block" />
</template>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<script setup lang="ts">
import { isFuturePost } from '~/composables/useBlogList'

// Ported from src/_includes/explore-more-content.njk: the blog / webinar / newsletter
// row. Named for the thank-you pages when it was extracted for them; the homepage renders
// the same row, so it is named for what it is.
const props = defineProps<{
readingResources?: string
collectionName?: string
downloadFollowUp?: boolean
hubspotReference: string
}>()

const { data: blogPosts } = await useAsyncData(`thank-you-blog-${props.collectionName || 'all'}`, async () => {
const { data: blogPosts } = await useAsyncData(`explore-more-blog-${props.collectionName || 'all'}`, async () => {
const all = await queryCollection('blog')
.select('path', 'title', 'date', 'tags')
.order('date', 'DESC')
Expand All @@ -20,7 +23,7 @@ const { data: blogPosts } = await useAsyncData(`thank-you-blog-${props.collectio

// Mirrors explore-more-content.njk: takes the single latest-dated webinar overall
// (not "next upcoming or most recent past"), then checks if that one date is in the future.
const { data: webinar } = await useAsyncData('thank-you-webinar', async () => {
const { data: webinar } = await useAsyncData('explore-more-webinar', async () => {
const all = await queryCollection('webinars')
.select('path', 'title', 'date', 'time')
.order('date', 'DESC')
Expand All @@ -32,12 +35,16 @@ const { data: webinar } = await useAsyncData('thank-you-webinar', async () => {
const webinarIsUpcoming = computed(() => webinar.value && new Date(webinar.value.date) >= new Date())

const blogListUrl = computed(() => `/blog/${props.collectionName || ''}`)

// The site sets trailingSlash: true, so an internal link without one takes a redirect on
// every click. queryCollection paths carry no trailing slash, hence appending it here
// rather than linking webinar.path directly.
</script>

<template>
<div>
<template v-if="readingResources === 'stories'">
<ThankYouStoriesBlock />
<ExploreMoreStories />
<h4 class="mt-20 w-full text-center text-gray-500 pt-12 border-t">
Learn more about how FlowFuse helps with your industrial data applications
</h4>
Expand Down Expand Up @@ -66,7 +73,7 @@ const blogListUrl = computed(() => `/blog/${props.collectionName || ''}`)

<div v-if="webinar" class="w-full my-2 grid grid-cols-1 pb-4">
<div class="pb-2 md:pb-0">
<a :href="webinar.path" class="md:min-w-[40%] max-w-[448px] mx-auto mb-4 aspect-video block overflow-hidden rounded-lg cursor-pointer">
<a :href="`${webinar.path}/`" class="md:min-w-[40%] max-w-[448px] mx-auto mb-4 aspect-video block overflow-hidden rounded-lg cursor-pointer">
<img :src="'/images/home/webinar.png'" alt="Image of hands typing on laptop working on Node-RED flows" class="w-full h-full object-cover transition-transform duration-500 hover:scale-110">
</a>
<h3 class="text-xl font-bold pb-3">
Expand All @@ -86,7 +93,7 @@ const blogListUrl = computed(() => `/blog/${props.collectionName || ''}`)
</div>
<CtaCustom
:label="webinarIsUpcoming ? 'Register Now' : 'Watch Webinar'"
:href="webinar.path"
:href="`${webinar.path}/`"
destination-key="latestWebinar"
position="webinar"
:variant="downloadFollowUp ? 'primary' : 'primary-outlined'"
Expand Down
53 changes: 53 additions & 0 deletions nuxt/components/HomeHeroSlides.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
// The homepage hero's cross-fading background, ported from src/index.njk plus
// src/_data/heroImages.js.
//
// The .njk read src/images/home/hero/config.json and filtered it to the files that exist,
// so a slide could be removed by deleting the image. The list is imported directly here;
// a config entry with no file is a missing image either way, and the build now surfaces
// that rather than silently dropping the slide.
//
// The .njk's inline script queried .hero-slide and ran two timers with no teardown. This
// is one interval, cleared on unmount, and it still honours prefers-reduced-motion by
// never starting.
import slides from '../../src/images/home/hero/config.json'

const FIRST_ADVANCE_MS = 3200
const INTERVAL_MS = 4400

const current = ref(0)
let startTimer: ReturnType<typeof setTimeout> | undefined
let cycleTimer: ReturnType<typeof setInterval> | undefined

function advance() {
current.value = (current.value + 1) % slides.length
}

onMounted(() => {
if (slides.length < 2) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
startTimer = setTimeout(() => {
advance()
cycleTimer = setInterval(advance, INTERVAL_MS)
}, FIRST_ADVANCE_MS)
})

onUnmounted(() => {
clearTimeout(startTimer)
clearInterval(cycleTimer)
})
</script>

<template>
<div v-if="slides.length" class="absolute inset-0 overflow-hidden" aria-hidden="true">
<div
v-for="(slide, i) in slides"
:key="slide.file"
class="hero-slide"
:class="{ 'is-active': i === current }"
>
<img :src="`/images/home/hero/${slide.file}`" :alt="slide.alt" width="1920" class="w-full h-auto">
<div v-if="slide.mobileOverlay" class="absolute inset-0 bg-gray-500/50 mix-blend-multiply md:hidden" />
</div>
</div>
</template>
97 changes: 97 additions & 0 deletions nuxt/components/TestimonialCarousel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<script setup lang="ts">
// Ported from src/_includes/testimonials.njk, shared by the homepage and the ABM landing
// pages. The .njk rendered every testimonial and then toggled an `active` class from a
// script that queried the DOM and ran a 10-second interval; that is `current` here.
//
// The auto-advance stops on interaction, as it did, and also stops when the component
// unmounts - the .njk's setInterval had no teardown because 11ty pages never unmount.
import testimonials from '../../src/_data/testimonials.json'

withDefaults(defineProps<{
hideReadMore?: boolean
browseAllUrl?: string
browseAllText?: string
}>(), {
hideReadMore: false,
browseAllUrl: undefined,
browseAllText: 'Browse all customer stories',
})

const AUTOPLAY_MS = 10000

const current = ref(0)
let timer: ReturnType<typeof setInterval> | undefined

function start() {
timer = setInterval(() => {
current.value = (current.value + 1) % testimonials.length
}, AUTOPLAY_MS)
}

function show(index: number) {
current.value = index
clearInterval(timer)
}

// Site-relative paths: the data file writes them "./images/..." for 11ty's image handler.
function asset(path: string) {
return '/' + path.replace(/^\.?\//, '')
}

onMounted(start)
onUnmounted(() => clearInterval(timer))
</script>

<template>
<div>
<div class="flex flex-col flex-wrap content-center justify-center p-6 sm:max-w-screen-lg max-w-5xl mx-auto border-2 border-indigo-200 rounded-xl bg-white hover:drop-shadow-lg hover:border-blue-600 hover:border-2 transition ease-in-out duration-300">
<NuxtLink
v-for="(t, i) in testimonials"
:id="t.id"
:key="t.id"
:to="t.url"
class="testimonial sm:text-left hover:no-underline sm:min-h-[284]"
:class="{ active: i === current }"
>
<div class="m-auto max-w-screen-lg">
<div class="max-w-none mx-auto flex flex-col sm:grid justify-center items-center" style="grid-template-columns: 35% auto;">
<div class="w-full h-full aspect-[331/239] ff-image-cover scale rounded-md mb-4 sm:mb-0">
<img :src="asset(t.imageFile)" :alt="`Image depicting${t.imageAlt}`" width="360" loading="lazy" class="w-full h-auto">
</div>
<div class="items-end justify-between sm:pl-12 flex-1 flex flex-col h-full w-full justify-items-end">
<span v-if="!hideReadMore" class="text-right pb-5 flex gap-1 hover:underline">
Read the full story <UIcon name="i-heroicons-arrow-long-right" class="w-5 h-5" />
</span>
<p class="font-normal italic text-2xl">"{{ t.quote }}"</p>
<p class="text-lg mt-4 flex flex-row gap-3 text-right items-center self-end mb-0 mr-0">
<span>{{ t.author }}, <span class="font-medium">{{ t.company }}</span></span>
<span class="w-16 h-16 min-w-16 rounded-full mx-1 bg-black p-1">
<img :src="asset(t.companyLogo)" :alt="`${t.company} logo`" width="56" loading="lazy" class="w-full h-auto">
</span>
</p>
</div>
</div>
</div>
</NuxtLink>
</div>
<div
class="flex items-center justify-center gap-3 my-6"
:class="browseAllUrl ? 'flex-col sm:flex-row sm:justify-between' : 'flex-row flex-wrap'"
>
<div class="flex flex-wrap flex-row justify-center gap-3">
<button
v-for="(t, i) in testimonials"
:key="t.id"
class="testimonial-button align-baseline max-md:p-2"
:class="{ active: i === current }"
:aria-label="`Show testimonial from ${t.author}, ${t.company}`"
@click="show(i)"
><span /></button>
</div>
<NuxtLink v-if="browseAllUrl" :to="browseAllUrl" class="flex items-center gap-1.5 text-blue-600 hover:underline">
{{ browseAllText }}
<UIcon name="i-heroicons-arrow-long-right" class="w-5 h-5" />
</NuxtLink>
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export default defineContentConfig({
}),
})
}),
// ThankYouStoriesBlock.vue also queries this `stories` collection - no separate one needed.
// ExploreMoreStories.vue also queries this `stories` collection - no separate one needed.
// Source files stay at src/webinars/ (still served by 11ty, see LEGACY_PREFIXES
// in nuxt/server/middleware/legacy.ts) - only queried for the "Latest/Upcoming
// Webinar" tile on the thank-you pages, so no `sitemap` field either.
Expand Down
2 changes: 1 addition & 1 deletion nuxt/lib/custom-cta-destinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const CUSTOM_CTA_DESTINATIONS = {
},
// href varies - always the single most-recently-dated webinar (past or
// upcoming), never the general webinars listing (see
// ThankYouExploreMore.vue's query). Named for that destination, not for
// ExploreMoreContent.vue's query). Named for that destination, not for
// the fact that it's shown as a post-conversion suggestion.
latestWebinar: {
event: 'cta-latest-webinar',
Expand Down
72 changes: 72 additions & 0 deletions nuxt/lib/faq-answer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Renders one FAQ answer into a list of HTML blocks. Kept free of Nuxt and Vue imports so
// it can be unit tested with `node --test`; <BlogFaq> is the only caller.
//
// Answers are authored as plain text, and any literal "<...>" in them - "<ip>", "<img>"
// and similar placeholders in the docs-derived answers - must stay literal. So the whole
// string is escaped first, and only a small markdown subset is enabled afterwards, which
// means a URL or a label cannot reintroduce markup.
//
// The subset covers what the 11ty answers expressed with raw tags under `| safe`:
//
// [label](url) inline link, http(s) or site-absolute only
// **bold** <strong>
// *italic* <em>
// blank line a new paragraph (the .njk answers used <p> tags)
// "- item" lines an unordered list (the .njk answers used <ul><li>)
// "1. item" lines an ordered list (the .njk answers used <ol><li>)
const ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }

function escapeHtml (text) {
return text.replace(/[&<>"]/g, char => ESCAPE_MAP[char])
}

// url is http(s) or site-absolute. Anything else - javascript:, data:, protocol-relative -
// is left as text.
const LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s/)][^\s)]*|\/)\)/g
// Non-greedy and single-line, so an unclosed marker in prose does not swallow the rest.
const BOLD = /\*\*([^*\n]+)\*\*/g
const ITALIC = /\*([^*\n]+)\*/g

function inline (text) {
return escapeHtml(text)
.replace(LINK, '<a href="$2" class="text-indigo-600 hover:underline">$1</a>')
.replace(BOLD, '<strong>$1</strong>')
.replace(ITALIC, '<em>$1</em>')
}

const UNORDERED_ITEM = /^[-*]\s+(.*)$/
const ORDERED_ITEM = /^\d+[.)]\s+(.*)$/

function renderList (lines, ordered) {
const tag = ordered ? 'ol' : 'ul'
const pattern = ordered ? ORDERED_ITEM : UNORDERED_ITEM
const items = lines
.map(line => line.match(pattern)?.[1])
.filter(item => item !== undefined)
.map(item => `<li>${inline(item)}</li>`)
return `<${tag}>${items.join('')}</${tag}>`
}

// A paragraph made entirely of list-item lines renders as a list; anything else renders as
// a paragraph with its lines joined, which is how the source wrapped long answers.
function renderBlock (paragraph) {
const lines = paragraph.split('\n').map(line => line.trim()).filter(Boolean)
if (lines.length && lines.every(line => UNORDERED_ITEM.test(line))) return renderList(lines, false)
if (lines.length && lines.every(line => ORDERED_ITEM.test(line))) return renderList(lines, true)
return inline(lines.join(' '))
}

export function renderFaqAnswer (answer) {
if (!answer) return []
return answer
.split(/\n\s*\n/)
.map(paragraph => paragraph.trim())
.filter(Boolean)
.map(renderBlock)
}

// A block that is a list must not be wrapped in <p>, which is invalid; <BlogFaq> asks
// this rather than sniffing the markup itself.
export function isListBlock (block) {
return block.startsWith('<ul>') || block.startsWith('<ol>')
}
Loading
Loading