Add Page Builder: 15 block types + publish/unpublish via TinaCMS

- New TinaCMS collection 'pages' with 15 block templates:
  Hero, TextBlock, TwoColumn, Features, Stats, Testimonials,
  Team, FAQ, CTABanner, Video, Gallery, Pricing, Timeline,
  Divider, ContactForm
- Each page has published toggle (unpublished → 404)
- Route /p/:slug renders dynamic pages from content/pages/*.json
- scripts/copy-pages.mjs copies content/pages → public/pages at build
- prerender.mjs extended to prerender published pages
- All blocks styled with design tokens + Framer Motion animations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-18 22:29:51 +00:00
parent 1f1c7508b4
commit 6cd63f1bdf
40 changed files with 1988 additions and 2 deletions

0
content/pages/.gitkeep Normal file
View file

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "tinacms dev -c \"vite\"",
"build": "tinacms build && node scripts/sync-blog.mjs && node scripts/generate-sitemap.mjs && tsc -b && vite build && node scripts/prerender.mjs",
"build": "tinacms build && node scripts/sync-blog.mjs && node scripts/copy-pages.mjs && node scripts/generate-sitemap.mjs && tsc -b && vite build && node scripts/prerender.mjs",
"lint": "eslint .",
"preview": "vite preview",
"sync-blog": "node scripts/sync-blog.mjs",

22
scripts/copy-pages.mjs Normal file
View file

@ -0,0 +1,22 @@
import { readdirSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const src = resolve(root, 'content/pages');
const dest = resolve(root, 'public/pages');
if (!existsSync(src)) {
console.log('No content/pages directory, skipping.');
process.exit(0);
}
mkdirSync(dest, { recursive: true });
const files = readdirSync(src).filter(f => f.endsWith('.json'));
for (const file of files) {
copyFileSync(resolve(src, file), resolve(dest, file));
console.log(`Copied: ${file}`);
}
console.log(`Pages copy complete: ${files.length} file(s).`);

View file

@ -5,7 +5,7 @@
*
* Usage: node scripts/prerender.mjs
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -20,6 +20,19 @@ function getBlogRoutes() {
} catch { return []; }
}
function getPageRoutes() {
try {
const pagesDir = resolve(root, 'public/pages');
const files = readdirSync(pagesDir).filter(f => f.endsWith('.json'));
return files
.map(f => {
const data = JSON.parse(readFileSync(resolve(pagesDir, f), 'utf-8'));
return data.published ? `/p/${f.replace('.json', '')}` : null;
})
.filter(Boolean);
} catch { return []; }
}
const routes = [
'/',
'/about',
@ -29,6 +42,7 @@ const routes = [
'/privacy-policy',
'/terms-of-use',
...getBlogRoutes(),
...getPageRoutes(),
];
async function prerender() {

View file

@ -6,6 +6,7 @@ import ScrollToTop from './components/ScrollToTop';
import CookieConsent from './components/CookieConsent';
import React from 'react';
const ChatWidget = React.lazy(() => import('./components/ChatWidget'));
const DynamicPage = React.lazy(() => import('./pages/DynamicPage'));
import HomePage from './pages/HomePage';
import BlogPage from './pages/BlogPage';
import BlogPostPage from './pages/BlogPostPage';
@ -30,6 +31,7 @@ function App() {
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicyPage />} />
<Route path="/terms-of-use" element={<TermsOfUsePage />} />
<Route path="/p/:slug" element={<React.Suspense fallback={null}><DynamicPage /></React.Suspense>} />
</Routes>
<Footer />
<ScrollToTop />

View file

@ -0,0 +1,78 @@
.block-cta-banner {
padding: 80px 24px;
}
.block-cta-banner-inner {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.block-cta-banner-headline {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 20px;
line-height: 1.3;
}
.block-cta-banner-subtext {
font-size: 1.05rem;
line-height: 1.6;
margin-bottom: 32px;
opacity: 0.9;
}
.block-cta-banner-btn {
display: inline-block;
padding: 14px 32px;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.block-cta-banner--orange {
background-color: var(--orange-100);
}
.block-cta-banner--orange .block-cta-banner-headline {
color: white;
}
.block-cta-banner--orange .block-cta-banner-subtext {
color: white;
}
.block-cta-banner--orange .block-cta-banner-btn {
background-color: white;
color: var(--orange-100);
}
.block-cta-banner--orange .block-cta-banner-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
.block-cta-banner--teal {
background-color: var(--dark-teal-100);
}
.block-cta-banner--teal .block-cta-banner-headline {
color: var(--light-grey-100);
}
.block-cta-banner--teal .block-cta-banner-subtext {
color: var(--light-grey-100);
}
.block-cta-banner--teal .block-cta-banner-btn {
background-color: var(--orange-100);
color: white;
}
.block-cta-banner--teal .block-cta-banner-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(255, 91, 4, 0.3);
}

View file

@ -0,0 +1,38 @@
import { motion } from 'framer-motion';
import './BlockCTABanner.css';
interface Props {
headline?: string;
subtext?: string;
btnText?: string;
btnUrl?: string;
style?: 'orange' | 'teal';
}
const BlockCTABanner: React.FC<Props> = ({
headline,
subtext,
btnText,
btnUrl = '/',
style = 'orange',
}) => (
<motion.section
className={`block-cta-banner block-cta-banner--${style}`}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-cta-banner-inner">
{headline && <h2 className="block-cta-banner-headline">{headline}</h2>}
{subtext && <p className="block-cta-banner-subtext">{subtext}</p>}
{btnText && (
<a href={btnUrl} className="block-cta-banner-btn">
{btnText}
</a>
)}
</div>
</motion.section>
);
export default BlockCTABanner;

View file

@ -0,0 +1,13 @@
import React from 'react';
import ContactSection from '../ContactSection';
interface Props {
title?: string;
subtitle?: string;
}
const BlockContactForm: React.FC<Props> = () => {
return <ContactSection />;
};
export default BlockContactForm;

View file

@ -0,0 +1,25 @@
.block-divider {
width: 100%;
}
.block-divider--space.block-divider--small {
height: 40px;
}
.block-divider--space.block-divider--medium {
height: 80px;
}
.block-divider--space.block-divider--large {
height: 120px;
}
.block-divider--line {
padding: 0 24px;
}
.block-divider-line {
border: none;
border-top: 1px solid rgba(211, 221, 222, 0.2);
margin: 0;
}

View file

@ -0,0 +1,15 @@
import { motion } from 'framer-motion';
import './BlockDivider.css';
interface Props {
type?: 'line' | 'space';
size?: 'small' | 'medium' | 'large';
}
const BlockDivider: React.FC<Props> = ({ type = 'space', size = 'medium' }) => (
<div className={`block-divider block-divider--${type} block-divider--${size}`}>
{type === 'line' && <hr className="block-divider-line" />}
</div>
);
export default BlockDivider;

View file

@ -0,0 +1,72 @@
.block-faq {
padding: 80px 24px;
}
.block-faq-inner {
max-width: 800px;
margin: 0 auto;
}
.block-faq-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--light-grey-100);
text-align: center;
margin-bottom: 48px;
}
.block-faq-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.block-faq-item {
background: rgba(7, 80, 86, 0.1);
border: 1px solid rgba(211, 221, 222, 0.1);
border-radius: 10px;
overflow: hidden;
}
.block-faq-item[open] {
border-color: rgba(255, 91, 4, 0.3);
}
.block-faq-question {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
cursor: pointer;
font-size: 1.05rem;
font-weight: 600;
color: var(--light-grey-100);
list-style: none;
}
.block-faq-question::marker {
display: none;
}
.block-faq-question::-webkit-details-marker {
display: none;
}
.block-faq-question::after {
content: '▶';
color: var(--orange-100);
font-size: 0.75rem;
transition: transform 0.25s;
}
.block-faq-item[open] .block-faq-question::after {
transform: rotate(90deg);
}
.block-faq-answer {
padding: 0 24px 20px;
font-size: 0.95rem;
color: var(--light-grey-100);
opacity: 0.8;
line-height: 1.7;
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import { motion } from 'framer-motion';
import './BlockFAQ.css';
interface Props {
title?: string;
items?: Array<{ question?: string; answer?: string }>;
}
const BlockFAQ: React.FC<Props> = ({ title, items = [] }) => (
<motion.section
className="block-faq"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-faq-inner">
{title && <h2 className="block-faq-title">{title}</h2>}
<div className="block-faq-list">
{items.map((item, i) => (
<details key={i} className="block-faq-item">
<summary className="block-faq-question">{item.question}</summary>
<p className="block-faq-answer">{item.answer}</p>
</details>
))}
</div>
</div>
</motion.section>
);
export default BlockFAQ;

View file

@ -0,0 +1,59 @@
.block-features {
padding: 80px 24px;
}
.block-features-inner {
max-width: 1100px;
margin: 0 auto;
}
.block-features-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--light-grey-100);
text-align: center;
margin-bottom: 48px;
line-height: 1.3;
}
.block-features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
}
.block-features-card {
background: rgba(7, 80, 86, 0.15);
border: 1px solid rgba(211, 221, 222, 0.1);
border-radius: 12px;
padding: 32px;
transition: all 0.3s ease;
}
.block-features-card:hover {
background: rgba(7, 80, 86, 0.25);
border-color: rgba(211, 221, 222, 0.2);
transform: translateY(-4px);
}
.block-features-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 16px;
}
.block-features-card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--light-grey-100);
margin-bottom: 8px;
line-height: 1.4;
}
.block-features-card-desc {
font-size: 0.9rem;
color: var(--light-grey-100);
opacity: 0.75;
line-height: 1.6;
margin: 0;
}

View file

@ -0,0 +1,39 @@
import { motion } from 'framer-motion';
import './BlockFeatures.css';
interface Props {
title?: string;
items?: Array<{ icon?: string; title?: string; description?: string }>;
}
const BlockFeatures: React.FC<Props> = ({ title, items = [] }) => (
<motion.section
className="block-features"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-features-inner">
{title && <h2 className="block-features-title">{title}</h2>}
<div className="block-features-grid">
{items.map((item, i) => (
<motion.div
key={i}
className="block-features-card"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.08 }}
>
{item.icon && <span className="block-features-icon">{item.icon}</span>}
{item.title && <h3 className="block-features-card-title">{item.title}</h3>}
{item.description && <p className="block-features-card-desc">{item.description}</p>}
</motion.div>
))}
</div>
</div>
</motion.section>
);
export default BlockFeatures;

View file

@ -0,0 +1,38 @@
.block-gallery {
padding: 60px 24px;
}
.block-gallery-grid {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.block-gallery-item {
margin: 0;
padding: 0;
overflow: hidden;
}
.block-gallery-item img {
width: 100%;
height: 240px;
object-fit: cover;
border-radius: 8px;
display: block;
transition: transform 0.3s ease;
}
.block-gallery-item:hover img {
transform: scale(1.05);
}
.block-gallery-item figcaption {
font-size: 0.8rem;
color: var(--light-grey-100);
opacity: 0.7;
text-align: center;
margin-top: 8px;
}

View file

@ -0,0 +1,29 @@
import { motion } from 'framer-motion';
import './BlockGallery.css';
interface Props {
images?: Array<{ src?: string; caption?: string; alt?: string }>;
}
const BlockGallery: React.FC<Props> = ({ images = [] }) => (
<motion.section
className="block-gallery"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-gallery-grid">
{images.map((img, i) =>
img.src ? (
<figure key={i} className="block-gallery-item">
<img src={img.src} alt={img.alt || img.caption || ''} loading="lazy" />
{img.caption && <figcaption>{img.caption}</figcaption>}
</figure>
) : null
)}
</div>
</motion.section>
);
export default BlockGallery;

View file

@ -0,0 +1,61 @@
.block-hero {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
padding: 80px 24px;
}
.block-hero--dark {
background: transparent;
}
.block-hero--teal {
background: rgba(7, 80, 86, 0.3);
}
.block-hero--gradient {
background: linear-gradient(135deg, rgba(7, 80, 86, 0.4) 0%, rgba(255, 91, 4, 0.1) 100%);
}
.block-hero-inner {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.block-hero-headline {
font-size: clamp(2rem, 5vw, 4rem);
font-weight: 800;
color: var(--light-grey-100);
line-height: 1.2;
margin-bottom: 24px;
}
.block-hero-subtext {
font-size: 1.15rem;
color: var(--light-grey-100);
opacity: 0.8;
line-height: 1.7;
margin-bottom: 40px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.block-hero-cta {
display: inline-block;
background-color: var(--orange-100);
color: white;
text-decoration: none;
padding: 16px 40px;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
transition: transform 0.2s ease;
cursor: pointer;
}
.block-hero-cta:hover {
transform: translateY(-2px);
}

View file

@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import './BlockHero.css';
interface Props {
headline?: string;
subtext?: string;
ctaText?: string;
ctaUrl?: string;
backgroundStyle?: 'dark' | 'teal' | 'gradient';
}
const BlockHero: React.FC<Props> = ({
headline,
subtext,
ctaText,
ctaUrl = '/',
backgroundStyle = 'dark',
}) => (
<section className={`block-hero block-hero--${backgroundStyle}`}>
<motion.div
className="block-hero-inner"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{headline && <h1 className="block-hero-headline">{headline}</h1>}
{subtext && <p className="block-hero-subtext">{subtext}</p>}
{ctaText && (
<a href={ctaUrl} className="block-hero-cta">
{ctaText}
</a>
)}
</motion.div>
</section>
);
export default BlockHero;

View file

@ -0,0 +1,102 @@
.block-pricing {
padding: 80px 24px;
}
.block-pricing-inner {
max-width: 1100px;
margin: 0 auto;
}
.block-pricing-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--light-grey-100);
text-align: center;
margin-bottom: 48px;
}
.block-pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
align-items: start;
}
.block-pricing-card {
background: rgba(35, 48, 56, 0.8);
border: 1px solid rgba(211, 221, 222, 0.1);
border-radius: 16px;
padding: 36px;
position: relative;
display: flex;
flex-direction: column;
}
.block-pricing-card--highlighted {
border: 2px solid var(--orange-100);
background: rgba(255, 91, 4, 0.05);
}
.block-pricing-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--orange-100);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 4px 16px;
border-radius: 20px;
}
.block-pricing-name {
font-size: 1.2rem;
font-weight: 700;
color: var(--light-grey-100);
margin-bottom: 16px;
}
.block-pricing-price {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 24px;
}
.block-pricing-amount {
font-size: 2.5rem;
font-weight: 800;
color: var(--orange-100);
}
.block-pricing-period {
font-size: 1rem;
color: var(--light-grey-100);
opacity: 0.6;
}
.block-pricing-features {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.block-pricing-features li {
color: var(--light-grey-100);
opacity: 0.85;
font-size: 0.9rem;
padding-left: 24px;
position: relative;
}
.block-pricing-features li::before {
content: '✓';
position: absolute;
left: 0;
font-weight: 700;
color: #0a7a82;
}

View file

@ -0,0 +1,58 @@
import React from 'react';
import { motion } from 'framer-motion';
import './BlockPricing.css';
interface Plan {
name?: string;
price?: string;
period?: string;
features?: string[];
highlighted?: boolean;
}
interface Props {
title?: string;
plans?: Plan[];
}
const BlockPricing: React.FC<Props> = ({ title, plans = [] }) => (
<motion.section
className="block-pricing"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-pricing-inner">
{title && <h2 className="block-pricing-title">{title}</h2>}
<div className="block-pricing-grid">
{plans.map((plan, i) => (
<motion.div
key={i}
className={`block-pricing-card${plan.highlighted ? ' block-pricing-card--highlighted' : ''}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
{plan.highlighted && <span className="block-pricing-badge">Popular</span>}
{plan.name && <h3 className="block-pricing-name">{plan.name}</h3>}
<div className="block-pricing-price">
<span className="block-pricing-amount">{plan.price}</span>
{plan.period && <span className="block-pricing-period">/{plan.period}</span>}
</div>
{plan.features && plan.features.length > 0 && (
<ul className="block-pricing-features">
{plan.features.map((f, j) => (
<li key={j}>{f}</li>
))}
</ul>
)}
</motion.div>
))}
</div>
</div>
</motion.section>
);
export default BlockPricing;

View file

@ -0,0 +1,40 @@
import type { PageBlock } from '../../types/pages';
import BlockHero from './BlockHero';
import BlockTextBlock from './BlockTextBlock';
import BlockTwoColumn from './BlockTwoColumn';
import BlockFeatures from './BlockFeatures';
import BlockStats from './BlockStats';
import BlockTestimonials from './BlockTestimonials';
import BlockTeam from './BlockTeam';
import BlockFAQ from './BlockFAQ';
import BlockCTABanner from './BlockCTABanner';
import BlockVideo from './BlockVideo';
import BlockGallery from './BlockGallery';
import BlockPricing from './BlockPricing';
import BlockTimeline from './BlockTimeline';
import BlockDivider from './BlockDivider';
import BlockContactForm from './BlockContactForm';
interface Props { block: PageBlock; }
const BlockRenderer: React.FC<Props> = ({ block }) => {
switch (block._template) {
case 'hero': return <BlockHero {...block} />;
case 'textBlock': return <BlockTextBlock {...block} />;
case 'twoColumn': return <BlockTwoColumn {...block} />;
case 'features': return <BlockFeatures {...block} />;
case 'stats': return <BlockStats {...block} />;
case 'testimonials':return <BlockTestimonials {...block} />;
case 'team': return <BlockTeam {...block} />;
case 'faq': return <BlockFAQ {...block} />;
case 'ctaBanner': return <BlockCTABanner {...block} />;
case 'video': return <BlockVideo {...block} />;
case 'gallery': return <BlockGallery {...block} />;
case 'pricing': return <BlockPricing {...block} />;
case 'timeline': return <BlockTimeline {...block} />;
case 'divider': return <BlockDivider {...block} />;
case 'contactForm': return <BlockContactForm {...block} />;
default: return null;
}
};
export default BlockRenderer;

View file

@ -0,0 +1,32 @@
.block-stats {
padding: 60px 24px;
}
.block-stats-grid {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 32px;
}
.block-stats-item {
display: flex;
flex-direction: column;
align-items: center;
}
.block-stats-value {
font-size: 3rem;
font-weight: 700;
color: var(--orange-100);
line-height: 1.2;
}
.block-stats-label {
font-size: 0.9rem;
color: var(--light-grey-100);
opacity: 0.8;
margin-top: 8px;
text-align: center;
}

View file

@ -0,0 +1,27 @@
import { motion } from 'framer-motion';
import './BlockStats.css';
interface Props {
items?: Array<{ value?: string; label?: string }>;
}
const BlockStats: React.FC<Props> = ({ items = [] }) => (
<motion.section
className="block-stats"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-stats-grid">
{items.map((item, i) => (
<div key={i} className="block-stats-item">
<span className="block-stats-value">{item.value}</span>
<span className="block-stats-label">{item.label}</span>
</div>
))}
</div>
</motion.section>
);
export default BlockStats;

View file

@ -0,0 +1,71 @@
.block-team {
padding: 80px 24px;
}
.block-team-inner {
max-width: 1100px;
margin: 0 auto;
}
.block-team-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--light-grey-100);
text-align: center;
margin-bottom: 48px;
}
.block-team-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 32px;
}
.block-team-card {
text-align: center;
}
.block-team-photo {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
margin: 0 auto 16px;
display: block;
border: 3px solid var(--dark-teal-100);
}
.block-team-photo-placeholder {
width: 120px;
height: 120px;
background: var(--dark-teal-100);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--light-grey-100);
font-weight: 700;
margin: 0 auto 16px;
border-radius: 50%;
}
.block-team-name {
font-size: 1.15rem;
font-weight: 700;
color: var(--light-grey-100);
margin-bottom: 4px;
}
.block-team-role {
font-size: 0.85rem;
color: var(--orange-100);
font-weight: 500;
margin-bottom: 12px;
}
.block-team-bio {
font-size: 0.85rem;
color: var(--light-grey-100);
opacity: 0.7;
line-height: 1.6;
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { motion } from 'framer-motion';
import './BlockTeam.css';
interface Props {
title?: string;
items?: Array<{ name?: string; role?: string; bio?: string; photo?: string }>;
}
const BlockTeam: React.FC<Props> = ({ title, items = [] }) => (
<motion.section
className="block-team"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-team-inner">
{title && <h2 className="block-team-title">{title}</h2>}
<div className="block-team-grid">
{items.map((member, i) => (
<motion.div
key={i}
className="block-team-card"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
{member.photo ? (
<img src={member.photo} alt={member.name || ''} className="block-team-photo" />
) : (
<div className="block-team-photo-placeholder">{member.name?.[0] || '?'}</div>
)}
{member.name && <h3 className="block-team-name">{member.name}</h3>}
{member.role && <p className="block-team-role">{member.role}</p>}
{member.bio && <p className="block-team-bio">{member.bio}</p>}
</motion.div>
))}
</div>
</div>
</motion.section>
);
export default BlockTeam;

View file

@ -0,0 +1,72 @@
.block-testimonials {
padding: 80px 24px;
}
.block-testimonials-inner {
max-width: 1100px;
margin: 0 auto;
}
.block-testimonials-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--light-grey-100);
text-align: center;
margin-bottom: 48px;
}
.block-testimonials-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.block-testimonials-card {
background: rgba(7, 80, 86, 0.15);
border: 1px solid rgba(211, 221, 222, 0.1);
border-radius: 16px;
padding: 32px;
position: relative;
display: flex;
flex-direction: column;
}
.block-testimonials-card::before {
content: '"';
font-size: 5rem;
color: var(--orange-100);
opacity: 0.3;
position: absolute;
top: 12px;
left: 20px;
line-height: 1;
}
.block-testimonials-quote {
font-size: 1rem;
color: var(--light-grey-100);
opacity: 0.85;
line-height: 1.7;
font-style: italic;
margin-bottom: 20px;
padding-top: 20px;
}
.block-testimonials-author {
margin-top: auto;
}
.block-testimonials-name {
display: block;
font-weight: 600;
color: var(--light-grey-100);
font-size: 0.95rem;
}
.block-testimonials-meta {
display: block;
font-size: 0.8rem;
color: var(--light-grey-100);
opacity: 0.6;
margin-top: 2px;
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { motion } from 'framer-motion';
import './BlockTestimonials.css';
interface Props {
title?: string;
items?: Array<{ quote?: string; author?: string; role?: string; company?: string }>;
}
const BlockTestimonials: React.FC<Props> = ({ title, items = [] }) => (
<motion.section
className="block-testimonials"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-testimonials-inner">
{title && <h2 className="block-testimonials-title">{title}</h2>}
<div className="block-testimonials-grid">
{items.map((item, i) => (
<motion.div
key={i}
className="block-testimonials-card"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<p className="block-testimonials-quote">"{item.quote}"</p>
<div className="block-testimonials-author">
<span className="block-testimonials-name">{item.author}</span>
{(item.role || item.company) && (
<span className="block-testimonials-meta">
{[item.role, item.company].filter(Boolean).join(', ')}
</span>
)}
</div>
</motion.div>
))}
</div>
</div>
</motion.section>
);
export default BlockTestimonials;

View file

@ -0,0 +1,80 @@
.block-text-block {
padding: 60px 24px;
}
.block-text-block--full .block-text-block-inner {
max-width: 1100px;
margin: 0 auto;
}
.block-text-block--narrow .block-text-block-inner {
max-width: 680px;
margin: 0 auto;
}
.block-text-block-inner h1,
.block-text-block-inner h2,
.block-text-block-inner h3 {
color: var(--light-grey-100);
font-weight: 700;
margin-bottom: 16px;
line-height: 1.4;
}
.block-text-block-inner h1 {
font-size: 2.2rem;
}
.block-text-block-inner h2 {
font-size: 1.8rem;
}
.block-text-block-inner h3 {
font-size: 1.4rem;
}
.block-text-block-inner p {
color: var(--light-grey-100);
opacity: 0.85;
line-height: 1.8;
margin-bottom: 16px;
font-size: 1.05rem;
}
.block-text-block-inner a {
color: var(--orange-100);
text-decoration: underline;
transition: opacity 0.2s ease;
}
.block-text-block-inner a:hover {
opacity: 0.8;
}
.block-text-block-inner strong {
color: var(--light-grey-100);
font-weight: 700;
}
.block-text-block-inner ul,
.block-text-block-inner ol {
color: var(--light-grey-100);
opacity: 0.85;
padding-left: 24px;
margin-bottom: 16px;
line-height: 1.8;
}
.block-text-block-inner li {
margin-bottom: 8px;
}
.block-text-block-inner blockquote {
border-left: 3px solid var(--orange-100);
padding-left: 20px;
color: var(--light-grey-100);
opacity: 0.8;
font-style: italic;
margin: 24px 0;
line-height: 1.8;
}

View file

@ -0,0 +1,28 @@
import { TinaMarkdown } from 'tinacms/dist/rich-text';
import { motion } from 'framer-motion';
import './BlockTextBlock.css';
interface Props {
content?: unknown;
width?: 'full' | 'narrow';
}
const BlockTextBlock: React.FC<Props> = ({ content, width = 'full' }) => {
if (!content) return null;
return (
<motion.section
className={`block-text-block block-text-block--${width}`}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-text-block-inner">
<TinaMarkdown content={content as any} />
</div>
</motion.section>
);
};
export default BlockTextBlock;

View file

@ -0,0 +1,94 @@
.block-timeline {
padding: 80px 24px;
}
.block-timeline-inner {
max-width: 800px;
margin: 0 auto;
}
.block-timeline-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--light-grey-100);
text-align: center;
margin-bottom: 48px;
}
.block-timeline-steps {
display: flex;
flex-direction: column;
gap: 0;
}
.block-timeline-step {
display: flex;
gap: 24px;
position: relative;
padding-bottom: 40px;
}
.block-timeline-step::after {
content: '';
position: absolute;
left: 20px;
top: 44px;
bottom: 0;
width: 2px;
background: rgba(211, 221, 222, 0.1);
}
.block-timeline-step:last-child::after {
display: none;
}
.block-timeline-step-number {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 50%;
background: var(--orange-100);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
}
.block-timeline-step-content {
flex: 1;
padding-top: 8px;
}
.block-timeline-step-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.block-timeline-step-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--light-grey-100);
}
.block-timeline-step-duration {
font-size: 0.8rem;
color: var(--orange-100);
background: rgba(255, 91, 4, 0.1);
padding: 3px 12px;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
}
.block-timeline-step-desc {
font-size: 0.9rem;
color: var(--light-grey-100);
opacity: 0.75;
line-height: 1.7;
}

View file

@ -0,0 +1,51 @@
import React from 'react';
import { motion } from 'framer-motion';
import './BlockTimeline.css';
interface Step {
title?: string;
description?: string;
duration?: string;
}
interface Props {
title?: string;
steps?: Step[];
}
const BlockTimeline: React.FC<Props> = ({ title, steps = [] }) => (
<motion.section
className="block-timeline"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-timeline-inner">
{title && <h2 className="block-timeline-title">{title}</h2>}
<div className="block-timeline-steps">
{steps.map((step, i) => (
<motion.div
key={i}
className="block-timeline-step"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="block-timeline-step-number">{i + 1}</div>
<div className="block-timeline-step-content">
<div className="block-timeline-step-header">
{step.title && <h3 className="block-timeline-step-title">{step.title}</h3>}
{step.duration && <span className="block-timeline-step-duration">{step.duration}</span>}
</div>
{step.description && <p className="block-timeline-step-desc">{step.description}</p>}
</div>
</motion.div>
))}
</div>
</div>
</motion.section>
);
export default BlockTimeline;

View file

@ -0,0 +1,82 @@
.block-two-column {
padding: 80px 24px;
}
.block-two-column-inner {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
}
.block-two-column-left,
.block-two-column-right {
display: flex;
flex-direction: column;
justify-content: center;
}
.block-two-column-img {
width: 100%;
border-radius: 12px;
object-fit: cover;
display: block;
}
.block-two-column-left h1,
.block-two-column-left h2,
.block-two-column-left h3,
.block-two-column-right h1,
.block-two-column-right h2,
.block-two-column-right h3 {
color: var(--light-grey-100);
font-weight: 700;
line-height: 1.4;
margin-bottom: 16px;
}
.block-two-column-left p,
.block-two-column-right p {
color: var(--light-grey-100);
opacity: 0.85;
line-height: 1.8;
margin-bottom: 16px;
}
.block-two-column-left a,
.block-two-column-right a {
color: var(--orange-100);
text-decoration: underline;
}
.block-two-column-left strong,
.block-two-column-right strong {
color: var(--light-grey-100);
font-weight: 700;
}
.block-two-column-left ul,
.block-two-column-left ol,
.block-two-column-right ul,
.block-two-column-right ol {
color: var(--light-grey-100);
opacity: 0.85;
line-height: 1.8;
}
@media (max-width: 768px) {
.block-two-column-inner {
grid-template-columns: 1fr;
gap: 40px;
}
.block-two-column--reverse .block-two-column-left {
order: 2;
}
.block-two-column--reverse .block-two-column-right {
order: 1;
}
}

View file

@ -0,0 +1,50 @@
import { TinaMarkdown } from 'tinacms/dist/rich-text';
import { motion } from 'framer-motion';
import './BlockTwoColumn.css';
interface Props {
leftType?: 'text' | 'image';
leftText?: unknown;
leftImage?: string;
rightType?: 'text' | 'image';
rightText?: unknown;
rightImage?: string;
reverseOnMobile?: boolean;
}
const BlockTwoColumn: React.FC<Props> = ({
leftType = 'text',
leftText,
leftImage,
rightType = 'text',
rightText,
rightImage,
reverseOnMobile = false,
}) => (
<motion.section
className={`block-two-column${reverseOnMobile ? ' block-two-column--reverse' : ''}`}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-two-column-inner">
<div className="block-two-column-left">
{leftType === 'image' && leftImage ? (
<img src={leftImage} alt="" className="block-two-column-img" />
) : leftText ? (
<TinaMarkdown content={leftText as any} />
) : null}
</div>
<div className="block-two-column-right">
{rightType === 'image' && rightImage ? (
<img src={rightImage} alt="" className="block-two-column-img" />
) : rightText ? (
<TinaMarkdown content={rightText as any} />
) : null}
</div>
</div>
</motion.section>
);
export default BlockTwoColumn;

View file

@ -0,0 +1,36 @@
.block-video {
padding: 60px 24px;
}
.block-video-inner {
max-width: 900px;
margin: 0 auto;
}
.block-video-wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
margin-bottom: 0;
}
.block-video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 12px;
}
.block-video-caption {
text-align: center;
color: var(--light-grey-100);
opacity: 0.7;
font-size: 0.85rem;
margin-top: 12px;
margin-bottom: 0;
}

View file

@ -0,0 +1,43 @@
import { motion } from 'framer-motion';
import './BlockVideo.css';
interface Props {
youtubeUrl?: string;
caption?: string;
}
function getYouTubeId(url: string): string | null {
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
}
const BlockVideo: React.FC<Props> = ({ youtubeUrl, caption }) => {
if (!youtubeUrl) return null;
const videoId = getYouTubeId(youtubeUrl);
if (!videoId) return null;
return (
<motion.section
className="block-video"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="block-video-inner">
<div className="block-video-wrapper">
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
title={caption || 'Video'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
/>
</div>
{caption && <p className="block-video-caption">{caption}</p>}
</div>
</motion.section>
);
};
export default BlockVideo;

View file

@ -0,0 +1,9 @@
export { default as BlockDivider } from './BlockDivider';
export { default as BlockStats } from './BlockStats';
export { default as BlockCTABanner } from './BlockCTABanner';
export { default as BlockVideo } from './BlockVideo';
export { default as BlockGallery } from './BlockGallery';
export { default as BlockFeatures } from './BlockFeatures';
export { default as BlockHero } from './BlockHero';
export { default as BlockTextBlock } from './BlockTextBlock';
export { default as BlockTwoColumn } from './BlockTwoColumn';

33
src/pages/DynamicPage.css Normal file
View file

@ -0,0 +1,33 @@
.dynamic-page {
min-height: 60vh;
padding-top: 80px;
}
.dynamic-page-container {
max-width: 800px;
margin: 0 auto;
padding: 60px 24px;
}
.dynamic-page-loading,
.dynamic-page-not-found {
color: var(--light-grey-100);
font-family: var(--font-primary);
text-align: center;
padding: 60px 0;
}
.dynamic-page-not-found {
font-size: 2rem;
font-weight: 600;
}
.dynamic-page-back {
display: inline-block;
color: var(--orange-100);
text-decoration: none;
font-family: var(--font-primary);
font-size: 0.9rem;
margin-bottom: 24px;
transition: opacity 0.2s;
}
.dynamic-page-back:hover { opacity: 0.7; }
.dynamic-page-blocks {
width: 100%;
}

58
src/pages/DynamicPage.tsx Normal file
View file

@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import SEO from '../components/SEO';
import BlockRenderer from '../components/blocks/BlockRenderer';
import type { PageData, PageBlock } from '../types/pages';
import './DynamicPage.css';
const DynamicPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const [page, setPage] = useState<PageData | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!slug) return;
fetch(`/pages/${slug}.json`)
.then(r => { if (!r.ok) throw new Error('not found'); return r.json(); })
.then((data: PageData) => {
if (!data.published) throw new Error('not published');
setPage(data);
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [slug]);
if (loading) return <main className="dynamic-page"><div className="dynamic-page-container"><p className="dynamic-page-loading">Loading...</p></div></main>;
if (notFound || !page) return (
<main className="dynamic-page">
<div className="dynamic-page-container">
<Link to="/" className="dynamic-page-back"> Back to Home</Link>
<h1 className="dynamic-page-not-found">Page not found</h1>
</div>
</main>
);
return (
<main className="dynamic-page">
<SEO
title={page.seo?.title || `${page.title} | AImpress`}
description={page.seo?.description || page.title}
url={`https://ai-impress.com/p/${slug}`}
/>
<motion.div
className="dynamic-page-blocks"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
{(page.blocks || []).map((block, i) => (
<BlockRenderer key={i} block={block} />
))}
</motion.div>
</main>
);
};
export default DynamicPage;

96
src/types/pages.ts Normal file
View file

@ -0,0 +1,96 @@
export interface BlockHero {
_template: 'hero';
headline?: string;
subtext?: string;
ctaText?: string;
ctaUrl?: string;
backgroundStyle?: 'dark' | 'teal' | 'gradient';
}
export interface BlockTextBlock {
_template: 'textBlock';
content?: unknown; // TinaCMS rich-text AST
width?: 'full' | 'narrow';
}
export interface BlockTwoColumn {
_template: 'twoColumn';
leftType?: 'text' | 'image';
leftText?: unknown;
leftImage?: string;
rightType?: 'text' | 'image';
rightText?: unknown;
rightImage?: string;
reverseOnMobile?: boolean;
}
export interface BlockFeatures {
_template: 'features';
title?: string;
items?: Array<{ icon?: string; title?: string; description?: string }>;
}
export interface BlockStats {
_template: 'stats';
items?: Array<{ value?: string; label?: string }>;
}
export interface BlockTestimonials {
_template: 'testimonials';
title?: string;
items?: Array<{ quote?: string; author?: string; role?: string; company?: string }>;
}
export interface BlockTeam {
_template: 'team';
title?: string;
items?: Array<{ name?: string; role?: string; bio?: string; photo?: string }>;
}
export interface BlockFAQ {
_template: 'faq';
title?: string;
items?: Array<{ question?: string; answer?: string }>;
}
export interface BlockCTABanner {
_template: 'ctaBanner';
headline?: string;
subtext?: string;
btnText?: string;
btnUrl?: string;
style?: 'orange' | 'teal';
}
export interface BlockVideo {
_template: 'video';
youtubeUrl?: string;
caption?: string;
}
export interface BlockGallery {
_template: 'gallery';
images?: Array<{ src?: string; caption?: string; alt?: string }>;
}
export interface BlockPricing {
_template: 'pricing';
title?: string;
plans?: Array<{ name?: string; price?: string; period?: string; features?: string[]; highlighted?: boolean }>;
}
export interface BlockTimeline {
_template: 'timeline';
title?: string;
steps?: Array<{ title?: string; description?: string; duration?: string }>;
}
export interface BlockDivider {
_template: 'divider';
type?: 'line' | 'space';
size?: 'small' | 'medium' | 'large';
}
export interface BlockContactForm {
_template: 'contactForm';
title?: string;
subtitle?: string;
}
export type PageBlock =
| BlockHero | BlockTextBlock | BlockTwoColumn | BlockFeatures | BlockStats
| BlockTestimonials | BlockTeam | BlockFAQ | BlockCTABanner | BlockVideo
| BlockGallery | BlockPricing | BlockTimeline | BlockDivider | BlockContactForm;
export interface PageData {
title: string;
published: boolean;
seo?: { title?: string; description?: string };
blocks?: PageBlock[];
}

View file

@ -533,6 +533,265 @@ export default defineConfig({
{ name: "body", type: "rich-text", label: "Body", isBody: true },
],
},
{
name: "pages",
label: "Pages",
path: "content/pages",
format: "json",
ui: {
router: ({ document }: { document: { _sys: { filename: string } } }) =>
`/p/${document._sys.filename}`,
filename: {
readonly: false,
slugify: (values: Record<string, unknown>) => {
const title = values.title as string | undefined;
return title
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)
: 'new-page';
},
},
},
fields: [
{ name: "title", type: "string" as const, label: "Page Title", required: true, isTitle: true },
{ name: "published", type: "boolean" as const, label: "Published (visible on site)" },
{
name: "seo",
type: "object" as const,
label: "SEO",
ui: { allowedActions: { create: false, delete: false } } as any,
fields: [
{ name: "title", type: "string" as const, label: "SEO Title" },
{ name: "description", type: "string" as const, label: "SEO Description", ui: { component: "textarea" } },
],
},
{
name: "blocks",
type: "object" as const,
label: "Page Blocks",
list: true,
templates: [
{
name: "hero",
label: "Hero",
fields: [
{ name: "headline", type: "string" as const, label: "Headline" },
{ name: "subtext", type: "string" as const, label: "Subtext", ui: { component: "textarea" } },
{ name: "ctaText", type: "string" as const, label: "Button Text" },
{ name: "ctaUrl", type: "string" as const, label: "Button URL" },
{ name: "backgroundStyle", type: "string" as const, label: "Background", options: ["dark", "teal", "gradient"] },
],
},
{
name: "textBlock",
label: "Text Block",
fields: [
{ name: "content", type: "rich-text" as const, label: "Content" },
{ name: "width", type: "string" as const, label: "Width", options: ["full", "narrow"] },
],
},
{
name: "twoColumn",
label: "Two Column",
fields: [
{ name: "leftType", type: "string" as const, label: "Left Side Type", options: ["text", "image"] },
{ name: "leftText", type: "rich-text" as const, label: "Left Text" },
{ name: "leftImage", type: "image" as const, label: "Left Image" },
{ name: "rightType", type: "string" as const, label: "Right Side Type", options: ["text", "image"] },
{ name: "rightText", type: "rich-text" as const, label: "Right Text" },
{ name: "rightImage", type: "image" as const, label: "Right Image" },
{ name: "reverseOnMobile",type: "boolean" as const, label: "Reverse on Mobile" },
],
},
{
name: "features",
label: "Features Grid",
fields: [
{ name: "title", type: "string" as const, label: "Section Title" },
{
name: "items",
type: "object" as const,
label: "Features",
list: true,
ui: { itemProps: (item: any) => ({ label: item.title || 'Feature' }) },
fields: [
{ name: "icon", type: "string" as const, label: "Icon (emoji)" },
{ name: "title", type: "string" as const, label: "Title" },
{ name: "description", type: "string" as const, label: "Description", ui: { component: "textarea" } },
],
},
],
},
{
name: "stats",
label: "Stats",
fields: [
{
name: "items",
type: "object" as const,
label: "Stats",
list: true,
ui: { itemProps: (item: any) => ({ label: item.value || 'Stat' }) },
fields: [
{ name: "value", type: "string" as const, label: "Value (e.g. 95%)" },
{ name: "label", type: "string" as const, label: "Label" },
],
},
],
},
{
name: "testimonials",
label: "Testimonials",
fields: [
{ name: "title", type: "string" as const, label: "Section Title" },
{
name: "items",
type: "object" as const,
label: "Testimonials",
list: true,
ui: { itemProps: (item: any) => ({ label: item.author || 'Testimonial' }) },
fields: [
{ name: "quote", type: "string" as const, label: "Quote", ui: { component: "textarea" } },
{ name: "author", type: "string" as const, label: "Author Name" },
{ name: "role", type: "string" as const, label: "Role" },
{ name: "company", type: "string" as const, label: "Company" },
],
},
],
},
{
name: "team",
label: "Team",
fields: [
{ name: "title", type: "string" as const, label: "Section Title" },
{
name: "items",
type: "object" as const,
label: "Team Members",
list: true,
ui: { itemProps: (item: any) => ({ label: item.name || 'Member' }) },
fields: [
{ name: "name", type: "string" as const, label: "Name" },
{ name: "role", type: "string" as const, label: "Role" },
{ name: "bio", type: "string" as const, label: "Bio", ui: { component: "textarea" } },
{ name: "photo", type: "image" as const, label: "Photo" },
],
},
],
},
{
name: "faq",
label: "FAQ",
fields: [
{ name: "title", type: "string" as const, label: "Section Title" },
{
name: "items",
type: "object" as const,
label: "Questions",
list: true,
ui: { itemProps: (item: any) => ({ label: item.question || 'Question' }) },
fields: [
{ name: "question", type: "string" as const, label: "Question" },
{ name: "answer", type: "string" as const, label: "Answer", ui: { component: "textarea" } },
],
},
],
},
{
name: "ctaBanner",
label: "CTA Banner",
fields: [
{ name: "headline", type: "string" as const, label: "Headline" },
{ name: "subtext", type: "string" as const, label: "Subtext", ui: { component: "textarea" } },
{ name: "btnText", type: "string" as const, label: "Button Text" },
{ name: "btnUrl", type: "string" as const, label: "Button URL" },
{ name: "style", type: "string" as const, label: "Style", options: ["orange", "teal"] },
],
},
{
name: "video",
label: "Video",
fields: [
{ name: "youtubeUrl", type: "string" as const, label: "YouTube URL" },
{ name: "caption", type: "string" as const, label: "Caption" },
],
},
{
name: "gallery",
label: "Image Gallery",
fields: [
{
name: "images",
type: "object" as const,
label: "Images",
list: true,
fields: [
{ name: "src", type: "image" as const, label: "Image" },
{ name: "caption", type: "string" as const, label: "Caption" },
{ name: "alt", type: "string" as const, label: "Alt Text" },
],
},
],
},
{
name: "pricing",
label: "Pricing",
fields: [
{ name: "title", type: "string" as const, label: "Section Title" },
{
name: "plans",
type: "object" as const,
label: "Plans",
list: true,
ui: { itemProps: (item: any) => ({ label: item.name || 'Plan' }) },
fields: [
{ name: "name", type: "string" as const, label: "Plan Name" },
{ name: "price", type: "string" as const, label: "Price (e.g. £499)" },
{ name: "period", type: "string" as const, label: "Period (e.g. month)" },
{ name: "features", type: "string" as const, label: "Features", list: true },
{ name: "highlighted", type: "boolean" as const, label: "Highlighted (Popular)" },
],
},
],
},
{
name: "timeline",
label: "Timeline",
fields: [
{ name: "title", type: "string" as const, label: "Section Title" },
{
name: "steps",
type: "object" as const,
label: "Steps",
list: true,
ui: { itemProps: (item: any) => ({ label: item.title || 'Step' }) },
fields: [
{ name: "title", type: "string" as const, label: "Step Title" },
{ name: "description", type: "string" as const, label: "Description", ui: { component: "textarea" } },
{ name: "duration", type: "string" as const, label: "Duration (e.g. Week 1-2)" },
],
},
],
},
{
name: "divider",
label: "Divider / Spacer",
fields: [
{ name: "type", type: "string" as const, label: "Type", options: ["line", "space"] },
{ name: "size", type: "string" as const, label: "Size", options: ["small", "medium", "large"] },
],
},
{
name: "contactForm",
label: "Contact Form",
fields: [
{ name: "title", type: "string" as const, label: "Title (optional)" },
{ name: "subtitle", type: "string" as const, label: "Subtitle (optional)", ui: { component: "textarea" } },
],
},
],
},
],
},
],
},
});