feat(site-builder): add social-proof, FAQ, pricing and team sections
Refs #52
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
@@ -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'),
|
||||
]),
|
||||
},
|
||||
];
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
@@ -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'),
|
||||
]),
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user