Template
Initial react editor website template
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.next/
|
||||
node_modules/
|
||||
.env*
|
||||
!.env.example
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.yarn/install-state.gz
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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
@@ -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); }
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 '•';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user