feat(site-builder): add social-proof, FAQ, pricing and team sections

Refs #52
This commit is contained in:
shadowdao committed 2026-10-07 12:14:30 -07:00
1 parent 48061d37ae
commit 761d2255f8
5 files changed
+693 -8

No files matched your search

@@ -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(/<img\b[^>]*>/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 <details> markup through the export sanitiser', () => {
const html = exportOne(FAQ_SECTIONS.find((s) => s.id === 'faq-accordion')!);
expect(html).toContain('<details');
expect(html).toContain('<summary');
expect(html).toContain('How do I get a quote?');
});
test('pricing tiers: featured plan is visually distinct (primary border) and carries a CTA', () => {
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');
});
});
+132 -2
View File
@@ -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 <details> 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<string, string> = {}): 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<string, string> = {}): 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<string, string> = {}): 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) =>
`<details${openFirst && i === 0 ? ' open' : ''} style="border:1px solid ${tokenRef('border')};border-radius:10px;margin:0 0 12px;background:${tokenRef('bg')}">` +
`<summary style="cursor:pointer;padding:16px 20px;font-weight:600;font-size:17px;color:${tokenRef('text')};font-family:${tokenRef('heading-font')}">${q}</summary>` +
`<div style="padding:0 20px 18px;color:${tokenRef('muted')};line-height:1.7;font-family:${tokenRef('body-font')}">${a}</div>` +
`</details>`).join('');
return `<div>${rows}</div>`;
}
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'),
},
];
+151 -2
View File
@@ -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<string, string> = {}): 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<string, string> = {}): 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<string, string> = {}): 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'),
]),
},
];
+192 -2
View File
@@ -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<string, string> = {}): 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<string, string> = {}): 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<string, string> = {}): 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<string, string> = {}): 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'),
},
];
+112 -2
View File
@@ -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<string, string> = {}): 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<string, string> = {}): 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<string, string> = {}): 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'),
]),
},
];