update admin dashboard & landing page editor

This commit is contained in:
2026-03-27 04:17:11 -05:00
parent 8c6891f80d
commit 482d2d6c97
10 changed files with 2646 additions and 589 deletions

File diff suppressed because one or more lines are too long

View File

@@ -281,58 +281,77 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
*/ */
export interface LandingPage { export interface LandingPage {
id: number; id: number;
hero?: { /**
badgeEmoji?: string | null; * Add, remove, and reorder landing page sections while keeping live preview and autosave.
badgeText?: string | null; */
headingPrefix?: string | null; layout?:
headingHighlight?: string | null; | (
description?: string | null; | {
primaryCta?: { badgeEmoji?: string | null;
label?: string | null; badgeText?: string | null;
url?: string | null; headingPrefix?: string | null;
}; headingHighlight?: string | null;
highlights?: description?: string | null;
| { primaryCta?: {
label?: string | null; label?: string | null;
id?: string | null; url?: string | null;
}[] };
| null; highlights?:
}; | {
features?: { label?: string | null;
heading?: string | null; id?: string | null;
description?: string | null; }[]
items?: | null;
| { id?: string | null;
icon?: string | null; blockName?: string | null;
title?: string | null; blockType: 'hero';
description?: string | null; }
id?: string | null; | {
}[] heading?: string | null;
| null; description?: string | null;
}; items?:
techStack?: { | {
heading?: string | null; icon?: string | null;
description?: string | null; title?: string | null;
categories?: description?: string | null;
| { id?: string | null;
category?: string | null; }[]
technologies?: | null;
| { id?: string | null;
name?: string | null; blockName?: string | null;
description?: string | null; blockType: 'features';
id?: string | null; }
}[] | {
| null; heading?: string | null;
id?: string | null; description?: string | null;
}[] categories?:
| null; | {
}; category?: string | null;
cta?: { technologies?:
heading?: string | null; | {
description?: string | null; name?: string | null;
commandLabel?: string | null; description?: string | null;
command?: string | null; id?: string | null;
}; }[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'techStack';
}
| {
heading?: string | null;
description?: string | null;
commandLabel?: string | null;
command?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'cta';
}
)[]
| null;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
@@ -342,67 +361,79 @@ export interface LandingPage {
* via the `definition` "landing-page_select". * via the `definition` "landing-page_select".
*/ */
export interface LandingPageSelect<T extends boolean = true> { export interface LandingPageSelect<T extends boolean = true> {
hero?: layout?:
| T | T
| { | {
badgeEmoji?: T; hero?:
badgeText?: T;
headingPrefix?: T;
headingHighlight?: T;
description?: T;
primaryCta?:
| T | T
| { | {
label?: T; badgeEmoji?: T;
url?: T; badgeText?: T;
}; headingPrefix?: T;
highlights?: headingHighlight?: T;
| T
| {
label?: T;
id?: T;
};
};
features?:
| T
| {
heading?: T;
description?: T;
items?:
| T
| {
icon?: T;
title?: T;
description?: T; description?: T;
id?: T; primaryCta?:
};
};
techStack?:
| T
| {
heading?: T;
description?: T;
categories?:
| T
| {
category?: T;
technologies?:
| T | T
| { | {
name?: T; label?: T;
url?: T;
};
highlights?:
| T
| {
label?: T;
id?: T;
};
id?: T;
blockName?: T;
};
features?:
| T
| {
heading?: T;
description?: T;
items?:
| T
| {
icon?: T;
title?: T;
description?: T; description?: T;
id?: T; id?: T;
}; };
id?: T; id?: T;
blockName?: T;
};
techStack?:
| T
| {
heading?: T;
description?: T;
categories?:
| T
| {
category?: T;
technologies?:
| T
| {
name?: T;
description?: T;
id?: T;
};
id?: T;
};
id?: T;
blockName?: T;
};
cta?:
| T
| {
heading?: T;
description?: T;
commandLabel?: T;
command?: T;
id?: T;
blockName?: T;
}; };
};
cta?:
| T
| {
heading?: T;
description?: T;
commandLabel?: T;
command?: T;
}; };
_status?: T; _status?: T;
updatedAt?: T; updatedAt?: T;

View File

@@ -1,4 +1,4 @@
import { CTA, Features, Hero, TechStack } from '@/components/landing'; import { LandingPageBuilder } from '@/components/landing';
import { RefreshRouteOnSave } from '@/components/payload/refresh-route-on-save'; import { RefreshRouteOnSave } from '@/components/payload/refresh-route-on-save';
import { getLandingPageContent } from '@/lib/payload/get-landing-page-content'; import { getLandingPageContent } from '@/lib/payload/get-landing-page-content';
@@ -19,10 +19,7 @@ const Home = async ({ searchParams }: HomeProps) => {
return ( return (
<main className='flex min-h-screen flex-col'> <main className='flex min-h-screen flex-col'>
{isPreview ? <RefreshRouteOnSave /> : null} {isPreview ? <RefreshRouteOnSave /> : null}
<Hero content={content.hero} /> <LandingPageBuilder blocks={content.layout} />
<Features content={content.features} />
<TechStack content={content.techStack} />
<CTA content={content.cta} />
</main> </main>
); );
}; };

View File

@@ -46,150 +46,231 @@ export interface LandingCtaContent {
command: string; command: string;
} }
export interface LandingPageContent { export interface LandingBlockMetadata {
hero: LandingHeroContent; id?: string;
features: LandingFeaturesContent; blockName?: string;
techStack: LandingTechStackContent;
cta: LandingCtaContent;
} }
export const defaultLandingPageContent: LandingPageContent = { export interface LandingHeroBlock
hero: { extends LandingHeroContent, LandingBlockMetadata {
badgeEmoji: '🚀', blockType: 'hero';
badgeText: 'Production-ready monorepo template', }
headingPrefix: 'Build Full-Stack Apps with',
headingHighlight: 'convex-monorepo', export interface LandingFeaturesBlock
description: extends LandingFeaturesContent, LandingBlockMetadata {
'A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web and mobile apps faster with shared code, type-safe backend, and complete control over your infrastructure.', blockType: 'features';
primaryCta: { }
label: 'View Source Code',
url: 'https://git.gbrown.org/gib/convex-monorepo', export interface LandingTechStackBlock
extends LandingTechStackContent, LandingBlockMetadata {
blockType: 'techStack';
}
export interface LandingCtaBlock
extends LandingCtaContent, LandingBlockMetadata {
blockType: 'cta';
}
export type LandingPageBlock =
| LandingHeroBlock
| LandingFeaturesBlock
| LandingTechStackBlock
| LandingCtaBlock;
export interface LandingPageContent {
layout: LandingPageBlock[];
}
interface PayloadLandingHeroRow {
blockType: 'hero';
badgeEmoji: string;
badgeText: string;
headingPrefix: string;
headingHighlight: string;
description: string;
primaryCta: {
label: string;
url: string;
};
highlights: Array<{
label: string;
}>;
}
interface PayloadLandingFeaturesRow {
blockType: 'features';
heading: string;
description: string;
items: LandingFeatureItem[];
}
interface PayloadLandingTechStackRow {
blockType: 'techStack';
heading: string;
description: string;
categories: LandingTechCategory[];
}
interface PayloadLandingCtaRow {
blockType: 'cta';
heading: string;
description: string;
commandLabel: string;
command: string;
}
export type PayloadLandingPageRow =
| PayloadLandingHeroRow
| PayloadLandingFeaturesRow
| PayloadLandingTechStackRow
| PayloadLandingCtaRow;
export const defaultLandingHeroContent: LandingHeroContent = {
badgeEmoji: '🚀',
badgeText: 'Production-ready monorepo template',
headingPrefix: 'Build Full-Stack Apps with',
headingHighlight: 'convex-monorepo',
description:
'A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web and mobile apps faster with shared code, type-safe backend, and complete control over your infrastructure.',
primaryCta: {
label: 'View Source Code',
url: 'https://git.gbrown.org/gib/convex-monorepo',
},
highlights: ['TypeScript', 'Self-Hosted', 'Real-time', 'Auth Included'],
};
export const defaultLandingFeaturesContent: LandingFeaturesContent = {
heading: 'Everything You Need to Ship Fast',
description:
'A complete monorepo template with all the tools and patterns you need for production-ready applications.',
items: [
{
title: 'Turborepo',
description:
'Efficient build system with intelligent caching. Share code between web and mobile apps seamlessly.',
icon: '⚡',
}, },
highlights: ['TypeScript', 'Self-Hosted', 'Real-time', 'Auth Included'], {
}, title: 'Self-Hosted Convex',
features: { description:
heading: 'Everything You Need to Ship Fast', 'Complete control over your data with self-hosted Convex backend. No vendor lock-in, deploy anywhere.',
description: icon: '🏠',
'A complete monorepo template with all the tools and patterns you need for production-ready applications.', },
items: [ {
{ title: 'Next.js 16 + Expo',
title: 'Turborepo', description:
description: 'Modern Next.js 16 with App Router for web, Expo 54 for mobile. One codebase, multiple platforms.',
'Efficient build system with intelligent caching. Share code between web and mobile apps seamlessly.', icon: '📱',
icon: '⚡', },
}, {
{ title: 'Type-Safe Backend',
title: 'Self-Hosted Convex', description:
description: 'Fully type-safe queries and mutations with Convex. Auto-generated TypeScript types for the entire API.',
'Complete control over your data with self-hosted Convex backend. No vendor lock-in, deploy anywhere.', icon: '🔒',
icon: '🏠', },
}, {
{ title: 'Authentication Included',
title: 'Next.js 16 + Expo', description:
description: 'OAuth with Authentik + custom password auth with email verification. Production-ready auth out of the box.',
'Modern Next.js 16 with App Router for web, Expo 54 for mobile. One codebase, multiple platforms.', icon: '🔐',
icon: '📱', },
}, {
{ title: 'Real-time Updates',
title: 'Type-Safe Backend', description:
description: 'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.',
'Fully type-safe queries and mutations with Convex. Auto-generated TypeScript types for the entire API.', icon: '⚡',
icon: '🔒', },
}, {
{ title: 'shadcn/ui Components',
title: 'Authentication Included', description:
description: 'Beautiful, accessible components from shadcn/ui. Customizable with Tailwind CSS v4.',
'OAuth with Authentik + custom password auth with email verification. Production-ready auth out of the box.', icon: '🎨',
icon: '🔐', },
}, {
{ title: 'Docker Ready',
title: 'Real-time Updates', description:
description: 'Production Docker setup included. Deploy to any server with docker-compose up.',
'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.', icon: '🐳',
icon: '⚡', },
}, {
{ title: 'Developer Experience',
title: 'shadcn/ui Components', description:
description: 'Hot reload, TypeScript strict mode, ESLint, Prettier, and Bun for blazing fast installs.',
'Beautiful, accessible components from shadcn/ui. Customizable with Tailwind CSS v4.', icon: '⚙️',
icon: '🎨', },
}, ],
{ };
title: 'Docker Ready',
description: export const defaultLandingTechStackContent: LandingTechStackContent = {
'Production Docker setup included. Deploy to any server with docker-compose up.', heading: 'Modern Tech Stack',
icon: '🐳', description:
}, 'Built with the latest and greatest tools for maximum productivity and performance.',
{ categories: [
title: 'Developer Experience', {
description: category: 'Frontend',
'Hot reload, TypeScript strict mode, ESLint, Prettier, and Bun for blazing fast installs.', technologies: [
icon: '⚙️', {
}, name: 'Next.js 16',
], description: 'React framework with App Router',
}, },
techStack: { { name: 'Expo 54', description: 'React Native framework' },
heading: 'Modern Tech Stack', {
description: name: 'React 19',
'Built with the latest and greatest tools for maximum productivity and performance.', description: 'Latest React with Server Components',
categories: [ },
{ {
category: 'Frontend', name: 'Tailwind CSS v4',
technologies: [ description: 'Utility-first CSS framework',
{ },
name: 'Next.js 16', { name: 'shadcn/ui', description: 'Beautiful component library' },
description: 'React framework with App Router', ],
}, },
{ name: 'Expo 54', description: 'React Native framework' }, {
{ category: 'Backend',
name: 'React 19', technologies: [
description: 'Latest React with Server Components', { name: 'Convex', description: 'Self-hosted reactive backend' },
}, {
{ name: '@convex-dev/auth',
name: 'Tailwind CSS v4', description: 'Multi-provider authentication',
description: 'Utility-first CSS framework', },
}, { name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'shadcn/ui', description: 'Beautiful component library' }, { name: 'File Storage', description: 'Built-in file uploads' },
], ],
}, },
{ {
category: 'Backend', category: 'Developer Tools',
technologies: [ technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' }, { name: 'Turborepo', description: 'High-performance build system' },
{ { name: 'TypeScript', description: 'Type-safe development' },
name: '@convex-dev/auth', { name: 'Bun', description: 'Fast package manager & runtime' },
description: 'Multi-provider authentication', { name: 'ESLint + Prettier', description: 'Code quality tools' },
}, { name: 'Docker', description: 'Containerized deployment' },
{ name: 'UseSend', description: 'Self-hosted email service' }, ],
{ name: 'File Storage', description: 'Built-in file uploads' }, },
], ],
}, };
{
category: 'Developer Tools', export const defaultLandingCtaContent: LandingCtaContent = {
technologies: [ heading: 'Ready to Build Something Amazing?',
{ name: 'Turborepo', description: 'High-performance build system' }, description:
{ name: 'TypeScript', description: 'Type-safe development' }, 'Clone the repository and start building your next project with everything pre-configured.',
{ name: 'Bun', description: 'Fast package manager & runtime' }, commandLabel: 'Quick Start',
{ name: 'ESLint + Prettier', description: 'Code quality tools' }, command:
{ name: 'Docker', description: 'Containerized deployment' }, 'git clone https://git.gbrown.org/gib/convex-monorepo.git\ncd convex-monorepo\nbun i',
], };
},
], const isRecord = (value: unknown): value is Record<string, unknown> => {
}, return typeof value === 'object' && value !== null;
cta: {
heading: 'Ready to Build Something Amazing?',
description:
'Clone the repository and start building your next project with everything pre-configured.',
commandLabel: 'Quick Start',
command:
'git clone https://git.gbrown.org/gib/convex-monorepo.git\ncd convex-monorepo\nbun i',
},
}; };
const sanitizeString = (value: unknown, fallback: string) => { const sanitizeString = (value: unknown, fallback: string) => {
return typeof value === 'string' && value.length > 0 ? value : fallback; return typeof value === 'string' && value.length > 0 ? value : fallback;
}; };
const sanitizeOptionalString = (value: unknown) => {
return typeof value === 'string' && value.length > 0 ? value : undefined;
};
const sanitizeStringArray = (value: unknown, fallback: string[]) => { const sanitizeStringArray = (value: unknown, fallback: string[]) => {
if (!Array.isArray(value)) return fallback; if (!Array.isArray(value)) return fallback;
@@ -197,9 +278,7 @@ const sanitizeStringArray = (value: unknown, fallback: string[]) => {
.map((item) => { .map((item) => {
if (typeof item === 'string' && item.length > 0) return item; if (typeof item === 'string' && item.length > 0) return item;
if ( if (
typeof item === 'object' && isRecord(item) &&
item !== null &&
'label' in item &&
typeof item.label === 'string' && typeof item.label === 'string' &&
item.label.length > 0 item.label.length > 0
) { ) {
@@ -213,154 +292,283 @@ const sanitizeStringArray = (value: unknown, fallback: string[]) => {
return items.length > 0 ? items : fallback; return items.length > 0 ? items : fallback;
}; };
export const mergeLandingPageContent = ( const sanitizeBlockMetadata = (value: unknown): LandingBlockMetadata => {
value: Partial<LandingPageContent> | null | undefined, if (!isRecord(value)) return {};
): LandingPageContent => {
const defaultFeatureFallback = {
title: '',
description: '',
icon: '',
};
const defaultTechCategoryFallback = {
category: '',
technologies: [{ name: '', description: '' }],
};
const defaultTechItemFallback = { name: '', description: '' };
const heroValue = value?.hero;
const featuresValue = value?.features;
const techStackValue = value?.techStack;
const ctaValue = value?.cta;
return { return {
hero: { id: sanitizeOptionalString(value.id),
badgeEmoji: sanitizeString( blockName: sanitizeOptionalString(value.blockName),
heroValue?.badgeEmoji, };
defaultLandingPageContent.hero.badgeEmoji, };
),
badgeText: sanitizeString( const sanitizeFeatureItems = (
heroValue?.badgeText, value: unknown,
defaultLandingPageContent.hero.badgeText, fallback: LandingFeatureItem[],
), ): LandingFeatureItem[] => {
headingPrefix: sanitizeString( if (!Array.isArray(value)) return fallback;
heroValue?.headingPrefix,
defaultLandingPageContent.hero.headingPrefix, const items = value
), .map((item, index) => {
headingHighlight: sanitizeString( if (!isRecord(item)) return null;
heroValue?.headingHighlight,
defaultLandingPageContent.hero.headingHighlight, const fallbackItem = fallback[index] ?? {
), title: '',
description: sanitizeString( description: '',
heroValue?.description, icon: '',
defaultLandingPageContent.hero.description, };
),
primaryCta: { return {
label: sanitizeString( title: sanitizeString(item.title, fallbackItem.title),
heroValue?.primaryCta?.label, description: sanitizeString(item.description, fallbackItem.description),
defaultLandingPageContent.hero.primaryCta.label, icon: sanitizeString(item.icon, fallbackItem.icon),
), };
url: sanitizeString( })
heroValue?.primaryCta?.url, .filter((item): item is LandingFeatureItem => item !== null);
defaultLandingPageContent.hero.primaryCta.url,
), return items.length > 0 ? items : fallback;
}, };
highlights: sanitizeStringArray(
heroValue?.highlights, const sanitizeTechCategories = (
defaultLandingPageContent.hero.highlights, value: unknown,
), fallback: LandingTechCategory[],
}, ): LandingTechCategory[] => {
features: { if (!Array.isArray(value)) return fallback;
heading: sanitizeString(
featuresValue?.heading, const categories = value
defaultLandingPageContent.features.heading, .map((category, categoryIndex) => {
), if (!isRecord(category)) return null;
description: sanitizeString(
featuresValue?.description, const fallbackCategory = fallback[categoryIndex] ?? {
defaultLandingPageContent.features.description, category: '',
), technologies: [{ name: '', description: '' }],
items: };
Array.isArray(featuresValue?.items) && featuresValue.items.length > 0
? featuresValue.items.map((item, index) => ({ const technologies = Array.isArray(category.technologies)
title: sanitizeString( ? category.technologies
item?.title, .map((technology, technologyIndex) => {
defaultLandingPageContent.features.items[index]?.title ?? if (!isRecord(technology)) return null;
defaultFeatureFallback.title,
), const fallbackTechnology = fallbackCategory.technologies[
description: sanitizeString( technologyIndex
item?.description, ] ?? {
defaultLandingPageContent.features.items[index]?.description ?? name: '',
defaultFeatureFallback.description, description: '',
), };
icon: sanitizeString(
item?.icon, return {
defaultLandingPageContent.features.items[index]?.icon ?? name: sanitizeString(technology.name, fallbackTechnology.name),
defaultFeatureFallback.icon, description: sanitizeString(
), technology.description,
})) fallbackTechnology.description,
: defaultLandingPageContent.features.items, ),
}, };
techStack: { })
heading: sanitizeString( .filter((item): item is LandingTechItem => item !== null)
techStackValue?.heading, : fallbackCategory.technologies;
defaultLandingPageContent.techStack.heading,
), return {
description: sanitizeString( category: sanitizeString(category.category, fallbackCategory.category),
techStackValue?.description, technologies:
defaultLandingPageContent.techStack.description, technologies.length > 0
), ? technologies
categories: : fallbackCategory.technologies,
Array.isArray(techStackValue?.categories) && };
techStackValue.categories.length > 0 })
? techStackValue.categories.map((category, categoryIndex) => ({ .filter((item): item is LandingTechCategory => item !== null);
category: sanitizeString(
category?.category, return categories.length > 0 ? categories : fallback;
defaultLandingPageContent.techStack.categories[categoryIndex] };
?.category ?? defaultTechCategoryFallback.category,
), const sanitizeHeroBlock = (value: unknown): LandingHeroBlock => {
technologies: const metadata = sanitizeBlockMetadata(value);
Array.isArray(category?.technologies) && const source = isRecord(value) ? value : {};
category.technologies.length > 0
? category.technologies.map( return {
(technology, technologyIndex) => ({ blockType: 'hero',
name: sanitizeString( ...metadata,
technology?.name, badgeEmoji: sanitizeString(
defaultLandingPageContent.techStack.categories[ source.badgeEmoji,
categoryIndex defaultLandingHeroContent.badgeEmoji,
]?.technologies[technologyIndex]?.name ?? ),
defaultTechItemFallback.name, badgeText: sanitizeString(
), source.badgeText,
description: sanitizeString( defaultLandingHeroContent.badgeText,
technology?.description, ),
defaultLandingPageContent.techStack.categories[ headingPrefix: sanitizeString(
categoryIndex source.headingPrefix,
]?.technologies[technologyIndex]?.description ?? defaultLandingHeroContent.headingPrefix,
defaultTechItemFallback.description, ),
), headingHighlight: sanitizeString(
}), source.headingHighlight,
) defaultLandingHeroContent.headingHighlight,
: (defaultLandingPageContent.techStack.categories[ ),
categoryIndex description: sanitizeString(
]?.technologies ?? source.description,
defaultTechCategoryFallback.technologies), defaultLandingHeroContent.description,
})) ),
: defaultLandingPageContent.techStack.categories, primaryCta: {
}, label: sanitizeString(
cta: { isRecord(source.primaryCta) ? source.primaryCta.label : undefined,
heading: sanitizeString( defaultLandingHeroContent.primaryCta.label,
ctaValue?.heading, ),
defaultLandingPageContent.cta.heading, url: sanitizeString(
), isRecord(source.primaryCta) ? source.primaryCta.url : undefined,
description: sanitizeString( defaultLandingHeroContent.primaryCta.url,
ctaValue?.description, ),
defaultLandingPageContent.cta.description, },
), highlights: sanitizeStringArray(
commandLabel: sanitizeString( source.highlights,
ctaValue?.commandLabel, defaultLandingHeroContent.highlights,
defaultLandingPageContent.cta.commandLabel, ),
), };
command: sanitizeString( };
ctaValue?.command,
defaultLandingPageContent.cta.command, const sanitizeFeaturesBlock = (value: unknown): LandingFeaturesBlock => {
), const metadata = sanitizeBlockMetadata(value);
}, const source = isRecord(value) ? value : {};
return {
blockType: 'features',
...metadata,
heading: sanitizeString(
source.heading,
defaultLandingFeaturesContent.heading,
),
description: sanitizeString(
source.description,
defaultLandingFeaturesContent.description,
),
items: sanitizeFeatureItems(
source.items,
defaultLandingFeaturesContent.items,
),
};
};
const sanitizeTechStackBlock = (value: unknown): LandingTechStackBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'techStack',
...metadata,
heading: sanitizeString(
source.heading,
defaultLandingTechStackContent.heading,
),
description: sanitizeString(
source.description,
defaultLandingTechStackContent.description,
),
categories: sanitizeTechCategories(
source.categories,
defaultLandingTechStackContent.categories,
),
};
};
const sanitizeCtaBlock = (value: unknown): LandingCtaBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'cta',
...metadata,
heading: sanitizeString(source.heading, defaultLandingCtaContent.heading),
description: sanitizeString(
source.description,
defaultLandingCtaContent.description,
),
commandLabel: sanitizeString(
source.commandLabel,
defaultLandingCtaContent.commandLabel,
),
command: sanitizeString(source.command, defaultLandingCtaContent.command),
};
};
const sanitizeLayout = (value: unknown): LandingPageBlock[] => {
if (!Array.isArray(value)) return [];
return value
.map((block) => {
if (!isRecord(block) || typeof block.blockType !== 'string') return null;
switch (block.blockType) {
case 'hero': {
return sanitizeHeroBlock(block);
}
case 'features': {
return sanitizeFeaturesBlock(block);
}
case 'techStack': {
return sanitizeTechStackBlock(block);
}
case 'cta': {
return sanitizeCtaBlock(block);
}
default: {
return null;
}
}
})
.filter((block): block is LandingPageBlock => block !== null);
};
const buildLegacyLandingPageLayout = (value: unknown): LandingPageBlock[] => {
const source = isRecord(value) ? value : {};
return [
sanitizeHeroBlock(source.hero),
sanitizeFeaturesBlock(source.features),
sanitizeTechStackBlock(source.techStack),
sanitizeCtaBlock(source.cta),
];
};
export const createDefaultLandingPageLayout = (): LandingPageBlock[] => {
return buildLegacyLandingPageLayout({
hero: defaultLandingHeroContent,
features: defaultLandingFeaturesContent,
techStack: defaultLandingTechStackContent,
cta: defaultLandingCtaContent,
});
};
export const createDefaultLandingPageLayoutForPayload =
(): PayloadLandingPageRow[] => {
return [
{
blockType: 'hero',
...defaultLandingHeroContent,
highlights: defaultLandingHeroContent.highlights.map((label) => ({
label,
})),
},
{
blockType: 'features',
...defaultLandingFeaturesContent,
},
{
blockType: 'techStack',
...defaultLandingTechStackContent,
},
{
blockType: 'cta',
...defaultLandingCtaContent,
},
];
};
export const defaultLandingPageContent: LandingPageContent = {
layout: createDefaultLandingPageLayout(),
};
export const mergeLandingPageContent = (value: unknown): LandingPageContent => {
const source = isRecord(value) ? value : {};
const layout = sanitizeLayout(source.layout);
return {
layout: layout.length > 0 ? layout : buildLegacyLandingPageLayout(source),
}; };
}; };

View File

@@ -2,3 +2,4 @@ export { Hero } from './hero';
export { Features } from './features'; export { Features } from './features';
export { TechStack } from './tech-stack'; export { TechStack } from './tech-stack';
export { CTA } from './cta'; export { CTA } from './cta';
export { LandingPageBuilder } from './page-builder';

View File

@@ -0,0 +1,33 @@
import type { LandingPageBlock } from './content';
import { CTA } from './cta';
import { Features } from './features';
import { Hero } from './hero';
import { TechStack } from './tech-stack';
interface LandingPageBuilderProps {
blocks: LandingPageBlock[];
}
export const LandingPageBuilder = ({ blocks }: LandingPageBuilderProps) => {
return blocks.map((block, index) => {
const key = block.id ?? `${block.blockType}-${index}`;
switch (block.blockType) {
case 'hero': {
return <Hero key={key} content={block} />;
}
case 'features': {
return <Features key={key} content={block} />;
}
case 'techStack': {
return <TechStack key={key} content={block} />;
}
case 'cta': {
return <CTA key={key} content={block} />;
}
default: {
return null;
}
}
});
};

View File

@@ -1,9 +1,6 @@
import type { LandingPageContent } from '@/components/landing/content'; import type { LandingPageContent } from '@/components/landing/content';
import { cache } from 'react'; import { cache } from 'react';
import { import { mergeLandingPageContent } from '@/components/landing/content';
defaultLandingPageContent,
mergeLandingPageContent,
} from '@/components/landing/content';
import { getPayloadClient } from './get-payload'; import { getPayloadClient } from './get-payload';
@@ -19,9 +16,6 @@ export const getLandingPageContent = cache(
} }
).findGlobal({ slug: 'landing-page', draft: isPreview }); ).findGlobal({ slug: 'landing-page', draft: isPreview });
return mergeLandingPageContent( return mergeLandingPageContent(landingPage as Partial<LandingPageContent>);
(landingPage as Partial<LandingPageContent> | null | undefined) ??
defaultLandingPageContent,
);
}, },
); );

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
import type { Block } from 'payload';
import {
defaultLandingCtaContent,
defaultLandingFeaturesContent,
defaultLandingHeroContent,
defaultLandingTechStackContent,
} from '../../components/landing/content';
export const landingPageBlocks: Block[] = [
{
slug: 'hero',
labels: {
singular: 'Hero Section',
plural: 'Hero Sections',
},
fields: [
{
name: 'badgeEmoji',
type: 'text',
defaultValue: defaultLandingHeroContent.badgeEmoji,
},
{
name: 'badgeText',
type: 'text',
defaultValue: defaultLandingHeroContent.badgeText,
},
{
name: 'headingPrefix',
type: 'text',
defaultValue: defaultLandingHeroContent.headingPrefix,
},
{
name: 'headingHighlight',
type: 'text',
defaultValue: defaultLandingHeroContent.headingHighlight,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingHeroContent.description,
},
{
name: 'primaryCta',
label: 'Primary CTA',
type: 'group',
fields: [
{
name: 'label',
type: 'text',
defaultValue: defaultLandingHeroContent.primaryCta.label,
},
{
name: 'url',
type: 'text',
defaultValue: defaultLandingHeroContent.primaryCta.url,
},
],
},
{
name: 'highlights',
type: 'array',
defaultValue: defaultLandingHeroContent.highlights.map((label) => ({
label,
})),
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
{
slug: 'features',
labels: {
singular: 'Features Section',
plural: 'Features Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingFeaturesContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingFeaturesContent.description,
},
{
name: 'items',
type: 'array',
defaultValue: defaultLandingFeaturesContent.items,
fields: [
{
name: 'icon',
type: 'text',
},
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
],
},
],
},
{
slug: 'techStack',
labels: {
singular: 'Tech Stack Section',
plural: 'Tech Stack Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingTechStackContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingTechStackContent.description,
},
{
name: 'categories',
type: 'array',
defaultValue: defaultLandingTechStackContent.categories,
fields: [
{
name: 'category',
type: 'text',
},
{
name: 'technologies',
type: 'array',
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'description',
type: 'text',
},
],
},
],
},
],
},
{
slug: 'cta',
labels: {
singular: 'CTA Section',
plural: 'CTA Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingCtaContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingCtaContent.description,
},
{
name: 'commandLabel',
type: 'text',
defaultValue: defaultLandingCtaContent.commandLabel,
},
{
name: 'command',
type: 'textarea',
defaultValue: defaultLandingCtaContent.command,
},
],
},
];

View File

@@ -1,6 +1,7 @@
import type { GlobalConfig } from 'payload'; import type { GlobalConfig } from 'payload';
import { defaultLandingPageContent } from '../../components/landing/content'; import { createDefaultLandingPageLayoutForPayload } from '../../components/landing/content';
import { landingPageBlocks } from './landing-page-blocks';
export const LandingPage: GlobalConfig = { export const LandingPage: GlobalConfig = {
slug: 'landing-page', slug: 'landing-page',
@@ -42,194 +43,17 @@ export const LandingPage: GlobalConfig = {
}, },
fields: [ fields: [
{ {
type: 'tabs', name: 'layout',
tabs: [ label: 'Layout Builder',
{ type: 'blocks',
label: 'Hero', minRows: 1,
fields: [ defaultValue: () => createDefaultLandingPageLayoutForPayload(),
{ blocks: landingPageBlocks,
name: 'hero', admin: {
type: 'group', description:
fields: [ 'Add, remove, and reorder landing page sections while keeping live preview and autosave.',
{ initCollapsed: true,
name: 'badgeEmoji', },
type: 'text',
defaultValue: defaultLandingPageContent.hero.badgeEmoji,
},
{
name: 'badgeText',
type: 'text',
defaultValue: defaultLandingPageContent.hero.badgeText,
},
{
name: 'headingPrefix',
type: 'text',
defaultValue: defaultLandingPageContent.hero.headingPrefix,
},
{
name: 'headingHighlight',
type: 'text',
defaultValue: defaultLandingPageContent.hero.headingHighlight,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingPageContent.hero.description,
},
{
name: 'primaryCta',
label: 'Primary CTA',
type: 'group',
fields: [
{
name: 'label',
type: 'text',
defaultValue:
defaultLandingPageContent.hero.primaryCta.label,
},
{
name: 'url',
type: 'text',
defaultValue:
defaultLandingPageContent.hero.primaryCta.url,
},
],
},
{
name: 'highlights',
type: 'array',
defaultValue: defaultLandingPageContent.hero.highlights.map(
(label) => ({ label }),
),
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
{
label: 'Features',
fields: [
{
name: 'features',
type: 'group',
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingPageContent.features.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingPageContent.features.description,
},
{
name: 'items',
type: 'array',
defaultValue: defaultLandingPageContent.features.items,
fields: [
{
name: 'icon',
type: 'text',
},
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
],
},
],
},
],
},
{
label: 'Tech Stack',
fields: [
{
name: 'techStack',
type: 'group',
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingPageContent.techStack.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingPageContent.techStack.description,
},
{
name: 'categories',
type: 'array',
defaultValue: defaultLandingPageContent.techStack.categories,
fields: [
{
name: 'category',
type: 'text',
},
{
name: 'technologies',
type: 'array',
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'description',
type: 'text',
},
],
},
],
},
],
},
],
},
{
label: 'CTA',
fields: [
{
name: 'cta',
type: 'group',
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingPageContent.cta.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingPageContent.cta.description,
},
{
name: 'commandLabel',
type: 'text',
defaultValue: defaultLandingPageContent.cta.commandLabel,
},
{
name: 'command',
type: 'textarea',
defaultValue: defaultLandingPageContent.cta.command,
},
],
},
],
},
],
}, },
], ],
}; };