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 {
id: number;
hero?: {
badgeEmoji?: string | null;
badgeText?: string | null;
headingPrefix?: string | null;
headingHighlight?: string | null;
description?: string | null;
primaryCta?: {
label?: string | null;
url?: string | null;
};
highlights?:
| {
label?: string | null;
id?: string | null;
}[]
| null;
};
features?: {
heading?: string | null;
description?: string | null;
items?:
| {
icon?: string | null;
title?: string | null;
description?: string | null;
id?: string | null;
}[]
| null;
};
techStack?: {
heading?: string | null;
description?: string | null;
categories?:
| {
category?: string | null;
technologies?:
| {
name?: string | null;
description?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
};
cta?: {
heading?: string | null;
description?: string | null;
commandLabel?: string | null;
command?: string | null;
};
/**
* Add, remove, and reorder landing page sections while keeping live preview and autosave.
*/
layout?:
| (
| {
badgeEmoji?: string | null;
badgeText?: string | null;
headingPrefix?: string | null;
headingHighlight?: string | null;
description?: string | null;
primaryCta?: {
label?: string | null;
url?: string | null;
};
highlights?:
| {
label?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'hero';
}
| {
heading?: string | null;
description?: string | null;
items?:
| {
icon?: string | null;
title?: string | null;
description?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'features';
}
| {
heading?: string | null;
description?: string | null;
categories?:
| {
category?: string | null;
technologies?:
| {
name?: string | null;
description?: 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;
updatedAt?: string | null;
createdAt?: string | null;
@@ -342,67 +361,79 @@ export interface LandingPage {
* via the `definition` "landing-page_select".
*/
export interface LandingPageSelect<T extends boolean = true> {
hero?:
layout?:
| T
| {
badgeEmoji?: T;
badgeText?: T;
headingPrefix?: T;
headingHighlight?: T;
description?: T;
primaryCta?:
hero?:
| T
| {
label?: T;
url?: T;
};
highlights?:
| T
| {
label?: T;
id?: T;
};
};
features?:
| T
| {
heading?: T;
description?: T;
items?:
| T
| {
icon?: T;
title?: T;
badgeEmoji?: T;
badgeText?: T;
headingPrefix?: T;
headingHighlight?: T;
description?: T;
id?: T;
};
};
techStack?:
| T
| {
heading?: T;
description?: T;
categories?:
| T
| {
category?: T;
technologies?:
primaryCta?:
| 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;
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;
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 { getLandingPageContent } from '@/lib/payload/get-landing-page-content';
@@ -19,10 +19,7 @@ const Home = async ({ searchParams }: HomeProps) => {
return (
<main className='flex min-h-screen flex-col'>
{isPreview ? <RefreshRouteOnSave /> : null}
<Hero content={content.hero} />
<Features content={content.features} />
<TechStack content={content.techStack} />
<CTA content={content.cta} />
<LandingPageBuilder blocks={content.layout} />
</main>
);
};

View File

@@ -46,150 +46,231 @@ export interface LandingCtaContent {
command: string;
}
export interface LandingPageContent {
hero: LandingHeroContent;
features: LandingFeaturesContent;
techStack: LandingTechStackContent;
cta: LandingCtaContent;
export interface LandingBlockMetadata {
id?: string;
blockName?: string;
}
export const defaultLandingPageContent: LandingPageContent = {
hero: {
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',
export interface LandingHeroBlock
extends LandingHeroContent, LandingBlockMetadata {
blockType: 'hero';
}
export interface LandingFeaturesBlock
extends LandingFeaturesContent, LandingBlockMetadata {
blockType: 'features';
}
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'],
},
features: {
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: '⚡',
},
{
title: 'Self-Hosted Convex',
description:
'Complete control over your data with self-hosted Convex backend. No vendor lock-in, deploy anywhere.',
icon: '🏠',
},
{
title: 'Next.js 16 + Expo',
description:
'Modern Next.js 16 with App Router for web, Expo 54 for mobile. One codebase, multiple platforms.',
icon: '📱',
},
{
title: 'Type-Safe Backend',
description:
'Fully type-safe queries and mutations with Convex. Auto-generated TypeScript types for the entire API.',
icon: '🔒',
},
{
title: 'Authentication Included',
description:
'OAuth with Authentik + custom password auth with email verification. Production-ready auth out of the box.',
icon: '🔐',
},
{
title: 'Real-time Updates',
description:
'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.',
icon: '⚡',
},
{
title: 'shadcn/ui Components',
description:
'Beautiful, accessible components from shadcn/ui. Customizable with Tailwind CSS v4.',
icon: '🎨',
},
{
title: 'Docker Ready',
description:
'Production Docker setup included. Deploy to any server with docker-compose up.',
icon: '🐳',
},
{
title: 'Developer Experience',
description:
'Hot reload, TypeScript strict mode, ESLint, Prettier, and Bun for blazing fast installs.',
icon: '⚙️',
},
],
},
techStack: {
heading: 'Modern Tech Stack',
description:
'Built with the latest and greatest tools for maximum productivity and performance.',
categories: [
{
category: 'Frontend',
technologies: [
{
name: 'Next.js 16',
description: 'React framework with App Router',
},
{ name: 'Expo 54', description: 'React Native framework' },
{
name: 'React 19',
description: 'Latest React with Server Components',
},
{
name: 'Tailwind CSS v4',
description: 'Utility-first CSS framework',
},
{ name: 'shadcn/ui', description: 'Beautiful component library' },
],
},
{
category: 'Backend',
technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' },
{
name: '@convex-dev/auth',
description: 'Multi-provider authentication',
},
{ name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' },
],
},
{
category: 'Developer Tools',
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager & runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
},
],
},
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',
},
{
title: 'Self-Hosted Convex',
description:
'Complete control over your data with self-hosted Convex backend. No vendor lock-in, deploy anywhere.',
icon: '🏠',
},
{
title: 'Next.js 16 + Expo',
description:
'Modern Next.js 16 with App Router for web, Expo 54 for mobile. One codebase, multiple platforms.',
icon: '📱',
},
{
title: 'Type-Safe Backend',
description:
'Fully type-safe queries and mutations with Convex. Auto-generated TypeScript types for the entire API.',
icon: '🔒',
},
{
title: 'Authentication Included',
description:
'OAuth with Authentik + custom password auth with email verification. Production-ready auth out of the box.',
icon: '🔐',
},
{
title: 'Real-time Updates',
description:
'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.',
icon: '⚡',
},
{
title: 'shadcn/ui Components',
description:
'Beautiful, accessible components from shadcn/ui. Customizable with Tailwind CSS v4.',
icon: '🎨',
},
{
title: 'Docker Ready',
description:
'Production Docker setup included. Deploy to any server with docker-compose up.',
icon: '🐳',
},
{
title: 'Developer Experience',
description:
'Hot reload, TypeScript strict mode, ESLint, Prettier, and Bun for blazing fast installs.',
icon: '⚙️',
},
],
};
export const defaultLandingTechStackContent: LandingTechStackContent = {
heading: 'Modern Tech Stack',
description:
'Built with the latest and greatest tools for maximum productivity and performance.',
categories: [
{
category: 'Frontend',
technologies: [
{
name: 'Next.js 16',
description: 'React framework with App Router',
},
{ name: 'Expo 54', description: 'React Native framework' },
{
name: 'React 19',
description: 'Latest React with Server Components',
},
{
name: 'Tailwind CSS v4',
description: 'Utility-first CSS framework',
},
{ name: 'shadcn/ui', description: 'Beautiful component library' },
],
},
{
category: 'Backend',
technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' },
{
name: '@convex-dev/auth',
description: 'Multi-provider authentication',
},
{ name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' },
],
},
{
category: 'Developer Tools',
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager & runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
},
],
};
export const defaultLandingCtaContent: LandingCtaContent = {
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 isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const sanitizeString = (value: unknown, fallback: string) => {
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[]) => {
if (!Array.isArray(value)) return fallback;
@@ -197,9 +278,7 @@ const sanitizeStringArray = (value: unknown, fallback: string[]) => {
.map((item) => {
if (typeof item === 'string' && item.length > 0) return item;
if (
typeof item === 'object' &&
item !== null &&
'label' in item &&
isRecord(item) &&
typeof item.label === 'string' &&
item.label.length > 0
) {
@@ -213,154 +292,283 @@ const sanitizeStringArray = (value: unknown, fallback: string[]) => {
return items.length > 0 ? items : fallback;
};
export const mergeLandingPageContent = (
value: Partial<LandingPageContent> | null | undefined,
): 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;
const sanitizeBlockMetadata = (value: unknown): LandingBlockMetadata => {
if (!isRecord(value)) return {};
return {
hero: {
badgeEmoji: sanitizeString(
heroValue?.badgeEmoji,
defaultLandingPageContent.hero.badgeEmoji,
),
badgeText: sanitizeString(
heroValue?.badgeText,
defaultLandingPageContent.hero.badgeText,
),
headingPrefix: sanitizeString(
heroValue?.headingPrefix,
defaultLandingPageContent.hero.headingPrefix,
),
headingHighlight: sanitizeString(
heroValue?.headingHighlight,
defaultLandingPageContent.hero.headingHighlight,
),
description: sanitizeString(
heroValue?.description,
defaultLandingPageContent.hero.description,
),
primaryCta: {
label: sanitizeString(
heroValue?.primaryCta?.label,
defaultLandingPageContent.hero.primaryCta.label,
),
url: sanitizeString(
heroValue?.primaryCta?.url,
defaultLandingPageContent.hero.primaryCta.url,
),
},
highlights: sanitizeStringArray(
heroValue?.highlights,
defaultLandingPageContent.hero.highlights,
),
},
features: {
heading: sanitizeString(
featuresValue?.heading,
defaultLandingPageContent.features.heading,
),
description: sanitizeString(
featuresValue?.description,
defaultLandingPageContent.features.description,
),
items:
Array.isArray(featuresValue?.items) && featuresValue.items.length > 0
? featuresValue.items.map((item, index) => ({
title: sanitizeString(
item?.title,
defaultLandingPageContent.features.items[index]?.title ??
defaultFeatureFallback.title,
),
description: sanitizeString(
item?.description,
defaultLandingPageContent.features.items[index]?.description ??
defaultFeatureFallback.description,
),
icon: sanitizeString(
item?.icon,
defaultLandingPageContent.features.items[index]?.icon ??
defaultFeatureFallback.icon,
),
}))
: defaultLandingPageContent.features.items,
},
techStack: {
heading: sanitizeString(
techStackValue?.heading,
defaultLandingPageContent.techStack.heading,
),
description: sanitizeString(
techStackValue?.description,
defaultLandingPageContent.techStack.description,
),
categories:
Array.isArray(techStackValue?.categories) &&
techStackValue.categories.length > 0
? techStackValue.categories.map((category, categoryIndex) => ({
category: sanitizeString(
category?.category,
defaultLandingPageContent.techStack.categories[categoryIndex]
?.category ?? defaultTechCategoryFallback.category,
),
technologies:
Array.isArray(category?.technologies) &&
category.technologies.length > 0
? category.technologies.map(
(technology, technologyIndex) => ({
name: sanitizeString(
technology?.name,
defaultLandingPageContent.techStack.categories[
categoryIndex
]?.technologies[technologyIndex]?.name ??
defaultTechItemFallback.name,
),
description: sanitizeString(
technology?.description,
defaultLandingPageContent.techStack.categories[
categoryIndex
]?.technologies[technologyIndex]?.description ??
defaultTechItemFallback.description,
),
}),
)
: (defaultLandingPageContent.techStack.categories[
categoryIndex
]?.technologies ??
defaultTechCategoryFallback.technologies),
}))
: defaultLandingPageContent.techStack.categories,
},
cta: {
heading: sanitizeString(
ctaValue?.heading,
defaultLandingPageContent.cta.heading,
),
description: sanitizeString(
ctaValue?.description,
defaultLandingPageContent.cta.description,
),
commandLabel: sanitizeString(
ctaValue?.commandLabel,
defaultLandingPageContent.cta.commandLabel,
),
command: sanitizeString(
ctaValue?.command,
defaultLandingPageContent.cta.command,
),
},
id: sanitizeOptionalString(value.id),
blockName: sanitizeOptionalString(value.blockName),
};
};
const sanitizeFeatureItems = (
value: unknown,
fallback: LandingFeatureItem[],
): LandingFeatureItem[] => {
if (!Array.isArray(value)) return fallback;
const items = value
.map((item, index) => {
if (!isRecord(item)) return null;
const fallbackItem = fallback[index] ?? {
title: '',
description: '',
icon: '',
};
return {
title: sanitizeString(item.title, fallbackItem.title),
description: sanitizeString(item.description, fallbackItem.description),
icon: sanitizeString(item.icon, fallbackItem.icon),
};
})
.filter((item): item is LandingFeatureItem => item !== null);
return items.length > 0 ? items : fallback;
};
const sanitizeTechCategories = (
value: unknown,
fallback: LandingTechCategory[],
): LandingTechCategory[] => {
if (!Array.isArray(value)) return fallback;
const categories = value
.map((category, categoryIndex) => {
if (!isRecord(category)) return null;
const fallbackCategory = fallback[categoryIndex] ?? {
category: '',
technologies: [{ name: '', description: '' }],
};
const technologies = Array.isArray(category.technologies)
? category.technologies
.map((technology, technologyIndex) => {
if (!isRecord(technology)) return null;
const fallbackTechnology = fallbackCategory.technologies[
technologyIndex
] ?? {
name: '',
description: '',
};
return {
name: sanitizeString(technology.name, fallbackTechnology.name),
description: sanitizeString(
technology.description,
fallbackTechnology.description,
),
};
})
.filter((item): item is LandingTechItem => item !== null)
: fallbackCategory.technologies;
return {
category: sanitizeString(category.category, fallbackCategory.category),
technologies:
technologies.length > 0
? technologies
: fallbackCategory.technologies,
};
})
.filter((item): item is LandingTechCategory => item !== null);
return categories.length > 0 ? categories : fallback;
};
const sanitizeHeroBlock = (value: unknown): LandingHeroBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'hero',
...metadata,
badgeEmoji: sanitizeString(
source.badgeEmoji,
defaultLandingHeroContent.badgeEmoji,
),
badgeText: sanitizeString(
source.badgeText,
defaultLandingHeroContent.badgeText,
),
headingPrefix: sanitizeString(
source.headingPrefix,
defaultLandingHeroContent.headingPrefix,
),
headingHighlight: sanitizeString(
source.headingHighlight,
defaultLandingHeroContent.headingHighlight,
),
description: sanitizeString(
source.description,
defaultLandingHeroContent.description,
),
primaryCta: {
label: sanitizeString(
isRecord(source.primaryCta) ? source.primaryCta.label : undefined,
defaultLandingHeroContent.primaryCta.label,
),
url: sanitizeString(
isRecord(source.primaryCta) ? source.primaryCta.url : undefined,
defaultLandingHeroContent.primaryCta.url,
),
},
highlights: sanitizeStringArray(
source.highlights,
defaultLandingHeroContent.highlights,
),
};
};
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 { TechStack } from './tech-stack';
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 { cache } from 'react';
import {
defaultLandingPageContent,
mergeLandingPageContent,
} from '@/components/landing/content';
import { mergeLandingPageContent } from '@/components/landing/content';
import { getPayloadClient } from './get-payload';
@@ -19,9 +16,6 @@ export const getLandingPageContent = cache(
}
).findGlobal({ slug: 'landing-page', draft: isPreview });
return mergeLandingPageContent(
(landingPage as Partial<LandingPageContent> | null | undefined) ??
defaultLandingPageContent,
);
return mergeLandingPageContent(landingPage as Partial<LandingPageContent>);
},
);

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 { defaultLandingPageContent } from '../../components/landing/content';
import { createDefaultLandingPageLayoutForPayload } from '../../components/landing/content';
import { landingPageBlocks } from './landing-page-blocks';
export const LandingPage: GlobalConfig = {
slug: 'landing-page',
@@ -42,194 +43,17 @@ export const LandingPage: GlobalConfig = {
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Hero',
fields: [
{
name: 'hero',
type: 'group',
fields: [
{
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,
},
],
},
],
},
],
name: 'layout',
label: 'Layout Builder',
type: 'blocks',
minRows: 1,
defaultValue: () => createDefaultLandingPageLayoutForPayload(),
blocks: landingPageBlocks,
admin: {
description:
'Add, remove, and reorder landing page sections while keeping live preview and autosave.',
initCollapsed: true,
},
},
],
};