chore: Add guides and comparison pages to landing site

This commit is contained in:
Dries Augustyns
2025-12-20 11:05:45 +01:00
parent 996852a506
commit 4156cd436d
38 changed files with 7037 additions and 132 deletions
@@ -0,0 +1,221 @@
import {Footer, Navbar} from '../';
import {motion} from 'framer-motion';
import React, {ReactNode, useEffect, useState} from 'react';
import Link from 'next/link';
import {ArticleJsonLd, BreadcrumbJsonLd, NextSeo} from 'next-seo';
import {Calendar, Clock} from 'lucide-react';
interface GuideLayoutProps {
title: string;
description: string;
lastUpdated: string;
readTime: string;
children: ReactNode;
canonical?: string;
ogImage?: string;
}
/**
* Reusable layout for educational guide pages
*/
export function GuideLayout({
title,
description,
lastUpdated,
readTime,
children,
canonical,
ogImage = 'https://www.useplunk.com/assets/card.png',
}: GuideLayoutProps) {
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>('');
// Extract headings for table of contents
useEffect(() => {
const elements = Array.from(document.querySelectorAll('h2, h3'));
// Generate IDs for headings that don't have them
const headingData = elements.map(element => {
let id = element.id;
if (!id) {
// Generate ID from text content
id = (element.textContent || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
element.id = id;
}
return {
id,
text: element.textContent || '',
level: parseInt(element.tagName.substring(1)),
};
});
setHeadings(headingData);
// Set up intersection observer for active heading
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{rootMargin: '-100px 0px -80% 0px'},
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, [children]);
// Generate breadcrumb items
const breadcrumbItems = [
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
{position: 3, name: title, item: canonical || ''},
];
return (
<>
<NextSeo
title={`${title} | Plunk`}
description={description}
canonical={canonical}
openGraph={{
title: `${title} | Plunk`,
description: description,
url: canonical,
type: 'article',
images: [{url: ogImage, alt: title}],
article: {
publishedTime: lastUpdated,
modifiedTime: lastUpdated,
authors: ['Plunk'],
},
}}
/>
<ArticleJsonLd
type="Article"
url={canonical || ''}
title={title}
images={[ogImage]}
datePublished={lastUpdated}
dateModified={lastUpdated}
authorName="Plunk"
description={description}
/>
<BreadcrumbJsonLd itemListElements={breadcrumbItems} />
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
<div className={'flex gap-12 py-16'}>
{/* Main Content */}
<article className={'flex-1 max-w-4xl'}>
{/* Breadcrumbs */}
<nav className={'mb-8'}>
<ol className={'flex items-center gap-2 text-sm text-neutral-600'}>
<li>
<Link href="/" className={'hover:text-neutral-900'}>
Home
</Link>
</li>
<li>/</li>
<li>
<Link href="/guides" className={'hover:text-neutral-900'}>
Guides
</Link>
</li>
<li>/</li>
<li className={'text-neutral-900 font-medium'}>{title}</li>
</ol>
</nav>
{/* Header */}
<motion.header
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-12'}
>
<h1 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl'}>{title}</h1>
<p className={'mt-6 text-lg text-neutral-600 leading-relaxed'}>{description}</p>
<div className={'mt-8 flex items-center gap-6 text-sm text-neutral-600'}>
<div className={'flex items-center gap-2'}>
<Calendar className="h-4 w-4" />
<span>
Updated{' '}
{new Date(lastUpdated).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className={'flex items-center gap-2'}>
<Clock className="h-4 w-4" />
<span>{readTime} read</span>
</div>
</div>
</motion.header>
{/* Content */}
<div className={'prose prose-neutral max-w-none'}>{children}</div>
</article>
{/* Table of Contents - Desktop Only */}
{headings.length > 0 && (
<aside className={'hidden lg:block w-64 shrink-0 sticky top-24 self-start'}>
<div className={'rounded-xl border border-neutral-200 bg-white p-6 shadow-sm'}>
<h2 className={'text-sm font-semibold text-neutral-900 mb-4 uppercase tracking-wide'}>
On this page
</h2>
<nav>
<ul className={'space-y-1'}>
{headings.map(heading => (
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
<a
href={`#${heading.id}`}
onClick={e => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
const offset = 100; // Account for fixed header
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementPosition - offset,
behavior: 'smooth',
});
}
}}
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
heading.level === 2
? activeId === heading.id
? 'border-neutral-900 text-neutral-900 font-semibold text-sm'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 font-medium text-sm'
: activeId === heading.id
? 'border-neutral-700 text-neutral-800 font-medium text-xs'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-200 text-xs'
}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</div>
</aside>
)}
</div>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,82 @@
import React, {ReactNode} from 'react';
import {AlertTriangle, CheckCircle2, Info, Lightbulb} from 'lucide-react';
type InfoBoxType = 'info' | 'warning' | 'tip' | 'success';
interface InfoBoxProps {
type?: InfoBoxType;
title?: string;
children: ReactNode;
className?: string;
}
const infoBoxConfig: Record<
InfoBoxType,
{
icon: React.ComponentType<{className?: string}>;
borderColor: string;
bgColor: string;
iconColor: string;
titleColor: string;
}
> = {
info: {
icon: Info,
borderColor: 'border-blue-200',
bgColor: 'bg-blue-50',
iconColor: 'text-blue-600',
titleColor: 'text-blue-900',
},
warning: {
icon: AlertTriangle,
borderColor: 'border-amber-200',
bgColor: 'bg-amber-50',
iconColor: 'text-amber-600',
titleColor: 'text-amber-900',
},
tip: {
icon: Lightbulb,
borderColor: 'border-purple-200',
bgColor: 'bg-purple-50',
iconColor: 'text-purple-600',
titleColor: 'text-purple-900',
},
success: {
icon: CheckCircle2,
borderColor: 'border-green-200',
bgColor: 'bg-green-50',
iconColor: 'text-green-600',
titleColor: 'text-green-900',
},
};
/**
* InfoBox component for displaying tips, warnings, notes, and other callouts in guides
*/
export function InfoBox({type = 'info', title, children, className}: InfoBoxProps) {
const config = infoBoxConfig[type];
const Icon = config.icon;
const defaultTitles: Record<InfoBoxType, string> = {
info: 'Note',
warning: 'Warning',
tip: 'Tip',
success: 'Success',
};
return (
<div className={`rounded-xl border ${config.borderColor} ${config.bgColor} p-6 my-6 ${className || ''}`}>
<div className={'flex gap-4'}>
<div className={'shrink-0'}>
<Icon className={`h-5 w-5 ${config.iconColor}`} />
</div>
<div>
{(title || defaultTitles[type]) && (
<h4 className={`not-prose font-semibold ${config.titleColor}`}>{title || defaultTitles[type]}</h4>
)}
<div className={'text-sm text-neutral-700 leading-relaxed mt-0 prose prose-sm max-w-none'}>{children}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export {GuideLayout} from './GuideLayout';
export {InfoBox} from './InfoBox';