Everything seems to be working with better UI for page editting

This commit is contained in:
2026-03-27 04:29:45 -05:00
parent 482d2d6c97
commit 57bcb2474f
12 changed files with 1245 additions and 135 deletions

File diff suppressed because one or more lines are too long

View File

@@ -306,6 +306,19 @@ export interface LandingPage {
blockName?: string | null; blockName?: string | null;
blockType: 'hero'; blockType: 'hero';
} }
| {
heading?: string | null;
items?:
| {
name?: string | null;
accent?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'logoCloud';
}
| { | {
heading?: string | null; heading?: string | null;
description?: string | null; description?: string | null;
@@ -321,6 +334,21 @@ export interface LandingPage {
blockName?: string | null; blockName?: string | null;
blockType: 'features'; blockType: 'features';
} }
| {
heading?: string | null;
description?: string | null;
items?:
| {
value?: string | null;
label?: string | null;
description?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'stats';
}
| { | {
heading?: string | null; heading?: string | null;
description?: string | null; description?: string | null;
@@ -341,6 +369,63 @@ export interface LandingPage {
blockName?: string | null; blockName?: string | null;
blockType: 'techStack'; blockType: 'techStack';
} }
| {
heading?: string | null;
description?: string | null;
items?:
| {
quote?: string | null;
name?: string | null;
role?: string | null;
company?: string | null;
avatarEmoji?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'testimonials';
}
| {
heading?: string | null;
description?: string | null;
plans?:
| {
name?: string | null;
price?: string | null;
billingPeriod?: string | null;
description?: string | null;
badge?: string | null;
isHighlighted?: boolean | null;
ctaLabel?: string | null;
ctaUrl?: string | null;
features?:
| {
label?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'pricing';
}
| {
heading?: string | null;
description?: string | null;
items?:
| {
question?: string | null;
answer?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'faq';
}
| { | {
heading?: string | null; heading?: string | null;
description?: string | null; description?: string | null;
@@ -387,6 +472,20 @@ export interface LandingPageSelect<T extends boolean = true> {
id?: T; id?: T;
blockName?: T; blockName?: T;
}; };
logoCloud?:
| T
| {
heading?: T;
items?:
| T
| {
name?: T;
accent?: T;
id?: T;
};
id?: T;
blockName?: T;
};
features?: features?:
| T | T
| { | {
@@ -403,6 +502,22 @@ export interface LandingPageSelect<T extends boolean = true> {
id?: T; id?: T;
blockName?: T; blockName?: T;
}; };
stats?:
| T
| {
heading?: T;
description?: T;
items?:
| T
| {
value?: T;
label?: T;
description?: T;
id?: T;
};
id?: T;
blockName?: T;
};
techStack?: techStack?:
| T | T
| { | {
@@ -424,6 +539,66 @@ export interface LandingPageSelect<T extends boolean = true> {
id?: T; id?: T;
blockName?: T; blockName?: T;
}; };
testimonials?:
| T
| {
heading?: T;
description?: T;
items?:
| T
| {
quote?: T;
name?: T;
role?: T;
company?: T;
avatarEmoji?: T;
id?: T;
};
id?: T;
blockName?: T;
};
pricing?:
| T
| {
heading?: T;
description?: T;
plans?:
| T
| {
name?: T;
price?: T;
billingPeriod?: T;
description?: T;
badge?: T;
isHighlighted?: T;
ctaLabel?: T;
ctaUrl?: T;
features?:
| T
| {
label?: T;
id?: T;
};
id?: T;
};
id?: T;
blockName?: T;
};
faq?:
| T
| {
heading?: T;
description?: T;
items?:
| T
| {
question?: T;
answer?: T;
id?: T;
};
id?: T;
blockName?: T;
};
cta?: cta?:
| T | T
| { | {

View File

@@ -39,6 +39,71 @@ export interface LandingTechStackContent {
categories: LandingTechCategory[]; categories: LandingTechCategory[];
} }
export interface LandingTestimonialItem {
quote: string;
name: string;
role: string;
company: string;
avatarEmoji: string;
}
export interface LandingTestimonialsContent {
heading: string;
description: string;
items: LandingTestimonialItem[];
}
export interface LandingLogoItem {
name: string;
accent: string;
}
export interface LandingLogoCloudContent {
heading: string;
items: LandingLogoItem[];
}
export interface LandingStatItem {
value: string;
label: string;
description: string;
}
export interface LandingStatsContent {
heading: string;
description: string;
items: LandingStatItem[];
}
export interface LandingPricingPlan {
name: string;
price: string;
billingPeriod: string;
description: string;
badge: string;
isHighlighted: boolean;
ctaLabel: string;
ctaUrl: string;
features: string[];
}
export interface LandingPricingContent {
heading: string;
description: string;
plans: LandingPricingPlan[];
}
export interface LandingFaqItem {
question: string;
answer: string;
}
export interface LandingFaqContent {
heading: string;
description: string;
items: LandingFaqItem[];
}
export interface LandingCtaContent { export interface LandingCtaContent {
heading: string; heading: string;
description: string; description: string;
@@ -66,6 +131,31 @@ export interface LandingTechStackBlock
blockType: 'techStack'; blockType: 'techStack';
} }
export interface LandingTestimonialsBlock
extends LandingTestimonialsContent, LandingBlockMetadata {
blockType: 'testimonials';
}
export interface LandingLogoCloudBlock
extends LandingLogoCloudContent, LandingBlockMetadata {
blockType: 'logoCloud';
}
export interface LandingStatsBlock
extends LandingStatsContent, LandingBlockMetadata {
blockType: 'stats';
}
export interface LandingPricingBlock
extends LandingPricingContent, LandingBlockMetadata {
blockType: 'pricing';
}
export interface LandingFaqBlock
extends LandingFaqContent, LandingBlockMetadata {
blockType: 'faq';
}
export interface LandingCtaBlock export interface LandingCtaBlock
extends LandingCtaContent, LandingBlockMetadata { extends LandingCtaContent, LandingBlockMetadata {
blockType: 'cta'; blockType: 'cta';
@@ -75,55 +165,46 @@ export type LandingPageBlock =
| LandingHeroBlock | LandingHeroBlock
| LandingFeaturesBlock | LandingFeaturesBlock
| LandingTechStackBlock | LandingTechStackBlock
| LandingTestimonialsBlock
| LandingLogoCloudBlock
| LandingStatsBlock
| LandingPricingBlock
| LandingFaqBlock
| LandingCtaBlock; | LandingCtaBlock;
export interface LandingPageContent { export interface LandingPageContent {
layout: LandingPageBlock[]; layout: LandingPageBlock[];
} }
interface PayloadLandingHeroRow { interface PayloadTextRow {
blockType: 'hero'; label: string;
badgeEmoji: string;
badgeText: string;
headingPrefix: string;
headingHighlight: string;
description: string;
primaryCta: {
label: string;
url: string;
};
highlights: Array<{
label: string;
}>;
} }
interface PayloadLandingFeaturesRow { interface PayloadLandingHeroRow extends Omit<LandingHeroBlock, 'highlights'> {
blockType: 'features'; highlights: PayloadTextRow[];
heading: string;
description: string;
items: LandingFeatureItem[];
} }
interface PayloadLandingTechStackRow { interface PayloadLandingPricingPlan extends Omit<
blockType: 'techStack'; LandingPricingPlan,
heading: string; 'features'
description: string; > {
categories: LandingTechCategory[]; features: PayloadTextRow[];
} }
interface PayloadLandingCtaRow { interface PayloadLandingPricingRow extends Omit<LandingPricingBlock, 'plans'> {
blockType: 'cta'; plans: PayloadLandingPricingPlan[];
heading: string;
description: string;
commandLabel: string;
command: string;
} }
export type PayloadLandingPageRow = export type PayloadLandingPageRow =
| PayloadLandingHeroRow | PayloadLandingHeroRow
| PayloadLandingFeaturesRow | LandingFeaturesBlock
| PayloadLandingTechStackRow | LandingTechStackBlock
| PayloadLandingCtaRow; | LandingTestimonialsBlock
| LandingLogoCloudBlock
| LandingStatsBlock
| PayloadLandingPricingRow
| LandingFaqBlock
| LandingCtaBlock;
export const defaultLandingHeroContent: LandingHeroContent = { export const defaultLandingHeroContent: LandingHeroContent = {
badgeEmoji: '🚀', badgeEmoji: '🚀',
@@ -180,24 +261,6 @@ export const defaultLandingFeaturesContent: LandingFeaturesContent = {
'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.', 'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.',
icon: '⚡', 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: '⚙️',
},
], ],
}; };
@@ -234,7 +297,7 @@ export const defaultLandingTechStackContent: LandingTechStackContent = {
description: 'Multi-provider authentication', description: 'Multi-provider authentication',
}, },
{ name: 'UseSend', description: 'Self-hosted email service' }, { name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' }, { name: 'Payload CMS', description: 'Embedded content platform' },
], ],
}, },
{ {
@@ -243,13 +306,170 @@ export const defaultLandingTechStackContent: LandingTechStackContent = {
{ name: 'Turborepo', description: 'High-performance build system' }, { name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' }, { name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager & runtime' }, { name: 'Bun', description: 'Fast package manager & runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' }, { name: 'Docker', description: 'Containerized deployment' },
], ],
}, },
], ],
}; };
export const defaultLandingTestimonialsContent: LandingTestimonialsContent = {
heading: 'What Clients Actually Care About',
description:
'The polished part is not just the frontend. Editors can shape the story, launch updates fast, and preview changes before they go live.',
items: [
{
quote:
'We changed homepage messaging during a sales call, hit save, and the preview updated instantly. That sold the workflow by itself.',
name: 'Avery Stone',
role: 'Founder',
company: 'Northline Studio',
avatarEmoji: '🧠',
},
{
quote:
'The admin feels like a real control panel, not a placeholder CMS. Our team could update sections without asking a developer.',
name: 'Maya Torres',
role: 'Marketing Lead',
company: 'Signal Harbor',
avatarEmoji: '✨',
},
{
quote:
'The live preview, drafts, and reusable blocks made the site feel custom while still being easy to maintain long term.',
name: 'Jonah Reed',
role: 'Product Consultant',
company: 'Field Current',
avatarEmoji: '🚀',
},
],
};
export const defaultLandingLogoCloudContent: LandingLogoCloudContent = {
heading: 'Designed For Modern Product Teams',
items: [
{ name: 'Northline', accent: 'Strategy' },
{ name: 'Field Current', accent: 'Operations' },
{ name: 'Signal Harbor', accent: 'Marketing' },
{ name: 'Brightlayer', accent: 'SaaS' },
{ name: 'Blue Quarry', accent: 'Infrastructure' },
{ name: 'Keystone Lab', accent: 'Startups' },
],
};
export const defaultLandingStatsContent: LandingStatsContent = {
heading: 'Built To Demo Well',
description:
'These are the kinds of outcomes you can point to when showing the template to clients.',
items: [
{
value: '10x',
label: 'Faster content updates',
description:
'Editors can revise sections without waiting on engineering.',
},
{
value: '< 1 min',
label: 'Preview feedback loop',
description: 'Draft, tweak, and validate messaging almost instantly.',
},
{
value: '100%',
label: 'Self-hosted control',
description: 'App, CMS, auth, and backend all stay under your control.',
},
{
value: '1 repo',
label: 'Shared product foundation',
description:
'Web app, backend, and future mobile work stay in one system.',
},
],
};
export const defaultLandingPricingContent: LandingPricingContent = {
heading: 'A Great Way To Pitch Productized Delivery',
description:
'Use pricing blocks to show packaged service tiers, subscriptions, or implementation options right inside the landing page builder.',
plans: [
{
name: 'Starter',
price: '$3k',
billingPeriod: 'one-time',
description: 'Launch a clean marketing site on top of the template fast.',
badge: '',
isHighlighted: false,
ctaLabel: 'Book Intro Call',
ctaUrl: '/sign-in',
features: [
'Custom homepage setup',
'Payload editor training',
'Auth + profile pages included',
],
},
{
name: 'Growth',
price: '$6k',
billingPeriod: 'one-time',
description:
'Expand the template into a more tailored product marketing experience.',
badge: 'Most Popular',
isHighlighted: true,
ctaLabel: 'Start Project',
ctaUrl: '/sign-in',
features: [
'Everything in Starter',
'Additional custom blocks',
'SEO + analytics setup',
'Admin workflow polish',
],
},
{
name: 'Retainer',
price: '$950',
billingPeriod: '/month',
description:
'Keep iterating on content, features, and admin workflows after launch.',
badge: '',
isHighlighted: false,
ctaLabel: 'Discuss Retainer',
ctaUrl: '/sign-in',
features: [
'Monthly improvements',
'Content support',
'New sections and experiments',
],
},
],
};
export const defaultLandingFaqContent: LandingFaqContent = {
heading: 'Questions You Will Probably Get In A Demo',
description:
'A good FAQ block makes the editor feel complete and gives clients another obvious content type they can manage themselves.',
items: [
{
question: 'Can non-developers reorder sections on the homepage?',
answer:
'Yes. The landing page now uses Payload blocks, so editors can add, remove, and reorder sections directly from the admin panel.',
},
{
question: 'Does the live preview still work with blocks?',
answer:
'Yes. Drafts, autosave, and live preview continue to work while editing the block-based layout.',
},
{
question: 'Can we create more block types later?',
answer:
'Absolutely. Testimonials, FAQ, pricing, stats, logo clouds, galleries, timelines, and case studies all fit naturally into this setup.',
},
{
question: 'Is this only for the homepage?',
answer:
'No. The same pattern can power additional marketing pages, service pages, or even internal admin-managed microsites.',
},
],
};
export const defaultLandingCtaContent: LandingCtaContent = { export const defaultLandingCtaContent: LandingCtaContent = {
heading: 'Ready to Build Something Amazing?', heading: 'Ready to Build Something Amazing?',
description: description:
@@ -267,6 +487,10 @@ 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 sanitizeBoolean = (value: unknown, fallback: boolean) => {
return typeof value === 'boolean' ? value : fallback;
};
const sanitizeOptionalString = (value: unknown) => { const sanitizeOptionalString = (value: unknown) => {
return typeof value === 'string' && value.length > 0 ? value : undefined; return typeof value === 'string' && value.length > 0 ? value : undefined;
}; };
@@ -301,82 +525,164 @@ const sanitizeBlockMetadata = (value: unknown): LandingBlockMetadata => {
}; };
}; };
const sanitizeFeatureItems = ( const sanitizeObjectArray = <T>(
value: unknown, value: unknown,
fallback: LandingFeatureItem[], fallback: T[],
): LandingFeatureItem[] => { mapItem: (item: Record<string, unknown>, index: number, fallbackItem: T) => T,
emptyItem: T,
): T[] => {
if (!Array.isArray(value)) return fallback; if (!Array.isArray(value)) return fallback;
const items = value const items = value
.map((item, index) => { .map((item, index) => {
if (!isRecord(item)) return null; if (!isRecord(item)) return null;
const fallbackItem = fallback[index] ?? { return mapItem(item, index, fallback[index] ?? emptyItem);
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); .filter((item): item is T => item !== null);
return items.length > 0 ? items : fallback; return items.length > 0 ? items : fallback;
}; };
const sanitizeFeatureItems = (
value: unknown,
fallback: LandingFeatureItem[],
) => {
return sanitizeObjectArray(
value,
fallback,
(item, _index, fallbackItem) => ({
title: sanitizeString(item.title, fallbackItem.title),
description: sanitizeString(item.description, fallbackItem.description),
icon: sanitizeString(item.icon, fallbackItem.icon),
}),
{ title: '', description: '', icon: '' },
);
};
const sanitizeTechCategories = ( const sanitizeTechCategories = (
value: unknown, value: unknown,
fallback: LandingTechCategory[], fallback: LandingTechCategory[],
): LandingTechCategory[] => { ) => {
if (!Array.isArray(value)) return fallback; return sanitizeObjectArray(
value,
fallback,
(category, _index, fallbackCategory) => ({
category: sanitizeString(category.category, fallbackCategory.category),
technologies: sanitizeObjectArray(
category.technologies,
fallbackCategory.technologies,
(technology, _technologyIndex, fallbackTechnology) => ({
name: sanitizeString(technology.name, fallbackTechnology.name),
description: sanitizeString(
technology.description,
fallbackTechnology.description,
),
}),
{ name: '', description: '' },
),
}),
{ category: '', technologies: [{ name: '', description: '' }] },
);
};
const categories = value const sanitizeTestimonials = (
.map((category, categoryIndex) => { value: unknown,
if (!isRecord(category)) return null; fallback: LandingTestimonialItem[],
) => {
return sanitizeObjectArray(
value,
fallback,
(item, _index, fallbackItem) => ({
quote: sanitizeString(item.quote, fallbackItem.quote),
name: sanitizeString(item.name, fallbackItem.name),
role: sanitizeString(item.role, fallbackItem.role),
company: sanitizeString(item.company, fallbackItem.company),
avatarEmoji: sanitizeString(item.avatarEmoji, fallbackItem.avatarEmoji),
}),
{
quote: '',
name: '',
role: '',
company: '',
avatarEmoji: '',
},
);
};
const fallbackCategory = fallback[categoryIndex] ?? { const sanitizeLogoItems = (value: unknown, fallback: LandingLogoItem[]) => {
category: '', return sanitizeObjectArray(
technologies: [{ name: '', description: '' }], value,
}; fallback,
(item, _index, fallbackItem) => ({
name: sanitizeString(item.name, fallbackItem.name),
accent: sanitizeString(item.accent, fallbackItem.accent),
}),
{ name: '', accent: '' },
);
};
const technologies = Array.isArray(category.technologies) const sanitizeStatItems = (value: unknown, fallback: LandingStatItem[]) => {
? category.technologies return sanitizeObjectArray(
.map((technology, technologyIndex) => { value,
if (!isRecord(technology)) return null; fallback,
(item, _index, fallbackItem) => ({
value: sanitizeString(item.value, fallbackItem.value),
label: sanitizeString(item.label, fallbackItem.label),
description: sanitizeString(item.description, fallbackItem.description),
}),
{ value: '', label: '', description: '' },
);
};
const fallbackTechnology = fallbackCategory.technologies[ const sanitizePricingPlans = (
technologyIndex value: unknown,
] ?? { fallback: LandingPricingPlan[],
name: '', ) => {
description: '', return sanitizeObjectArray(
}; value,
fallback,
(plan, _index, fallbackPlan) => ({
name: sanitizeString(plan.name, fallbackPlan.name),
price: sanitizeString(plan.price, fallbackPlan.price),
billingPeriod: sanitizeString(
plan.billingPeriod,
fallbackPlan.billingPeriod,
),
description: sanitizeString(plan.description, fallbackPlan.description),
badge: sanitizeString(plan.badge, fallbackPlan.badge),
isHighlighted: sanitizeBoolean(
plan.isHighlighted,
fallbackPlan.isHighlighted,
),
ctaLabel: sanitizeString(plan.ctaLabel, fallbackPlan.ctaLabel),
ctaUrl: sanitizeString(plan.ctaUrl, fallbackPlan.ctaUrl),
features: sanitizeStringArray(plan.features, fallbackPlan.features),
}),
{
name: '',
price: '',
billingPeriod: '',
description: '',
badge: '',
isHighlighted: false,
ctaLabel: '',
ctaUrl: '',
features: [''],
},
);
};
return { const sanitizeFaqItems = (value: unknown, fallback: LandingFaqItem[]) => {
name: sanitizeString(technology.name, fallbackTechnology.name), return sanitizeObjectArray(
description: sanitizeString( value,
technology.description, fallback,
fallbackTechnology.description, (item, _index, fallbackItem) => ({
), question: sanitizeString(item.question, fallbackItem.question),
}; answer: sanitizeString(item.answer, fallbackItem.answer),
}) }),
.filter((item): item is LandingTechItem => item !== null) { question: '', answer: '' },
: 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 sanitizeHeroBlock = (value: unknown): LandingHeroBlock => {
@@ -467,6 +773,102 @@ const sanitizeTechStackBlock = (value: unknown): LandingTechStackBlock => {
}; };
}; };
const sanitizeTestimonialsBlock = (
value: unknown,
): LandingTestimonialsBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'testimonials',
...metadata,
heading: sanitizeString(
source.heading,
defaultLandingTestimonialsContent.heading,
),
description: sanitizeString(
source.description,
defaultLandingTestimonialsContent.description,
),
items: sanitizeTestimonials(
source.items,
defaultLandingTestimonialsContent.items,
),
};
};
const sanitizeLogoCloudBlock = (value: unknown): LandingLogoCloudBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'logoCloud',
...metadata,
heading: sanitizeString(
source.heading,
defaultLandingLogoCloudContent.heading,
),
items: sanitizeLogoItems(
source.items,
defaultLandingLogoCloudContent.items,
),
};
};
const sanitizeStatsBlock = (value: unknown): LandingStatsBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'stats',
...metadata,
heading: sanitizeString(source.heading, defaultLandingStatsContent.heading),
description: sanitizeString(
source.description,
defaultLandingStatsContent.description,
),
items: sanitizeStatItems(source.items, defaultLandingStatsContent.items),
};
};
const sanitizePricingBlock = (value: unknown): LandingPricingBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'pricing',
...metadata,
heading: sanitizeString(
source.heading,
defaultLandingPricingContent.heading,
),
description: sanitizeString(
source.description,
defaultLandingPricingContent.description,
),
plans: sanitizePricingPlans(
source.plans,
defaultLandingPricingContent.plans,
),
};
};
const sanitizeFaqBlock = (value: unknown): LandingFaqBlock => {
const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {};
return {
blockType: 'faq',
...metadata,
heading: sanitizeString(source.heading, defaultLandingFaqContent.heading),
description: sanitizeString(
source.description,
defaultLandingFaqContent.description,
),
items: sanitizeFaqItems(source.items, defaultLandingFaqContent.items),
};
};
const sanitizeCtaBlock = (value: unknown): LandingCtaBlock => { const sanitizeCtaBlock = (value: unknown): LandingCtaBlock => {
const metadata = sanitizeBlockMetadata(value); const metadata = sanitizeBlockMetadata(value);
const source = isRecord(value) ? value : {}; const source = isRecord(value) ? value : {};
@@ -495,21 +897,26 @@ const sanitizeLayout = (value: unknown): LandingPageBlock[] => {
if (!isRecord(block) || typeof block.blockType !== 'string') return null; if (!isRecord(block) || typeof block.blockType !== 'string') return null;
switch (block.blockType) { switch (block.blockType) {
case 'hero': { case 'hero':
return sanitizeHeroBlock(block); return sanitizeHeroBlock(block);
} case 'features':
case 'features': {
return sanitizeFeaturesBlock(block); return sanitizeFeaturesBlock(block);
} case 'techStack':
case 'techStack': {
return sanitizeTechStackBlock(block); return sanitizeTechStackBlock(block);
} case 'testimonials':
case 'cta': { return sanitizeTestimonialsBlock(block);
case 'logoCloud':
return sanitizeLogoCloudBlock(block);
case 'stats':
return sanitizeStatsBlock(block);
case 'pricing':
return sanitizePricingBlock(block);
case 'faq':
return sanitizeFaqBlock(block);
case 'cta':
return sanitizeCtaBlock(block); return sanitizeCtaBlock(block);
} default:
default: {
return null; return null;
}
} }
}) })
.filter((block): block is LandingPageBlock => block !== null); .filter((block): block is LandingPageBlock => block !== null);
@@ -520,8 +927,13 @@ const buildLegacyLandingPageLayout = (value: unknown): LandingPageBlock[] => {
return [ return [
sanitizeHeroBlock(source.hero), sanitizeHeroBlock(source.hero),
sanitizeLogoCloudBlock(undefined),
sanitizeFeaturesBlock(source.features), sanitizeFeaturesBlock(source.features),
sanitizeStatsBlock(undefined),
sanitizeTechStackBlock(source.techStack), sanitizeTechStackBlock(source.techStack),
sanitizeTestimonialsBlock(undefined),
sanitizePricingBlock(undefined),
sanitizeFaqBlock(undefined),
sanitizeCtaBlock(source.cta), sanitizeCtaBlock(source.cta),
]; ];
}; };
@@ -545,14 +957,38 @@ export const createDefaultLandingPageLayoutForPayload =
label, label,
})), })),
}, },
{
blockType: 'logoCloud',
...defaultLandingLogoCloudContent,
},
{ {
blockType: 'features', blockType: 'features',
...defaultLandingFeaturesContent, ...defaultLandingFeaturesContent,
}, },
{
blockType: 'stats',
...defaultLandingStatsContent,
},
{ {
blockType: 'techStack', blockType: 'techStack',
...defaultLandingTechStackContent, ...defaultLandingTechStackContent,
}, },
{
blockType: 'testimonials',
...defaultLandingTestimonialsContent,
},
{
blockType: 'pricing',
...defaultLandingPricingContent,
plans: defaultLandingPricingContent.plans.map((plan) => ({
...plan,
features: plan.features.map((label) => ({ label })),
})),
},
{
blockType: 'faq',
...defaultLandingFaqContent,
},
{ {
blockType: 'cta', blockType: 'cta',
...defaultLandingCtaContent, ...defaultLandingCtaContent,

View File

@@ -0,0 +1,46 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@gib/ui';
import type { LandingFaqContent } from './content';
interface FaqProps {
content: LandingFaqContent;
}
export const FAQ = ({ content }: FaqProps) => (
<section id='faq' className='border-border/40 bg-muted/20 border-y'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-4xl'>
<div className='mb-12 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
{content.heading}
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
{content.description}
</p>
</div>
<Accordion type='single' collapsible className='space-y-4'>
{content.items.map((item) => (
<AccordionItem
key={item.question}
value={item.question}
className='bg-background border-border/60 rounded-2xl border px-5'
>
<AccordionTrigger className='text-left text-base font-semibold hover:no-underline'>
{item.question}
</AccordionTrigger>
<AccordionContent className='text-muted-foreground pb-5 text-base leading-7'>
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</section>
);

View File

@@ -1,5 +1,10 @@
export { Hero } from './hero'; export { Hero } from './hero';
export { Features } from './features'; export { Features } from './features';
export { TechStack } from './tech-stack'; export { TechStack } from './tech-stack';
export { Testimonials } from './testimonials';
export { LogoCloud } from './logo-cloud';
export { Stats } from './stats';
export { Pricing } from './pricing';
export { FAQ } from './faq';
export { CTA } from './cta'; export { CTA } from './cta';
export { LandingPageBuilder } from './page-builder'; export { LandingPageBuilder } from './page-builder';

View File

@@ -0,0 +1,32 @@
import { Badge } from '@gib/ui';
import type { LandingLogoCloudContent } from './content';
interface LogoCloudProps {
content: LandingLogoCloudContent;
}
export const LogoCloud = ({ content }: LogoCloudProps) => (
<section className='border-border/40 bg-muted/25 border-y'>
<div className='container mx-auto px-4 py-12'>
<div className='mx-auto max-w-6xl'>
<p className='text-muted-foreground mb-6 text-center text-sm font-medium tracking-[0.28em] uppercase'>
{content.heading}
</p>
<div className='flex flex-wrap items-center justify-center gap-3 md:gap-4'>
{content.items.map((logo) => (
<div
key={logo.name}
className='bg-background/80 border-border/60 flex min-w-40 items-center justify-between gap-4 rounded-2xl border px-4 py-3 shadow-sm'
>
<span className='text-sm font-semibold tracking-[0.18em] uppercase'>
{logo.name}
</span>
<Badge variant='outline'>{logo.accent}</Badge>
</div>
))}
</div>
</div>
</div>
</section>
);

View File

@@ -1,8 +1,13 @@
import type { LandingPageBlock } from './content'; import type { LandingPageBlock } from './content';
import { CTA } from './cta'; import { CTA } from './cta';
import { FAQ } from './faq';
import { Features } from './features'; import { Features } from './features';
import { Hero } from './hero'; import { Hero } from './hero';
import { LogoCloud } from './logo-cloud';
import { Pricing } from './pricing';
import { Stats } from './stats';
import { TechStack } from './tech-stack'; import { TechStack } from './tech-stack';
import { Testimonials } from './testimonials';
interface LandingPageBuilderProps { interface LandingPageBuilderProps {
blocks: LandingPageBlock[]; blocks: LandingPageBlock[];
@@ -22,6 +27,21 @@ export const LandingPageBuilder = ({ blocks }: LandingPageBuilderProps) => {
case 'techStack': { case 'techStack': {
return <TechStack key={key} content={block} />; return <TechStack key={key} content={block} />;
} }
case 'testimonials': {
return <Testimonials key={key} content={block} />;
}
case 'logoCloud': {
return <LogoCloud key={key} content={block} />;
}
case 'stats': {
return <Stats key={key} content={block} />;
}
case 'pricing': {
return <Pricing key={key} content={block} />;
}
case 'faq': {
return <FAQ key={key} content={block} />;
}
case 'cta': { case 'cta': {
return <CTA key={key} content={block} />; return <CTA key={key} content={block} />;
} }

View File

@@ -0,0 +1,85 @@
import Link from 'next/link';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
cn,
} from '@gib/ui';
import type { LandingPricingContent } from './content';
interface PricingProps {
content: LandingPricingContent;
}
export const Pricing = ({ content }: PricingProps) => (
<section id='pricing' className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
{content.heading}
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
{content.description}
</p>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
{content.plans.map((plan) => (
<Card
key={plan.name}
className={cn(
'border-border/50 relative flex h-full flex-col',
plan.isHighlighted &&
'border-primary/60 shadow-primary/10 bg-muted/30 shadow-xl',
)}
>
<CardHeader className='space-y-4'>
<div className='flex items-start justify-between gap-4'>
<div>
<CardTitle className='text-2xl'>{plan.name}</CardTitle>
<p className='text-muted-foreground mt-2 text-sm'>
{plan.description}
</p>
</div>
{plan.badge ? <Badge>{plan.badge}</Badge> : null}
</div>
<div>
<div className='flex items-end gap-2'>
<span className='text-4xl font-bold tracking-tight'>
{plan.price}
</span>
<span className='text-muted-foreground pb-1 text-sm'>
{plan.billingPeriod}
</span>
</div>
</div>
</CardHeader>
<CardContent className='flex flex-1 flex-col gap-6'>
<ul className='space-y-3'>
{plan.features.map((feature) => (
<li key={feature} className='flex items-start gap-3 text-sm'>
<span className='text-primary mt-0.5'></span>
<span>{feature}</span>
</li>
))}
</ul>
<Button
className='mt-auto w-full'
variant={plan.isHighlighted ? 'default' : 'outline'}
asChild
>
<Link href={plan.ctaUrl}>{plan.ctaLabel}</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);

View File

@@ -0,0 +1,43 @@
import { Card, CardContent } from '@gib/ui';
import type { LandingStatsContent } from './content';
interface StatsProps {
content: LandingStatsContent;
}
export const Stats = ({ content }: StatsProps) => (
<section className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
<div className='mb-12 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
{content.heading}
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
{content.description}
</p>
</div>
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-4'>
{content.items.map((item) => (
<Card
key={`${item.label}-${item.value}`}
className='border-border/50'
>
<CardContent className='space-y-4 p-6'>
<div className='text-4xl font-bold tracking-tight'>
{item.value}
</div>
<div className='space-y-2'>
<h3 className='text-lg font-semibold'>{item.label}</h3>
<p className='text-muted-foreground text-sm'>
{item.description}
</p>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);

View File

@@ -0,0 +1,46 @@
import { Card, CardContent } from '@gib/ui';
import type { LandingTestimonialsContent } from './content';
interface TestimonialsProps {
content: LandingTestimonialsContent;
}
export const Testimonials = ({ content }: TestimonialsProps) => (
<section className='border-border/40 bg-muted/20 border-y'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
{content.heading}
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
{content.description}
</p>
</div>
<div className='grid gap-6 lg:grid-cols-3'>
{content.items.map((item) => (
<Card
key={`${item.name}-${item.company}`}
className='border-border/50'
>
<CardContent className='flex h-full flex-col gap-6 p-6'>
<div className='text-4xl'>{item.avatarEmoji}</div>
<blockquote className='text-lg leading-8'>
&ldquo;{item.quote}&rdquo;
</blockquote>
<div className='mt-auto'>
<div className='font-semibold'>{item.name}</div>
<div className='text-muted-foreground text-sm'>
{item.role} at {item.company}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</section>
);

View File

@@ -1,21 +1,20 @@
import type { LandingPageContent } from '@/components/landing/content'; import type { LandingPageContent } from '@/components/landing/content';
import { cache } from 'react'; import { unstable_noStore as noStore } from 'next/cache';
import { mergeLandingPageContent } from '@/components/landing/content'; import { mergeLandingPageContent } from '@/components/landing/content';
import { getPayloadClient } from './get-payload'; import { getPayloadClient } from './get-payload';
export const getLandingPageContent = cache( export const getLandingPageContent = async (
async (isPreview = false): Promise<LandingPageContent> => { isPreview = false,
const payload = await getPayloadClient(); ): Promise<LandingPageContent> => {
const landingPage = await ( noStore();
payload as {
findGlobal: (args: {
slug: string;
draft?: boolean;
}) => Promise<unknown>;
}
).findGlobal({ slug: 'landing-page', draft: isPreview });
return mergeLandingPageContent(landingPage as Partial<LandingPageContent>); const payload = await getPayloadClient();
}, const landingPage = await (
); payload as {
findGlobal: (args: { slug: string; draft?: boolean }) => Promise<unknown>;
}
).findGlobal({ slug: 'landing-page', draft: isPreview });
return mergeLandingPageContent(landingPage as Partial<LandingPageContent>);
};

View File

@@ -2,9 +2,14 @@ import type { Block } from 'payload';
import { import {
defaultLandingCtaContent, defaultLandingCtaContent,
defaultLandingFaqContent,
defaultLandingFeaturesContent, defaultLandingFeaturesContent,
defaultLandingHeroContent, defaultLandingHeroContent,
defaultLandingLogoCloudContent,
defaultLandingPricingContent,
defaultLandingStatsContent,
defaultLandingTechStackContent, defaultLandingTechStackContent,
defaultLandingTestimonialsContent,
} from '../../components/landing/content'; } from '../../components/landing/content';
export const landingPageBlocks: Block[] = [ export const landingPageBlocks: Block[] = [
@@ -72,6 +77,35 @@ export const landingPageBlocks: Block[] = [
}, },
], ],
}, },
{
slug: 'logoCloud',
labels: {
singular: 'Logo Cloud',
plural: 'Logo Clouds',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingLogoCloudContent.heading,
},
{
name: 'items',
type: 'array',
defaultValue: defaultLandingLogoCloudContent.items,
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'accent',
type: 'text',
},
],
},
],
},
{ {
slug: 'features', slug: 'features',
labels: { labels: {
@@ -110,6 +144,44 @@ export const landingPageBlocks: Block[] = [
}, },
], ],
}, },
{
slug: 'stats',
labels: {
singular: 'Stats Section',
plural: 'Stats Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingStatsContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingStatsContent.description,
},
{
name: 'items',
type: 'array',
defaultValue: defaultLandingStatsContent.items,
fields: [
{
name: 'value',
type: 'text',
},
{
name: 'label',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
],
},
],
},
{ {
slug: 'techStack', slug: 'techStack',
labels: { labels: {
@@ -154,6 +226,157 @@ export const landingPageBlocks: Block[] = [
}, },
], ],
}, },
{
slug: 'testimonials',
labels: {
singular: 'Testimonials Section',
plural: 'Testimonials Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingTestimonialsContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingTestimonialsContent.description,
},
{
name: 'items',
type: 'array',
defaultValue: defaultLandingTestimonialsContent.items,
fields: [
{
name: 'quote',
type: 'textarea',
},
{
name: 'name',
type: 'text',
},
{
name: 'role',
type: 'text',
},
{
name: 'company',
type: 'text',
},
{
name: 'avatarEmoji',
type: 'text',
},
],
},
],
},
{
slug: 'pricing',
labels: {
singular: 'Pricing Section',
plural: 'Pricing Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingPricingContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingPricingContent.description,
},
{
name: 'plans',
type: 'array',
defaultValue: defaultLandingPricingContent.plans.map((plan) => ({
...plan,
features: plan.features.map((label) => ({ label })),
})),
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'price',
type: 'text',
},
{
name: 'billingPeriod',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'badge',
type: 'text',
},
{
name: 'isHighlighted',
type: 'checkbox',
},
{
name: 'ctaLabel',
type: 'text',
},
{
name: 'ctaUrl',
type: 'text',
},
{
name: 'features',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
{
slug: 'faq',
labels: {
singular: 'FAQ Section',
plural: 'FAQ Sections',
},
fields: [
{
name: 'heading',
type: 'text',
defaultValue: defaultLandingFaqContent.heading,
},
{
name: 'description',
type: 'textarea',
defaultValue: defaultLandingFaqContent.description,
},
{
name: 'items',
type: 'array',
defaultValue: defaultLandingFaqContent.items,
fields: [
{
name: 'question',
type: 'text',
},
{
name: 'answer',
type: 'textarea',
},
],
},
],
},
{ {
slug: 'cta', slug: 'cta',
labels: { labels: {