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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.')]),
|
||||
] },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user