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
+8
View File
@@ -0,0 +1,8 @@
.next/
node_modules/
.env*
!.env.example
*.log
*.tsbuildinfo
.DS_Store
.yarn/install-state.gz
+44
View File
@@ -0,0 +1,44 @@
# React Editor Website
A clean, commerce-neutral website template built with Next.js, React Editor, shadcn-style components, Tailwind CSS v4, and `next-themes`.
## Included
- Responsive Header and Footer stored as synced global blocks
- Hero, Logo Marquee, Features, Stats, Testimonials, Pricing, Posts Carousel, FAQ, and CTA blocks
- Contact page with an accessible form and validated API endpoint
- Light, dark, and system themes
- JSON-backed page content with visual editor routes
- Per-page SEO fields
## Run locally
```bash
yarn install
yarn dev
```
Open `/editor` for the home page editor and `/contact/editor` for the contact page editor. Publishing from the editor updates the matching `page.json` and shared `app.globals.json` files when the filesystem is writable.
## Block structure
Each editable block pairs a rendering component with a React Editor configuration:
```text
components/website/pricing.tsx
components/website/pricing.config.tsx
```
Register new blocks in `editor.config.tsx` and add their prop type to `config/types.ts`.
## Contact submissions
`app/api/contact/route.ts` validates incoming data but intentionally does not choose a database, CRM, or email provider. Replace the marked storage comment with the integration required by the project using this template.
## Customize
- Global content: `app.globals.json`
- Home page: `app/page.json`
- Contact page: `app/contact/page.json`
- Site identity and canonical URL: `config/site.ts`
- Theme tokens: `app/globals.css`
+37
View File
@@ -0,0 +1,37 @@
{
"header": {
"props": {
"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"
}
},
"footer": {
"props": {
"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"
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().trim().min(2, 'Please enter your name.').max(100),
email: z.string().trim().email('Please enter a valid email address.').max(254),
company: z.string().trim().max(120).optional().default(''),
message: z.string().trim().min(10, 'Please share a little more detail.').max(5000),
website: z.string().max(0).optional().default(''),
});
export async function POST(request: Request) {
try {
const payload: unknown = await request.json();
const result = contactSchema.safeParse(payload);
if (!result.success) {
return NextResponse.json(
{ error: result.error.issues[0]?.message ?? 'Please check the form and try again.' },
{ status: 400 }
);
}
// Store the validated contact data here.
// For example: await db.contactSubmission.create({ data: result.data });
return NextResponse.json({ ok: true }, { status: 202 });
} catch {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 });
}
}
+6
View File
@@ -0,0 +1,6 @@
import PageEditor from '@/components/page-editor';
import page from '../page.json';
export default function ContactEditorPage() {
return <PageEditor pagePath="app/contact/page.json" page={page} />;
}
+29
View File
@@ -0,0 +1,29 @@
{
"root": {
"props": {
"title": "Contact — Your Company",
"description": "Tell us what you are working on and how we can help.",
"ogImage": ""
}
},
"content": [
{ "type": "header", "props": { "id": "header" }, "synced": true },
{
"type": "contact-form",
"props": {
"id": "contact-form",
"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."
}
},
{ "type": "footer", "props": { "id": "footer" }, "synced": true }
]
}
+9
View File
@@ -0,0 +1,9 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/contact' });
export default function ContactPage() {
return <PageRender page={page} />;
}
+6
View File
@@ -0,0 +1,6 @@
import PageEditor from '@/components/page-editor';
import page from '../page.json';
export default function EditorPage() {
return <PageEditor pagePath="app/page.json" page={page} />;
}
+137
View File
@@ -0,0 +1,137 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.22 0.02 245);
--card: oklch(1 0 0);
--card-foreground: oklch(0.22 0.02 245);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.02 245);
--primary: oklch(0.23 0.025 245);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.008 245);
--secondary-foreground: oklch(0.23 0.025 245);
--muted: oklch(0.97 0.007 245);
--muted-foreground: oklch(0.5 0.015 245);
--accent: oklch(0.94 0.025 250);
--accent-foreground: oklch(0.34 0.12 255);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.88 0.01 245);
--input: oklch(0.88 0.01 245);
--ring: oklch(0.56 0.2 255);
--brand: oklch(0.56 0.21 255);
--brand-soft: oklch(0.94 0.03 250);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.15 0.014 264);
--foreground: oklch(0.95 0.006 85);
--card: oklch(0.19 0.016 264);
--card-foreground: oklch(0.95 0.006 85);
--popover: oklch(0.19 0.016 264);
--popover-foreground: oklch(0.95 0.006 85);
--primary: oklch(0.92 0.012 85);
--primary-foreground: oklch(0.18 0.012 264);
--secondary: oklch(0.24 0.018 264);
--secondary-foreground: oklch(0.93 0.008 85);
--muted: oklch(0.22 0.016 264);
--muted-foreground: oklch(0.69 0.012 85);
--accent: oklch(0.27 0.035 155);
--accent-foreground: oklch(0.9 0.04 155);
--destructive: oklch(0.7 0.18 27);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.7 0.1 155);
--brand: oklch(0.7 0.16 250);
--brand-soft: oklch(0.27 0.04 250);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-soft: var(--brand-soft);
--radius-sm: calc(var(--radius) - 6px);
--radius-md: calc(var(--radius) - 3px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 6px);
--font-sans: var(--font-outfit), ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@layer base {
* {
border-color: var(--border);
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-feature-settings: 'kern' 1, 'tnum' 1;
}
h1,
h2,
h3 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.animate-marquee {
animation: marquee var(--marquee-duration, 28s) linear infinite;
}
.marquee-reverse {
animation-direction: reverse;
}
.marquee-pause:hover {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.animate-marquee { animation: none; transform: none; }
}
.section-shell {
width: min(100% - 2rem, 80rem);
margin-inline: auto;
}
@media (min-width: 768px) {
.section-shell { width: min(100% - 5rem, 80rem); }
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from 'next';
import { Outfit } from 'next/font/google';
import { ThemeProvider } from '@/components/theme-provider';
import { site } from '@/config/site';
import './globals.css';
const outfit = Outfit({
subsets: ['latin'],
variable: '--font-outfit',
});
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: { default: site.name, template: `%s — ${site.name}` },
description: site.description,
openGraph: {
type: 'website',
siteName: site.name,
title: site.name,
description: site.description,
},
twitter: {
card: 'summary_large_image',
title: site.name,
description: site.description,
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className={outfit.variable}>
<body className="min-h-screen bg-background font-sans text-foreground antialiased">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+142
View File
@@ -0,0 +1,142 @@
{
"root": {
"props": {
"title": "Your Company — A clear website headline",
"description": "A short description of your business and the value it provides.",
"ogImage": ""
}
},
"content": [
{ "type": "header", "props": { "id": "header" }, "synced": true },
{
"type": "hero",
"props": {
"id": "hero",
"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"
}
},
{
"type": "logo-marquee",
"props": {
"id": "logos",
"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"
}
},
{
"type": "features",
"props": {
"id": "features",
"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": "#" }
]
}
},
{
"type": "stats",
"props": {
"id": "stats",
"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." }
]
}
},
{
"type": "testimonials",
"props": {
"id": "testimonials",
"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": "" }
]
}
},
{
"type": "pricing",
"props": {
"id": "pricing",
"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" }
]
}
},
{
"type": "posts-carousel",
"props": {
"id": "posts-carousel",
"heading": "Explore useful articles, guides, and stories.",
"description": "",
"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" }
],
"viewAllLabel": "View all",
"viewAllUrl": "#"
}
},
{
"type": "faq",
"props": {
"id": "faq",
"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." }
]
}
},
{
"type": "cta",
"props": {
"id": "cta",
"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"
}
},
{ "type": "footer", "props": { "id": "footer" }, "synced": true }
]
}
+9
View File
@@ -0,0 +1,9 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/' });
export default function Page() {
return <PageRender page={page} />;
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+11
View File
@@ -0,0 +1,11 @@
import Link from 'next/link';
export function Logo({ name = 'Your Company', logoUrl, href = '/' }: { name?: string; logoUrl?: string; href?: string }) {
const mark = logoUrl ? (
<img src={logoUrl} alt={name} className="h-7 w-auto object-contain" />
) : (
<span className="text-base font-semibold tracking-tight">{name}</span>
);
return <Link href={href} className="inline-flex items-center">{mark}</Link>;
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useCallback, useMemo } from 'react';
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
import { appConfig } from '@/editor.config';
import { publishPage } from '@/lib/publish-page';
import globals from '@/app.globals.json';
const plugins = [outlinePlugin(), createTailwindCdnPlugin()];
export default function PageEditor({ pagePath, page }: { pagePath: string; page: Record<string, unknown> }) {
const data = useMemo(() => ({ ...page, globals }) as unknown as Data, [page]);
const handlePublish = useCallback(async (published: Data) => {
const result = await publishPage(pagePath, published);
if (result.error) throw new Error(result.error);
}, [pagePath]);
return <Editor theme="light" config={appConfig as never} data={data} plugins={plugins} onPublish={handlePublish} />;
}
+11
View File
@@ -0,0 +1,11 @@
'use client';
import { Render } from '@reacteditor/core/render';
import { appConfig } from '@/editor.config';
import globals from '@/app.globals.json';
export type PageData = { root?: unknown; content?: unknown; globals?: unknown };
export default function PageRender({ page }: { page: PageData }) {
return <Render config={appConfig as never} data={{ root: page.root, content: page.content, globals } as never} />;
}
+8
View File
@@ -0,0 +1,8 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={mounted && resolvedTheme === 'dark' ? 'Use light theme' : 'Use dark theme'}
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
{mounted && resolvedTheme === 'dark' ? <Sun aria-hidden="true" /> : <Moon aria-hidden="true" />}
</Button>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { Accordion as AccordionPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Accordion(props: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return <AccordionPrimitive.Item data-slot="accordion-item" className={cn('border-b', className)} {...props} />;
}
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn('flex flex-1 items-start justify-between gap-6 py-6 text-left text-lg font-medium outline-none transition-colors hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/50 [&[data-state=open]>svg]:rotate-180', className)}
{...props}
>
{children}
<ChevronDown className="mt-1 size-5 shrink-0 transition-transform duration-200" aria-hidden="true" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content data-slot="accordion-content" className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" {...props}>
<div className={cn('pb-6', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border bg-background/80 shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-11 rounded-lg px-6',
icon: 'size-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
);
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card" className={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm', className)} {...props} />;
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-header" className={cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-title" className={cn('font-semibold leading-none', className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-action" className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)} {...props} />;
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />;
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+81
View File
@@ -0,0 +1,81 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselContextValue = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: CarouselApi;
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
orientation: 'horizontal' | 'vertical';
};
const CarouselContext = React.createContext<CarouselContextValue | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) throw new Error('useCarousel must be used within a Carousel');
return context;
}
function Carousel({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }: React.ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical'; opts?: CarouselOptions; setApi?: (api: CarouselApi) => void; plugins?: CarouselPlugin }) {
const [carouselRef, api] = useEmblaCarousel({ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, plugins);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((carouselApi: CarouselApi) => {
if (!carouselApi) return;
setCanScrollPrev(carouselApi.canScrollPrev());
setCanScrollNext(carouselApi.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);
const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);
React.useEffect(() => { if (api && setApi) setApi(api); }, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => { api.off('reInit', onSelect); api.off('select', onSelect); };
}, [api, onSelect]);
return (
<CarouselContext.Provider value={{ carouselRef, api, scrollPrev, scrollNext, canScrollPrev, canScrollNext, orientation }}>
<div role="region" aria-roledescription="carousel" data-slot="carousel" className={cn('relative', className)} onKeyDownCapture={(event) => { if (event.key === 'ArrowLeft') { event.preventDefault(); scrollPrev(); } else if (event.key === 'ArrowRight') { event.preventDefault(); scrollNext(); } }} {...props}>{children}</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel();
return <div ref={carouselRef} className="overflow-hidden"><div data-slot="carousel-content" className={cn('flex', orientation === 'horizontal' ? '-ml-5' : '-mt-5 flex-col', className)} {...props} /></div>;
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel();
return <div role="group" aria-roledescription="slide" data-slot="carousel-item" className={cn('min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-5' : 'pt-5', className)} {...props} />;
}
function CarouselPrevious({ className, ...props }: React.ComponentProps<typeof Button>) {
const { scrollPrev, canScrollPrev } = useCarousel();
return <Button data-slot="carousel-previous" variant="outline" size="icon" className={cn('absolute left-0 top-1/2 -translate-y-1/2 rounded-full', className)} disabled={!canScrollPrev} onClick={scrollPrev} {...props}><ArrowLeft aria-hidden="true" /><span className="sr-only">Previous slide</span></Button>;
}
function CarouselNext({ className, ...props }: React.ComponentProps<typeof Button>) {
const { scrollNext, canScrollNext } = useCarousel();
return <Button data-slot="carousel-next" variant="outline" size="icon" className={cn('absolute right-0 top-1/2 -translate-y-1/2 rounded-full', className)} disabled={!canScrollNext} onClick={scrollNext} {...props}><ArrowRight aria-hidden="true" /><span className="sr-only">Next slide</span></Button>;
}
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
+12
View File
@@ -0,0 +1,12 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
className={cn('h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30', className)}
{...props}
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
className={cn('min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30', className)}
{...props}
/>
);
}
@@ -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 '•';
}
+24
View File
@@ -0,0 +1,24 @@
import type { RootConfig } from '@reacteditor/core';
export type RootProps = {
title?: string;
description?: string;
ogImage?: string;
};
export const Root: RootConfig<{ props: RootProps }> = {
global: false,
defaultProps: {
title: 'Your Company',
description: 'A clean, flexible foundation for your next website.',
ogImage: '',
},
fields: {
title: { label: 'Page title', type: 'text' },
description: { label: 'Meta description', type: 'textarea' },
ogImage: { label: 'Social share image', type: 'image' },
},
render: ({ children }) => <>{children}</>,
};
export default Root;
+9
View File
@@ -0,0 +1,9 @@
export const site = {
name: 'Your Company',
description: 'A clean, flexible foundation for your next website.',
url:
process.env.NEXT_PUBLIC_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000'),
};
+37
View File
@@ -0,0 +1,37 @@
import type { Config, Data } from '@reacteditor/core';
import type { HeaderProps } from '@/components/website/header';
import type { FooterProps } from '@/components/website/footer';
import type { HeroProps } from '@/components/website/hero';
import type { LogoMarqueeProps } from '@/components/website/logo-marquee';
import type { FeaturesProps } from '@/components/website/features';
import type { TestimonialsProps } from '@/components/website/testimonials';
import type { PricingProps } from '@/components/website/pricing';
import type { CtaProps } from '@/components/website/cta';
import type { ContactFormProps } from '@/components/website/contact-form';
import type { StatsProps } from '@/components/website/stats';
import type { FaqProps } from '@/components/website/faq';
import type { PostsCarouselProps } from '@/components/website/posts-carousel';
import type { RootProps } from './root';
export type Components = {
header: HeaderProps;
footer: FooterProps;
hero: HeroProps;
'logo-marquee': LogoMarqueeProps;
features: FeaturesProps;
testimonials: TestimonialsProps;
pricing: PricingProps;
cta: CtaProps;
'contact-form': ContactFormProps;
stats: StatsProps;
faq: FaqProps;
'posts-carousel': PostsCarouselProps;
};
export type UserConfig = Config<{
components: Components;
root: RootProps;
categories: ['navigation', 'marketing', 'social-proof', 'content', 'conversion'];
}>;
export type UserData = Data<Components, RootProps>;
+41
View File
@@ -0,0 +1,41 @@
import Root from '@/config/root';
import type { UserConfig } from '@/config/types';
import headerConfig from '@/components/website/header.config';
import footerConfig from '@/components/website/footer.config';
import heroConfig from '@/components/website/hero.config';
import logoMarqueeConfig from '@/components/website/logo-marquee.config';
import featuresConfig from '@/components/website/features.config';
import testimonialsConfig from '@/components/website/testimonials.config';
import pricingConfig from '@/components/website/pricing.config';
import ctaConfig from '@/components/website/cta.config';
import contactFormConfig from '@/components/website/contact-form.config';
import statsConfig from '@/components/website/stats.config';
import faqConfig from '@/components/website/faq.config';
import postsCarouselConfig from '@/components/website/posts-carousel.config';
export const appConfig: UserConfig = {
root: Root,
categories: {
navigation: { title: 'Navigation' },
marketing: { title: 'Marketing' },
'social-proof': { title: 'Social proof' },
content: { title: 'Content' },
conversion: { title: 'Conversion' },
},
components: {
header: headerConfig,
footer: footerConfig,
hero: heroConfig,
'logo-marquee': logoMarqueeConfig,
features: featuresConfig,
testimonials: testimonialsConfig,
pricing: pricingConfig,
cta: ctaConfig,
'contact-form': contactFormConfig,
stats: statsConfig,
faq: faqConfig,
'posts-carousel': postsCarouselConfig,
},
};
export default appConfig;
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from 'next';
import { site } from '@/config/site';
interface EditorPage {
root?: {
props?: {
title?: string;
description?: string;
ogImage?: string;
};
};
}
export function pageMetadata(
page: EditorPage,
overrides: { title?: string; description?: string; path?: string } = {}
): Metadata {
const props = page.root?.props ?? {};
const title = overrides.title ?? props.title ?? site.name;
const description = overrides.description ?? props.description ?? site.description;
const image = props.ogImage;
return {
title: { absolute: title },
description,
alternates: overrides.path ? { canonical: overrides.path } : undefined,
openGraph: {
type: 'website',
siteName: site.name,
title,
description,
url: overrides.path ?? '/',
images: image ? [{ url: image, alt: title }] : undefined,
},
twitter: {
card: 'summary_large_image',
title,
description,
images: image ? [image] : undefined,
},
};
}
+42
View File
@@ -0,0 +1,42 @@
'use server';
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
export async function publishPage(pagePath: string, page: unknown) {
if (!page || typeof page !== 'object') {
return { error: 'Expected a page object.' };
}
const appDir = path.join(process.cwd(), 'app');
const file = path.resolve(process.cwd(), pagePath);
const relativeToApp = path.relative(appDir, file);
if (
!relativeToApp ||
relativeToApp.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeToApp) ||
path.basename(file) !== 'page.json'
) {
return { error: 'Invalid page file.' };
}
const { globals, ...pageData } = page as Record<string, unknown>;
try {
await writeFile(file, `${JSON.stringify(pageData, null, 2)}\n`, 'utf8');
if (globals && typeof globals === 'object') {
await writeFile(
GLOBALS_FILE,
`${JSON.stringify(globals, null, 2)}\n`,
'utf8'
);
}
return { file: path.relative(process.cwd(), file) };
} catch (error) {
console.error(`Failed to write ${file}:`, error);
return { error: 'Could not save the page. Run the editor on a writable filesystem.' };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+15
View File
@@ -0,0 +1,15 @@
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export default {
reactStrictMode: true,
outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)),
webpack: (config, { isServer }) => {
config.cache = { type: 'memory' };
if (isServer) {
config.optimization = config.optimization || {};
config.optimization.splitChunks = false;
}
return config;
},
};
+38
View File
@@ -0,0 +1,38 @@
{
"name": "react-editor-website",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-slot": "^1.3.3",
"@reacteditor/core": "0.0.42",
"@reacteditor/plugin-tailwind-cdn": "0.0.4",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.562.0",
"next": "16.2.10",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4",
"tw-animate-css": "1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5.7.3"
},
"packageManager": "yarn@4.18.0"
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+5099
View File
File diff suppressed because it is too large Load Diff