diff --git a/craft/src/panels/sections/SectionLibraryModal.test.tsx b/craft/src/panels/sections/SectionLibraryModal.test.tsx index 328e94c..bc76de8 100644 --- a/craft/src/panels/sections/SectionLibraryModal.test.tsx +++ b/craft/src/panels/sections/SectionLibraryModal.test.tsx @@ -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'); diff --git a/craft/src/sections/families/a-content.test.tsx b/craft/src/sections/families/a-content.test.tsx new file mode 100644 index 0000000..43d3af3 --- /dev/null +++ b/craft/src/sections/families/a-content.test.tsx @@ -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(/]*>/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('

x

')).toEqual(['#ff0000']); + expect(strayHex('background:linear-gradient(135deg,#112233 0%,#445566 100%)')).toEqual([]); + }); +}); diff --git a/craft/src/sections/families/about.ts b/craft/src/sections/families/about.ts index d8198a7..4b3993e 100644 --- a/craft/src/sections/families/about.ts +++ b/craft/src/sections/families/about.ts @@ -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'), + ], + }, + }, +]; diff --git a/craft/src/sections/families/cta.ts b/craft/src/sections/families/cta.ts index 637fdf9..60e00a6 100644 --- a/craft/src/sections/families/cta.ts +++ b/craft/src/sections/families/cta.ts @@ -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 = {}): 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' } } }, + ], + }, + }, +]; diff --git a/craft/src/sections/families/hero.ts b/craft/src/sections/families/hero.ts index de4003c..c719670 100644 --- a/craft/src/sections/families/hero.ts +++ b/craft/src/sections/families/hero.ts @@ -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 } }, ], }, }, diff --git a/craft/src/sections/families/services.ts b/craft/src/sections/families/services.ts index 1d0929b..be98deb 100644 --- a/craft/src/sections/families/services.ts +++ b/craft/src/sections/families/services.ts @@ -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.')]), + ] }, + ], + }, + }, ];