diff --git a/content/pages/.gitkeep b/content/pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index d7623a5..4dec62b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/copy-pages.mjs b/scripts/copy-pages.mjs new file mode 100644 index 0000000..8038384 --- /dev/null +++ b/scripts/copy-pages.mjs @@ -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).`); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 945c779..f0b50e3 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -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() { diff --git a/src/App.tsx b/src/App.tsx index 01bbf10..bf13b69 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/src/components/blocks/BlockCTABanner.css b/src/components/blocks/BlockCTABanner.css new file mode 100644 index 0000000..8151d13 --- /dev/null +++ b/src/components/blocks/BlockCTABanner.css @@ -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); +} diff --git a/src/components/blocks/BlockCTABanner.tsx b/src/components/blocks/BlockCTABanner.tsx new file mode 100644 index 0000000..41aa4ac --- /dev/null +++ b/src/components/blocks/BlockCTABanner.tsx @@ -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 = ({ + headline, + subtext, + btnText, + btnUrl = '/', + style = 'orange', +}) => ( + + + {headline && {headline}} + {subtext && {subtext}} + {btnText && ( + + {btnText} + + )} + + +); + +export default BlockCTABanner; diff --git a/src/components/blocks/BlockContactForm.tsx b/src/components/blocks/BlockContactForm.tsx new file mode 100644 index 0000000..1f4ba6f --- /dev/null +++ b/src/components/blocks/BlockContactForm.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ContactSection from '../ContactSection'; + +interface Props { + title?: string; + subtitle?: string; +} + +const BlockContactForm: React.FC = () => { + return ; +}; + +export default BlockContactForm; diff --git a/src/components/blocks/BlockDivider.css b/src/components/blocks/BlockDivider.css new file mode 100644 index 0000000..dc7cb39 --- /dev/null +++ b/src/components/blocks/BlockDivider.css @@ -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; +} diff --git a/src/components/blocks/BlockDivider.tsx b/src/components/blocks/BlockDivider.tsx new file mode 100644 index 0000000..4427b17 --- /dev/null +++ b/src/components/blocks/BlockDivider.tsx @@ -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 = ({ type = 'space', size = 'medium' }) => ( + + {type === 'line' && } + +); + +export default BlockDivider; diff --git a/src/components/blocks/BlockFAQ.css b/src/components/blocks/BlockFAQ.css new file mode 100644 index 0000000..61673dd --- /dev/null +++ b/src/components/blocks/BlockFAQ.css @@ -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; +} diff --git a/src/components/blocks/BlockFAQ.tsx b/src/components/blocks/BlockFAQ.tsx new file mode 100644 index 0000000..293520a --- /dev/null +++ b/src/components/blocks/BlockFAQ.tsx @@ -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 = ({ title, items = [] }) => ( + + + {title && {title}} + + {items.map((item, i) => ( + + {item.question} + {item.answer} + + ))} + + + +); + +export default BlockFAQ; diff --git a/src/components/blocks/BlockFeatures.css b/src/components/blocks/BlockFeatures.css new file mode 100644 index 0000000..53c8d19 --- /dev/null +++ b/src/components/blocks/BlockFeatures.css @@ -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; +} diff --git a/src/components/blocks/BlockFeatures.tsx b/src/components/blocks/BlockFeatures.tsx new file mode 100644 index 0000000..ca5149f --- /dev/null +++ b/src/components/blocks/BlockFeatures.tsx @@ -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 = ({ title, items = [] }) => ( + + + {title && {title}} + + {items.map((item, i) => ( + + {item.icon && {item.icon}} + {item.title && {item.title}} + {item.description && {item.description}} + + ))} + + + +); + +export default BlockFeatures; diff --git a/src/components/blocks/BlockGallery.css b/src/components/blocks/BlockGallery.css new file mode 100644 index 0000000..1331cc4 --- /dev/null +++ b/src/components/blocks/BlockGallery.css @@ -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; +} diff --git a/src/components/blocks/BlockGallery.tsx b/src/components/blocks/BlockGallery.tsx new file mode 100644 index 0000000..e83180e --- /dev/null +++ b/src/components/blocks/BlockGallery.tsx @@ -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 = ({ images = [] }) => ( + + + {images.map((img, i) => + img.src ? ( + + + {img.caption && {img.caption}} + + ) : null + )} + + +); + +export default BlockGallery; diff --git a/src/components/blocks/BlockHero.css b/src/components/blocks/BlockHero.css new file mode 100644 index 0000000..fb31692 --- /dev/null +++ b/src/components/blocks/BlockHero.css @@ -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); +} diff --git a/src/components/blocks/BlockHero.tsx b/src/components/blocks/BlockHero.tsx new file mode 100644 index 0000000..0565ac4 --- /dev/null +++ b/src/components/blocks/BlockHero.tsx @@ -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 = ({ + headline, + subtext, + ctaText, + ctaUrl = '/', + backgroundStyle = 'dark', +}) => ( + + + {headline && {headline}} + {subtext && {subtext}} + {ctaText && ( + + {ctaText} + + )} + + +); + +export default BlockHero; diff --git a/src/components/blocks/BlockPricing.css b/src/components/blocks/BlockPricing.css new file mode 100644 index 0000000..8f39e0d --- /dev/null +++ b/src/components/blocks/BlockPricing.css @@ -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; +} diff --git a/src/components/blocks/BlockPricing.tsx b/src/components/blocks/BlockPricing.tsx new file mode 100644 index 0000000..68ff952 --- /dev/null +++ b/src/components/blocks/BlockPricing.tsx @@ -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 = ({ title, plans = [] }) => ( + + + {title && {title}} + + {plans.map((plan, i) => ( + + {plan.highlighted && Popular} + {plan.name && {plan.name}} + + {plan.price} + {plan.period && /{plan.period}} + + {plan.features && plan.features.length > 0 && ( + + {plan.features.map((f, j) => ( + {f} + ))} + + )} + + ))} + + + +); + +export default BlockPricing; diff --git a/src/components/blocks/BlockRenderer.tsx b/src/components/blocks/BlockRenderer.tsx new file mode 100644 index 0000000..94efdf3 --- /dev/null +++ b/src/components/blocks/BlockRenderer.tsx @@ -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 = ({ block }) => { + switch (block._template) { + case 'hero': return ; + case 'textBlock': return ; + case 'twoColumn': return ; + case 'features': return ; + case 'stats': return ; + case 'testimonials':return ; + case 'team': return ; + case 'faq': return ; + case 'ctaBanner': return ; + case 'video': return ; + case 'gallery': return ; + case 'pricing': return ; + case 'timeline': return ; + case 'divider': return ; + case 'contactForm': return ; + default: return null; + } +}; +export default BlockRenderer; diff --git a/src/components/blocks/BlockStats.css b/src/components/blocks/BlockStats.css new file mode 100644 index 0000000..a7c9ce5 --- /dev/null +++ b/src/components/blocks/BlockStats.css @@ -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; +} diff --git a/src/components/blocks/BlockStats.tsx b/src/components/blocks/BlockStats.tsx new file mode 100644 index 0000000..176d34d --- /dev/null +++ b/src/components/blocks/BlockStats.tsx @@ -0,0 +1,27 @@ +import { motion } from 'framer-motion'; +import './BlockStats.css'; + +interface Props { + items?: Array<{ value?: string; label?: string }>; +} + +const BlockStats: React.FC = ({ items = [] }) => ( + + + {items.map((item, i) => ( + + {item.value} + {item.label} + + ))} + + +); + +export default BlockStats; diff --git a/src/components/blocks/BlockTeam.css b/src/components/blocks/BlockTeam.css new file mode 100644 index 0000000..e2d94ab --- /dev/null +++ b/src/components/blocks/BlockTeam.css @@ -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; +} diff --git a/src/components/blocks/BlockTeam.tsx b/src/components/blocks/BlockTeam.tsx new file mode 100644 index 0000000..5aff3ae --- /dev/null +++ b/src/components/blocks/BlockTeam.tsx @@ -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 = ({ title, items = [] }) => ( + + + {title && {title}} + + {items.map((member, i) => ( + + {member.photo ? ( + + ) : ( + {member.name?.[0] || '?'} + )} + {member.name && {member.name}} + {member.role && {member.role}} + {member.bio && {member.bio}} + + ))} + + + +); + +export default BlockTeam; diff --git a/src/components/blocks/BlockTestimonials.css b/src/components/blocks/BlockTestimonials.css new file mode 100644 index 0000000..9e3a4a8 --- /dev/null +++ b/src/components/blocks/BlockTestimonials.css @@ -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; +} diff --git a/src/components/blocks/BlockTestimonials.tsx b/src/components/blocks/BlockTestimonials.tsx new file mode 100644 index 0000000..33db0ac --- /dev/null +++ b/src/components/blocks/BlockTestimonials.tsx @@ -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 = ({ title, items = [] }) => ( + + + {title && {title}} + + {items.map((item, i) => ( + + "{item.quote}" + + {item.author} + {(item.role || item.company) && ( + + {[item.role, item.company].filter(Boolean).join(', ')} + + )} + + + ))} + + + +); + +export default BlockTestimonials; diff --git a/src/components/blocks/BlockTextBlock.css b/src/components/blocks/BlockTextBlock.css new file mode 100644 index 0000000..32f197f --- /dev/null +++ b/src/components/blocks/BlockTextBlock.css @@ -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; +} diff --git a/src/components/blocks/BlockTextBlock.tsx b/src/components/blocks/BlockTextBlock.tsx new file mode 100644 index 0000000..85c920a --- /dev/null +++ b/src/components/blocks/BlockTextBlock.tsx @@ -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 = ({ content, width = 'full' }) => { + if (!content) return null; + + return ( + + + + + + ); +}; + +export default BlockTextBlock; diff --git a/src/components/blocks/BlockTimeline.css b/src/components/blocks/BlockTimeline.css new file mode 100644 index 0000000..1895b2a --- /dev/null +++ b/src/components/blocks/BlockTimeline.css @@ -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; +} diff --git a/src/components/blocks/BlockTimeline.tsx b/src/components/blocks/BlockTimeline.tsx new file mode 100644 index 0000000..b3b7be4 --- /dev/null +++ b/src/components/blocks/BlockTimeline.tsx @@ -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 = ({ title, steps = [] }) => ( + + + {title && {title}} + + {steps.map((step, i) => ( + + {i + 1} + + + {step.title && {step.title}} + {step.duration && {step.duration}} + + {step.description && {step.description}} + + + ))} + + + +); + +export default BlockTimeline; diff --git a/src/components/blocks/BlockTwoColumn.css b/src/components/blocks/BlockTwoColumn.css new file mode 100644 index 0000000..3f07302 --- /dev/null +++ b/src/components/blocks/BlockTwoColumn.css @@ -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; + } +} diff --git a/src/components/blocks/BlockTwoColumn.tsx b/src/components/blocks/BlockTwoColumn.tsx new file mode 100644 index 0000000..3624c5f --- /dev/null +++ b/src/components/blocks/BlockTwoColumn.tsx @@ -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 = ({ + leftType = 'text', + leftText, + leftImage, + rightType = 'text', + rightText, + rightImage, + reverseOnMobile = false, +}) => ( + + + + {leftType === 'image' && leftImage ? ( + + ) : leftText ? ( + + ) : null} + + + {rightType === 'image' && rightImage ? ( + + ) : rightText ? ( + + ) : null} + + + +); + +export default BlockTwoColumn; diff --git a/src/components/blocks/BlockVideo.css b/src/components/blocks/BlockVideo.css new file mode 100644 index 0000000..c33b7f2 --- /dev/null +++ b/src/components/blocks/BlockVideo.css @@ -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; +} diff --git a/src/components/blocks/BlockVideo.tsx b/src/components/blocks/BlockVideo.tsx new file mode 100644 index 0000000..0f14e54 --- /dev/null +++ b/src/components/blocks/BlockVideo.tsx @@ -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 = ({ youtubeUrl, caption }) => { + if (!youtubeUrl) return null; + const videoId = getYouTubeId(youtubeUrl); + if (!videoId) return null; + + return ( + + + + + + {caption && {caption}} + + + ); +}; + +export default BlockVideo; diff --git a/src/components/blocks/index.ts b/src/components/blocks/index.ts new file mode 100644 index 0000000..b5be249 --- /dev/null +++ b/src/components/blocks/index.ts @@ -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'; diff --git a/src/pages/DynamicPage.css b/src/pages/DynamicPage.css new file mode 100644 index 0000000..e85b48c --- /dev/null +++ b/src/pages/DynamicPage.css @@ -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%; +} diff --git a/src/pages/DynamicPage.tsx b/src/pages/DynamicPage.tsx new file mode 100644 index 0000000..355e197 --- /dev/null +++ b/src/pages/DynamicPage.tsx @@ -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(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 Loading...; + + if (notFound || !page) return ( + + + ← Back to Home + Page not found + + + ); + + return ( + + + + {(page.blocks || []).map((block, i) => ( + + ))} + + + ); +}; +export default DynamicPage; diff --git a/src/types/pages.ts b/src/types/pages.ts new file mode 100644 index 0000000..5681cd5 --- /dev/null +++ b/src/types/pages.ts @@ -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[]; +} diff --git a/tina/config.ts b/tina/config.ts index 1d0359c..6bf53ec 100644 --- a/tina/config.ts +++ b/tina/config.ts @@ -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) => { + 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" } }, + ], + }, + ], + }, + ], + }, ], }, });
{subtext}
{item.answer}
{item.description}
{member.role}
{member.bio}
"{item.quote}"
{step.description}
{caption}
Loading...