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:
parent
1f1c7508b4
commit
6cd63f1bdf
40 changed files with 1988 additions and 2 deletions
0
content/pages/.gitkeep
Normal file
0
content/pages/.gitkeep
Normal 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
22
scripts/copy-pages.mjs
Normal 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).`);
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
78
src/components/blocks/BlockCTABanner.css
Normal file
78
src/components/blocks/BlockCTABanner.css
Normal 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);
|
||||
}
|
||||
38
src/components/blocks/BlockCTABanner.tsx
Normal file
38
src/components/blocks/BlockCTABanner.tsx
Normal 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;
|
||||
13
src/components/blocks/BlockContactForm.tsx
Normal file
13
src/components/blocks/BlockContactForm.tsx
Normal 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;
|
||||
25
src/components/blocks/BlockDivider.css
Normal file
25
src/components/blocks/BlockDivider.css
Normal 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;
|
||||
}
|
||||
15
src/components/blocks/BlockDivider.tsx
Normal file
15
src/components/blocks/BlockDivider.tsx
Normal 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;
|
||||
72
src/components/blocks/BlockFAQ.css
Normal file
72
src/components/blocks/BlockFAQ.css
Normal 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;
|
||||
}
|
||||
32
src/components/blocks/BlockFAQ.tsx
Normal file
32
src/components/blocks/BlockFAQ.tsx
Normal 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;
|
||||
59
src/components/blocks/BlockFeatures.css
Normal file
59
src/components/blocks/BlockFeatures.css
Normal 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;
|
||||
}
|
||||
39
src/components/blocks/BlockFeatures.tsx
Normal file
39
src/components/blocks/BlockFeatures.tsx
Normal 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;
|
||||
38
src/components/blocks/BlockGallery.css
Normal file
38
src/components/blocks/BlockGallery.css
Normal 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;
|
||||
}
|
||||
29
src/components/blocks/BlockGallery.tsx
Normal file
29
src/components/blocks/BlockGallery.tsx
Normal 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;
|
||||
61
src/components/blocks/BlockHero.css
Normal file
61
src/components/blocks/BlockHero.css
Normal 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);
|
||||
}
|
||||
37
src/components/blocks/BlockHero.tsx
Normal file
37
src/components/blocks/BlockHero.tsx
Normal 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;
|
||||
102
src/components/blocks/BlockPricing.css
Normal file
102
src/components/blocks/BlockPricing.css
Normal 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;
|
||||
}
|
||||
58
src/components/blocks/BlockPricing.tsx
Normal file
58
src/components/blocks/BlockPricing.tsx
Normal 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;
|
||||
40
src/components/blocks/BlockRenderer.tsx
Normal file
40
src/components/blocks/BlockRenderer.tsx
Normal 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;
|
||||
32
src/components/blocks/BlockStats.css
Normal file
32
src/components/blocks/BlockStats.css
Normal 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;
|
||||
}
|
||||
27
src/components/blocks/BlockStats.tsx
Normal file
27
src/components/blocks/BlockStats.tsx
Normal 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;
|
||||
71
src/components/blocks/BlockTeam.css
Normal file
71
src/components/blocks/BlockTeam.css
Normal 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;
|
||||
}
|
||||
45
src/components/blocks/BlockTeam.tsx
Normal file
45
src/components/blocks/BlockTeam.tsx
Normal 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;
|
||||
72
src/components/blocks/BlockTestimonials.css
Normal file
72
src/components/blocks/BlockTestimonials.css
Normal 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;
|
||||
}
|
||||
46
src/components/blocks/BlockTestimonials.tsx
Normal file
46
src/components/blocks/BlockTestimonials.tsx
Normal 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;
|
||||
80
src/components/blocks/BlockTextBlock.css
Normal file
80
src/components/blocks/BlockTextBlock.css
Normal 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;
|
||||
}
|
||||
28
src/components/blocks/BlockTextBlock.tsx
Normal file
28
src/components/blocks/BlockTextBlock.tsx
Normal 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;
|
||||
94
src/components/blocks/BlockTimeline.css
Normal file
94
src/components/blocks/BlockTimeline.css
Normal 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;
|
||||
}
|
||||
51
src/components/blocks/BlockTimeline.tsx
Normal file
51
src/components/blocks/BlockTimeline.tsx
Normal 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;
|
||||
82
src/components/blocks/BlockTwoColumn.css
Normal file
82
src/components/blocks/BlockTwoColumn.css
Normal 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;
|
||||
}
|
||||
}
|
||||
50
src/components/blocks/BlockTwoColumn.tsx
Normal file
50
src/components/blocks/BlockTwoColumn.tsx
Normal 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;
|
||||
36
src/components/blocks/BlockVideo.css
Normal file
36
src/components/blocks/BlockVideo.css
Normal 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;
|
||||
}
|
||||
43
src/components/blocks/BlockVideo.tsx
Normal file
43
src/components/blocks/BlockVideo.tsx
Normal 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;
|
||||
9
src/components/blocks/index.ts
Normal file
9
src/components/blocks/index.ts
Normal 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
33
src/pages/DynamicPage.css
Normal 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
58
src/pages/DynamicPage.tsx
Normal 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
96
src/types/pages.ts
Normal 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[];
|
||||
}
|
||||
259
tina/config.ts
259
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<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" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue