feat(site-builder): contact, hours, gallery and local-business sections

Adds 17 token-based sections across contact, hours, gallery and local strips,
with a real-editor test covering insert, hex-free export, alt text and links.

Refs #52

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 12:13:05 -07:00
1 parent 48061d37ae
commit a16a724048
6 files changed
+492 -7

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/** Small tree builders shared by the contact/hours/gallery/local families.
* Everything is token-based and uses padding longhands (single padding control). */
export const sectionPad = (top = '72px', side = '20px', bg = tokenRef('bg')) => ({
paddingTop: top, paddingRight: side, paddingBottom: top, paddingLeft: side, backgroundColor: bg,
});
export const heading = (text: string, level = 'h2', align: 'center' | 'left' = 'center', color = tokenRef('text')): TemplateComponent => ({
type: 'Heading',
props: { text, level, style: { textAlign: align, color, fontFamily: tokenRef('heading-font'), marginBottom: '12px' } },
});
export const para = (text: string, align: 'center' | 'left' = 'center', color = tokenRef('muted'), extra: Record<string, string> = {}): TemplateComponent => ({
type: 'TextBlock',
props: { text, style: { textAlign: align, color, lineHeight: '1.7', marginBottom: '20px', ...extra } },
});
export const button = (text: string, href: string, solid = true): TemplateComponent => ({
type: 'ButtonLink',
props: {
text, href,
style: solid
? { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' }
: { backgroundColor: 'transparent', color: tokenRef('primary'), padding: '12px 28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: `2px solid ${tokenRef('primary')}` },
},
});
/** A ColumnLayout column: each child becomes a col-N linked node, so it is a Container. */
export const column = (...children: TemplateComponent[]): TemplateComponent => ({
type: 'Container',
props: { style: { padding: '0px', minHeight: '' } },
children,
});
export const columns = (split: string, gap: string, ...cols: TemplateComponent[]): TemplateComponent => ({
type: 'ColumnLayout',
props: { columns: cols.length, split, gap, style: { alignItems: 'center' } },
children: cols,
});
export const contactForm = (): TemplateComponent => ({
type: 'ContactForm',
props: {
style: { backgroundColor: tokenRef('bg'), borderRadius: '12px', border: `1px solid ${tokenRef('border')}` },
labelColor: tokenRef('text'), inputBg: tokenRef('bg'), inputBorder: tokenRef('border'),
},
});
export const hoursBlock = (title = 'Opening Hours', note = ''): TemplateComponent => ({
type: 'OpeningHours',
props: { title, note, style: { backgroundColor: 'transparent', paddingTop: '0px', paddingBottom: '0px', paddingLeft: '0px', paddingRight: '0px' } },
});
export const map = (height = '360px'): TemplateComponent => ({
type: 'MapEmbed',
props: { address: 'Your Business, Main Street', zoom: 15, height },
});
export const img = (alt: string, aspect = '4 / 3', radius = '12px'): TemplateComponent => ({
type: 'ImageBlock',
props: { alt, style: { width: '100%', height: 'auto', aspectRatio: aspect, objectFit: 'cover', borderRadius: radius } },
});
@@ -0,0 +1,114 @@
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 { CONTACT_SECTIONS } from './contact';
import { HOURS_SECTIONS } from './hours';
import { GALLERY_SECTIONS } from './gallery';
import { LOCAL_SECTIONS } from './local';
import type { TemplateComponent } from '../../templates/definitions';
let h: EditorHarness;
afterEach(() => h?.unmount());
const ALL: SectionDef[] = [...CONTACT_SECTIONS, ...HOURS_SECTIONS, ...GALLERY_SECTIONS, ...LOCAL_SECTIONS];
const TOP_LEVEL = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction']);
/** Drop everything that is allowed to contain hex: linear-gradient(...) and data:image/svg URIs. */
export function stripAllowedHex(html: string): string {
return html
.replace(/linear-gradient\([^)]*\)/g, '')
.replace(/data:image\/svg[^"')\s]*/g, '')
// ContactForm's required-field marker hardcodes a red in the component's own toHtml.
.replace(/<span style="color:#ef4444;margin-left:2px">\*<\/span>/g, '')
// ...and its submit button text (#ffffff, no prop) and Gallery's <img> placeholder background (#f1f5f9, no prop).
.replace(/(<button type="submit"[^>]*?)color:#ffffff;/g, '$1')
.replace(/(<img [^>]*?);background-color:#f1f5f9/g, '$1');
}
function walk(t: TemplateComponent, fn: (n: TemplateComponent) => void) {
fn(t);
(t.children || []).forEach((c) => walk(c, fn));
}
function exported(def: SectionDef): string {
h = renderEditorHarness();
h.act(() => { insertSection(h.query, h.actions, def); });
const html = exportBodyHtml(h.getSerialized()).html;
h.unmount();
return html;
}
describe('content C section families', () => {
test('about 16 sections with 3+ layouts per family', () => {
expect(ALL.length).toBeGreaterThanOrEqual(15);
for (const fam of [CONTACT_SECTIONS, HOURS_SECTIONS, GALLERY_SECTIONS]) expect(fam.length).toBeGreaterThanOrEqual(3);
expect(LOCAL_SECTIONS.length).toBeGreaterThanOrEqual(3);
});
test('ids unique across files, prefixed, and keywords >= 3', () => {
expect(new Set(ALL.map((s) => s.id)).size).toBe(ALL.length);
for (const s of ALL) expect(s.keywords.length).toBeGreaterThanOrEqual(3);
for (const s of [...CONTACT_SECTIONS, ...HOURS_SECTIONS, ...GALLERY_SECTIONS]) expect(s.id.startsWith(`${s.family}-`)).toBe(true);
for (const s of LOCAL_SECTIONS) expect(s.id.startsWith('local-')).toBe(true);
});
test('stripAllowedHex really detects stray hex (guard against a vacuous check)', () => {
expect(stripAllowedHex('<p style="color:#abcdef">x</p>')).toMatch(/#[0-9a-f]{6}/i);
expect(stripAllowedHex('<i style="background:linear-gradient(#abcdef,#123456)"></i>')).not.toMatch(/#[0-9a-f]{6}/i);
});
for (const def of ALL) {
describe(def.id, () => {
test('root is a top-level block with empty anchorId', () => {
expect(TOP_LEVEL.has(def.tree.type)).toBe(true);
expect((def.tree.props as any).anchorId ?? '').toBe('');
});
test('inserts via the real editor and exports without hex colours', () => {
const html = exported(def);
expect(html.length).toBeGreaterThan(0);
expect(html).not.toContain('Export failed');
expect(stripAllowedHex(html)).not.toMatch(/#[0-9a-f]{6}/i);
expect(JSON.stringify(def.tree)).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
});
test('no animation props; ImageBlocks/gallery images have alt; links are # or page-relative (tel ok)', () => {
walk(def.tree, (n) => {
expect((n.props as any).animation ?? '').toBe('');
if (n.type === 'ImageBlock') expect(String((n.props as any).alt || '').length).toBeGreaterThan(0);
if (n.type === 'Gallery') for (const im of (n.props as any).images || []) expect(String(im.alt || '').length).toBeGreaterThan(0);
const href = (n.props as any).href;
if (typeof href === 'string') expect(href === '#' || /^(tel:\+?\d+|[a-z0-9][a-z0-9\-_./#]*)$/i.test(href) && !/^[a-z]+:\/\//i.test(href)).toBe(true);
});
});
test('exported <a> links are # or page-relative or tel:', () => {
const html = exported(def);
for (const m of html.matchAll(/<a [^>]*href="([^"]*)"/g)) {
const href = m[1];
// StickyCallBar's directions link is the one deliberate absolute https link.
if (def.tree.children?.some((c) => c.type === 'StickyCallBar') && /^https:\/\/maps\.google\.com\//.test(href)) continue;
expect(href === '#' || href.startsWith('tel:') || !/^[a-z][a-z0-9+.-]*:/i.test(href)).toBe(true);
}
});
test('every <img> in the export has alt', () => {
const html = exported(def);
for (const m of html.matchAll(/<img\b[^>]*>/g)) expect(m[0]).toMatch(/\balt="[^"]+"/);
});
});
}
test('ColumnLayout columns match the declared column count', () => {
for (const def of ALL) walk(def.tree, (n) => {
if (n.type === 'ColumnLayout') expect(n.children?.length).toBe((n.props as any).columns);
});
});
test('array props carry full item[0] key sets', () => {
for (const def of ALL) walk(def.tree, (n) => {
if (n.type === 'Gallery') for (const im of (n.props as any).images) expect(Object.keys(im).sort()).toEqual(['alt', 'caption', 'src']);
});
});
});
+85 -1
View File
@@ -1,5 +1,6 @@
import type { SectionDef } from '../catalog';
import { tokenRef } from '../../utils/tokens';
import { sectionPad, heading, para, button, column, columns, contactForm, hoursBlock, map } from './_shared-c';
/** Contact sections. Content tasks append to CONTACT_SECTIONS only. */
export const CONTACT_SECTIONS: SectionDef[] = [
@@ -14,7 +15,90 @@ export const CONTACT_SECTIONS: SectionDef[] = [
children: [
{ type: 'Heading', props: { text: 'Get in Touch', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '12px' } } },
{ type: 'TextBlock', props: { text: 'Send us a message and we will reply as soon as we can.', style: { textAlign: 'center', color: tokenRef('muted'), marginBottom: '24px' } } },
{ type: 'ContactForm', props: {} },
contactForm(),
],
},
},
{
id: 'contact-form-map',
name: 'Contact - form with map',
family: 'contact',
keywords: ['form', 'map', 'location', 'visit', 'directions'],
tree: {
type: 'Section',
props: { aiName: 'Contact', anchorId: '', style: sectionPad() },
children: [
heading('Contact Us'),
para('Drop us a line or come and see us. We are happy to help.'),
columns('50-50', '32px', column(contactForm()), column(map('420px'))),
],
},
},
{
id: 'contact-form-hours',
name: 'Contact - form with hours',
family: 'contact',
keywords: ['form', 'hours', 'opening', 'enquiry', 'availability'],
tree: {
type: 'Section',
props: { aiName: 'Contact', anchorId: '', style: sectionPad('72px', '20px', tokenRef('bg')) },
children: [
heading('Send Us a Message'),
para('We usually reply within one working day.'),
columns('60-40', '40px', column(contactForm()), column(hoursBlock('When to reach us', 'Closed on public holidays.'))),
],
},
},
{
id: 'contact-info-columns',
name: 'Contact - details in three columns',
family: 'contact',
keywords: ['address', 'phone', 'email', 'details', 'columns'],
tree: {
type: 'Section',
props: { aiName: 'Contact', anchorId: '', style: sectionPad() },
children: [
heading('Find Us'),
para('Three easy ways to get in touch.'),
columns('equal', '24px',
column(heading('Visit', 'h3'), para('123 Main Street, Your Town')),
column(heading('Call', 'h3'), para('(555) 123-4567')),
column(heading('Email', 'h3'), para('hello@yourbusiness.example')),
),
],
},
},
{
id: 'contact-cta-strip',
name: 'Contact - call and email strip',
family: 'contact',
keywords: ['cta', 'strip', 'call', 'email', 'banner'],
tree: {
type: 'Section',
props: { aiName: 'Contact strip', anchorId: '', style: sectionPad('56px', '20px', tokenRef('primary')) },
children: [
heading('Ready when you are', 'h2', 'center', tokenRef('bg')),
para('Call us today or send a quick message.', 'center', tokenRef('bg')),
{
type: 'ButtonLink',
props: { text: 'Contact us', href: 'contact', style: { backgroundColor: tokenRef('bg'), color: tokenRef('primary'), padding: '12px 28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } },
},
],
},
},
{
id: 'contact-book-appointment',
name: 'Contact - book an appointment',
family: 'contact',
keywords: ['booking', 'appointment', 'schedule', 'calendar', 'reserve'],
tree: {
type: 'Section',
props: { aiName: 'Booking', anchorId: '', style: sectionPad() },
children: [
heading('Book an Appointment'),
para('Pick a time that suits you. Paste your booking link into the embed below.'),
{ type: 'BookingEmbed', props: { provider: 'calendly', url: '', height: '700px', title: 'Book an appointment' } },
{ type: 'Container', props: { style: { padding: '0px', minHeight: '', textAlign: 'center' } }, children: [button('Prefer to ask first? Contact us', 'contact', false)] },
],
},
},
+78 -2
View File
@@ -1,4 +1,80 @@
import type { SectionDef } from '../catalog';
import { PLACEHOLDER_SRC } from '../../components/media/ImageBlock';
import { tokenRef } from '../../utils/tokens';
import { sectionPad, heading, para, button, column, columns, img } from './_shared-c';
/** Content task appends to GALLERY_SECTIONS only (see hero.ts for the shape). */
export const GALLERY_SECTIONS: SectionDef[] = [];
const photos = (n: number) =>
Array.from({ length: n }, (_, i) => ({ src: PLACEHOLDER_SRC, alt: `Photo ${i + 1} of our work`, caption: '' }));
const galleryStyle = { backgroundColor: 'transparent', paddingTop: '0px', paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px' };
/** Gallery sections. */
export const GALLERY_SECTIONS: SectionDef[] = [
{
id: 'gallery-grid-3',
name: 'Gallery - three-column grid',
family: 'gallery',
keywords: ['gallery', 'photos', 'grid', 'portfolio', 'images'],
tree: {
type: 'Section',
props: { aiName: 'Gallery', anchorId: '', style: sectionPad() },
children: [
heading('Our Work'),
para('A few recent projects and moments.'),
{ type: 'Gallery', props: { images: photos(6), columns: 3, gap: '16px', lightbox: false, style: galleryStyle } },
],
},
},
{
id: 'gallery-grid-4',
name: 'Gallery - tight four-column grid',
family: 'gallery',
keywords: ['gallery', 'photos', 'compact', 'masonry', 'tiles'],
tree: {
type: 'Section',
props: { aiName: 'Gallery', anchorId: '', style: sectionPad('56px', '20px', tokenRef('bg')) },
children: [
heading('Gallery'),
{ type: 'Gallery', props: { images: photos(8), columns: 4, gap: '8px', lightbox: false, style: galleryStyle } },
],
},
},
{
id: 'gallery-feature-text',
name: 'Gallery - photos with intro text',
family: 'gallery',
keywords: ['gallery', 'photos', 'story', 'text', 'showcase'],
tree: {
type: 'Section',
props: { aiName: 'Gallery', anchorId: '', style: sectionPad() },
children: [
columns('40-60', '40px',
column(
heading('See the difference', 'h2', 'left'),
para('Every job is done with care. Take a look at some of our favourites, then get in touch.', 'left'),
button('Start a project', 'contact'),
),
column({ type: 'Gallery', props: { images: photos(4), columns: 2, gap: '12px', lightbox: false, style: galleryStyle } }),
),
],
},
},
{
id: 'gallery-photo-trio',
name: 'Gallery - three feature photos',
family: 'gallery',
keywords: ['gallery', 'photos', 'three', 'featured', 'highlights'],
tree: {
type: 'Section',
props: { aiName: 'Featured photos', anchorId: '', style: sectionPad('64px', '20px', tokenRef('secondary')) },
children: [
heading('Highlights'),
columns('equal', '24px',
column(img('Featured photo one', '3 / 4'), para('Our workshop', 'center', tokenRef('muted'), { marginTop: '12px', marginBottom: '0px' })),
column(img('Featured photo two', '3 / 4'), para('Our team', 'center', tokenRef('muted'), { marginTop: '12px', marginBottom: '0px' })),
column(img('Featured photo three', '3 / 4'), para('Our results', 'center', tokenRef('muted'), { marginTop: '12px', marginBottom: '0px' })),
),
],
},
},
];
+71 -2
View File
@@ -1,4 +1,73 @@
import type { SectionDef } from '../catalog';
import { tokenRef } from '../../utils/tokens';
import { sectionPad, heading, para, button, column, columns, hoursBlock, map } from './_shared-c';
/** Content task appends to HOURS_SECTIONS only (see hero.ts for the shape). */
export const HOURS_SECTIONS: SectionDef[] = [];
/** Opening-hours sections. */
export const HOURS_SECTIONS: SectionDef[] = [
{
id: 'hours-table',
name: 'Hours - centred table',
family: 'hours',
keywords: ['hours', 'opening', 'schedule', 'times', 'open'],
tree: {
type: 'Section',
props: { aiName: 'Hours', anchorId: '', style: sectionPad('64px') },
children: [
{ type: 'OpeningHours', props: { title: 'Opening Hours', note: 'Hours may vary on public holidays.', style: { backgroundColor: 'transparent', paddingTop: '0px', paddingBottom: '0px', paddingLeft: '0px', paddingRight: '0px' } } },
],
},
},
{
id: 'hours-with-map',
name: 'Hours - table beside map',
family: 'hours',
keywords: ['hours', 'map', 'location', 'find us', 'visit'],
tree: {
type: 'Section',
props: { aiName: 'Hours and map', anchorId: '', style: sectionPad() },
children: [
heading('Visit Us'),
para('Find us on Main Street. Drop in during opening hours.'),
columns('45-55', '40px', column(hoursBlock()), column(map('380px'))),
],
},
},
{
id: 'hours-with-details',
name: 'Hours - with address and call button',
family: 'hours',
keywords: ['hours', 'address', 'phone', 'call', 'contact'],
tree: {
type: 'Section',
props: { aiName: 'Hours and details', anchorId: '', style: sectionPad('72px', '20px', tokenRef('bg')) },
children: [
columns('50-50', '48px',
column(hoursBlock('When we are open')),
column(
heading('Come and see us', 'h2', 'left'),
para('123 Main Street, Your Town', 'left'),
para('Phone: (555) 123-4567', 'left'),
button('Call now', 'tel:+15551234567'),
),
),
],
},
},
{
id: 'hours-compact-strip',
name: 'Hours - compact info strip',
family: 'hours',
keywords: ['hours', 'strip', 'compact', 'footer', 'summary'],
tree: {
type: 'Section',
props: { aiName: 'Hours strip', anchorId: '', style: sectionPad('40px', '20px', tokenRef('secondary')) },
children: [
columns('equal', '24px',
column(heading('Open', 'h3', 'center'), para('Mon-Fri 9am-5pm', 'center', tokenRef('text'), { marginBottom: '0px' })),
column(heading('Weekend', 'h3', 'center'), para('Sat 10am-2pm, Sun closed', 'center', tokenRef('text'), { marginBottom: '0px' })),
column(heading('Call', 'h3', 'center'), para('(555) 123-4567', 'center', tokenRef('text'), { marginBottom: '0px' })),
),
],
},
},
];
+79 -2
View File
@@ -1,4 +1,81 @@
import type { SectionDef } from '../catalog';
import { tokenRef } from '../../utils/tokens';
import { sectionPad, heading, para, button, column, columns, hoursBlock, map } from './_shared-c';
/** Content task appends to LOCAL_SECTIONS only (see hero.ts for the shape). */
export const LOCAL_SECTIONS: SectionDef[] = [];
/** Local-business strips (map + hours + call). Entries carry a regular family id. */
export const LOCAL_SECTIONS: SectionDef[] = [
{
id: 'local-visit-us',
name: 'Local - map, hours and call',
family: 'hours',
keywords: ['local', 'map', 'hours', 'call', 'visit'],
tree: {
type: 'Section',
props: { aiName: 'Visit us', anchorId: '', style: sectionPad() },
children: [
columns('50-50', '40px',
column(map('420px')),
column(
heading('Visit Us', 'h2', 'left'),
para('123 Main Street, Your Town', 'left'),
hoursBlock('Opening Hours'),
button('Call (555) 123-4567', 'tel:+15551234567'),
),
),
],
},
},
{
id: 'local-call-strip',
name: 'Local - call us strip',
family: 'contact',
keywords: ['local', 'call', 'phone', 'sticky', 'mobile'],
tree: {
type: 'Section',
props: { aiName: 'Call strip', anchorId: '', style: sectionPad('48px', '20px', tokenRef('primary')) },
children: [
heading('Call us today', 'h2', 'center', tokenRef('bg')),
para('Mon-Fri 9am-5pm. Same-day answers on most enquiries.', 'center', tokenRef('bg')),
{
type: 'ButtonLink',
props: { text: 'Call (555) 123-4567', href: 'tel:+15551234567', style: { backgroundColor: tokenRef('bg'), color: tokenRef('primary'), padding: '12px 28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } },
},
// Mobile-only fixed bar (hidden on desktop/tablet by the component's own flags).
{ type: 'StickyCallBar', props: { phone: '+15551234567', callLabel: 'Call us', directionsUrl: 'https://maps.google.com/?q=Your+Business', directionsLabel: 'Directions', bgColor: tokenRef('primary'), textColor: tokenRef('bg'), position: 'bottom', style: {}, hideOnDesktop: true, hideOnTablet: true, hideOnMobile: false } },
],
},
},
{
id: 'local-reviews-visit',
name: 'Local - reviews then map and hours',
family: 'social-proof',
keywords: ['local', 'reviews', 'map', 'hours', 'customers'],
tree: {
type: 'Section',
props: { aiName: 'Reviews and visit', anchorId: '', style: sectionPad('48px') },
children: [
{ type: 'ReviewsStrip', props: { title: 'What our customers say', cardBg: tokenRef('bg'), style: { backgroundColor: 'transparent', paddingTop: '0px', paddingBottom: '32px', paddingLeft: '0px', paddingRight: '0px' } } },
columns('45-55', '32px', column(hoursBlock()), column(map('340px'))),
],
},
},
{
id: 'local-map-banner',
name: 'Local - wide map with address bar',
family: 'contact',
keywords: ['local', 'map', 'directions', 'address', 'wide'],
tree: {
type: 'Section',
props: { aiName: 'Map banner', anchorId: '', style: sectionPad('56px', '20px', tokenRef('secondary')) },
children: [
heading('Find Us'),
map('380px'),
columns('equal', '24px',
column(para('123 Main Street, Your Town', 'center', tokenRef('text'), { marginTop: '16px', marginBottom: '0px' })),
column(para('Mon-Fri 9am-5pm, Sat 10am-2pm', 'center', tokenRef('text'), { marginTop: '16px', marginBottom: '0px' })),
column({ type: 'Container', props: { style: { padding: '0px', minHeight: '', textAlign: 'center', paddingTop: '8px' } }, children: [button('Get directions', 'contact', false)] }),
),
],
},
},
];