Initial react editor website template

This commit is contained in:
Rami Bitar
2026-08-29 14:03:50 -04:00
commit 3e072967e6
61 changed files with 7041 additions and 0 deletions
@@ -0,0 +1,19 @@
import type { ComponentConfig } from '@reacteditor/core';
import { Mail } from 'lucide-react';
import ContactForm, { type ContactFormProps } from './contact-form';
const contactFormConfig: ComponentConfig<ContactFormProps> = {
label: 'Contact form', icon: <Mail size={16} />, category: 'conversion',
defaultProps: {
heading: 'Let’s make something meaningful.', description: 'Tell us what you are working on, where you are stuck, or what you want to make possible.', email: 'hello@example.com', location: 'New York, NY',
nameLabel: 'Name', emailLabel: 'Email', companyLabel: 'Company', messageLabel: 'How can we help?', submitLabel: 'Send message', successMessage: 'Thanks — we will be in touch soon.',
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true },
email: { label: 'Contact email', type: 'text', contentEditable: true }, location: { label: 'Location', type: 'text', contentEditable: true },
nameLabel: { label: 'Name field label', type: 'text' }, emailLabel: { label: 'Email field label', type: 'text' }, companyLabel: { label: 'Company field label', type: 'text' }, messageLabel: { label: 'Message field label', type: 'text' }, submitLabel: { label: 'Submit label', type: 'text', contentEditable: true }, successMessage: { label: 'Success message', type: 'text' },
},
render: (props) => <ContactForm {...props} />,
};
export default contactFormConfig;
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useState, type FormEvent } from 'react';
import { ArrowRight, Mail, MapPin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
export type ContactFormProps = {
heading?: string;
description?: string;
email?: string;
location?: string;
nameLabel?: string;
emailLabel?: string;
companyLabel?: string;
messageLabel?: string;
submitLabel?: string;
successMessage?: string;
};
export default function ContactForm({
heading = '', description = '', email = '', location = '', nameLabel = 'Name', emailLabel = 'Email', companyLabel = 'Company', messageLabel = 'Message', submitLabel = 'Send message', successMessage = 'Thanks — we will be in touch soon.',
}: ContactFormProps) {
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [error, setError] = useState('');
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus('submitting');
setError('');
const form = event.currentTarget;
const body = Object.fromEntries(new FormData(form).entries());
try {
const response = await fetch('/api/contact', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const result = await response.json() as { error?: string };
if (!response.ok) throw new Error(result.error || 'We could not send your message.');
form.reset();
setStatus('success');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'We could not send your message.');
setStatus('error');
}
}
return (
<main className="bg-background py-16 sm:py-24">
<div className="section-shell grid gap-12 lg:grid-cols-[.8fr_1.2fr] lg:gap-20">
<div>
<h1 className="text-5xl font-normal leading-[0.96] tracking-[-0.05em] sm:text-6xl lg:text-7xl">{heading}</h1>
{description && <p className="mt-6 max-w-md text-lg leading-8 text-muted-foreground">{description}</p>}
<div className="mt-10 space-y-4 text-sm">
{email && <a href={`mailto:${email}`} className="flex items-center gap-3 hover:text-brand"><Mail className="size-4 text-muted-foreground" aria-hidden="true" />{email}</a>}
{location && <p className="flex items-center gap-3"><MapPin className="size-4 text-muted-foreground" aria-hidden="true" />{location}</p>}
</div>
</div>
<form onSubmit={handleSubmit} className="border-t pt-8">
<div className="grid gap-5 sm:grid-cols-2">
<Field label={nameLabel} htmlFor="contact-name"><Input id="contact-name" name="name" autoComplete="name" required /></Field>
<Field label={emailLabel} htmlFor="contact-email"><Input id="contact-email" name="email" type="email" autoComplete="email" required /></Field>
<Field label={companyLabel} htmlFor="contact-company" className="sm:col-span-2"><Input id="contact-company" name="company" autoComplete="organization" /></Field>
<Field label={messageLabel} htmlFor="contact-message" className="sm:col-span-2"><Textarea id="contact-message" name="message" rows={6} required /></Field>
</div>
<div className="sr-only" aria-hidden="true"><label htmlFor="contact-website">Website</label><input id="contact-website" name="website" tabIndex={-1} autoComplete="off" /></div>
<div className="mt-6 flex flex-col items-start gap-4 sm:flex-row sm:items-center">
<Button type="submit" size="lg" disabled={status === 'submitting'}>{status === 'submitting' ? 'Sending…' : submitLabel}<ArrowRight aria-hidden="true" /></Button>
<p aria-live="polite" className={`text-sm ${status === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}>{status === 'success' ? successMessage : status === 'error' ? error : ''}</p>
</div>
</form>
</div>
</main>
);
}
function Field({ label, htmlFor, className = '', children }: { label: string; htmlFor: string; className?: string; children: React.ReactNode }) {
return <div className={className}><label htmlFor={htmlFor} className="mb-2 block text-sm font-medium">{label}</label>{children}</div>;
}
+15
View File
@@ -0,0 +1,15 @@
import type { ComponentConfig } from '@reacteditor/core';
import { MousePointerClick } from 'lucide-react';
import Cta, { type CtaProps } from './cta';
const ctaConfig: ComponentConfig<CtaProps> = {
label: 'Call to action', icon: <MousePointerClick size={16} />, category: 'conversion',
defaultProps: { heading: 'Add a clear call to action.', description: 'Use this space to explain what visitors should do next and why it matters.', primaryLabel: 'Get started', primaryUrl: '/contact', secondaryLabel: '', secondaryUrl: '/contact' },
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true },
primaryLabel: { label: 'Primary CTA', type: 'text', contentEditable: true }, primaryUrl: { label: 'Primary URL', type: 'text' }, secondaryLabel: { label: 'Secondary CTA', type: 'text', contentEditable: true }, secondaryUrl: { label: 'Secondary URL', type: 'text' },
},
render: (props) => <Cta {...props} />,
};
export default ctaConfig;
+24
View File
@@ -0,0 +1,24 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
export type CtaProps = { heading?: string; description?: string; primaryLabel?: string; primaryUrl?: string; secondaryLabel?: string; secondaryUrl?: string };
export default function Cta({ heading = '', description = '', primaryLabel = '', primaryUrl = '#', secondaryLabel = '', secondaryUrl = '#' }: CtaProps) {
return (
<section className="bg-background text-foreground">
<div className="section-shell grid gap-10 py-16 sm:py-20 lg:min-h-64 lg:grid-cols-2 lg:items-center lg:gap-20 lg:py-0">
<div className="flex flex-col justify-center">
<h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>
</div>
<div className="flex flex-col justify-center">
{description && <p className="max-w-xl text-base leading-7 text-muted-foreground">{description}</p>}
<div className="mt-7 flex flex-wrap gap-3">
{primaryLabel && <Button asChild size="lg"><Link href={primaryUrl}>{primaryLabel}<ArrowRight aria-hidden="true" /></Link></Button>}
{secondaryLabel && <Button asChild variant="outline" size="lg"><Link href={secondaryUrl}>{secondaryLabel}</Link></Button>}
</div>
</div>
</div>
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ComponentConfig } from '@reacteditor/core';
import { CircleHelp } from 'lucide-react';
import Faq, { type FaqProps } from './faq';
const faqConfig: ComponentConfig<FaqProps> = {
label: 'FAQ', icon: <CircleHelp size={16} />, category: 'content',
defaultProps: {
heading: 'Get answers to frequently asked questions.', description: '',
items: [
{ question: 'What does your business offer?', answer: 'Use this space to provide a clear, concise answer to a common customer question.' },
{ question: 'Who is this for?', answer: 'Describe the people, teams, or organizations that will benefit most from your offer.' },
{ question: 'How do I get started?', answer: 'Explain the simplest next step and what customers can expect after they begin.' },
{ question: 'Where can I learn more?', answer: 'Point visitors toward your contact page, documentation, or another helpful resource.' },
],
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true },
items: { label: 'Questions', type: 'array', defaultItemProps: { question: 'Question', answer: 'Answer' }, getItemSummary: (item) => item?.question || 'Question', arrayFields: { question: { label: 'Question', type: 'text', contentEditable: true }, answer: { label: 'Answer', type: 'textarea', contentEditable: true } } },
},
render: (props) => <Faq {...props} />,
};
export default faqConfig;
+28
View File
@@ -0,0 +1,28 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
export type FaqProps = {
heading?: string;
description?: string;
items?: Array<{ question: string; answer: string }>;
};
export default function Faq({ heading = '', description = '', items = [] }: FaqProps) {
return (
<section id="faq" className="scroll-mt-20 bg-background py-20 sm:py-28">
<div className="section-shell">
<div className="max-w-4xl">
<h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>
{description && <p className="mt-4 max-w-2xl leading-7 text-muted-foreground">{description}</p>}
</div>
<Accordion type="single" collapsible className="mt-12 border-t">
{items.map((item, index) => (
<AccordionItem key={index} value={`faq-${index}`}>
<AccordionTrigger className="py-7 text-2xl font-normal sm:py-8 sm:text-3xl">{item.question}</AccordionTrigger>
<AccordionContent className="max-w-3xl pb-8 text-base leading-7 text-muted-foreground">{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</section>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ComponentConfig } from '@reacteditor/core';
import { LayoutGrid } from 'lucide-react';
import Features, { type FeaturesProps } from './features';
const featuresConfig: ComponentConfig<FeaturesProps> = {
label: 'Features', icon: <LayoutGrid size={16} />, category: 'marketing',
defaultProps: {
heading: 'Explain the most important benefits of your business.', description: '',
imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', imagePosition: 'left',
items: [
{ title: 'First key benefit', description: 'Add a concise explanation of the value this benefit creates for your customers.', ctaLabel: '', ctaUrl: '#' },
{ title: 'Second key benefit', description: 'Use a clear, outcome-focused description that is easy to scan.', ctaLabel: '', ctaUrl: '#' },
{ title: 'Third key benefit', description: 'Give visitors one more reason to understand and trust your offer.', ctaLabel: '', ctaUrl: '#' },
],
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true },
imageUrl: { label: 'Image', type: 'image' }, imageAlt: { label: 'Image alt text', type: 'text' }, imagePosition: { label: 'Image position', type: 'radio', options: [{ label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }] },
items: { label: 'Features', type: 'array', defaultItemProps: { title: 'Feature', description: 'Describe the benefit.', ctaLabel: '', ctaUrl: '#' }, getItemSummary: (item) => item?.title || 'Feature', arrayFields: { title: { label: 'Title', type: 'text', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true }, ctaLabel: { label: 'Link label', type: 'text', contentEditable: true }, ctaUrl: { label: 'Link URL', type: 'text' } } },
},
render: (props) => <Features {...props} />,
};
export default featuresConfig;
+43
View File
@@ -0,0 +1,43 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
export type FeaturesProps = {
heading?: string;
description?: string;
imageUrl?: string;
imageAlt?: string;
imagePosition?: 'left' | 'right';
items?: Array<{ title: string; description: string; ctaLabel?: string; ctaUrl?: string }>;
};
export default function Features({ heading = '', description = '', imageUrl = '', imageAlt = '', imagePosition = 'left', items = [] }: FeaturesProps) {
const media = imageUrl ? <img src={imageUrl} alt={imageAlt} className="h-full min-h-96 w-full rounded-xl object-cover" /> : <div className="flex min-h-96 items-center justify-center rounded-xl bg-muted text-sm text-muted-foreground">Add a feature image</div>;
const details = (
<Accordion type="single" collapsible defaultValue="feature-0" className="border-t">
{items.map((item, index) => (
<AccordionItem key={index} value={`feature-${index}`}>
<AccordionTrigger className="py-7 text-2xl sm:text-3xl">{item.title}</AccordionTrigger>
<AccordionContent className="max-w-xl pb-7 text-base leading-7 text-muted-foreground">
<p>{item.description}</p>
{item.ctaLabel && <Link href={item.ctaUrl || '#'} className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-brand hover:underline">{item.ctaLabel}<ArrowRight className="size-4" aria-hidden="true" /></Link>}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
return (
<section id="features" className="scroll-mt-20 bg-background py-20 sm:py-28 lg:py-32">
<div className="section-shell">
<div className="max-w-4xl">
<h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>
{description && <p className="mt-4 max-w-2xl leading-7 text-muted-foreground">{description}</p>}
</div>
<div className="mt-14 grid items-stretch gap-10 lg:grid-cols-2 lg:gap-16">
{imagePosition === 'left' ? <>{media}{details}</> : <>{details}{media}</>}
</div>
</div>
</section>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ComponentConfig } from '@reacteditor/core';
import { PanelBottom } from 'lucide-react';
import Footer, { type FooterProps } from './footer';
const footerConfig: ComponentConfig<FooterProps> = {
label: 'Footer', icon: <PanelBottom size={16} />, category: 'navigation', global: true,
defaultProps: {
siteName: 'Your Company', logoUrl: '', description: 'Add a short description of your business.',
links: [{ label: 'Features', url: '/#features' }, { label: 'Pricing', url: '/#pricing' }, { label: 'Resources', url: '/#resources' }, { label: 'Contact', url: '/contact' }],
socialLinks: [{ label: 'LinkedIn', url: '#' }, { label: 'Instagram', url: '#' }],
copyright: '© 2026 Your Company. All rights reserved.', showThemeToggle: 'yes',
},
fields: {
siteName: { label: 'Site name', type: 'text', contentEditable: true },
logoUrl: { label: 'Logo', type: 'image' },
description: { label: 'Description', type: 'textarea', contentEditable: true },
links: { label: 'Links', type: 'array', defaultItemProps: { label: 'Link', url: '/' }, getItemSummary: (item) => item?.label || 'Link', arrayFields: { label: { label: 'Label', type: 'text', contentEditable: true }, url: { label: 'URL', type: 'text' } } },
socialLinks: { label: 'Social links', type: 'array', defaultItemProps: { label: 'Network', url: '#' }, getItemSummary: (item) => item?.label || 'Social link', arrayFields: { label: { label: 'Label', type: 'text', contentEditable: true }, url: { label: 'URL', type: 'text' } } },
copyright: { label: 'Copyright', type: 'text', contentEditable: true },
showThemeToggle: { label: 'Theme toggle', type: 'radio', options: [{ label: 'Show', value: 'yes' }, { label: 'Hide', value: 'no' }] },
},
render: (props) => <Footer {...props} />,
};
export default footerConfig;
+42
View File
@@ -0,0 +1,42 @@
import Link from 'next/link';
import { Logo } from '@/components/logo';
import { ThemeToggle } from '@/components/theme-toggle';
export type FooterProps = {
siteName?: string;
logoUrl?: string;
description?: string;
links?: Array<{ label: string; url: string }>;
socialLinks?: Array<{ label: string; url: string }>;
copyright?: string;
showThemeToggle?: 'yes' | 'no';
};
export default function Footer({
siteName = 'Your Company', logoUrl = '', description = '', links = [], socialLinks = [], copyright, showThemeToggle = 'yes',
}: FooterProps) {
return (
<footer className="bg-background">
<div className="section-shell grid gap-10 py-14 md:grid-cols-[1.3fr_1fr] md:py-16">
<div className="max-w-sm">
<Logo name={siteName} logoUrl={logoUrl} />
{description && <p className="mt-4 text-sm leading-6 text-muted-foreground">{description}</p>}
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-[1fr_1fr_auto]">
<nav aria-label="Footer navigation" className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Explore</p>
{links.map((link) => <Link key={`${link.label}-${link.url}`} href={link.url} className="text-sm hover:text-brand">{link.label}</Link>)}
</nav>
<nav aria-label="Social links" className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Connect</p>
{socialLinks.map((link) => <a key={`${link.label}-${link.url}`} href={link.url} className="text-sm hover:text-brand">{link.label}</a>)}
</nav>
{showThemeToggle === 'yes' && <div className="col-span-2 sm:col-span-1"><ThemeToggle /></div>}
</div>
</div>
<div className="section-shell py-5 text-xs text-muted-foreground">
{copyright || `© ${new Date().getFullYear()} ${siteName}. All rights reserved.`}
</div>
</footer>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ComponentConfig } from '@reacteditor/core';
import { PanelTop } from 'lucide-react';
import Header, { type HeaderProps } from './header';
const headerConfig: ComponentConfig<HeaderProps> = {
label: 'Header',
icon: <PanelTop size={16} />,
category: 'navigation',
global: true,
defaultProps: {
siteName: 'Your Company',
logoUrl: '',
links: [
{ label: 'Features', url: '/#features' },
{ label: 'Pricing', url: '/#pricing' },
{ label: 'Resources', url: '/#resources' },
{ label: 'FAQ', url: '/#faq' },
],
ctaLabel: 'Contact',
ctaUrl: '/contact',
showThemeToggle: 'yes',
sticky: 'yes',
},
fields: {
siteName: { label: 'Site name', type: 'text', contentEditable: true },
logoUrl: { label: 'Logo', type: 'image' },
links: {
label: 'Navigation links', type: 'array',
defaultItemProps: { label: 'Link', url: '/' },
getItemSummary: (item) => item?.label || 'Link',
arrayFields: {
label: { label: 'Label', type: 'text', contentEditable: true },
url: { label: 'URL', type: 'text' },
},
},
ctaLabel: { label: 'CTA label', type: 'text', contentEditable: true },
ctaUrl: { label: 'CTA URL', type: 'text' },
showThemeToggle: { label: 'Theme toggle', type: 'radio', options: [{ label: 'Show', value: 'yes' }, { label: 'Hide', value: 'no' }] },
sticky: { label: 'Sticky header', type: 'radio', options: [{ label: 'Yes', value: 'yes' }, { label: 'No', value: 'no' }] },
},
render: (props) => <Header {...props} />,
};
export default headerConfig;
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Menu, X } from 'lucide-react';
import { Logo } from '@/components/logo';
import { ThemeToggle } from '@/components/theme-toggle';
import { Button } from '@/components/ui/button';
export type NavigationLink = { label: string; url: string };
export type HeaderProps = {
siteName?: string;
logoUrl?: string;
links?: NavigationLink[];
ctaLabel?: string;
ctaUrl?: string;
showThemeToggle?: 'yes' | 'no';
sticky?: 'yes' | 'no';
};
export default function Header({
siteName = 'Your Company',
logoUrl = '',
links = [],
ctaLabel = 'Get started',
ctaUrl = '/#pricing',
showThemeToggle = 'yes',
sticky = 'yes',
}: HeaderProps) {
const [open, setOpen] = useState(false);
return (
<header className={`${sticky === 'yes' ? 'sticky top-0' : 'relative'} z-50 bg-background`}>
<div className="section-shell flex h-16 items-center justify-between">
<Logo name={siteName} logoUrl={logoUrl} />
<nav aria-label="Primary navigation" className="hidden items-center gap-7 md:flex">
{links.map((link) => (
<Link key={`${link.label}-${link.url}`} href={link.url} className="text-sm text-muted-foreground transition-colors hover:text-foreground">
{link.label}
</Link>
))}
</nav>
<div className="flex items-center gap-1.5">
{showThemeToggle === 'yes' && <ThemeToggle />}
{ctaLabel && (
<Button asChild size="sm" className="hidden sm:inline-flex">
<Link href={ctaUrl}>{ctaLabel}</Link>
</Button>
)}
<Button type="button" variant="ghost" size="icon" className="md:hidden" aria-label={open ? 'Close navigation' : 'Open navigation'} aria-expanded={open} onClick={() => setOpen((value) => !value)}>
{open ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
</Button>
</div>
</div>
{open && (
<nav aria-label="Mobile navigation" className="bg-background px-4 py-4 md:hidden">
<div className="section-shell flex flex-col gap-1">
{links.map((link) => (
<Link key={`${link.label}-${link.url}`} href={link.url} onClick={() => setOpen(false)} className="rounded-md px-3 py-2.5 text-sm hover:bg-muted">
{link.label}
</Link>
))}
{ctaLabel && (
<Button asChild className="mt-2 w-full">
<Link href={ctaUrl} onClick={() => setOpen(false)}>{ctaLabel}</Link>
</Button>
)}
</div>
</nav>
)}
</header>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ComponentConfig } from '@reacteditor/core';
import { Sparkles } from 'lucide-react';
import Hero, { type HeroProps } from './hero';
const heroConfig: ComponentConfig<HeroProps> = {
label: 'Hero', icon: <Sparkles size={16} />, category: 'marketing',
defaultProps: {
heading: 'A clear headline about what your business does.',
description: 'Use this space to explain your value in one or two simple sentences.',
primaryLabel: 'Get started', primaryUrl: '/contact', secondaryLabel: '', secondaryUrl: '#features',
imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', imagePosition: 'right',
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true },
description: { label: 'Description', type: 'textarea', contentEditable: true },
primaryLabel: { label: 'Primary CTA', type: 'text', contentEditable: true }, primaryUrl: { label: 'Primary URL', type: 'text' },
secondaryLabel: { label: 'Secondary CTA', type: 'text', contentEditable: true }, secondaryUrl: { label: 'Secondary URL', type: 'text' },
imageUrl: { label: 'Image', type: 'image' }, imageAlt: { label: 'Image alt text', type: 'text' },
imagePosition: { label: 'Image position', type: 'radio', options: [{ label: 'Right', value: 'right' }, { label: 'Left', value: 'left' }] },
},
render: (props) => <Hero {...props} />,
};
export default heroConfig;
+46
View File
@@ -0,0 +1,46 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
export type HeroProps = {
heading?: string;
description?: string;
primaryLabel?: string;
primaryUrl?: string;
secondaryLabel?: string;
secondaryUrl?: string;
imageUrl?: string;
imageAlt?: string;
imagePosition?: 'left' | 'right';
};
export default function Hero({
heading = '', description = '', primaryLabel = '', primaryUrl = '#', secondaryLabel = '', secondaryUrl = '#', imageUrl = '', imageAlt = '', imagePosition = 'right',
}: HeroProps) {
const copy = (
<div className="flex max-w-xl flex-col items-start justify-center py-14 sm:py-20 lg:py-28">
<h1 className="text-5xl font-normal leading-[0.96] tracking-[-0.05em] sm:text-6xl lg:text-7xl xl:text-8xl">{heading}</h1>
{description && <p className="mt-6 max-w-lg text-base leading-7 text-muted-foreground sm:text-lg">{description}</p>}
<div className="mt-8 flex flex-wrap gap-3">
{primaryLabel && <Button asChild size="lg"><Link href={primaryUrl}>{primaryLabel}<ArrowRight aria-hidden="true" /></Link></Button>}
{secondaryLabel && <Button asChild variant="outline" size="lg"><Link href={secondaryUrl}>{secondaryLabel}</Link></Button>}
</div>
</div>
);
const image = (
<div className="flex py-8 sm:py-12 lg:py-16">
<div className="min-h-80 w-full overflow-hidden rounded-xl lg:min-h-[32rem]">
{imageUrl ? <img src={imageUrl} alt={imageAlt} className="h-full min-h-80 w-full object-cover lg:min-h-[32rem]" /> : <div className="flex h-full min-h-80 items-center justify-center bg-secondary text-sm text-muted-foreground lg:min-h-[32rem]">Add a hero image</div>}
</div>
</div>
);
return (
<section className="bg-background">
<div className="section-shell grid items-stretch gap-10 lg:grid-cols-2 lg:gap-16">
{imagePosition === 'left' ? <>{image}{copy}</> : <>{copy}{image}</>}
</div>
</section>
);
}
@@ -0,0 +1,22 @@
import type { ComponentConfig } from '@reacteditor/core';
import { GalleryHorizontalEnd } from 'lucide-react';
import LogoMarquee, { type LogoMarqueeProps } from './logo-marquee';
const logoMarqueeConfig: ComponentConfig<LogoMarqueeProps> = {
label: 'Logo marquee', icon: <GalleryHorizontalEnd size={16} />, category: 'social-proof',
defaultProps: {
heading: 'Trusted by teams at',
logos: [{ name: 'Logo One', imageUrl: '' }, { name: 'Logo Two', imageUrl: '' }, { name: 'Logo Three', imageUrl: '' }, { name: 'Logo Four', imageUrl: '' }, { name: 'Logo Five', imageUrl: '' }],
speed: 'normal', direction: 'left', pauseOnHover: 'yes',
},
fields: {
heading: { label: 'Heading', type: 'text', contentEditable: true },
logos: { label: 'Logos', type: 'array', defaultItemProps: { name: 'Company', imageUrl: '' }, getItemSummary: (item) => item?.name || 'Logo', arrayFields: { name: { label: 'Name', type: 'text' }, imageUrl: { label: 'Image', type: 'image' } } },
speed: { label: 'Speed', type: 'select', options: [{ label: 'Slow', value: 'slow' }, { label: 'Normal', value: 'normal' }, { label: 'Fast', value: 'fast' }] },
direction: { label: 'Direction', type: 'radio', options: [{ label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }] },
pauseOnHover: { label: 'Pause on hover', type: 'radio', options: [{ label: 'Yes', value: 'yes' }, { label: 'No', value: 'no' }] },
},
render: (props) => <LogoMarquee {...props} />,
};
export default logoMarqueeConfig;
+34
View File
@@ -0,0 +1,34 @@
import type { CSSProperties } from 'react';
export type LogoMarqueeProps = {
heading?: string;
logos?: Array<{ name: string; imageUrl?: string }>;
speed?: 'slow' | 'normal' | 'fast';
direction?: 'left' | 'right';
pauseOnHover?: 'yes' | 'no';
};
export default function LogoMarquee({ heading = '', logos = [], speed = 'normal', direction = 'left', pauseOnHover = 'yes' }: LogoMarqueeProps) {
const duration = speed === 'slow' ? '42s' : speed === 'fast' ? '18s' : '28s';
const items = [...logos, ...logos];
return (
<section className="bg-background py-12 sm:py-14">
<div className="section-shell">
{heading && <p className="text-center text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">{heading}</p>}
<div className="mt-8 overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]">
<div
className={`animate-marquee flex w-max items-center ${direction === 'right' ? 'marquee-reverse' : ''} ${pauseOnHover === 'yes' ? 'marquee-pause' : ''}`}
style={{ '--marquee-duration': duration } as CSSProperties}
>
{items.map((logo, index) => (
<div key={`${logo.name}-${index}`} aria-hidden={index >= logos.length} className="mx-8 flex h-10 min-w-32 items-center justify-center sm:mx-12">
{logo.imageUrl ? <img src={logo.imageUrl} alt={logo.name} className="max-h-7 max-w-32 object-contain opacity-55 grayscale dark:invert" /> : <span className="text-lg font-semibold tracking-[-0.03em] text-muted-foreground/80">{logo.name}</span>}
</div>
))}
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,24 @@
import type { ComponentConfig } from '@reacteditor/core';
import { PanelsTopLeft } from 'lucide-react';
import PostsCarousel, { type PostsCarouselProps } from './posts-carousel';
const postsCarouselConfig: ComponentConfig<PostsCarouselProps> = {
label: 'Posts carousel', icon: <PanelsTopLeft size={16} />, category: 'content',
defaultProps: {
heading: 'Explore useful articles, guides, and stories.', description: '', viewAllLabel: 'View all', viewAllUrl: '#',
posts: [
{ title: 'Add the title of your first article or resource', category: 'Guide', date: 'August 20, 2026', imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', url: '#', typeLabel: 'Read' },
{ title: 'Share an update that helps customers learn more', category: 'News', date: 'August 14, 2026', imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', url: '#', typeLabel: 'Read' },
{ title: 'Feature a customer story or helpful perspective', category: 'Story', date: 'August 8, 2026', imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', url: '#', typeLabel: 'Read' },
{ title: 'Use additional cards to demonstrate the carousel', category: 'Article', date: 'August 1, 2026', imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', url: '#', typeLabel: 'Read' },
],
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true },
posts: { label: 'Posts', type: 'array', defaultItemProps: { title: 'Post title', category: 'Article', date: '', imageUrl: 'https://api.dicebear.com/10.x/glass/svg', imageAlt: 'Abstract glass avatar illustration', url: '#', typeLabel: 'Read' }, getItemSummary: (item) => item?.title || 'Post', arrayFields: { title: { label: 'Title', type: 'textarea', contentEditable: true }, category: { label: 'Category', type: 'text', contentEditable: true }, date: { label: 'Date', type: 'text', contentEditable: true }, imageUrl: { label: 'Image', type: 'image' }, imageAlt: { label: 'Image alt text', type: 'text' }, url: { label: 'URL', type: 'text' }, typeLabel: { label: 'Type label', type: 'text', contentEditable: true } } },
viewAllLabel: { label: 'View all label', type: 'text', contentEditable: true }, viewAllUrl: { label: 'View all URL', type: 'text' },
},
render: (props) => <PostsCarousel {...props} />,
};
export default postsCarouselConfig;
+50
View File
@@ -0,0 +1,50 @@
import Link from 'next/link';
import { ArrowRight, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel';
export type PostsCarouselProps = {
heading?: string;
description?: string;
posts?: Array<{ title: string; category?: string; date?: string; imageUrl?: string; imageAlt?: string; url: string; typeLabel?: string }>;
viewAllLabel?: string;
viewAllUrl?: string;
};
export default function PostsCarousel({ heading = '', description = '', posts = [], viewAllLabel = '', viewAllUrl = '#' }: PostsCarouselProps) {
return (
<section id="resources" className="scroll-mt-20 bg-background py-20 sm:py-28">
<div className="section-shell">
<Carousel opts={{ align: 'start' }}>
<div className="flex items-end justify-between gap-8">
<div className="max-w-4xl">
<h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>
{description && <p className="mt-4 max-w-2xl leading-7 text-muted-foreground">{description}</p>}
</div>
<div className="hidden shrink-0 gap-2 sm:flex"><CarouselPrevious className="static translate-y-0" /><CarouselNext className="static translate-y-0" /></div>
</div>
<CarouselContent className="mt-12">
{posts.map((post, index) => (
<CarouselItem key={index} className="sm:basis-1/2 lg:basis-1/3">
<article className="h-full">
<Link href={post.url} className="group block">
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-secondary">
{post.imageUrl ? <img src={post.imageUrl} alt={post.imageAlt || ''} className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]" /> : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Add an image</div>}
{post.typeLabel && <span className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full bg-background px-3 py-2 text-xs font-medium"><FileText className="size-3.5" aria-hidden="true" />{post.typeLabel}</span>}
</div>
<div className="pt-5">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">{post.category && <span>{post.category}</span>}{post.date && <span>{post.date}</span>}</div>
<h3 className="mt-3 text-2xl font-normal leading-tight tracking-[-0.025em] group-hover:underline">{post.title}</h3>
</div>
</Link>
</article>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-8 flex gap-2 sm:hidden"><CarouselPrevious className="static translate-y-0" /><CarouselNext className="static translate-y-0" /></div>
</Carousel>
{viewAllLabel && <Button asChild variant="outline" size="lg" className="mt-12"><Link href={viewAllUrl}>{viewAllLabel}<ArrowRight aria-hidden="true" /></Link></Button>}
</div>
</section>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ComponentConfig } from '@reacteditor/core';
import { BadgeDollarSign } from 'lucide-react';
import Pricing, { type PricingProps } from './pricing';
const pricingConfig: ComponentConfig<PricingProps> = {
label: 'Pricing', icon: <BadgeDollarSign size={16} />, category: 'conversion',
defaultProps: {
heading: 'Simple plans for different needs.', description: 'Replace these sample plans with pricing that fits your business.', billingNote: 'Prices shown in USD.',
plans: [
{ name: 'Starter', price: '$0', period: '/ month', description: 'A short description of who this plan is for.', features: 'First included feature\nSecond included feature\nThird included feature', ctaLabel: 'Get started', ctaUrl: '/contact', badge: '', highlighted: 'no' },
{ name: 'Team', price: '$24', period: '/ month', description: 'A short description of who this plan is for.', features: 'Everything in Starter\nAnother useful feature\nPriority support', ctaLabel: 'Get started', ctaUrl: '/contact', badge: 'Popular', highlighted: 'yes' },
{ name: 'Business', price: 'Custom', period: '', description: 'A short description of who this plan is for.', features: 'Everything in Team\nAdvanced controls\nDedicated support', ctaLabel: 'Contact us', ctaUrl: '/contact', badge: '', highlighted: 'no' },
],
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true }, billingNote: { label: 'Billing note', type: 'text', contentEditable: true },
plans: { label: 'Plans', type: 'array', defaultItemProps: { name: 'Plan', price: '$0', period: '/ month', description: 'Describe this plan.', features: 'Feature one\nFeature two', ctaLabel: 'Get started', ctaUrl: '/contact', badge: '', highlighted: 'no' }, getItemSummary: (item) => item?.name || 'Plan', arrayFields: {
name: { label: 'Name', type: 'text', contentEditable: true }, price: { label: 'Price', type: 'text', contentEditable: true }, period: { label: 'Period', type: 'text', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true }, features: { label: 'Features (one per line)', type: 'textarea' }, ctaLabel: { label: 'CTA label', type: 'text', contentEditable: true }, ctaUrl: { label: 'CTA URL', type: 'text' }, badge: { label: 'Badge', type: 'text', contentEditable: true }, highlighted: { label: 'Highlighted', type: 'radio', options: [{ label: 'Yes', value: 'yes' }, { label: 'No', value: 'no' }] },
} },
},
render: (props) => <Pricing {...props} />,
};
export default pricingConfig;
+57
View File
@@ -0,0 +1,57 @@
import Link from 'next/link';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
export type PricingProps = {
heading?: string;
description?: string;
billingNote?: string;
plans?: Array<{
name: string;
price: string;
period: string;
description: string;
features: string;
ctaLabel: string;
ctaUrl: string;
badge?: string;
highlighted?: 'yes' | 'no';
}>;
};
export default function Pricing({ heading = '', description = '', billingNote = '', plans = [] }: PricingProps) {
return (
<section id="pricing" className="scroll-mt-20 bg-background py-20 sm:py-28">
<div className="section-shell">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>
{description && <p className="mt-4 text-base leading-7 text-muted-foreground">{description}</p>}
{billingNote && <p className="mt-3 text-sm text-muted-foreground">{billingNote}</p>}
</div>
<div className={`mx-auto mt-12 grid max-w-6xl gap-8 ${plans.length > 2 ? 'lg:grid-cols-3' : 'md:grid-cols-2'}`}>
{plans.map((plan, index) => (
<article key={`${plan.name}-${index}`} className="h-full">
<Card className={`h-full gap-0 bg-background py-0 shadow-none ${plan.highlighted === 'yes' ? 'border-brand ring-1 ring-brand/15' : ''}`}>
<CardHeader className="p-7 pb-0">
<CardTitle><h3 className="text-xl font-medium">{plan.name}</h3></CardTitle>
{plan.badge && <CardAction><span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-brand">{plan.badge}</span></CardAction>}
<div className="col-span-full mt-5 flex items-end gap-1.5"><span className="text-5xl font-medium tracking-[-0.05em]">{plan.price}</span><span className="pb-1 text-sm text-muted-foreground">{plan.period}</span></div>
<CardDescription className="col-span-full mt-3 min-h-12 leading-6">{plan.description}</CardDescription>
</CardHeader>
<CardContent className="flex-1 px-7 pt-8">
<ul className="space-y-3">
{plan.features.split('\n').map((feature) => feature.trim()).filter(Boolean).map((feature) => <li key={feature} className="flex gap-2.5 text-sm"><Check className="mt-0.5 size-4 shrink-0 text-brand" aria-hidden="true" />{feature}</li>)}
</ul>
</CardContent>
<CardFooter className="p-7 pt-9">
<Button asChild variant={plan.highlighted === 'yes' ? 'default' : 'outline'} size="lg" className="w-full"><Link href={plan.ctaUrl}>{plan.ctaLabel}</Link></Button>
</CardFooter>
</Card>
</article>
))}
</div>
</div>
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ComponentConfig } from '@reacteditor/core';
import { ChartNoAxesColumnIncreasing } from 'lucide-react';
import Stats, { type StatsProps } from './stats';
const statsConfig: ComponentConfig<StatsProps> = {
label: 'Stats', icon: <ChartNoAxesColumnIncreasing size={16} />, category: 'social-proof',
defaultProps: {
heading: 'Use a few clear numbers to build confidence.',
stats: [
{ value: '10k+', label: 'Customers', description: 'A short note about this result.' },
{ value: '42%', label: 'Growth', description: 'A short note about this result.' },
{ value: '24/7', label: 'Availability', description: 'A short note about this result.' },
{ value: '98%', label: 'Satisfaction', description: 'A short note about this result.' },
],
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true },
stats: { label: 'Stats', type: 'array', defaultItemProps: { value: '100+', label: 'Stat label', description: '' }, getItemSummary: (item) => item?.label || 'Stat', arrayFields: { value: { label: 'Value', type: 'text', contentEditable: true }, label: { label: 'Label', type: 'text', contentEditable: true }, description: { label: 'Description', type: 'textarea', contentEditable: true } } },
},
render: (props) => <Stats {...props} />,
};
export default statsConfig;
+25
View File
@@ -0,0 +1,25 @@
export type StatsProps = {
heading?: string;
stats?: Array<{ value: string; label: string; description?: string }>;
};
export default function Stats({ heading = '', stats = [] }: StatsProps) {
return (
<section className="bg-background">
<div className="section-shell py-20 sm:py-24">
<div className="max-w-3xl">
{heading && <h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>}
</div>
<div className="mt-12 grid gap-x-10 gap-y-8 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<div key={index} className="py-4">
<p className="text-4xl font-normal tracking-[-0.04em] sm:text-5xl">{stat.value}</p>
<p className="mt-3 text-sm font-medium">{stat.label}</p>
{stat.description && <p className="mt-2 text-sm leading-6 text-muted-foreground">{stat.description}</p>}
</div>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,22 @@
import type { ComponentConfig } from '@reacteditor/core';
import { MessagesSquare } from 'lucide-react';
import Testimonials, { type TestimonialsProps } from './testimonials';
const testimonialsConfig: ComponentConfig<TestimonialsProps> = {
label: 'Testimonials', icon: <MessagesSquare size={16} />, category: 'social-proof',
defaultProps: {
heading: 'Share what customers say about your business.',
testimonials: [
{ quote: 'Add a short customer quote that explains the value of your work.', name: 'Customer name', role: 'Role, Company', avatarUrl: '' },
{ quote: 'Use a second testimonial to reinforce the outcome customers can expect.', name: 'Customer name', role: 'Role, Company', avatarUrl: '' },
{ quote: 'Keep each quote focused, credible, and easy for visitors to scan.', name: 'Customer name', role: 'Role, Company', avatarUrl: '' },
],
},
fields: {
heading: { label: 'Heading', type: 'textarea', contentEditable: true },
testimonials: { label: 'Testimonials', type: 'array', defaultItemProps: { quote: 'Share a customer story.', name: 'Customer name', role: 'Role, Company', avatarUrl: '' }, getItemSummary: (item) => item?.name || 'Testimonial', arrayFields: { quote: { label: 'Quote', type: 'textarea', contentEditable: true }, name: { label: 'Name', type: 'text', contentEditable: true }, role: { label: 'Role and company', type: 'text', contentEditable: true }, avatarUrl: { label: 'Avatar', type: 'image' } } },
},
render: (props) => <Testimonials {...props} />,
};
export default testimonialsConfig;
+48
View File
@@ -0,0 +1,48 @@
import { isValidElement } from 'react';
export type TestimonialsProps = {
heading?: string;
testimonials?: Array<{ quote: string; name: string; role: string; avatarUrl?: string }>;
};
export default function Testimonials({ heading = '', testimonials = [] }: TestimonialsProps) {
return (
<section id="testimonials" className="scroll-mt-20 bg-background py-20 sm:py-28">
<div className="section-shell">
<div className="max-w-2xl">
<h2 className="text-4xl font-normal leading-[0.98] tracking-[-0.04em] sm:text-5xl lg:text-6xl">{heading}</h2>
</div>
<div className="mt-12 grid gap-4 lg:grid-cols-3">
{testimonials.map((testimonial, index) => (
<figure key={index} className="flex min-h-72 flex-col pt-7">
<blockquote className="text-xl font-normal leading-8 tracking-[-0.02em]">“{testimonial.quote}”</blockquote>
<figcaption className="mt-auto flex items-center gap-3 pt-10">
{testimonial.avatarUrl ? <img src={testimonial.avatarUrl} alt="" className="size-10 rounded-full object-cover" /> : <span aria-hidden="true" className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-semibold">{getInitial(testimonial.name)}</span>}
<span><span className="block text-sm font-medium">{testimonial.name}</span><span className="block text-xs text-muted-foreground">{testimonial.role}</span></span>
</figcaption>
</figure>
))}
</div>
</div>
</section>
);
}
function getInitial(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim().charAt(0).toUpperCase() || '•';
}
if (Array.isArray(value)) {
for (const child of value) {
const initial = getInitial(child);
if (initial !== '•') return initial;
}
}
if (isValidElement<{ children?: unknown }>(value)) {
return getInitial(value.props.children);
}
return '•';
}