feat(site-builder): add hero, services, about and cta sections to the library

Token-based sections (21 total across four families) with tests covering
insert, export (no stray hex, alt on images, relative links) and tree shape.

Refs #52
This commit is contained in:
shadowdao committed 2026-10-07 12:15:12 -07:00
1 parent 48061d37ae
commit 74dcbb003b
6 files changed
+571 -8

No files matched your search

@@ -53,7 +53,9 @@ describe('SectionLibraryModal', () => {
h.act(() => openSectionLibrary());
const chip = (l: string) => $$('.section-lib-chip').find((c) => c.textContent === l)!;
h.act(() => chip('Hero').click());
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual(['hero-centered']);
const heroIds = $$('.section-lib-card').map((c) => c.dataset.sectionId);
expect(heroIds).toContain('hero-centered');
expect(heroIds.every((id) => id!.startsWith('hero-'))).toBe(true);
h.act(() => chip('All').click());
type($('.section-lib-search') as HTMLInputElement, 'form');
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('contact-form');
@@ -0,0 +1,94 @@
import { describe, test, expect, afterEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { exportBodyHtml } from '../../utils/html-export';
import { insertSection, type SectionDef } from '../catalog';
import { HERO_SECTIONS, SERVICES_SECTIONS, ABOUT_SECTIONS, CTA_SECTIONS } from './index';
import type { TemplateComponent } from '../../templates/definitions';
let h: EditorHarness;
afterEach(() => h?.unmount());
const FAMILIES: Array<[string, SectionDef[]]> = [
['hero', HERO_SECTIONS],
['services', SERVICES_SECTIONS],
['about', ABOUT_SECTIONS],
['cta', CTA_SECTIONS],
];
/** FeaturesGrid hardcodes card colours in its own render/toHtml (no colour props), so the
* skeleton's `services-grid` cannot be token-clean; `services-icon-cards` is the token-clean
* equivalent. Only the exported-HTML hex check is skipped (the tree itself is still checked). */
const HEX_EXEMPT_EXPORT = new Set(['services-grid']);
const ALL = FAMILIES.flatMap(([, defs]) => defs);
function walk(node: TemplateComponent, fn: (n: TemplateComponent) => void) {
fn(node);
(node.children || []).forEach((c) => walk(c, fn));
}
/** Strip the places a hex colour is legitimate, then look for any left. */
export function strayHex(html: string): string[] {
const cleaned = html
.replace(/data:image\/svg[^"')\s]*/g, '')
.replace(/linear-gradient\([^;"]*\)/g, '');
return cleaned.match(/#[0-9a-fA-F]{6}\b/g) || [];
}
describe('A-content section families', () => {
test('about 18+ sections overall, 3+ layouts per family', () => {
expect(ALL.length).toBeGreaterThanOrEqual(18);
for (const [fam, defs] of FAMILIES) {
expect(defs.length, fam).toBeGreaterThanOrEqual(3);
for (const d of defs) expect(d.family).toBe(fam);
}
});
test('ids are unique and prefixed by family; keywords >= 3', () => {
expect(new Set(ALL.map((s) => s.id)).size).toBe(ALL.length);
for (const s of ALL) {
expect(s.id.startsWith(`${s.family}-`), s.id).toBe(true);
expect(s.keywords.length, s.id).toBeGreaterThanOrEqual(3);
}
});
test('tree shape: top-level block, anchorId blank, no animation, relative links, alt on images, no hex in props', () => {
const tops = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction']);
for (const s of ALL) {
expect(tops.has(s.tree.type), s.id).toBe(true);
expect(s.tree.props.anchorId, s.id).toBe('');
walk(s.tree, (n) => {
expect(n.props.animation, s.id).toBeUndefined();
if (n.type === 'ImageBlock') expect(String(n.props.alt || '').length, `${s.id} alt`).toBeGreaterThan(0);
for (const k of ['href', 'buttonUrl']) {
const v = n.props[k];
if (typeof v === 'string' && v) expect(v === '#' || /^[a-z0-9][a-z0-9\-_/.]*$/i.test(v), `${s.id} ${k}=${v}`).toBe(true);
}
for (const c of (n.props.ctas as Array<{ href: string }> | undefined) || []) {
expect(c.href === '#' || /^[a-z0-9][a-z0-9\-_/.]*$/i.test(c.href), `${s.id} cta ${c.href}`).toBe(true);
}
});
expect(strayHex(JSON.stringify(s.tree)), `${s.id} tree`).toEqual([]);
}
});
for (const def of ALL) {
test(`${def.id}: inserts and exports with no stray hex and alt on every image`, () => {
h = renderEditorHarness();
h.act(() => { insertSection(h.query, h.actions, def); });
expect(h.query.node('ROOT').get().data.nodes.length).toBe(1);
const out = exportBodyHtml(h.getSerialized()).html;
expect(out).not.toContain('Export failed');
expect(out.length).toBeGreaterThan(50);
if (!HEX_EXEMPT_EXPORT.has(def.id)) expect(strayHex(out), 'stray hex').toEqual([]);
for (const img of out.match(/<img\b[^>]*>/g) || []) expect(img, img).toMatch(/\balt="[^"]+"/);
expect(out).toContain('var(--wsb-');
for (const href of [...out.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])) {
expect(href === '#' || /^[a-z0-9][a-z0-9\-_/.]*$/i.test(href), `href ${href}`).toBe(true);
}
});
}
test('strayHex detector can fail (guards the guard)', () => {
expect(strayHex('<p style="color:#ff0000">x</p>')).toEqual(['#ff0000']);
expect(strayHex('background:linear-gradient(135deg,#112233 0%,#445566 100%)')).toEqual([]);
});
});
+129 -2
View File
@@ -1,4 +1,131 @@
import type { SectionDef } from '../catalog';
import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/** Content task appends to ABOUT_SECTIONS only (see hero.ts for the shape). */
export const ABOUT_SECTIONS: SectionDef[] = [];
/** About sections. Content tasks append to ABOUT_SECTIONS only. */
const pad = (v: string, h = '20px') => ({ paddingTop: v, paddingBottom: v, paddingLeft: h, paddingRight: h });
const h2 = (text: string, align = 'left'): TemplateComponent => ({
type: 'Heading',
props: { text, level: 'h2', style: { textAlign: align, color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '16px' } },
});
const h3 = (text: string, align = 'left'): TemplateComponent => ({
type: 'Heading',
props: { text, level: 'h3', style: { textAlign: align, color: tokenRef('text'), fontFamily: tokenRef('heading-font'), fontSize: '22px', marginBottom: '8px' } },
});
const p = (text: string, align = 'left'): TemplateComponent => ({
type: 'TextBlock',
props: { text, style: { textAlign: align, color: tokenRef('muted'), fontFamily: tokenRef('body-font'), lineHeight: '1.7', marginBottom: '12px' } },
});
const plain = (children: TemplateComponent[]): TemplateComponent => ({
type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children,
});
const photo = (alt: string): TemplateComponent => ({
type: 'ImageBlock',
props: { alt, style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } },
});
const BTN = { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' };
export const ABOUT_SECTIONS: SectionDef[] = [
{
id: 'about-story-photo',
name: 'About - story with photo',
family: 'about',
keywords: ['story', 'about us', 'photo', 'history', 'who we are'],
tree: {
type: 'Section',
props: { aiName: 'About', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
{ type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } }, children: [
photo('Our team at work'),
plain([
h2('Our story'),
p('Tell visitors how your business started and what you care about. A few honest sentences go a long way.'),
p('Mention how long you have been in the area and the kind of customers you love working with.'),
{ type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact', style: BTN } },
]),
] },
],
},
},
{
id: 'about-story-photo-right',
name: 'About - photo on the right',
family: 'about',
keywords: ['story', 'about us', 'photo', 'owner', 'welcome'],
tree: {
type: 'Section',
props: { aiName: 'About', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
{ type: 'ColumnLayout', props: { columns: 2, split: '60-40', gap: '48px', style: { alignItems: 'center' } }, children: [
plain([
h2('Meet the people behind the business'),
p('Introduce yourself and your team in a friendly, down-to-earth way. Say what you stand for.'),
p('Mon-Fri 9am-5pm, and always happy to answer your questions.'),
]),
photo('Portrait of the owner'),
] },
],
},
},
{
id: 'about-stats',
name: 'About - numbers band',
family: 'about',
keywords: ['stats', 'numbers', 'counters', 'experience', 'achievements'],
tree: {
type: 'Section',
props: { aiName: 'About', anchorId: '', style: { ...pad('64px'), backgroundColor: tokenRef('bg') } },
children: [
h2('A track record you can trust', 'center'),
p('Real numbers that show how long we have been serving the community.', 'center'),
{
type: 'NumberCounter',
props: {
counters: [
{ number: 15, suffix: '+', label: 'Years in business' },
{ number: 500, suffix: '+', label: 'Happy customers' },
{ number: 1200, suffix: '+', label: 'Jobs completed' },
{ number: 100, suffix: '%', label: 'Local' },
],
columns: 4,
style: { backgroundColor: tokenRef('bg') },
},
},
],
},
},
{
id: 'about-values',
name: 'About - three values',
family: 'about',
keywords: ['values', 'principles', 'why choose us', 'three column'],
tree: {
type: 'Section',
props: { aiName: 'About', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
h2('What we stand for', 'center'),
{ type: 'ColumnLayout', props: { columns: 3, split: '33-33-33', gap: '32px' }, children: [
plain([h3('Honest', 'center'), p('Clear prices and straight answers, every time.', 'center')]),
plain([h3('Reliable', 'center'), p('We turn up when we say we will and finish what we start.', 'center')]),
plain([h3('Local', 'center'), p('Proudly part of the community we serve.', 'center')]),
] },
],
},
},
{
id: 'about-mission',
name: 'About - mission statement',
family: 'about',
keywords: ['mission', 'statement', 'quote', 'centered', 'purpose'],
tree: {
type: 'Section',
props: { aiName: 'About', anchorId: '', innerMaxWidth: '760px', style: { ...pad('96px'), backgroundColor: tokenRef('bg') } },
children: [
{ type: 'TextBlock', props: { text: 'OUR MISSION', style: { textAlign: 'center', color: tokenRef('primary'), fontFamily: tokenRef('body-font'), fontSize: '14px', fontWeight: '700', letterSpacing: '2px', marginBottom: '12px' } } },
{ type: 'Heading', props: { text: 'To make life easier for our customers with work we are proud of.', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), fontSize: '32px', lineHeight: '1.4', marginBottom: '16px' } } },
p('A sentence or two expanding on what that means in practice.', 'center'),
],
},
},
];
+109 -2
View File
@@ -1,4 +1,111 @@
import type { SectionDef } from '../catalog';
import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/** Content task appends to CTA_SECTIONS only (see hero.ts for the shape). */
export const CTA_SECTIONS: SectionDef[] = [];
/** CTA sections. Content tasks append to CTA_SECTIONS only. */
const gradient = `linear-gradient(135deg, ${tokenRef('primary')} 0%, ${tokenRef('secondary')} 100%)`;
const btn = (text: string, href: string, kind: 'solid' | 'outline' = 'solid'): TemplateComponent => ({
type: 'ButtonLink',
props: {
text, href,
style: kind === 'solid'
? { backgroundColor: tokenRef('bg'), color: tokenRef('primary'), padding: '14px 32px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' }
: { backgroundColor: 'transparent', color: tokenRef('bg'), padding: '12px 30px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: `2px solid ${tokenRef('bg')}` },
},
});
const onColour = (type: 'Heading' | 'TextBlock', text: string, extra: Record<string, unknown> = {}): TemplateComponent => (
type === 'Heading'
? { type, props: { text, level: 'h2', style: { textAlign: 'center', color: tokenRef('bg'), fontFamily: tokenRef('heading-font'), marginBottom: '12px', ...extra } } }
: { type, props: { text, style: { textAlign: 'center', color: tokenRef('bg'), fontFamily: tokenRef('body-font'), marginBottom: '24px', ...extra } } }
);
const gradientBand = { paddingTop: '80px', paddingBottom: '80px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('primary'), backgroundImage: gradient, textAlign: 'center' };
const buttonRow = (children: TemplateComponent[]): TemplateComponent => ({
type: 'Container', props: { style: { padding: '0px', minHeight: '0px', display: 'flex', justifyContent: 'center', gap: '16px', flexWrap: 'wrap' } }, children,
});
const plain = (children: TemplateComponent[]): TemplateComponent => ({
type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children,
});
export const CTA_SECTIONS: SectionDef[] = [
{
id: 'cta-gradient-band',
name: 'CTA - gradient band',
family: 'cta',
keywords: ['call to action', 'gradient', 'banner', 'contact', 'button'],
tree: {
type: 'Section',
props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '760px', style: gradientBand },
children: [
onColour('Heading', 'Ready to get started?'),
onColour('TextBlock', 'Get in touch today and we will get back to you within one working day.'),
btn('Contact us', 'contact'),
],
},
},
{
id: 'cta-two-buttons',
name: 'CTA - two buttons',
family: 'cta',
keywords: ['call to action', 'buttons', 'quote', 'book', 'primary secondary'],
tree: {
type: 'Section',
props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '760px', style: { ...gradientBand, backgroundImage: 'none' } },
children: [
onColour('Heading', 'Let us help with your next project'),
onColour('TextBlock', 'Request a free quote or take a look at what we offer.'),
buttonRow([btn('Get a free quote', 'contact'), btn('See our services', 'services', 'outline')]),
],
},
},
{
id: 'cta-strip',
name: 'CTA - slim strip',
family: 'cta',
keywords: ['call to action', 'strip', 'slim', 'inline', 'button'],
tree: {
type: 'Section',
props: { aiName: 'Call to action', anchorId: '', style: { paddingTop: '40px', paddingBottom: '40px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('primary') } },
children: [
{ type: 'ColumnLayout', props: { columns: 2, split: '70-30', gap: '24px', style: { alignItems: 'center' } }, children: [
plain([
{ type: 'Heading', props: { text: 'Questions? We are happy to help.', level: 'h3', style: { color: tokenRef('bg'), fontFamily: tokenRef('heading-font'), fontSize: '26px', marginBottom: '4px' } } },
{ type: 'TextBlock', props: { text: 'Mon-Fri 9am-5pm', style: { color: tokenRef('bg'), fontFamily: tokenRef('body-font') } } },
]),
btn('Contact us', 'contact'),
] },
],
},
},
{
id: 'cta-card',
name: 'CTA - centered card',
family: 'cta',
keywords: ['call to action', 'card', 'boxed', 'stay in touch', 'button'],
tree: {
type: 'Section',
props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '720px', style: { paddingTop: '64px', paddingBottom: '64px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') } },
children: [
{ type: 'Container', props: { style: { paddingTop: '40px', paddingBottom: '40px', paddingLeft: '32px', paddingRight: '32px', minHeight: '0px', border: `1px solid ${tokenRef('border')}`, borderRadius: '16px', backgroundColor: tokenRef('bg'), textAlign: 'center' } }, children: [
{ type: 'Heading', props: { text: 'Stay in touch', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '8px' } } },
{ type: 'TextBlock', props: { text: 'Questions, quotes or just a hello. We would love to hear from you.', style: { textAlign: 'center', color: tokenRef('muted'), fontFamily: tokenRef('body-font'), marginBottom: '20px' } } },
{ type: 'ButtonLink', props: { text: 'Send a message', href: 'contact', style: { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } } },
] },
],
},
},
{
id: 'cta-call-now',
name: 'CTA - call us',
family: 'cta',
keywords: ['phone', 'call', 'call now', 'contact', 'urgent'],
tree: {
type: 'Section',
props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '760px', style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
children: [
{ type: 'Heading', props: { text: 'Need a hand? Give us a call', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '12px' } } },
{ type: 'TextBlock', props: { text: 'Speak to a real person, Mon-Fri 9am-5pm.', style: { textAlign: 'center', color: tokenRef('muted'), fontFamily: tokenRef('body-font'), marginBottom: '20px' } } },
{ type: 'ButtonLink', props: { text: 'Call 555-0100', href: 'contact', style: { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } } },
],
},
},
];
+109 -2
View File
@@ -1,4 +1,5 @@
import type { SectionDef } from '../catalog';
import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/**
@@ -7,6 +8,17 @@ import { tokenRef } from '../../utils/tokens';
* on the site's palette and fonts, and set no `animation` (the thumbnail
* iframe is script-less).
*/
const h1 = (text: string, align: 'center' | 'left' = 'center', color = tokenRef('text')): TemplateComponent => ({
type: 'Heading',
props: { text, level: 'h1', style: { textAlign: align, color, fontFamily: tokenRef('heading-font'), fontSize: '48px', marginBottom: '16px' } },
});
const lead = (text: string, align: 'center' | 'left' = 'center', color = tokenRef('muted')): TemplateComponent => ({
type: 'TextBlock',
props: { text, style: { textAlign: align, color, fontFamily: tokenRef('body-font'), fontSize: '18px', lineHeight: '1.7', maxWidth: '640px', marginBottom: '24px', ...(align === 'center' ? { marginLeft: 'auto', marginRight: 'auto' } : {}) } },
});
const BTN = { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' };
export const HERO_SECTIONS: SectionDef[] = [
{
id: 'hero-centered',
@@ -15,11 +27,106 @@ export const HERO_SECTIONS: SectionDef[] = [
keywords: ['headline', 'banner', 'intro', 'welcome', 'landing'],
tree: {
type: 'Section',
props: { aiName: 'Hero', style: { padding: '96px 20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
props: { aiName: 'Hero', anchorId: '', style: { padding: '96px 20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
children: [
{ type: 'Heading', props: { text: 'Welcome to Your Business', level: 'h1', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '16px' } } },
{ type: 'TextBlock', props: { text: 'Tell visitors in one or two sentences what you do and who you do it for.', style: { textAlign: 'center', maxWidth: '640px', margin: '0 auto 24px', color: tokenRef('muted'), lineHeight: '1.7' } } },
{ type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact' } },
{ type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact', style: BTN } },
],
},
},
{
id: 'hero-split-photo',
name: 'Hero - split with photo',
family: 'hero',
keywords: ['image', 'photo', 'two column', 'split', 'headline'],
tree: {
type: 'Section',
props: { aiName: 'Hero', anchorId: '', style: { paddingTop: '80px', paddingBottom: '80px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') } },
children: [
{
type: 'ColumnLayout',
props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } },
children: [
{ type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children: [
h1('Friendly service you can count on', 'left'),
lead('A short sentence about what makes your business different and why customers keep coming back.', 'left'),
{ type: 'ButtonLink', props: { text: 'Book a visit', href: 'contact', style: BTN } },
] },
{ type: 'ImageBlock', props: { alt: 'Photo of your business', style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } } },
],
},
],
},
},
{
id: 'hero-gradient-banner',
name: 'Hero - gradient banner',
family: 'hero',
keywords: ['gradient', 'bold', 'banner', 'buttons', 'colour'],
tree: {
type: 'HeroSimple',
props: {
anchorId: '',
heading: 'Quality you can see, service you can trust',
subtitle: 'Add a short line that explains your offer and invites visitors to take the next step.',
ctas: [
{ text: 'Get a quote', href: 'contact', variant: 'primary' },
{ text: 'Our services', href: 'services', variant: 'outline' },
],
bgType: 'gradient',
bgGradientFrom: tokenRef('primary'),
bgGradientTo: tokenRef('secondary'),
bgGradientAngle: 135,
overlayColor: tokenRef('text'),
overlayOpacity: 0,
textColor: tokenRef('bg'),
buttonBgColor: tokenRef('accent'),
buttonTextColor: tokenRef('text'),
minHeight: '480px',
style: {},
},
},
},
{
id: 'hero-solid-banner',
name: 'Hero - solid colour banner',
family: 'hero',
keywords: ['solid', 'colour', 'banner', 'call', 'headline'],
tree: {
type: 'HeroSimple',
props: {
anchorId: '',
heading: 'Local, reliable and ready to help',
subtitle: 'Call us today or send a message and we will get back to you quickly.',
ctas: [
{ text: 'Contact us', href: 'contact', variant: 'primary' },
],
bgType: 'color',
bgColor: tokenRef('primary'),
overlayColor: tokenRef('text'),
overlayOpacity: 0,
textColor: tokenRef('bg'),
buttonBgColor: tokenRef('bg'),
buttonTextColor: tokenRef('primary'),
minHeight: '420px',
style: {},
},
},
},
{
id: 'hero-minimal',
name: 'Hero - minimal headline',
family: 'hero',
keywords: ['minimal', 'simple', 'headline', 'text only', 'clean'],
tree: {
type: 'Section',
props: { aiName: 'Hero', anchorId: '', innerMaxWidth: '800px', style: { paddingTop: '120px', paddingBottom: '96px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') } },
children: [
{ type: 'TextBlock', props: { text: 'YOUR BUSINESS', style: { color: tokenRef('primary'), fontFamily: tokenRef('body-font'), fontSize: '14px', fontWeight: '700', letterSpacing: '2px', marginBottom: '12px' } } },
h1('Simple, honest work done well', 'left'),
lead('One clear sentence about what you offer and where you work.', 'left'),
{ type: 'ButtonLink', props: { text: 'See how we can help', href: 'services', style: BTN } },
],
},
},
+127 -1
View File
@@ -1,7 +1,36 @@
import type { SectionDef } from '../catalog';
import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/** Services sections. Content tasks append to SERVICES_SECTIONS only. */
const pad = (v: string, h = '20px') => ({ paddingTop: v, paddingBottom: v, paddingLeft: h, paddingRight: h });
const title = (text: string): TemplateComponent => ({
type: 'Heading',
props: { text, level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '32px' } },
});
const h3 = (text: string, align = 'left'): TemplateComponent => ({
type: 'Heading',
props: { text, level: 'h3', style: { textAlign: align, color: tokenRef('text'), fontFamily: tokenRef('heading-font'), fontSize: '22px', marginBottom: '8px' } },
});
const p = (text: string, align = 'left'): TemplateComponent => ({
type: 'TextBlock',
props: { text, style: { textAlign: align, color: tokenRef('muted'), fontFamily: tokenRef('body-font'), lineHeight: '1.6', marginBottom: '8px' } },
});
const emoji = (e: string): TemplateComponent => ({
type: 'TextBlock',
props: { text: e, style: { fontSize: '36px', lineHeight: '1.2', marginBottom: '8px' } },
});
const card = (children: TemplateComponent[]): TemplateComponent => ({
type: 'Container',
props: { style: { paddingTop: '28px', paddingBottom: '28px', paddingLeft: '28px', paddingRight: '28px', minHeight: '0px', border: `1px solid ${tokenRef('border')}`, borderRadius: '12px', backgroundColor: tokenRef('bg') } },
children,
});
const plain = (children: TemplateComponent[]): TemplateComponent => ({
type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children,
});
const BTN = { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' };
export const SERVICES_SECTIONS: SectionDef[] = [
{
id: 'services-grid',
@@ -10,7 +39,7 @@ export const SERVICES_SECTIONS: SectionDef[] = [
keywords: ['features', 'offerings', 'what we do', 'benefits'],
tree: {
type: 'Section',
props: { aiName: 'Services', style: { padding: '72px 20px', backgroundColor: tokenRef('bg') } },
props: { aiName: 'Services', anchorId: '', style: { padding: '72px 20px', backgroundColor: tokenRef('bg') } },
children: [
{ type: 'Heading', props: { text: 'Our Services', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '24px' } } },
{
@@ -27,4 +56,101 @@ export const SERVICES_SECTIONS: SectionDef[] = [
],
},
},
{
id: 'services-icon-cards',
name: 'Services - icon cards',
family: 'services',
keywords: ['icons', 'cards', 'three column', 'what we do', 'benefits'],
tree: {
type: 'Section',
props: { aiName: 'Services', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
title('Our Services'),
{ type: 'ColumnLayout', props: { columns: 3, split: '33-33-33', gap: '24px' }, children: [
card([emoji('⭐'), h3('Service one'), p('A short description of what this service includes.')]),
card([emoji('🛠️'), h3('Service two'), p('A short description of what this service includes.')]),
card([emoji('💬'), h3('Service three'), p('A short description of what this service includes.')]),
] },
],
},
},
{
id: 'services-cards-2x2',
name: 'Services - 2x2 cards',
family: 'services',
keywords: ['cards', 'four', 'grid', 'offerings', 'what we do'],
tree: {
type: 'Section',
props: { aiName: 'Services', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
title('What we do'),
{ type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '24px' }, children: [
card([emoji('🔧'), h3('Repairs and maintenance'), p('Fast, tidy work to keep everything running as it should.')]),
card([emoji('📋'), h3('Planning and advice'), p('Clear recommendations and honest quotes before we start.')]),
] },
{ type: 'Spacer', props: { height: '24px' } },
{ type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '24px' }, children: [
card([emoji('🏠'), h3('On-site visits'), p('We come to you, on a day and time that suits you.')]),
card([emoji('🤝'), h3('Ongoing support'), p('Regular check-ins and a friendly voice when you need one.')]),
] },
],
},
},
{
id: 'services-alternating-rows',
name: 'Services - alternating rows',
family: 'services',
keywords: ['rows', 'image', 'detail', 'alternating', 'zigzag'],
tree: {
type: 'Section',
props: { aiName: 'Services', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
title('How we can help'),
{ type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } }, children: [
{ type: 'ImageBlock', props: { alt: 'First service', style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } } },
plain([h3('Our main service'), p('Describe the service, who it is for and the result customers can expect.'), { type: 'ButtonLink', props: { text: 'Learn more', href: 'contact', style: BTN } }]),
] },
{ type: 'Spacer', props: { height: '48px' } },
{ type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } }, children: [
plain([h3('Our second service'), p('Explain what is included and what makes your approach different.'), { type: 'ButtonLink', props: { text: 'Learn more', href: 'contact', style: BTN } }]),
{ type: 'ImageBlock', props: { alt: 'Second service', style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } } },
] },
],
},
},
{
id: 'services-steps',
name: 'Services - how it works steps',
family: 'services',
keywords: ['process', 'steps', 'how it works', 'numbered'],
tree: {
type: 'Section',
props: { aiName: 'Process', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
children: [
title('How it works'),
{ type: 'ColumnLayout', props: { columns: 3, split: '33-33-33', gap: '32px' }, children: [
plain([{ type: 'Heading', props: { text: '1', level: 'h3', style: { color: tokenRef('primary'), fontFamily: tokenRef('heading-font'), fontSize: '40px', textAlign: 'center', marginBottom: '4px' } } }, h3('Get in touch', 'center'), p('Tell us what you need and we will reply quickly.', 'center')]),
plain([{ type: 'Heading', props: { text: '2', level: 'h3', style: { color: tokenRef('primary'), fontFamily: tokenRef('heading-font'), fontSize: '40px', textAlign: 'center', marginBottom: '4px' } } }, h3('We make a plan', 'center'), p('A clear quote and a date that works for you.', 'center')]),
plain([{ type: 'Heading', props: { text: '3', level: 'h3', style: { color: tokenRef('primary'), fontFamily: tokenRef('heading-font'), fontSize: '40px', textAlign: 'center', marginBottom: '4px' } } }, h3('Job done', 'center'), p('We finish on time and check you are happy.', 'center')]),
] },
],
},
},
{
id: 'services-list-columns',
name: 'Services - simple list',
family: 'services',
keywords: ['list', 'compact', 'offerings', 'two column'],
tree: {
type: 'Section',
props: { aiName: 'Services', anchorId: '', innerMaxWidth: '900px', style: { ...pad('64px'), backgroundColor: tokenRef('bg') } },
children: [
title('Services we offer'),
{ type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '40px' }, children: [
plain([h3('Service one'), p('One line about this service.'), h3('Service two'), p('One line about this service.'), h3('Service three'), p('One line about this service.')]),
plain([h3('Service four'), p('One line about this service.'), h3('Service five'), p('One line about this service.'), h3('Service six'), p('One line about this service.')]),
] },
],
},
},
];