Everything seems to be working with better UI for page editting
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -306,6 +306,19 @@ export interface LandingPage {
|
||||
blockName?: string | null;
|
||||
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;
|
||||
description?: string | null;
|
||||
@@ -321,6 +334,21 @@ export interface LandingPage {
|
||||
blockName?: string | null;
|
||||
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;
|
||||
description?: string | null;
|
||||
@@ -341,6 +369,63 @@ export interface LandingPage {
|
||||
blockName?: string | null;
|
||||
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;
|
||||
description?: string | null;
|
||||
@@ -387,6 +472,20 @@ export interface LandingPageSelect<T extends boolean = true> {
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
logoCloud?:
|
||||
| T
|
||||
| {
|
||||
heading?: T;
|
||||
items?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
accent?: T;
|
||||
id?: T;
|
||||
};
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
features?:
|
||||
| T
|
||||
| {
|
||||
@@ -403,6 +502,22 @@ export interface LandingPageSelect<T extends boolean = true> {
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
stats?:
|
||||
| T
|
||||
| {
|
||||
heading?: T;
|
||||
description?: T;
|
||||
items?:
|
||||
| T
|
||||
| {
|
||||
value?: T;
|
||||
label?: T;
|
||||
description?: T;
|
||||
id?: T;
|
||||
};
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
techStack?:
|
||||
| T
|
||||
| {
|
||||
@@ -424,6 +539,66 @@ export interface LandingPageSelect<T extends boolean = true> {
|
||||
id?: 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?:
|
||||
| T
|
||||
| {
|
||||
|
||||
@@ -39,6 +39,71 @@ export interface LandingTechStackContent {
|
||||
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 {
|
||||
heading: string;
|
||||
description: string;
|
||||
@@ -66,6 +131,31 @@ export interface LandingTechStackBlock
|
||||
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
|
||||
extends LandingCtaContent, LandingBlockMetadata {
|
||||
blockType: 'cta';
|
||||
@@ -75,55 +165,46 @@ export type LandingPageBlock =
|
||||
| LandingHeroBlock
|
||||
| LandingFeaturesBlock
|
||||
| LandingTechStackBlock
|
||||
| LandingTestimonialsBlock
|
||||
| LandingLogoCloudBlock
|
||||
| LandingStatsBlock
|
||||
| LandingPricingBlock
|
||||
| LandingFaqBlock
|
||||
| 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 PayloadTextRow {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PayloadLandingFeaturesRow {
|
||||
blockType: 'features';
|
||||
heading: string;
|
||||
description: string;
|
||||
items: LandingFeatureItem[];
|
||||
interface PayloadLandingHeroRow extends Omit<LandingHeroBlock, 'highlights'> {
|
||||
highlights: PayloadTextRow[];
|
||||
}
|
||||
|
||||
interface PayloadLandingTechStackRow {
|
||||
blockType: 'techStack';
|
||||
heading: string;
|
||||
description: string;
|
||||
categories: LandingTechCategory[];
|
||||
interface PayloadLandingPricingPlan extends Omit<
|
||||
LandingPricingPlan,
|
||||
'features'
|
||||
> {
|
||||
features: PayloadTextRow[];
|
||||
}
|
||||
|
||||
interface PayloadLandingCtaRow {
|
||||
blockType: 'cta';
|
||||
heading: string;
|
||||
description: string;
|
||||
commandLabel: string;
|
||||
command: string;
|
||||
interface PayloadLandingPricingRow extends Omit<LandingPricingBlock, 'plans'> {
|
||||
plans: PayloadLandingPricingPlan[];
|
||||
}
|
||||
|
||||
export type PayloadLandingPageRow =
|
||||
| PayloadLandingHeroRow
|
||||
| PayloadLandingFeaturesRow
|
||||
| PayloadLandingTechStackRow
|
||||
| PayloadLandingCtaRow;
|
||||
| LandingFeaturesBlock
|
||||
| LandingTechStackBlock
|
||||
| LandingTestimonialsBlock
|
||||
| LandingLogoCloudBlock
|
||||
| LandingStatsBlock
|
||||
| PayloadLandingPricingRow
|
||||
| LandingFaqBlock
|
||||
| LandingCtaBlock;
|
||||
|
||||
export const defaultLandingHeroContent: LandingHeroContent = {
|
||||
badgeEmoji: '🚀',
|
||||
@@ -180,24 +261,6 @@ export const defaultLandingFeaturesContent: LandingFeaturesContent = {
|
||||
'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: '⚙️',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -234,7 +297,7 @@ export const defaultLandingTechStackContent: LandingTechStackContent = {
|
||||
description: 'Multi-provider authentication',
|
||||
},
|
||||
{ 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: '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 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 = {
|
||||
heading: 'Ready to Build Something Amazing?',
|
||||
description:
|
||||
@@ -267,6 +487,10 @@ const sanitizeString = (value: unknown, fallback: string) => {
|
||||
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) => {
|
||||
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,
|
||||
fallback: LandingFeatureItem[],
|
||||
): LandingFeatureItem[] => {
|
||||
fallback: T[],
|
||||
mapItem: (item: Record<string, unknown>, index: number, fallbackItem: T) => T,
|
||||
emptyItem: T,
|
||||
): T[] => {
|
||||
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),
|
||||
};
|
||||
return mapItem(item, index, fallback[index] ?? emptyItem);
|
||||
})
|
||||
.filter((item): item is LandingFeatureItem => item !== null);
|
||||
.filter((item): item is T => item !== null);
|
||||
|
||||
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 = (
|
||||
value: unknown,
|
||||
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
|
||||
.map((category, categoryIndex) => {
|
||||
if (!isRecord(category)) return null;
|
||||
const sanitizeTestimonials = (
|
||||
value: unknown,
|
||||
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] ?? {
|
||||
category: '',
|
||||
technologies: [{ name: '', description: '' }],
|
||||
};
|
||||
const sanitizeLogoItems = (value: unknown, fallback: LandingLogoItem[]) => {
|
||||
return sanitizeObjectArray(
|
||||
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)
|
||||
? category.technologies
|
||||
.map((technology, technologyIndex) => {
|
||||
if (!isRecord(technology)) return null;
|
||||
const sanitizeStatItems = (value: unknown, fallback: LandingStatItem[]) => {
|
||||
return sanitizeObjectArray(
|
||||
value,
|
||||
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[
|
||||
technologyIndex
|
||||
] ?? {
|
||||
name: '',
|
||||
description: '',
|
||||
};
|
||||
const sanitizePricingPlans = (
|
||||
value: unknown,
|
||||
fallback: LandingPricingPlan[],
|
||||
) => {
|
||||
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 {
|
||||
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 sanitizeFaqItems = (value: unknown, fallback: LandingFaqItem[]) => {
|
||||
return sanitizeObjectArray(
|
||||
value,
|
||||
fallback,
|
||||
(item, _index, fallbackItem) => ({
|
||||
question: sanitizeString(item.question, fallbackItem.question),
|
||||
answer: sanitizeString(item.answer, fallbackItem.answer),
|
||||
}),
|
||||
{ question: '', answer: '' },
|
||||
);
|
||||
};
|
||||
|
||||
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 metadata = sanitizeBlockMetadata(value);
|
||||
const source = isRecord(value) ? value : {};
|
||||
@@ -495,21 +897,26 @@ const sanitizeLayout = (value: unknown): LandingPageBlock[] => {
|
||||
if (!isRecord(block) || typeof block.blockType !== 'string') return null;
|
||||
|
||||
switch (block.blockType) {
|
||||
case 'hero': {
|
||||
case 'hero':
|
||||
return sanitizeHeroBlock(block);
|
||||
}
|
||||
case 'features': {
|
||||
case 'features':
|
||||
return sanitizeFeaturesBlock(block);
|
||||
}
|
||||
case 'techStack': {
|
||||
case 'techStack':
|
||||
return sanitizeTechStackBlock(block);
|
||||
}
|
||||
case 'cta': {
|
||||
case 'testimonials':
|
||||
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);
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((block): block is LandingPageBlock => block !== null);
|
||||
@@ -520,8 +927,13 @@ const buildLegacyLandingPageLayout = (value: unknown): LandingPageBlock[] => {
|
||||
|
||||
return [
|
||||
sanitizeHeroBlock(source.hero),
|
||||
sanitizeLogoCloudBlock(undefined),
|
||||
sanitizeFeaturesBlock(source.features),
|
||||
sanitizeStatsBlock(undefined),
|
||||
sanitizeTechStackBlock(source.techStack),
|
||||
sanitizeTestimonialsBlock(undefined),
|
||||
sanitizePricingBlock(undefined),
|
||||
sanitizeFaqBlock(undefined),
|
||||
sanitizeCtaBlock(source.cta),
|
||||
];
|
||||
};
|
||||
@@ -545,14 +957,38 @@ export const createDefaultLandingPageLayoutForPayload =
|
||||
label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
blockType: 'logoCloud',
|
||||
...defaultLandingLogoCloudContent,
|
||||
},
|
||||
{
|
||||
blockType: 'features',
|
||||
...defaultLandingFeaturesContent,
|
||||
},
|
||||
{
|
||||
blockType: 'stats',
|
||||
...defaultLandingStatsContent,
|
||||
},
|
||||
{
|
||||
blockType: 'techStack',
|
||||
...defaultLandingTechStackContent,
|
||||
},
|
||||
{
|
||||
blockType: 'testimonials',
|
||||
...defaultLandingTestimonialsContent,
|
||||
},
|
||||
{
|
||||
blockType: 'pricing',
|
||||
...defaultLandingPricingContent,
|
||||
plans: defaultLandingPricingContent.plans.map((plan) => ({
|
||||
...plan,
|
||||
features: plan.features.map((label) => ({ label })),
|
||||
})),
|
||||
},
|
||||
{
|
||||
blockType: 'faq',
|
||||
...defaultLandingFaqContent,
|
||||
},
|
||||
{
|
||||
blockType: 'cta',
|
||||
...defaultLandingCtaContent,
|
||||
|
||||
46
apps/next/src/components/landing/faq.tsx
Normal file
46
apps/next/src/components/landing/faq.tsx
Normal 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>
|
||||
);
|
||||
@@ -1,5 +1,10 @@
|
||||
export { Hero } from './hero';
|
||||
export { Features } from './features';
|
||||
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 { LandingPageBuilder } from './page-builder';
|
||||
|
||||
32
apps/next/src/components/landing/logo-cloud.tsx
Normal file
32
apps/next/src/components/landing/logo-cloud.tsx
Normal 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>
|
||||
);
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { LandingPageBlock } from './content';
|
||||
import { CTA } from './cta';
|
||||
import { FAQ } from './faq';
|
||||
import { Features } from './features';
|
||||
import { Hero } from './hero';
|
||||
import { LogoCloud } from './logo-cloud';
|
||||
import { Pricing } from './pricing';
|
||||
import { Stats } from './stats';
|
||||
import { TechStack } from './tech-stack';
|
||||
import { Testimonials } from './testimonials';
|
||||
|
||||
interface LandingPageBuilderProps {
|
||||
blocks: LandingPageBlock[];
|
||||
@@ -22,6 +27,21 @@ export const LandingPageBuilder = ({ blocks }: LandingPageBuilderProps) => {
|
||||
case 'techStack': {
|
||||
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': {
|
||||
return <CTA key={key} content={block} />;
|
||||
}
|
||||
|
||||
85
apps/next/src/components/landing/pricing.tsx
Normal file
85
apps/next/src/components/landing/pricing.tsx
Normal 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>
|
||||
);
|
||||
43
apps/next/src/components/landing/stats.tsx
Normal file
43
apps/next/src/components/landing/stats.tsx
Normal 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>
|
||||
);
|
||||
46
apps/next/src/components/landing/testimonials.tsx
Normal file
46
apps/next/src/components/landing/testimonials.tsx
Normal 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'>
|
||||
“{item.quote}”
|
||||
</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>
|
||||
);
|
||||
@@ -1,21 +1,20 @@
|
||||
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 { getPayloadClient } from './get-payload';
|
||||
|
||||
export const getLandingPageContent = cache(
|
||||
async (isPreview = false): Promise<LandingPageContent> => {
|
||||
const payload = await getPayloadClient();
|
||||
const landingPage = await (
|
||||
payload as {
|
||||
findGlobal: (args: {
|
||||
slug: string;
|
||||
draft?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
).findGlobal({ slug: 'landing-page', draft: isPreview });
|
||||
export const getLandingPageContent = async (
|
||||
isPreview = false,
|
||||
): Promise<LandingPageContent> => {
|
||||
noStore();
|
||||
|
||||
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>);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { Block } from 'payload';
|
||||
|
||||
import {
|
||||
defaultLandingCtaContent,
|
||||
defaultLandingFaqContent,
|
||||
defaultLandingFeaturesContent,
|
||||
defaultLandingHeroContent,
|
||||
defaultLandingLogoCloudContent,
|
||||
defaultLandingPricingContent,
|
||||
defaultLandingStatsContent,
|
||||
defaultLandingTechStackContent,
|
||||
defaultLandingTestimonialsContent,
|
||||
} from '../../components/landing/content';
|
||||
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
labels: {
|
||||
|
||||
Reference in New Issue
Block a user