chore: Add guides and comparison pages to landing site
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user