From 761d2255f874faf030e5b62b4af76b5f2091f991 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:14:30 -0700 Subject: [PATCH] feat(site-builder): add social-proof, FAQ, pricing and team sections Refs #52 --- .../src/sections/families/b-content.test.tsx | 106 ++++++++++ craft/src/sections/families/faq.ts | 134 +++++++++++- craft/src/sections/families/pricing.ts | 153 +++++++++++++- craft/src/sections/families/social-proof.ts | 194 +++++++++++++++++- craft/src/sections/families/team.ts | 114 +++++++++- 5 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 craft/src/sections/families/b-content.test.tsx diff --git a/craft/src/sections/families/b-content.test.tsx b/craft/src/sections/families/b-content.test.tsx new file mode 100644 index 0000000..6618149 --- /dev/null +++ b/craft/src/sections/families/b-content.test.tsx @@ -0,0 +1,106 @@ +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 { SOCIAL_PROOF_SECTIONS } from './social-proof'; +import { FAQ_SECTIONS } from './faq'; +import { PRICING_SECTIONS } from './pricing'; +import { TEAM_SECTIONS } from './team'; +import type { TemplateComponent } from '../../templates/definitions'; + +const FAMILIES: Array<[string, SectionDef[]]> = [ + ['social-proof', SOCIAL_PROOF_SECTIONS], + ['faq', FAQ_SECTIONS], + ['pricing', PRICING_SECTIONS], + ['team', TEAM_SECTIONS], +]; +const ALL = FAMILIES.flatMap(([, s]) => s); +const ROOT_TYPES = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction']); + +let h: EditorHarness; +afterEach(() => h?.unmount()); + +const walk = (n: TemplateComponent, fn: (n: TemplateComponent) => void) => { fn(n); (n.children || []).forEach((c) => walk(c, fn)); }; + +/** Body HTML of one inserted section (real editor, real export). */ +function exportOne(def: SectionDef): string { + h = renderEditorHarness(); + h.act(() => { insertSection(h.query, h.actions, def, { select: false }); }); + const html = exportBodyHtml(h.getSerialized()).html; + h.unmount(); + return html; +} + +describe('content B families', () => { + test('counts: 3+ layouts per family and roughly 18 sections', () => { + for (const [name, defs] of FAMILIES) expect(defs.length, name).toBeGreaterThanOrEqual(3); + expect(ALL.length).toBeGreaterThanOrEqual(18); + }); + + test('ids unique across the files, prefixed by family, family field correct', () => { + expect(new Set(ALL.map((s) => s.id)).size).toBe(ALL.length); + for (const [name, defs] of FAMILIES) { + for (const d of defs) { + expect(d.family).toBe(name); + expect(d.id.startsWith(`${name}-`), d.id).toBe(true); + } + } + }); + + test('keywords >= 3, root type is a top-level block with anchorId ""', () => { + for (const d of ALL) { + expect(d.keywords.length, d.id).toBeGreaterThanOrEqual(3); + expect(ROOT_TYPES.has(d.tree.type), d.id).toBe(true); + expect(d.tree.props.anchorId, d.id).toBe(''); + } + }); + + test('ColumnLayout children count equals columns; no animation props; padding longhands on Section', () => { + for (const d of ALL) { + walk(d.tree, (n) => { + if (n.type === 'ColumnLayout') expect(n.children?.length, d.id).toBe(n.props.columns); + expect(n.props.animation, d.id).toBeUndefined(); + }); + expect(d.tree.props.style.padding, d.id).toBeUndefined(); + expect(d.tree.props.style.paddingTop, d.id).toBeTruthy(); + } + }); + + test.each(ALL.map((d) => [d.id, d] as const))('%s: inserts, exports token-only, alts and links ok', (_id, def) => { + h = renderEditorHarness(); + let rootId = ''; + h.act(() => { rootId = insertSection(h.query, h.actions, def, { select: false }); }); + expect(h.query.node(rootId).get().data.parent).toBe('ROOT'); + + // Every ImageBlock in the node tree has a non-empty alt (the export omits placeholders, so check nodes). + const ids = [rootId, ...h.query.node(rootId).decendants(true)]; + const imgs = ids.filter((i) => h.query.node(i).get().data.displayName === 'Image'); + for (const i of imgs) expect(String(h.query.node(i).get().data.props.alt || '').trim().length, def.id).toBeGreaterThan(0); + + const html = exportBodyHtml(h.getSerialized()).html; + expect(html.length).toBeGreaterThan(100); + const scrubbed = html.replace(/linear-gradient\([^)]*\)/g, '').replace(/data:image\/svg[^"')]*/g, ''); + expect(scrubbed.match(/#[0-9a-fA-F]{6}\b/g) || [], def.id).toEqual([]); + expect(html).toContain('var(--wsb-'); + + for (const m of html.matchAll(/]*>/g)) expect(m[0], def.id).toMatch(/\balt="[^"]+"/); + for (const m of html.matchAll(/\bhref="([^"]*)"/g)) { + const href = m[1]; + expect(href === '#' || /^#[\w-]+$/.test(href) || (!/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//')), `${def.id} href ${href}`).toBe(true); + } + }); + + test('FAQ accordion keeps its
markup through the export sanitiser', () => { + const html = exportOne(FAQ_SECTIONS.find((s) => s.id === 'faq-accordion')!); + expect(html).toContain(' { + const html = exportOne(PRICING_SECTIONS.find((s) => s.id === 'pricing-three-tiers')!); + expect(html).toContain('2px solid var(--wsb-primary)'); + expect(html).toContain('$99'); + expect(html).toContain('Choose Standard'); + }); +}); diff --git a/craft/src/sections/families/faq.ts b/craft/src/sections/families/faq.ts index 245c64d..45e69eb 100644 --- a/craft/src/sections/families/faq.ts +++ b/craft/src/sections/families/faq.ts @@ -1,4 +1,134 @@ import type { SectionDef } from '../catalog'; +import type { TemplateComponent } from '../../templates/definitions'; +import { tokenRef } from '../../utils/tokens'; -/** Content task appends to FAQ_SECTIONS only (see hero.ts for the shape). */ -export const FAQ_SECTIONS: SectionDef[] = []; +/** FAQ sections. The accordion is a script-free
list in an HtmlBlock: + * the Accordion component's static export hard-codes hex body colours, which + * would not follow the site palette. */ + +type Tok = 'bg' | 'primary' | 'text' | 'muted' | 'border'; + +const section = (aiName: string, children: TemplateComponent[], maxWidth = '900px'): TemplateComponent => ({ + type: 'Section', + props: { + aiName, anchorId: '', innerMaxWidth: maxWidth, + style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') }, + }, + children, +}); + +const heading = (t: string, level: 'h2' | 'h3', align = 'center', extra: Record = {}): TemplateComponent => ({ + type: 'Heading', + props: { text: t, level, style: { color: tokenRef('text'), fontFamily: tokenRef('heading-font'), textAlign: align, marginBottom: '16px', ...extra } }, +}); + +const text = (t: string, color: Tok = 'muted', align = 'center', extra: Record = {}): TemplateComponent => ({ + type: 'TextBlock', + props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.7', textAlign: align, ...extra } }, +}); + +const box = (children: TemplateComponent[], extra: Record = {}): TemplateComponent => ({ + type: 'Container', + props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...extra } }, + children, +}); + +const columns = (n: number, split: string, kids: TemplateComponent[], gap = '32px'): TemplateComponent => ({ + type: 'ColumnLayout', + props: { columns: n, split, gap, anchorId: '' }, + children: kids, +}); + +const QA: Array<[string, string]> = [ + ['How do I get a quote?', 'Get in touch by phone or through the contact form. We will ask a few questions and send you a clear, no-obligation quote.'], + ['What areas do you cover?', 'We serve your town and the surrounding area. If you are unsure whether we cover your address, just ask.'], + ['How long does a typical job take?', 'Most jobs are completed within a few days. We will give you a realistic timeline before we start.'], + ['What are your opening hours?', 'We are open Monday to Friday, 9am to 5pm. Outside those hours, leave a message and we will reply the next working day.'], + ['Do you offer a guarantee?', 'Yes. If you are not happy with our work, tell us and we will put it right.'], +]; + +/** Accordion markup; styles reference tokens only so it follows the palette. */ +function detailsHtml(items: Array<[string, string]>, openFirst = true): string { + const rows = items.map(([q, a], i) => + `` + + `${q}` + + `
${a}
` + + `
`).join(''); + return `
${rows}
`; +} + +const accordion = (items: Array<[string, string]>, openFirst = true): TemplateComponent => ({ + type: 'HtmlBlock', + props: { code: detailsHtml(items, openFirst) }, +}); + +const qaBlock = (q: string, a: string): TemplateComponent => + box([heading(q, 'h3', 'left', { fontSize: '20px', marginBottom: '8px' }), text(a, 'muted', 'left')]); + +const qaCard = (q: string, a: string): TemplateComponent => ({ + type: 'Container', + props: { + tag: 'div', + style: { + paddingTop: '24px', paddingBottom: '24px', paddingLeft: '24px', paddingRight: '24px', + backgroundColor: tokenRef('bg'), border: `1px solid ${tokenRef('border')}`, borderRadius: '12px', + }, + }, + children: [heading(q, 'h3', 'left', { fontSize: '18px', marginBottom: '8px' }), text(a, 'muted', 'left', { fontSize: '15px' })], +}); + +export const FAQ_SECTIONS: SectionDef[] = [ + { + id: 'faq-accordion', + name: 'FAQ - centred accordion', + family: 'faq', + keywords: ['questions', 'answers', 'accordion', 'help', 'collapsible'], + tree: section('FAQ', [ + heading('Frequently asked questions', 'h2'), + text('Quick answers to the things people ask us most.', 'muted', 'center', { marginBottom: '32px' }), + accordion(QA), + ]), + }, + { + id: 'faq-split-intro', + name: 'FAQ - intro beside accordion', + family: 'faq', + keywords: ['questions', 'answers', 'accordion', 'split', 'contact'], + tree: section('FAQ with intro', [ + columns(2, '35-65', [ + box([ + heading('Questions? We have answers.', 'h2', 'left'), + text('Cannot find what you are looking for? Get in touch and we will be happy to help.', 'muted', 'left', { marginBottom: '20px' }), + { type: 'ButtonLink', props: { text: 'Contact us', href: '#contact', style: { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), paddingTop: '12px', paddingBottom: '12px', paddingLeft: '24px', paddingRight: '24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px' } } }, + ]), + box([accordion(QA.slice(0, 4))]), + ]), + ], '1100px'), + }, + { + id: 'faq-two-column', + name: 'FAQ - two columns, all visible', + family: 'faq', + keywords: ['questions', 'answers', 'two column', 'list', 'plain'], + tree: section('FAQ two columns', [ + heading('Good to know', 'h2'), + text('Everything you need to know before you get in touch.', 'muted', 'center', { marginBottom: '40px' }), + columns(2, '50-50', [ + box([qaBlock(QA[0][0], QA[0][1]), qaBlock(QA[1][0], QA[1][1]), qaBlock(QA[2][0], QA[2][1])]), + box([qaBlock(QA[3][0], QA[3][1]), qaBlock(QA[4][0], QA[4][1]), qaBlock('Can I change or cancel a booking?', 'Yes. Let us know as early as you can and we will rearrange or cancel at no charge.')]), + ], '40px'), + ], '1100px'), + }, + { + id: 'faq-cards-grid', + name: 'FAQ - card grid', + family: 'faq', + keywords: ['questions', 'answers', 'cards', 'grid', 'boxes'], + tree: section('FAQ cards', [ + heading('Your questions, answered', 'h2', 'center', { marginBottom: '36px' }), + columns(2, '50-50', [qaCard(QA[0][0], QA[0][1]), qaCard(QA[1][0], QA[1][1])], '20px'), + { type: 'Spacer', props: { height: '20px' } }, + columns(2, '50-50', [qaCard(QA[2][0], QA[2][1]), qaCard(QA[4][0], QA[4][1])], '20px'), + ], '1000px'), + }, +]; diff --git a/craft/src/sections/families/pricing.ts b/craft/src/sections/families/pricing.ts index a5621a7..af0d098 100644 --- a/craft/src/sections/families/pricing.ts +++ b/craft/src/sections/families/pricing.ts @@ -1,4 +1,153 @@ import type { SectionDef } from '../catalog'; +import type { TemplateComponent } from '../../templates/definitions'; +import { tokenRef } from '../../utils/tokens'; -/** Content task appends to PRICING_SECTIONS only (see hero.ts for the shape). */ -export const PRICING_SECTIONS: SectionDef[] = []; +/** Pricing sections, built from primitives: the PricingTable component's static + * export hard-codes hex colours (badge, borders, check marks) that would not + * follow the site palette. */ + +type Tok = 'bg' | 'primary' | 'text' | 'muted' | 'border' | 'accent'; + +const section = (aiName: string, children: TemplateComponent[], maxWidth = '1100px'): TemplateComponent => ({ + type: 'Section', + props: { + aiName, anchorId: '', innerMaxWidth: maxWidth, + style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') }, + }, + children, +}); + +const heading = (t: string, level: 'h2' | 'h3' | 'h4', color: Tok = 'text', align = 'center', extra: Record = {}): TemplateComponent => ({ + type: 'Heading', + props: { text: t, level, style: { color: tokenRef(color), fontFamily: tokenRef('heading-font'), textAlign: align, marginBottom: '12px', ...extra } }, +}); + +const text = (t: string, color: Tok = 'muted', align = 'center', extra: Record = {}): TemplateComponent => ({ + type: 'TextBlock', + props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.6', textAlign: align, ...extra } }, +}); + +const button = (label: string, filled: boolean): TemplateComponent => ({ + type: 'ButtonLink', + props: { + text: label, href: '#contact', + style: filled + ? { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), paddingTop: '12px', paddingBottom: '12px', paddingLeft: '28px', paddingRight: '28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px' } + : { backgroundColor: 'transparent', color: tokenRef('primary'), border: `2px solid ${tokenRef('primary')}`, paddingTop: '10px', paddingBottom: '10px', paddingLeft: '26px', paddingRight: '26px', borderRadius: '8px', fontWeight: '600', fontSize: '16px' }, + }, +}); + +const columns = (n: number, split: string, kids: TemplateComponent[], gap = '24px'): TemplateComponent => ({ + type: 'ColumnLayout', + props: { columns: n, split, gap, anchorId: '' }, + children: kids, +}); + +const box = (children: TemplateComponent[], style: Record = {}): TemplateComponent => ({ + type: 'Container', + props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...style } }, + children, +}); + +const feature = (f: string): TemplateComponent => + text(`✓ ${f}`, 'text', 'left', { marginBottom: '8px', fontSize: '15px' }); + +function plan(name: string, price: string, period: string, blurb: string, features: string[], cta: string, featured: boolean): TemplateComponent { + const kids: TemplateComponent[] = []; + if (featured) kids.push(text('MOST POPULAR', 'primary', 'center', { fontSize: '12px', fontWeight: '700', letterSpacing: '2px', marginBottom: '8px' })); + kids.push( + heading(name, 'h3', 'text', 'center', { fontSize: '22px', marginBottom: '4px' }), + text(blurb, 'muted', 'center', { fontSize: '14px', marginBottom: '16px' }), + heading(price, 'h2', 'primary', 'center', { fontSize: '44px', marginBottom: '0', lineHeight: '1.1' }), + text(period, 'muted', 'center', { fontSize: '14px', marginBottom: '20px' }), + ...features.map(feature), + box([button(cta, featured)], { textAlign: 'center', marginTop: '16px' }), + ); + return { + type: 'Container', + props: { + tag: 'div', + style: { + paddingTop: '32px', paddingBottom: '32px', paddingLeft: '28px', paddingRight: '28px', + backgroundColor: tokenRef('bg'), + border: featured ? `2px solid ${tokenRef('primary')}` : `1px solid ${tokenRef('border')}`, + borderRadius: '14px', + }, + }, + children: kids, + }; +} + +/** One row of a price list: name + note on the left, price on the right. */ +const priceRow = (name: string, note: string, price: string): TemplateComponent => + box([ + columns(2, '75-25', [ + box([heading(name, 'h4', 'text', 'left', { fontSize: '18px', marginBottom: '2px' }), text(note, 'muted', 'left', { fontSize: '14px' })]), + box([heading(price, 'h4', 'primary', 'right', { fontSize: '20px', marginBottom: '0' })]), + ], '12px'), + ], { borderBottom: `1px solid ${tokenRef('border')}`, paddingTop: '14px', paddingBottom: '14px' }); + +export const PRICING_SECTIONS: SectionDef[] = [ + { + id: 'pricing-three-tiers', + name: 'Pricing - three tiers', + family: 'pricing', + keywords: ['plans', 'packages', 'tiers', 'prices', 'comparison'], + tree: section('Pricing', [ + heading('Simple, clear pricing', 'h2'), + text('Pick the package that suits you. No hidden fees.', 'muted', 'center', { marginBottom: '40px' }), + columns(3, '33-33-33', [ + plan('Basic', '$49', 'per month', 'For getting started', ['One service visit', 'Email support', 'Online booking'], 'Choose Basic', false), + plan('Standard', '$99', 'per month', 'Our most popular option', ['Everything in Basic', 'Priority scheduling', 'Phone support', 'Monthly report'], 'Choose Standard', true), + plan('Premium', '$199', 'per month', 'For the full service', ['Everything in Standard', 'Dedicated contact', 'Same-day response', 'Quarterly review'], 'Choose Premium', false), + ]), + ]), + }, + { + id: 'pricing-two-plans', + name: 'Pricing - two plans', + family: 'pricing', + keywords: ['plans', 'packages', 'two options', 'prices', 'compare'], + tree: section('Pricing two plans', [ + heading('Choose your plan', 'h2'), + text('Two straightforward options. Change or cancel any time.', 'muted', 'center', { marginBottom: '40px' }), + columns(2, '50-50', [ + plan('Essential', '$79', 'per month', 'Everything you need', ['Core service', 'Email support', 'Cancel any time'], 'Get Essential', false), + plan('Complete', '$149', 'per month', 'The full package', ['Everything in Essential', 'Priority support', 'Extra visits included'], 'Get Complete', true), + ], '28px'), + ], '820px'), + }, + { + id: 'pricing-price-list', + name: 'Pricing - simple price list', + family: 'pricing', + keywords: ['price list', 'menu', 'rates', 'services', 'fees'], + tree: section('Price list', [ + heading('Our prices', 'h2'), + text('Clear starting prices for our most common services.', 'muted', 'center', { marginBottom: '32px' }), + priceRow('Standard service', 'A short description of what is included', 'from $60'), + priceRow('Extended service', 'More time and more detail for larger jobs', 'from $120'), + priceRow('Premium service', 'Our complete, all-inclusive option', 'from $220'), + priceRow('Call-out fee', 'Applies to visits outside normal hours', '$40'), + box([text('Prices are a guide and may vary by job. Contact us for a free quote.', 'muted', 'center', { fontSize: '14px', marginTop: '20px' })]), + ], '760px'), + }, + { + id: 'pricing-single-offer', + name: 'Pricing - single offer with details', + family: 'pricing', + keywords: ['offer', 'flat fee', 'package', 'price', 'highlight'], + tree: section('Pricing single offer', [ + columns(2, '55-45', [ + box([ + heading('One fair price for the whole job', 'h2', 'text', 'left'), + text('No surprises and no extras. You pay one flat fee and we take care of everything from start to finish.', 'muted', 'left', { marginBottom: '20px' }), + feature('Free, no-obligation consultation'), + feature('All materials and labour included'), + feature('Written guarantee on our work'), + ]), + plan('Complete package', '$299', 'one-off payment', 'Everything included', ['Planning and preparation', 'Full delivery', 'Aftercare support'], 'Get started', true), + ], '40px'), + ]), + }, +]; diff --git a/craft/src/sections/families/social-proof.ts b/craft/src/sections/families/social-proof.ts index 6dbdc6f..26c1f4b 100644 --- a/craft/src/sections/families/social-proof.ts +++ b/craft/src/sections/families/social-proof.ts @@ -1,4 +1,194 @@ import type { SectionDef } from '../catalog'; +import type { TemplateComponent } from '../../templates/definitions'; +import { tokenRef } from '../../utils/tokens'; -/** Content task appends to SOCIAL_PROOF_SECTIONS only (see hero.ts for the shape). */ -export const SOCIAL_PROOF_SECTIONS: SectionDef[] = []; +/** Social-proof sections: testimonials, logos / trust badges, stats. + * Built from primitives (Section / ColumnLayout / Heading / TextBlock) rather + * than the Testimonials component, whose static export hard-codes hex colours. */ + +type Tok = 'bg' | 'primary' | 'secondary' | 'accent' | 'text' | 'muted' | 'border'; + +const section = (aiName: string, children: TemplateComponent[], bg: Tok = 'bg'): TemplateComponent => ({ + type: 'Section', + props: { + aiName, anchorId: '', innerMaxWidth: '1100px', + style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef(bg) }, + }, + children, +}); + +const heading = (text: string, level: 'h2' | 'h3' | 'h4', color: Tok = 'text', extra: Record = {}): TemplateComponent => ({ + type: 'Heading', + props: { text, level, style: { color: tokenRef(color), fontFamily: tokenRef('heading-font'), textAlign: 'center', marginBottom: '16px', ...extra } }, +}); + +const text = (t: string, color: Tok = 'muted', extra: Record = {}): TemplateComponent => ({ + type: 'TextBlock', + props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.7', textAlign: 'center', ...extra } }, +}); + +const stars = (): TemplateComponent => + text('★★★★★', 'accent', { fontSize: '20px', letterSpacing: '3px', marginBottom: '12px' }); + +const card = (children: TemplateComponent[], extra: Record = {}): TemplateComponent => ({ + type: 'Container', + props: { + tag: 'div', + style: { + paddingTop: '28px', paddingBottom: '28px', paddingLeft: '24px', paddingRight: '24px', + backgroundColor: tokenRef('bg'), border: `1px solid ${tokenRef('border')}`, borderRadius: '12px', ...extra, + }, + }, + children, +}); + +const plain = (children: TemplateComponent[], extra: Record = {}): TemplateComponent => ({ + type: 'Container', + props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...extra } }, + children, +}); + +const columns = (n: number, split: string, kids: TemplateComponent[], gap = '24px'): TemplateComponent => ({ + type: 'ColumnLayout', + props: { columns: n, split, gap, anchorId: '' }, + children: kids, +}); + +const testimonialCard = (quote: string, name: string, role: string): TemplateComponent => + card([ + stars(), + text(`“${quote}”`, 'text', { fontStyle: 'italic', marginBottom: '16px' }), + text(name, 'text', { fontWeight: '700', fontSize: '15px' }), + text(role, 'muted', { fontSize: '13px' }), + ]); + +const logoWord = (label: string): TemplateComponent => + plain([text(label, 'muted', { fontWeight: '700', fontSize: '18px', letterSpacing: '1px', textTransform: 'uppercase' })], { textAlign: 'center' }); + +const badge = (icon: string, title: string, sub: string): TemplateComponent => + plain([ + { type: 'Icon', props: { icon, size: '32px', color: tokenRef('primary') } }, + text(title, 'text', { fontWeight: '700', fontSize: '15px' }), + text(sub, 'muted', { fontSize: '13px' }), + ], { textAlign: 'center' }); + +const stat = (value: string, label: string, valueColor: Tok, labelColor: Tok): TemplateComponent => + plain([ + heading(value, 'h3', valueColor, { fontSize: '48px', marginBottom: '4px', lineHeight: '1.1' }), + text(label, labelColor, { fontWeight: '500' }), + ], { textAlign: 'center' }); + +export const SOCIAL_PROOF_SECTIONS: SectionDef[] = [ + { + id: 'social-proof-testimonials-cards', + name: 'Testimonials - three cards', + family: 'social-proof', + keywords: ['reviews', 'customers', 'quotes', 'feedback', 'cards'], + tree: section('Testimonials', [ + heading('What our customers say', 'h2'), + text('Real feedback from the people we have worked with.', 'muted', { maxWidth: '560px', margin: '0 auto 40px' }), + columns(3, '33-33-33', [ + testimonialCard('Friendly, fast and great value. We would happily use them again.', 'Alex P.', 'Local customer'), + testimonialCard('Exactly what we needed, and the team was a pleasure to deal with.', 'Jordan M.', 'Small business owner'), + testimonialCard('Professional from the first call to the finished job.', 'Sam R.', 'Repeat customer'), + ]), + ], 'bg'), + }, + { + id: 'social-proof-testimonial-featured', + name: 'Testimonial - single featured quote', + family: 'social-proof', + keywords: ['quote', 'review', 'featured', 'customer story', 'centred'], + tree: section('Featured testimonial', [ + stars(), + heading('“The best decision we made for our business this year. Clear, reliable and genuinely helpful.”', 'h3', 'text', { fontSize: '28px', lineHeight: '1.4', maxWidth: '760px', margin: '0 auto 24px', fontWeight: '600' }), + text('Jordan M.', 'text', { fontWeight: '700' }), + text('Owner, Example Company', 'muted', { fontSize: '14px' }), + ], 'bg'), + }, + { + id: 'social-proof-testimonial-photo', + name: 'Testimonial - quote beside photo', + family: 'social-proof', + keywords: ['quote', 'photo', 'customer story', 'split', 'review'], + tree: section('Testimonial with photo', [ + columns(2, '40-60', [ + plain([{ type: 'ImageBlock', props: { alt: 'Photo of a happy customer', style: { width: '100%', height: 'auto', aspectRatio: '1 / 1', objectFit: 'cover', borderRadius: '16px' } } }]), + plain([ + stars(), + text('“From our first conversation I knew we were in good hands. The result exceeded what we had hoped for, and it was delivered on time.”', 'text', { textAlign: 'left', fontSize: '20px', fontStyle: 'italic', marginBottom: '20px' }), + text('Sam R.', 'text', { textAlign: 'left', fontWeight: '700' }), + text('Customer since 2022', 'muted', { textAlign: 'left', fontSize: '14px' }), + ], { paddingLeft: '24px' }), + ], '32px'), + ], 'bg'), + }, + { + id: 'social-proof-reviews-strip', + name: 'Testimonials - star review strip', + family: 'social-proof', + keywords: ['reviews', 'stars', 'ratings', 'google', 'strip'], + tree: { + type: 'Section', + props: { + aiName: 'Reviews', anchorId: '', innerMaxWidth: '1100px', + style: { paddingTop: '24px', paddingBottom: '24px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') }, + }, + children: [{ type: 'ReviewsStrip', props: { cardBg: tokenRef('bg'), style: { backgroundColor: 'transparent' } } }], + }, + }, + { + id: 'social-proof-logos-strip', + name: 'Logos - trusted by strip', + family: 'social-proof', + keywords: ['logos', 'clients', 'partners', 'trusted by', 'brands'], + tree: section('Trusted by', [ + text('Trusted by local businesses and organisations', 'muted', { fontSize: '14px', textTransform: 'uppercase', letterSpacing: '2px', marginBottom: '28px' }), + columns(5, '20-20-20-20-20', [ + logoWord('Client one'), logoWord('Client two'), logoWord('Client three'), logoWord('Client four'), logoWord('Client five'), + ], '16px'), + ], 'bg'), + }, + { + id: 'social-proof-trust-badges', + name: 'Trust badges - four icons', + family: 'social-proof', + keywords: ['badges', 'trust', 'guarantee', 'licensed', 'accreditations'], + tree: section('Trust badges', [ + columns(4, '25-25-25-25', [ + badge('fa-shield', 'Licensed & insured', 'Fully covered for your peace of mind'), + badge('fa-star', 'Highly rated', 'Rated five stars by local customers'), + badge('fa-check-circle', 'Satisfaction guaranteed', 'We make it right or we refund it'), + badge('fa-clock-o', 'Established 2010', 'Over a decade serving the community'), + ], '16px'), + ], 'bg'), + }, + { + id: 'social-proof-stats-row', + name: 'Stats - four numbers', + family: 'social-proof', + keywords: ['stats', 'numbers', 'counters', 'achievements', 'metrics'], + tree: section('Stats', [ + columns(4, '25-25-25-25', [ + stat('500+', 'Happy customers', 'primary', 'muted'), + stat('12', 'Years in business', 'primary', 'muted'), + stat('98%', 'Satisfaction rate', 'primary', 'muted'), + stat('24h', 'Average response time', 'primary', 'muted'), + ], '16px'), + ], 'bg'), + }, + { + id: 'social-proof-stats-band', + name: 'Stats - bold colour band', + family: 'social-proof', + keywords: ['stats', 'numbers', 'band', 'highlights', 'colour'], + tree: section('Stats band', [ + heading('Our track record', 'h2', 'bg', { marginBottom: '32px' }), + columns(3, '33-33-33', [ + stat('500+', 'Projects completed', 'bg', 'bg'), + stat('12', 'Years of experience', 'bg', 'bg'), + stat('98%', 'Customers who return', 'bg', 'bg'), + ], '16px'), + ], 'primary'), + }, +]; diff --git a/craft/src/sections/families/team.ts b/craft/src/sections/families/team.ts index 4995301..4871f8e 100644 --- a/craft/src/sections/families/team.ts +++ b/craft/src/sections/families/team.ts @@ -1,4 +1,114 @@ import type { SectionDef } from '../catalog'; +import type { TemplateComponent } from '../../templates/definitions'; +import { tokenRef } from '../../utils/tokens'; -/** Content task appends to TEAM_SECTIONS only (see hero.ts for the shape). */ -export const TEAM_SECTIONS: SectionDef[] = []; +/** Team sections. Photos are placeholder ImageBlocks (omitted from the export + * until the user drops a photo in). */ + +type Tok = 'bg' | 'primary' | 'text' | 'muted' | 'border'; + +const section = (aiName: string, children: TemplateComponent[]): TemplateComponent => ({ + type: 'Section', + props: { + aiName, anchorId: '', innerMaxWidth: '1100px', + style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') }, + }, + children, +}); + +const heading = (t: string, level: 'h2' | 'h3' | 'h4', align = 'center', extra: Record = {}): TemplateComponent => ({ + type: 'Heading', + props: { text: t, level, style: { color: tokenRef('text'), fontFamily: tokenRef('heading-font'), textAlign: align, marginBottom: '8px', ...extra } }, +}); + +const text = (t: string, color: Tok = 'muted', align = 'center', extra: Record = {}): TemplateComponent => ({ + type: 'TextBlock', + props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.6', textAlign: align, ...extra } }, +}); + +const columns = (n: number, split: string, kids: TemplateComponent[], gap = '24px'): TemplateComponent => ({ + type: 'ColumnLayout', + props: { columns: n, split, gap, anchorId: '' }, + children: kids, +}); + +const box = (children: TemplateComponent[], style: Record = {}): TemplateComponent => ({ + type: 'Container', + props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...style } }, + children, +}); + +const photo = (alt: string, round: boolean): TemplateComponent => ({ + type: 'ImageBlock', + props: { + alt, + style: round + ? { width: '120px', height: '120px', aspectRatio: '1 / 1', objectFit: 'cover', borderRadius: '50%', marginLeft: 'auto', marginRight: 'auto', marginBottom: '12px' } + : { width: '100%', height: 'auto', aspectRatio: '4 / 5', objectFit: 'cover', borderRadius: '12px', marginBottom: '16px' }, + }, +}); + +const member = (name: string, role: string, bio: string): TemplateComponent => + box([ + photo(`Portrait of ${name}`, false), + heading(name, 'h3', 'center', { fontSize: '20px' }), + text(role, 'primary', 'center', { fontWeight: '600', fontSize: '14px', marginBottom: '8px' }), + text(bio, 'muted', 'center', { fontSize: '15px' }), + ]); + +const compact = (name: string, role: string): TemplateComponent => + box([ + photo(`Portrait of ${name}`, true), + heading(name, 'h3', 'center', { fontSize: '18px', marginBottom: '2px' }), + text(role, 'muted', 'center', { fontSize: '14px' }), + ]); + +export const TEAM_SECTIONS: SectionDef[] = [ + { + id: 'team-grid-three', + name: 'Team - three profiles with bios', + family: 'team', + keywords: ['staff', 'people', 'meet the team', 'bios', 'profiles'], + tree: section('Team', [ + heading('Meet the team', 'h2'), + text('The friendly people behind your Business.', 'muted', 'center', { marginBottom: '40px' }), + columns(3, '33-33-33', [ + member('Alex Taylor', 'Owner', 'A short line about experience, what they love about the work and how they can help you.'), + member('Jordan Lee', 'Senior specialist', 'A short line about experience, what they love about the work and how they can help you.'), + member('Sam Rivera', 'Customer care', 'A short line about experience, what they love about the work and how they can help you.'), + ], '32px'), + ]), + }, + { + id: 'team-compact-four', + name: 'Team - four round portraits', + family: 'team', + keywords: ['staff', 'people', 'portraits', 'compact', 'avatars'], + tree: section('Team compact', [ + heading('Our people', 'h2', 'center', { marginBottom: '36px' }), + columns(4, '25-25-25-25', [ + compact('Alex Taylor', 'Owner'), + compact('Jordan Lee', 'Senior specialist'), + compact('Sam Rivera', 'Customer care'), + compact('Casey Morgan', 'Office manager'), + ], '16px'), + ]), + }, + { + id: 'team-owner-feature', + name: 'Team - owner story beside photo', + family: 'team', + keywords: ['owner', 'founder', 'about', 'story', 'meet'], + tree: section('Meet the owner', [ + columns(2, '40-60', [ + box([photo('Portrait of the owner', false)]), + box([ + text('MEET THE OWNER', 'primary', 'left', { fontSize: '13px', fontWeight: '700', letterSpacing: '2px', marginBottom: '8px' }), + heading('Hello, I am Alex', 'h2', 'left'), + text('I started this business to give local people a service they can trust. Tell visitors a little about your background, what drives you and what customers can expect when they work with you.', 'muted', 'left', { marginBottom: '16px' }), + text('Alex Taylor, Owner', 'text', 'left', { fontWeight: '700' }), + ], { paddingLeft: '16px' }), + ], '32px'), + ]), + }, +];