chore: Add guides and comparison pages to landing site
This commit is contained in:
@@ -1,31 +1,63 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
import React, {useState} from 'react';
|
||||
import {Check, Copy} from 'lucide-react';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable code block component with syntax highlighting styling
|
||||
* Reusable code block component with syntax highlighting styling and copy functionality
|
||||
*/
|
||||
export function CodeBlock({code, language = 'javascript', title}: CodeBlockProps) {
|
||||
export function CodeBlock({code, language = 'javascript', title, showCopy = true}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50'}
|
||||
className={'group relative overflow-hidden rounded-xl border border-neutral-200 bg-neutral-900'}
|
||||
>
|
||||
{title && (
|
||||
<div className={'border-b border-neutral-200 bg-white px-6 py-3'}>
|
||||
<span className={'text-sm font-medium text-neutral-900'}>{title}</span>
|
||||
{(title || showCopy) && (
|
||||
<div className={'flex items-center justify-between border-b border-neutral-800 bg-neutral-900 px-6 py-3'}>
|
||||
{title && <span className={'text-sm font-medium text-neutral-400'}>{title}</span>}
|
||||
{!title && <span className={'text-xs font-medium text-neutral-500 uppercase'}>{language}</span>}
|
||||
{showCopy && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={
|
||||
'flex items-center gap-2 rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs font-medium text-neutral-300 transition hover:bg-neutral-700 hover:text-white'
|
||||
}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<pre className={'overflow-x-auto p-6'}>
|
||||
<code className={'font-mono text-sm text-neutral-900'}>{code}</code>
|
||||
<pre className={'overflow-x-auto p-6 text-neutral-100'}>
|
||||
<code className={'font-mono text-sm leading-relaxed'}>{code}</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'mainEntity': faqs.map((faq) => ({
|
||||
'mainEntity': faqs.map(faq => ({
|
||||
'@type': 'Question',
|
||||
'name': faq.question,
|
||||
'acceptedAnswer': {
|
||||
|
||||
@@ -67,7 +67,10 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
Made by humans
|
||||
</Link>
|
||||
</li>
|
||||
@@ -131,9 +134,7 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
<div className="mt-16 border-t border-neutral-200 pt-8">
|
||||
<p className="text-sm text-neutral-500">
|
||||
© {new Date().getFullYear()} Plunk. All rights reserved.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -25,19 +25,34 @@ export default function Navbar() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
<Link href={'/made-by-humans'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link href={'/pricing'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className={'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
className={
|
||||
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
Docs
|
||||
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -47,32 +62,27 @@ export default function Navbar() {
|
||||
strokeWidth="2"
|
||||
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19.25 9.25V4.75H14.75"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 5L11.75 12.25"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a href={`${DASHBOARD_URI}/auth/login`} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Get started
|
||||
</motion.a>
|
||||
@@ -146,6 +156,14 @@ export default function Navbar() {
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
|
||||
@@ -38,16 +38,14 @@ export function PricingCalculator({competitors, defaultVolume = 100000}: Pricing
|
||||
{/* Header */}
|
||||
<div className={'border-b border-neutral-200 bg-neutral-50 p-8'}>
|
||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Pricing Calculator</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>
|
||||
Compare costs across platforms at different email volumes
|
||||
</p>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>Compare costs across platforms at different email volumes</p>
|
||||
</div>
|
||||
|
||||
{/* Volume Selector */}
|
||||
<div className={'p-8'}>
|
||||
<label className={'block text-sm font-semibold text-neutral-900'}>Monthly Email Volume</label>
|
||||
<div className={'mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4'}>
|
||||
{volumeOptions.map((option) => (
|
||||
{volumeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setVolume(option.value)}
|
||||
|
||||
@@ -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