Template
Initial react editor website template
This commit is contained in:
@@ -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 '•';
|
||||
}
|
||||
Reference in New Issue
Block a user