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
7 changes: 7 additions & 0 deletions .eleventyignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@
# directly); 11ty must not also render them.
src/changelog/**/*.md
src/blog/**/*.md

# Customer stories listing page is served by Nuxt (nuxt/pages/customer-stories/index.vue).
# The individual story markdown files are NOT ignored here - they keep `permalink: false`
# (src/customer-stories/customer-stories.json) instead, so `collections.stories` stays
# populated for the few live 11ty pages that still read it (src/landing/tulip.njk,
# src/node-red/index.njk, src/_includes/stories-block.njk) without 11ty writing output files.
src/customer-stories.njk
34 changes: 34 additions & 0 deletions nuxt/components/StoryTile.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
path: string
brand: string
logo?: string | null
image?: string | null
}>(), {
logo: undefined,
image: undefined,
})
</script>

<template>
<li class="w-full rounded-lg border bg-white transition duration-300 ease-in-out hover:border-blue-600 hover:drop-shadow-lg">
<NuxtLink :to="path" class="group flex h-full flex-col hover:no-underline">
<div class="relative border-b">
<div class="ff-image-cover ff-image-top-rounded h-52 w-full sm:h-48">
<img
:src="image ?? '/images/og-blog.jpg'"
:alt="image ? `Image representing ${title}` : 'Elevate Node-RED with Flowfuse'"
>
</div>
<div v-if="logo" class="absolute left-0 top-0 flex h-full w-1/2 items-center justify-center rounded-tl-lg bg-white">
<img :src="logo" :alt="`Image representing ${brand} logo`" class="max-h-full max-w-full object-contain p-2">
</div>
</div>
<div class="flex flex-grow flex-col gap-2 p-5 pt-3">
<span class="text-sm font-bold text-gray-500">{{ brand }}</span>
<h3 class="text-base group-hover:text-blue-600">{{ title }}</h3>
</div>
</NuxtLink>
</li>
</template>
40 changes: 40 additions & 0 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,46 @@ export default defineContentConfig({
}).optional(),
})
}),
// Source files stay at src/customer-stories/ (11ty's historical location) rather than
// being copied into nuxt/content/ - keeps this migration a content-config-only change.
// The directory data file (src/customer-stories/customer-stories.json) sets
// `permalink: false` so 11ty keeps these in `collections.stories` (still read by a
// few live 11ty pages - src/landing/tulip.njk, src/node-red/index.njk,
// src/_includes/stories-block.njk) without also writing output files for them.
stories: defineCollection({
type: 'page',
source: {
cwd: join(__dirname, '../src'),
include: 'customer-stories/**/*.md',
},
schema: z.object({
description: z.string().optional(),
image: z.string().optional(),
date: z.coerce.date(),
// Card-badge logo shown on the listing/related-stories tiles - distinct from
// story.logo below (the sidebar logo on the detail page). Most stories leave
// this unset even when story.logo is set; that's existing 11ty behaviour, not
// a migration bug. Nullable because most story files write the key with no
// value ("logo:"), which YAML parses as null rather than omitting the key.
logo: z.string().nullable().optional(),
usecase: z.array(z.string()).optional(),
subtitle: z.string().optional(),
hubspot: z.object({
formId: z.string(),
}),
story: z.object({
brand: z.string(),
// Nullable for the same blank-key-in-YAML reason as top-level `logo` above.
url: z.string().nullable().optional(),
logo: z.string().optional(),
quote: z.string().optional(),
challenge: z.string(),
solution: z.string(),
products: z.array(z.string()),
results: z.array(z.string()),
}),
})
}),
ebooks: defineCollection({
type: 'page',
source: 'ebooks/*.md',
Expand Down
12 changes: 12 additions & 0 deletions nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ function collectProductRoutes (dir: string): string[] {
return routes
}

// Same idea as collectApplicationGuideRoutes above, for customer stories (flat
// src/customer-stories/ dir, see content.config.ts).
function collectStoryRoutes(dir: string): string[] {
const routes = ['/customer-stories/']
for (const file of readdirSync(dir)) {
if (!file.endsWith('.md')) continue
routes.push(`/customer-stories/${basename(file, '.md')}/`)
}
return routes
}

// Same idea for blog posts. Each entry also carries its `tags` so the 13 tag-listing
// pages (and their own pagination, 19 entries/page) can be sized correctly, and its
// `authors` so the /blog/author/{slug}/ pages can be enumerated.
Expand Down Expand Up @@ -361,6 +372,7 @@ export default defineNuxtConfig({
...blogFiles.map(f => f.route),
...blogAuthorRoutes,
...collectHandbookRoutes(join(__dirname, 'content/handbook'), '/handbook'),
...collectStoryRoutes(join(__dirname, '../src/customer-stories')),
]
})(),
crawlLinks: false,
Expand Down
183 changes: 183 additions & 0 deletions nuxt/pages/customer-stories/[slug].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string

const { data: page } = await useAsyncData(`customer-story-${slug}`, () =>
queryCollection('stories').path(`/customer-stories/${slug}`).first()
)

if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Story not found' })
}

const { data: allStories } = await useAsyncData(
'customer-stories-all',
() => queryCollection('stories').order('date', 'DESC').all()
)

const otherStories = computed(() => (allStories.value || []).filter(item => item.path !== page.value?.path))

// Deterministic on first render (SSR and pre-hydration client render must match to avoid a
// hydration mismatch); onMounted then reshuffles client-side so each visit gets a fresh pick,
// matching the spirit of 11ty's per-build `shuffle` filter without freezing one order into the
// prerendered static output.
const relatedStories = ref(otherStories.value.slice(0, 3))
onMounted(() => {
relatedStories.value = [...otherStories.value].sort(() => Math.random() - 0.5).slice(0, 3)
})

const productUrls: Record<string, string> = {
'Node-RED': '/node-red/',
'FlowFuse Dashboard': '/platform/dashboard/',
FlowFuse: '/platform/features/',
'FlowFuse Device Agent': '/docs/hardware/introduction/#device-agent-hardware',
'FlowFuse Project Nodes': '/docs/user/projectnodes/#flowfuse-project-nodes',
}
const productIcons: Record<string, string> = {
'Node-RED': '/images/stories/product-icons/node-red.svg',
'FlowFuse Dashboard': '/images/stories/product-icons/ff-dashboard.svg',
FlowFuse: '/images/stories/product-icons/ff-icon.svg',
'FlowFuse Device Agent': '/images/stories/product-icons/ff-device-agent.svg',
'FlowFuse Project Nodes': '/images/stories/product-icons/ff-project-nodes.svg',
}

const pageTitle = computed(() => page.value?.title ?? 'Customer Story')
const fullTitle = computed(() => `${pageTitle.value} • FlowFuse`)
const canonicalUrl = computed(() => `https://flowfuse.com${route.path}`)

useSeoMeta({
title: fullTitle,
description: computed(() => page.value?.description || ''),
ogTitle: fullTitle,
ogDescription: computed(() => page.value?.description || ''),
ogUrl: canonicalUrl,
ogImage: computed(() => page.value?.image),
ogType: 'article',
twitterCard: 'summary_large_image',
twitterSite: '@FlowFuseinc',
})
</script>

<template>
<div class="page post story w-full">
<div
v-if="page.title"
class="w-full bg-cover bg-center py-6 md:flex md:min-h-[272px] md:content-center md:py-9"
:style="{ backgroundImage: `linear-gradient(to right, #1F2937, #1F293700), url(${page.image})` }"
>
<div class="post-title container m-auto flex max-w-screen-lg text-center max-lg:px-6">
<div class="max-w-screen-md text-left md:pr-32">
<label><span class="text-indigo-200">Customer Story</span></label>
<h1 class="text-shadow-header text-white">
{{ page.title }}
</h1>
<!-- eslint-disable-next-line vue/no-v-html -->
<h4 v-if="page.subtitle" v-html="page.subtitle" />
</div>
</div>
</div>

<div class="blog nohero w-full bg-gray-50 pb-24 pt-6">
<div class="container m-auto flex flex-col items-stretch text-left max-lg:px-6 md:max-w-screen-lg">
<NuxtLink to="/customer-stories" class="group mb-5 inline-flex items-center gap-1 hover:no-underline md:mb-4">
<UIcon name="i-heroicons-chevron-left" />
<span class="group-hover:underline">Back to Customer Stories</span>
</NuxtLink>

<div class="ff-prose mb-6 flex flex-col-reverse border-b md:flex-row md:gap-8">
<div class="flex-grow">
<div class="prose">
<q v-if="page.story.quote" class="block w-full px-6 py-6 text-xl font-bold italic text-gray-600 md:pt-3">
{{ page.story.quote }}
</q>
<ContentRenderer :value="page" />
</div>
</div>

<div class="w-80 max-w-full flex-shrink-0 self-center md:self-auto">
<div class="flex flex-col rounded-lg border px-6 py-6" style="box-shadow: 4px 4px 6px rgba(75,85,99,0.05)">
<template v-if="page.story.logo">
<div class="flex h-[180px] items-center justify-center bg-white p-2 object-contain">
<a :href="page.story.url" target="_blank" rel="noopener" class="ff-image-contain h-full">
<img :src="page.story.logo" :alt="`Image representing ${page.story.brand} logo`" class="h-full max-w-full object-contain">
</a>
</div>
<div class="border-t pb-3" />
</template>

<div class="border-b pb-3">
<h3 class="text-base">
Challenge
</h3>
<p class="mt-2">
{{ page.story.challenge }}
</p>
</div>

<div class="border-b pb-3 pt-3">
<h3 class="text-base">
Solution
</h3>
<p class="mt-2">
{{ page.story.solution }}
</p>
<template v-if="page.story.products?.length">
<div class="flex flex-row items-center">
<h5 class="mr-2 text-sm font-normal text-gray-500">
using:
</h5>
<hr class="flex-grow border-gray-200">
</div>
<ul class="mt-4 flex flex-row flex-wrap gap-4">
<li v-for="product in page.story.products" :key="product">
<NuxtLink v-if="productIcons[product]" :to="productUrls[product]" :title="product" class="mb-3 flex h-10 w-10 items-center">
<img :src="productIcons[product]" :alt="product" class="h-10 w-10">
</NuxtLink>
</li>
</ul>
</template>
</div>

<div class="border-b pb-3 pt-3">
<h3 class="text-base">
Results
</h3>
<ul class="list-disc pl-6">
<li v-for="result in page.story.results" :key="result" class="mb-3 text-base">
{{ result }}
</li>
</ul>
</div>

<CtaBookDemo variant="primary" position="customer-story" uppercase class="mt-3 w-full md:self-end" />
</div>

<div v-if="page.hubspot?.formId" class="mt-6 flex flex-col px-6">
<div class="flex flex-col pb-3 pt-3">
<h3 class="mb-3">
Download Case Study
</h3>
<HubSpotForm :form-id="page.hubspot.formId" />
</div>
</div>
</div>
</div>

<h3 v-if="relatedStories.length" class="mt-6 text-indigo-400">
Read more stories
</h3>
<ul v-if="relatedStories.length" class="grid grid-cols-1 gap-4 pt-6 sm:grid-cols-2 md:grid-cols-3">
<StoryTile
v-for="item in relatedStories"
:key="item.path"
:title="item.title"
:path="item.path"
:brand="item.story.brand"
:logo="item.logo"
:image="item.image"
/>
</ul>
</div>
</div>
</div>
</template>
40 changes: 40 additions & 0 deletions nuxt/pages/customer-stories/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
definePageMeta({ layout: 'default' })

const { data: stories } = await useAsyncData(
'customer-stories-index',
() => queryCollection('stories').order('date', 'DESC').all()
)

useSeoMeta({
title: 'Customer Stories • FlowFuse',
description: 'Read how FlowFuse customers efficiently leverage Node-RED with FlowFuse for automation across various industries, including manufacturing, automobile, and building management.',
})
</script>

<template>
<div class="container m-auto w-full max-w-md pb-24 pt-8 text-left sm:max-w-6xl">
<div class="px-6">
<h1>Customer Stories</h1>
</div>
<ul v-if="stories && stories.length > 0" class="grid grid-cols-1 gap-4 px-6 sm:grid-cols-2 md:grid-cols-3">
<StoryTile
v-for="item in stories"
:key="item.path"
:title="item.title"
:path="item.path"
:brand="item.story.brand"
:logo="item.logo"
:image="item.image"
/>
</ul>
<div v-else class="mx-auto">
<div class="pb-3 text-3xl font-medium text-blue-hero md:text-5xl">
Ooops!
</div>
<div class="text-lg text-black-hero-body">
No-one has written anything yet. Come back soon!
</div>
</div>
</div>
</template>
9 changes: 9 additions & 0 deletions nuxt/public/images/stories/product-icons/ff-dashboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions nuxt/public/images/stories/product-icons/ff-device-agent.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions nuxt/public/images/stories/product-icons/ff-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading