feat(site-builder): local-business block pack (hours, call bar, reviews, booking)

Refs #60
This commit is contained in:
shadowdao committed 2026-10-07 11:53:44 -07:00
1 parent 8860349c9b
commit 2cb04d2537
15 files changed
+837 -6

No files matched your search

@@ -0,0 +1,65 @@
import { describe, test, expect } from 'vitest';
import { BookingEmbed, bookingEmbedUrl } from './BookingEmbed';
const toHtml = (BookingEmbed as any).toHtml;
const defaults = () => ({ ...(BookingEmbed as any).craft.props });
const run = (url: string, extra: any = {}) => toHtml({ ...defaults(), url, ...extra }, '').html as string;
describe('BookingEmbed.toHtml host allowlist', () => {
test.each([
'https://calendly.com/acme/30min',
'https://app.calendly.com/x',
'https://squareup.com/appointments/book/abc',
'https://app.squareup.com/appointments/book/abc/L1/start',
'https://acme.square.site/appointments',
])('allows %s', (u) => {
const html = run(u);
expect(html).toContain('<iframe src="');
expect(html).toContain('sandbox="allow-scripts allow-forms allow-same-origin allow-popups"');
expect(html).toContain('referrerpolicy="strict-origin-when-cross-origin"');
expect(html).toContain('loading="lazy"');
expect(html).toContain('height:700px');
});
test.each([
'https://evil.com',
'javascript:alert(1)',
' JaVaScRiPt:alert(1)',
'data:text/html,<script>alert(1)</script>',
'http://calendly.com/acme',
'https://calendly.com.evil.com/x',
'https://evilcalendly.com/x',
'https://evil.com/calendly.com',
'https://evil.com?u=https://calendly.com',
'https://calendly.com@evil.com/x',
'https://user:pw@calendly.com/x',
'https://square.site/x',
'//evil.com',
'',
])('never iframes %s', (u) => {
expect(bookingEmbedUrl(u)).toBe('');
const html = run(u);
expect(html).not.toContain('<iframe');
expect(html).not.toMatch(/javascript:/i);
expect(html).not.toContain('<script');
});
test("provider 'custom' is not an escape hatch", () => {
expect(run('https://evil.com', { provider: 'custom' })).not.toContain('<iframe');
});
test('refused https URL degrades to an escaped link, never an iframe', () => {
const html = run('https://evil.com/a?x=1&y="><script>', { title: '<b>Book</b>' });
expect(html).toContain('<a href="https://evil.com/a?x=1&amp;y=&quot;&gt;&lt;script&gt;"');
expect(html).not.toContain('<script');
expect(html).toContain('&lt;b&gt;Book&lt;/b&gt;');
expect(html).not.toContain('<iframe');
});
test('title and height are escaped / sanitised', () => {
const html = run('https://calendly.com/a', { title: '"><script>x</script>', height: '700px;position:fixed" onload="x' });
expect(html).not.toContain('<script>');
// quotes/semicolons are stripped from height: the payload stays inside style="".
expect(html).toMatch(/style="width:100%;height:[^";]*;border:none;display:block"><\/iframe>/);
});
});
@@ -0,0 +1,103 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, cssValue, safeUrl } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface BookingEmbedProps {
provider?: 'calendly' | 'square' | 'custom';
url?: string;
height?: string;
title?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
// Exact hosts, plus subdomains of the *.suffix entries. Everything else is
// refused, whatever the `provider` says ('custom' is NOT an escape hatch).
const EXACT_HOSTS = ['calendly.com', 'squareup.com', 'app.squareup.com'];
const SUFFIX_HOSTS = ['.calendly.com', '.square.site'];
/** Returns the normalised https URL when its host is allow-listed, else ''. */
export function bookingEmbedUrl(raw: unknown): string {
const s = safeUrl(typeof raw === 'string' ? raw : '');
if (!s) return '';
let u: URL;
try { u = new URL(s); } catch { return ''; }
if (u.protocol !== 'https:') return '';
if (u.username || u.password) return '';
const host = u.hostname.toLowerCase();
const ok = EXACT_HOSTS.includes(host) || SUFFIX_HOSTS.some((suf) => host.endsWith(suf) && host.length > suf.length);
return ok ? u.toString() : '';
}
const PROVIDER_LABEL: Record<string, string> = { calendly: 'Calendly', square: 'Square Appointments', custom: 'Booking' };
export const BookingEmbed: UserComponent<BookingEmbedProps> = ({
provider = 'calendly',
url = '',
height = '700px',
title = 'Book an appointment',
style = {},
}) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const ok = !!bookingEmbedUrl(url);
// No live iframe in the canvas: a placeholder card keeps the editor offline-safe.
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{ padding: 24, outline: selected ? '2px solid #3b82f6' : 'none', ...style }}
>
<div style={{ border: `2px dashed ${tokenRef('border')}`, borderRadius: 12, padding: 32, textAlign: 'center', color: tokenRef('muted'), minHeight: 160 }}>
<div style={{ fontWeight: 600, color: tokenRef('text') }}>{title}</div>
<div style={{ marginTop: 8, fontSize: 14 }}>
{PROVIDER_LABEL[provider] || 'Booking'} embed{ok ? `: ${url}` : url ? ': this address is not an allowed booking host' : ': paste your booking URL in the panel'}
</div>
</div>
</div>
);
};
BookingEmbed.craft = {
displayName: 'Booking',
props: {
provider: 'calendly',
url: '',
height: '700px',
title: 'Book an appointment',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
};
(BookingEmbed as any).toHtml = (props: BookingEmbedProps, _childrenHtml: string) => {
const title = typeof props.title === 'string' && props.title ? props.title : 'Book an appointment';
const wrapStyle = cssPropsToString({ width: '100%', ...(props.style || {}) });
const wrap = (inner: string) => `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>${inner}</div>`;
const src = bookingEmbedUrl(props.url);
if (src) {
const height = cssValue(props.height) || '700px';
return {
html: wrap(`<iframe src="${escapeAttr(src)}" title="${escapeAttr(title)}" loading="lazy" sandbox="allow-scripts allow-forms allow-same-origin allow-popups" referrerpolicy="strict-origin-when-cross-origin" style="width:100%;height:${height};border:none;display:block"></iframe>`),
};
}
// Not allow-listed: never an iframe. A plain http(s) link at most.
const raw = safeUrl(typeof props.url === 'string' ? props.url : '');
const link = /^https?:\/\//i.test(raw) ? raw : '';
return {
html: wrap(link
? `<p><a href="${escapeAttr(link)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(title)}</a></p>`
: ''),
};
};
@@ -0,0 +1,48 @@
import { describe, test, expect } from 'vitest';
import { OpeningHours } from './OpeningHours';
const toHtml = (OpeningHours as any).toHtml;
describe('OpeningHours.toHtml', () => {
test('default: 7 rows, th scope=row, hidden caption, token colours', () => {
const { html } = toHtml({ ...(OpeningHours as any).craft.props }, '', 'n1');
expect(html.match(/<th scope="row"/g)).toHaveLength(7);
expect(html).toContain('<caption');
expect(html).toContain('clip:rect(0,0,0,0)');
expect(html).toContain('var(--wsb-text)');
expect(html).toContain('Sunday');
expect(html).toMatch(/Sunday<\/th><td[^>]*>Closed</);
});
test('closed overrides stale hours text', () => {
const { html } = toHtml({ rows: [{ day: 'Mon', hours: '9-5', closed: true }] }, '', 'n1');
expect(html).toContain('>Closed<');
expect(html).not.toContain('9-5');
});
test('escapes day, hours, note and title; sanitises colours', () => {
const { html } = toHtml({
title: '<img src=x onerror=1>',
note: '"><script>alert(1)</script>',
rows: [{ day: '<b>Mon</b>', hours: '<script>x</script>', closed: false }],
labelColor: 'red;background:url(javascript:alert(1))"',
}, '', 'n1');
expect(html).not.toContain('<script>');
expect(html).not.toContain('<img');
expect(html).not.toContain('<b>');
expect(html).toContain('&lt;b&gt;Mon&lt;/b&gt;');
// cssValue strips ( ) ; " so no url()/extra declaration can form.
expect(html).not.toContain('url(');
expect(html).not.toMatch(/color:red[^"]*;background/);
});
test('tolerates non-array rows', () => {
expect(() => toHtml({ rows: 'nope' }, '', 'n1')).not.toThrow();
});
test('two instances get distinct heading ids', () => {
const a = toHtml({ title: 'T' }, '', 'a').html.match(/id="(hours_[a-z0-9]+)"/)[1];
const b = toHtml({ title: 'T' }, '', 'b').html.match(/id="(hours_[a-z0-9]+)"/)[1];
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,121 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, cssValue, scopeId } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
export interface HoursRow {
day: string;
hours: string;
closed: boolean;
}
interface OpeningHoursProps {
title?: string;
rows?: HoursRow[];
note?: string;
style?: CSSProperties;
labelColor?: string;
valueColor?: string;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
// Every key is present on every default row: the generic array editor derives
// its per-item fields from Object.keys(items[0]).
export const defaultHoursRows = (): HoursRow[] => [
{ day: 'Monday', hours: '9:00 AM - 5:00 PM', closed: false },
{ day: 'Tuesday', hours: '9:00 AM - 5:00 PM', closed: false },
{ day: 'Wednesday', hours: '9:00 AM - 5:00 PM', closed: false },
{ day: 'Thursday', hours: '9:00 AM - 5:00 PM', closed: false },
{ day: 'Friday', hours: '9:00 AM - 5:00 PM', closed: false },
{ day: 'Saturday', hours: '10:00 AM - 2:00 PM', closed: false },
{ day: 'Sunday', hours: '', closed: true },
];
// "Open now" is deliberately NOT computed: the published page is a static
// snapshot, so this is a plain table.
function cellText(r: HoursRow): string {
return r.closed ? 'Closed' : (r.hours || '');
}
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0';
export const OpeningHours: UserComponent<OpeningHoursProps> = ({
title = 'Opening Hours',
rows = defaultHoursRows(),
note = '',
style = {},
labelColor = tokenRef('text'),
valueColor = tokenRef('muted'),
anchorId,
}) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const items = Array.isArray(rows) ? rows : [];
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ padding: '48px 20px', outline: selected ? '2px solid #3b82f6' : 'none', ...style }}
>
<div style={{ maxWidth: '480px', margin: '0 auto' }}>
{title && <h2 style={{ margin: '0 0 16px', fontSize: '28px', color: labelColor }}>{title}</h2>}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<caption style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>{title || 'Opening hours'}</caption>
<tbody>
{items.map((r, i) => (
<tr key={i} style={{ borderBottom: `1px solid ${tokenRef('border')}` }}>
<th scope="row" style={{ textAlign: 'left', padding: '10px 0', fontWeight: 600, color: labelColor }}>{r.day}</th>
<td style={{ textAlign: 'right', padding: '10px 0', color: valueColor }}>{cellText(r)}</td>
</tr>
))}
</tbody>
</table>
{note && <p style={{ margin: '12px 0 0', fontSize: '14px', color: valueColor }}>{note}</p>}
</div>
</section>
);
};
OpeningHours.craft = {
displayName: 'Opening Hours',
props: {
title: 'Opening Hours',
rows: defaultHoursRows(),
note: '',
style: { backgroundColor: 'transparent' },
labelColor: tokenRef('text'),
valueColor: tokenRef('muted'),
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
};
(OpeningHours as any).toHtml = (props: OpeningHoursProps, _childrenHtml: string, nodeId?: string) => {
const rows = Array.isArray(props.rows) ? props.rows : [];
const title = typeof props.title === 'string' ? props.title : 'Opening Hours';
const note = typeof props.note === 'string' ? props.note : '';
const labelColor = cssValue(props.labelColor) || tokenRef('text');
const valueColor = cssValue(props.valueColor) || tokenRef('muted');
const sectionStyle = cssPropsToString({ padding: '48px 20px', ...(props.style || {}) });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const hid = scopeId(nodeId, title, 'hours');
const body = rows.map((r) =>
`<tr style="border-bottom:1px solid ${tokenRef('border')}"><th scope="row" style="text-align:left;padding:10px 0;font-weight:600;color:${labelColor}">${escapeHtml(r.day)}</th><td style="text-align:right;padding:10px 0;color:${valueColor}">${escapeHtml(cellText(r))}</td></tr>`
).join('');
return {
html: `<section${idAttr}${title ? ` aria-labelledby="${hid}"` : ''}${sectionStyle ? ` style="${sectionStyle}"` : ''}><div style="max-width:480px;margin:0 auto">${title ? `<h2 id="${hid}" style="margin:0 0 16px;font-size:28px;color:${labelColor}">${escapeHtml(title)}</h2>` : ''}<table style="width:100%;border-collapse:collapse"><caption style="${VISUALLY_HIDDEN}">${escapeHtml(title || 'Opening hours')}</caption><tbody>${body}</tbody></table>${note ? `<p style="margin:12px 0 0;font-size:14px;color:${valueColor}">${escapeHtml(note)}</p>` : ''}</div></section>`,
};
};
@@ -0,0 +1,52 @@
import { describe, test, expect } from 'vitest';
import { ReviewsStrip, clampRating } from './ReviewsStrip';
const toHtml = (ReviewsStrip as any).toHtml;
const defaults = () => ({ ...(ReviewsStrip as any).craft.props });
describe('ReviewsStrip.toHtml', () => {
test('default: 3 cards, token colours, aria-label stars, no aggregate markup', () => {
const { html } = toHtml(defaults(), '', 'n1');
expect(html.match(/<figure/g)).toHaveLength(3);
expect(html).toContain('var(--wsb-bg)');
expect(html).toContain('aria-label="5 out of 5 stars"');
expect(html).toContain('aria-label="4 out of 5 stars"');
expect(html).not.toContain('itemtype');
expect(html).not.toContain('application/ld+json');
});
test('escapes names, text and source label', () => {
const { html } = toHtml({ ...defaults(), reviews: [{ name: '<i>Eve</i>', rating: 5, text: '"><script>alert(1)</script>', source: '<b>G</b>', url: '' }] }, '', 'n1');
expect(html).not.toContain('<script>');
expect(html).not.toContain('<i>');
expect(html).not.toContain('<b>');
expect(html).toContain('&lt;i&gt;Eve&lt;/i&gt;');
});
test('hostile review and all-reviews URLs are dropped; https kept', () => {
const hostile = toHtml({ ...defaults(), allReviewsUrl: 'javascript:alert(1)', reviews: [{ name: 'A', rating: 5, text: 't', source: 'G', url: ' JaVa\tScript:alert(1)' }] }, '', 'n1').html;
expect(hostile).not.toMatch(/javascript/i);
expect(hostile).not.toContain('<a ');
const ok = toHtml({ ...defaults(), allReviewsUrl: 'https://g.page/r/abc?x=1&y=2', reviews: [{ name: 'A', rating: 5, text: 't', source: 'G', url: 'https://yelp.com/biz/x' }] }, '', 'n1').html;
expect(ok).toContain('href="https://g.page/r/abc?x=1&amp;y=2"');
expect(ok).toContain('href="https://yelp.com/biz/x"');
expect(ok).toContain('rel="noopener noreferrer nofollow"');
});
test('rating is clamped to 1-5 and garbage is safe; max 6 cards', () => {
expect(clampRating(99)).toBe(5);
expect(clampRating(0)).toBe(1);
expect(clampRating('"><x')).toBe(5);
const many = Array.from({ length: 9 }, (_, i) => ({ name: `n${i}`, rating: 'x"', text: 't', source: '', url: '' }));
const { html } = toHtml({ ...defaults(), reviews: many }, '', 'n1');
expect(html.match(/<figure/g)).toHaveLength(6);
expect(html.match(/aria-label="5 out of 5 stars"/g)).toHaveLength(6);
});
test('cardBg is sanitised', () => {
const { html } = toHtml({ ...defaults(), cardBg: 'red"onmouseover="x' }, '', 'n1');
// cssValue strips the quotes, so the payload stays inert text inside style="".
expect(html).not.toMatch(/onmouseover\s*=\s*"/);
expect((html.match(/<figure style="[^"]*"/g) || [])).toHaveLength(3);
});
});
@@ -0,0 +1,127 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, cssValue, scopeId } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
import { httpUrl } from './StickyCallBar';
export interface ReviewItem {
name: string;
rating: number;
text: string;
source: string;
url: string;
}
interface ReviewsStripProps {
title?: string;
reviews?: ReviewItem[];
allReviewsUrl?: string;
allReviewsLabel?: string;
cardBg?: string;
style?: CSSProperties;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
// v1: reviews are entered by hand. The `source` is a plain-text label
// (e.g. "Google"), never an official integration.
export const defaultReviews = (): ReviewItem[] => [
{ name: 'Alex P.', rating: 5, text: 'Friendly, fast and great value. Highly recommend.', source: 'Google', url: '' },
{ name: 'Jordan M.', rating: 5, text: 'Exactly what we needed, and the team was a pleasure to deal with.', source: 'Google', url: '' },
{ name: 'Sam R.', rating: 4, text: 'Professional service from start to finish.', source: 'Yelp', url: '' },
];
export function clampRating(r: unknown): number {
const n = Math.round(Number(r));
if (!Number.isFinite(n)) return 5;
return Math.min(5, Math.max(1, n));
}
const stars = (n: number) => '★'.repeat(n) + '☆'.repeat(5 - n);
const CARD_CSS = (bg: string) =>
`background-color:${bg};border:1px solid ${tokenRef('border')};border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:8px`;
export const ReviewsStrip: UserComponent<ReviewsStripProps> = ({
title = 'What our customers say',
reviews = defaultReviews(),
allReviewsUrl = '',
allReviewsLabel = 'Read all reviews',
cardBg = tokenRef('bg'),
style = {},
anchorId,
}) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const items = (Array.isArray(reviews) ? reviews : []).slice(0, 6);
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ padding: '56px 20px', outline: selected ? '2px solid #3b82f6' : 'none', ...style }}
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
{title && <h2 style={{ margin: '0 0 24px', textAlign: 'center', color: tokenRef('text') }}>{title}</h2>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(260px,1fr))', gap: '20px' }}>
{items.map((r, i) => (
<figure key={i} style={{ margin: 0, backgroundColor: cardBg, border: `1px solid ${tokenRef('border')}`, borderRadius: 12, padding: 24, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div role="img" aria-label={`${clampRating(r.rating)} out of 5 stars`} style={{ color: tokenRef('warning'), letterSpacing: 2 }}>{stars(clampRating(r.rating))}</div>
<blockquote style={{ margin: 0, color: tokenRef('text') }}>{r.text}</blockquote>
<figcaption style={{ fontSize: 14, color: tokenRef('muted') }}>{r.name}{r.source ? ` · ${r.source}` : ''}</figcaption>
</figure>
))}
</div>
{allReviewsUrl && <p style={{ textAlign: 'center', marginTop: 24 }}><span style={{ color: tokenRef('link'), fontWeight: 600 }}>{allReviewsLabel}</span></p>}
</div>
</section>
);
};
ReviewsStrip.craft = {
displayName: 'Reviews',
props: {
title: 'What our customers say',
reviews: defaultReviews(),
allReviewsUrl: '',
allReviewsLabel: 'Read all reviews',
cardBg: tokenRef('bg'),
style: { backgroundColor: 'transparent' },
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
};
(ReviewsStrip as any).toHtml = (props: ReviewsStripProps, _childrenHtml: string, nodeId?: string) => {
const title = typeof props.title === 'string' ? props.title : '';
const items = (Array.isArray(props.reviews) ? props.reviews : []).slice(0, 6);
const cardBg = cssValue(props.cardBg) || tokenRef('bg');
const sectionStyle = cssPropsToString({ padding: '56px 20px', ...(props.style || {}) });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const hid = scopeId(nodeId, title, 'reviews');
const cards = items.map((r) => {
const n = clampRating(r.rating);
const link = httpUrl(r.url);
const who = `${escapeHtml(r.name)}${r.source ? ` &middot; ${escapeHtml(r.source)}` : ''}`;
const cap = link ? `<a href="${escapeAttr(link)}" target="_blank" rel="noopener noreferrer nofollow" style="color:inherit">${who}</a>` : who;
return `<figure style="margin:0;${CARD_CSS(cardBg)}"><div role="img" aria-label="${n} out of 5 stars" style="color:${tokenRef('warning')};letter-spacing:2px">${stars(n)}</div><blockquote style="margin:0;color:${tokenRef('text')}">${escapeHtml(r.text)}</blockquote><figcaption style="font-size:14px;color:${tokenRef('muted')}">${cap}</figcaption></figure>`;
}).join('');
const all = httpUrl(props.allReviewsUrl);
const allHtml = all
? `<p style="text-align:center;margin-top:24px"><a href="${escapeAttr(all)}" target="_blank" rel="noopener noreferrer nofollow" style="color:${tokenRef('link')};font-weight:600">${escapeHtml(props.allReviewsLabel ?? 'Read all reviews')}</a></p>`
: '';
return {
html: `<section${idAttr}${title ? ` aria-labelledby="${hid}"` : ''}${sectionStyle ? ` style="${sectionStyle}"` : ''}><div style="max-width:1100px;margin:0 auto">${title ? `<h2 id="${hid}" style="margin:0 0 24px;text-align:center;color:${tokenRef('text')}">${escapeHtml(title)}</h2>` : ''}<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px">${cards}</div>${allHtml}</div></section>`,
};
};
@@ -0,0 +1,48 @@
import { describe, test, expect } from 'vitest';
import { StickyCallBar, telHref } from './StickyCallBar';
import { exportBodyHtml } from '../../utils/html-export';
const toHtml = (StickyCallBar as any).toHtml;
const defaults = () => ({ ...(StickyCallBar as any).craft.props });
describe('StickyCallBar.toHtml', () => {
test('default: fixed bar, primary token, tel: preserved, two links', () => {
const { html } = toHtml(defaults(), '');
expect(html).toContain('position:fixed;bottom:0;left:0;right:0;z-index:999');
expect(html).toContain('var(--wsb-primary)');
expect(html).toContain('href="tel:+15551234567"');
expect(html.match(/<a /g)).toHaveLength(2);
});
test('formats free-text phone into a dialable tel: href', () => {
expect(telHref('(555) 123-4567')).toBe('tel:5551234567');
expect(telHref('+1 555 123 4567')).toBe('tel:+15551234567');
expect(telHref('javascript:alert(1)')).toBe('tel:1');
expect(telHref('call me')).toBe('');
});
test('hostile directions URL and labels', () => {
const { html } = toHtml({ ...defaults(), directionsUrl: 'javascript:alert(1)', callLabel: '<script>x</script>', phone: '+1555' }, '');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
expect(html.match(/<a /g)).toHaveLength(1);
});
test('no dialable phone and no directions renders nothing', () => {
expect((toHtml({ ...defaults(), phone: '', directionsUrl: '' }, '')).html).toBe('');
});
test('exportBodyHtml emits data-hide-desktop data-hide-tablet (mobile-only)', () => {
const state = JSON.stringify({
ROOT: { type: 'div', isCanvas: true, props: {}, displayName: 'div', custom: {}, hidden: false, nodes: ['bar'], linkedNodes: {} },
bar: { type: { resolvedName: 'StickyCallBar' }, isCanvas: false, props: defaults(), displayName: 'Call Bar', custom: {}, parent: 'ROOT', hidden: false, nodes: [], linkedNodes: {} },
});
const out = exportBodyHtml(state);
const body = typeof out === 'string' ? out : (out as any).html ?? JSON.stringify(out);
expect(body).toContain('data-hide-desktop');
expect(body).toContain('data-hide-tablet');
expect(body).not.toContain('data-hide-mobile');
expect(body).toContain('href="tel:+15551234567"');
});
});
@@ -0,0 +1,96 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { escapeHtml, escapeAttr, cssValue, safeUrl } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface StickyCallBarProps {
phone?: string;
callLabel?: string;
directionsUrl?: string;
directionsLabel?: string;
bgColor?: string;
textColor?: string;
position?: 'bottom';
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
/** tel: href from free text: keep only digits and a leading +. '' when nothing dialable remains. */
export function telHref(phone: unknown): string {
const raw = typeof phone === 'string' ? phone.trim() : '';
const digits = raw.replace(/[^0-9]/g, '');
if (!digits) return '';
return `tel:${raw.startsWith('+') ? '+' : ''}${digits}`;
}
/** Directions link: http(s) only (Maps / Apple Maps / OSM), never other schemes. */
export function httpUrl(url: unknown): string {
const u = safeUrl(typeof url === 'string' ? url : '');
return /^https?:\/\//i.test(u) ? u : '';
}
const linkCss = (textColor: string, sep: boolean) =>
`flex:1;display:block;text-align:center;padding:14px 8px;font-weight:600;font-size:16px;text-decoration:none;color:${textColor}${sep ? ';border-left:1px solid rgba(255,255,255,0.35)' : ''}`;
export const StickyCallBar: UserComponent<StickyCallBarProps> = ({
phone = '+15551234567',
callLabel = 'Call us',
directionsUrl = 'https://maps.google.com/?q=Your+Business',
directionsLabel = 'Directions',
bgColor = tokenRef('primary'),
textColor = '#ffffff',
style = {},
}) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
// The editor shows the bar in-flow (not position:fixed) so it never covers the canvas.
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{ display: 'flex', backgroundColor: bgColor, outline: selected ? '2px solid #3b82f6' : 'none', ...style }}
>
{phone && <span style={{ flex: 1, textAlign: 'center', padding: '14px 8px', fontWeight: 600, color: textColor }}>{callLabel}</span>}
{directionsUrl && <span style={{ flex: 1, textAlign: 'center', padding: '14px 8px', fontWeight: 600, color: textColor, borderLeft: '1px solid rgba(255,255,255,0.35)' }}>{directionsLabel}</span>}
</div>
);
};
StickyCallBar.craft = {
displayName: 'Call Bar',
props: {
phone: '+15551234567',
callLabel: 'Call us',
directionsUrl: 'https://maps.google.com/?q=Your+Business',
directionsLabel: 'Directions',
bgColor: tokenRef('primary'),
textColor: '#ffffff',
position: 'bottom',
style: {},
animation: '',
animationDelay: '',
// Mobile-only: the export's VISIBILITY_CSS hides it on desktop and tablet.
hideOnDesktop: true,
hideOnTablet: true,
hideOnMobile: false,
},
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
};
// The fixed bar can cover page content at the bottom edge; add a Spacer at the
// end of the page to compensate (documented, not automatic).
(StickyCallBar as any).toHtml = (props: StickyCallBarProps, _childrenHtml: string) => {
const bg = cssValue(props.bgColor) || tokenRef('primary');
const fg = cssValue(props.textColor) || '#ffffff';
const tel = telHref(props.phone);
const dir = httpUrl(props.directionsUrl);
const links: string[] = [];
if (tel) links.push(`<a href="${escapeAttr(tel)}" style="${linkCss(fg, false)}">${escapeHtml(props.callLabel ?? 'Call us')}</a>`);
if (dir) links.push(`<a href="${escapeAttr(dir)}" target="_blank" rel="noopener noreferrer" style="${linkCss(fg, links.length > 0)}">${escapeHtml(props.directionsLabel ?? 'Directions')}</a>`);
if (links.length === 0) return { html: '' };
return {
html: `<nav aria-label="Quick contact" style="position:fixed;bottom:0;left:0;right:0;z-index:999;display:flex;background-color:${bg}">${links.join('')}</nav>`,
};
};
@@ -0,0 +1,64 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { insertBlock } from '../../panels/left/blocks-registry';
import { BusinessStylePanel } from '../../panels/right/styles/BusinessStylePanel';
import { GenericPropsEditor } from '../../panels/right/styles/GenericPropsEditor';
import { deriveVirtualRows } from '../../panels/left/layers-virtual-rows';
import { exportBodyHtml } from '../../utils/html-export';
let h: EditorHarness | null = null;
afterEach(() => { h?.unmount(); h = null; });
const BLOCKS_UNDER_TEST: Array<[string, string, string | null]> = [
['opening-hours', 'OpeningHours', 'rows'],
['call-bar', 'StickyCallBar', null],
['reviews-strip', 'ReviewsStrip', 'reviews'],
['booking-embed', 'BookingEmbed', null],
];
const Probe: React.FC<{ id: string; which: 'business' | 'generic' }> = ({ id, which }) => {
const { props, name } = useEditor((state) => ({ props: state.nodes[id]?.data.props, name: state.nodes[id]?.data.displayName }));
const Panel = which === 'business' ? BusinessStylePanel : GenericPropsEditor;
return <div data-testid="panel" data-name={name}><Panel selectedId={id} nodeProps={props || {}} typeName={name} /></div>;
};
describe('local-business blocks in the real editor', () => {
test.each(BLOCKS_UNDER_TEST)('insertBlock(%s) creates a %s node and exports', (blockId, resolvedName) => {
h = renderEditorHarness();
let id: string | null = null;
h.act(() => { id = insertBlock(h!.query, h!.actions, blockId); });
expect(id).toBeTruthy();
expect(JSON.parse(h.getSerialized())[id!].type.resolvedName).toBe(resolvedName);
const out: any = exportBodyHtml(h.getSerialized());
expect(typeof out === 'string' ? out : out.html).toBeTruthy();
});
test.each(['business', 'generic'] as const)('%s panel renders per-item fields for rows / reviews', (which) => {
const check = (blockId: string, fields: string[]) => {
h = renderEditorHarness();
let id: string | null = null;
h.act(() => { id = insertBlock(h!.query, h!.actions, blockId); });
h.mountChild(<Probe id={id!} which={which} />);
const text = (h.container.querySelector('[data-testid="panel"]')?.textContent || '').toLowerCase();
for (const f of fields) expect(text, `${blockId}:${f}`).toContain(f);
h.unmount(); h = null;
};
check('opening-hours', ['day', 'hours', 'closed']);
check('reviews-strip', ['name', 'rating', 'text', 'source', 'url']);
});
test('layers virtual rows are derived for hours and reviews', () => {
h = renderEditorHarness();
let id: string | null = null;
h.act(() => { id = insertBlock(h!.query, h!.actions, 'opening-hours'); });
const n = JSON.parse(h.getSerialized())[id!];
const rows = deriveVirtualRows(n.displayName, n.props);
expect(rows.map((r) => r.label)).toEqual(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']);
let rid: string | null = null;
h.act(() => { rid = insertBlock(h!.query, h!.actions, 'reviews-strip'); });
const r = JSON.parse(h.getSerialized())[rid!];
expect(deriveVirtualRows(r.displayName, r.props).map((x) => x.label)).toEqual(['Alex P.', 'Jordan M.', 'Sam R.']);
});
});
+8
View File
@@ -37,6 +37,10 @@ import { NumberCounter } from './sections/NumberCounter';
import { SubscribeForm } from './forms/SubscribeForm';
import { SearchBar } from './basic/SearchBar';
import { HtmlBlock } from './basic/HtmlBlock';
import { OpeningHours } from './business/OpeningHours';
import { StickyCallBar } from './business/StickyCallBar';
import { ReviewsStrip } from './business/ReviewsStrip';
import { BookingEmbed } from './business/BookingEmbed';
export const componentResolver = {
Container,
@@ -78,4 +82,8 @@ export const componentResolver = {
SubscribeForm,
SearchBar,
HtmlBlock,
OpeningHours,
StickyCallBar,
ReviewsStrip,
BookingEmbed,
};
@@ -11,12 +11,12 @@ const rootChildren = (hh: EditorHarness): string[] => JSON.parse(hh.getSerialize
const typeOf = (hh: EditorHarness, id: string): string => JSON.parse(hh.getSerialized())[id].type.resolvedName;
describe('blocks registry', () => {
test('43 blocks with the original category split', () => {
expect(BLOCKS).toHaveLength(43);
test('47 blocks with the original category split', () => {
expect(BLOCKS).toHaveLength(47);
const count = (c: string) => BLOCKS.filter((b) => b.category === c).length;
expect([count('basic'), count('layout'), count('sections'), count('media'), count('forms'), count('business')]).toEqual([12, 11, 11, 3, 6, 0]);
expect([count('basic'), count('layout'), count('sections'), count('media'), count('forms'), count('business')]).toEqual([12, 11, 11, 3, 6, 4]);
expect(BLOCK_CATEGORIES.map((c) => c.id)).toContain('business');
expect(new Set(BLOCKS.map((b) => b.id)).size).toBe(43);
expect(new Set(BLOCKS.map((b) => b.id)).size).toBe(47);
for (const b of BLOCKS) expect(b.keywords.length).toBeGreaterThanOrEqual(3);
});
@@ -27,7 +27,7 @@ describe('blocks registry', () => {
});
test('searchBlocks: empty returns all, pric ranks Pricing first, matches keywords and category', () => {
expect(searchBlocks('')).toHaveLength(43);
expect(searchBlocks('')).toHaveLength(47);
expect(searchBlocks('pric')[0].id).toBe('pricing-table');
expect(searchBlocks('FAQ').map((b) => b.id)).toEqual(['accordion']);
expect(searchBlocks('newsletter').map((b) => b.id)).toContain('subscribe-form');
@@ -81,7 +81,7 @@ describe('BlocksPanel smoke', () => {
h.mountChild(<BlocksPanel />);
const cats = Array.from(h.container.querySelectorAll('.block-category'));
const summary = cats.map((c) => [c.querySelector('.block-category-header span')!.textContent, c.querySelectorAll('.block-item').length]);
expect(summary).toEqual([['Basic', 12], ['Layout', 11], ['Sections', 11], ['Media', 3], ['Forms', 6]]);
expect(summary).toEqual([['Basic', 12], ['Layout', 11], ['Sections', 11], ['Media', 3], ['Forms', 6], ['Business', 4]]);
});
test('double-click appends to the canvas without selecting', () => {
+12
View File
@@ -35,6 +35,10 @@ import { ContactForm } from '../../components/forms/ContactForm';
import { SubscribeForm } from '../../components/forms/SubscribeForm';
import { StarRating } from '../../components/basic/StarRating';
import { SocialLinks } from '../../components/basic/SocialLinks';
import { OpeningHours } from '../../components/business/OpeningHours';
import { StickyCallBar } from '../../components/business/StickyCallBar';
import { ReviewsStrip } from '../../components/business/ReviewsStrip';
import { BookingEmbed } from '../../components/business/BookingEmbed';
export type BlockCategoryId = 'basic' | 'layout' | 'sections' | 'media' | 'forms' | 'business';
@@ -145,6 +149,14 @@ export const BLOCKS: ReadonlyArray<BlockDef> = [
create: () => <TextareaField /> },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', category: 'forms', keywords: ['submit', 'send', 'form', 'button'],
create: () => <FormButton /> },
{ id: 'opening-hours', label: 'Opening Hours', icon: 'fa-clock-o', category: 'business', keywords: ['hours', 'opening', 'times', 'schedule', 'open'],
create: () => <OpeningHours /> },
{ id: 'call-bar', label: 'Call Bar', icon: 'fa-phone', category: 'business', keywords: ['call', 'phone', 'directions', 'sticky', 'mobile'],
create: () => <StickyCallBar /> },
{ id: 'reviews-strip', label: 'Reviews', icon: 'fa-star', category: 'business', keywords: ['reviews', 'testimonials', 'google', 'stars', 'ratings'],
create: () => <ReviewsStrip /> },
{ id: 'booking-embed', label: 'Booking', icon: 'fa-calendar', category: 'business', keywords: ['booking', 'calendly', 'appointment', 'schedule', 'square'],
create: () => <BookingEmbed /> },
];
export function getBlock(id: string): BlockDef | undefined {
@@ -66,6 +66,8 @@ export const VIRTUAL_CHILD_PROPS: Record<string, VirtualChildSpec> = {
Hero: { prop: 'ctas', label: 'text', fallback: 'Button' },
'Call to Action': { prop: 'ctas', label: 'text', fallback: 'Button' },
'CTA Section': { prop: 'ctas', label: 'text', fallback: 'Button' },
'Opening Hours': { prop: 'rows', label: 'day', fallback: 'Day' },
Reviews: { prop: 'reviews', label: 'name', fallback: 'Review' },
};
const MAX_LABEL = 40;
@@ -0,0 +1,84 @@
import React from 'react';
import {
StylePanelProps,
CollapsibleSection,
ColorPickerField,
labelStyle,
inputStyle,
sectionGap,
useNodeProp,
AnimationControl,
VisibilityControl,
} from './shared';
import { ArrayItemFieldsEditor } from './ArrayItemFields';
/* ---------- Local-business blocks: Opening Hours / Call Bar / Reviews / Booking ----------
Text fields are listed per prop; a field renders only when the prop exists on
the node (present-but-blank defaults make controls appear). Array props reuse
the shared item editor (fields come from items[0]). Classified by the
integrator in GuidedStyles: /^opening hours$|^call bar$|^reviews$|^booking$/i */
const TEXT_PROPS: Array<[string, string]> = [
['title', 'Title'], ['note', 'Note'],
['phone', 'Phone'], ['callLabel', 'Call button label'],
['directionsUrl', 'Directions link'], ['directionsLabel', 'Directions button label'],
['allReviewsUrl', 'All-reviews link'], ['allReviewsLabel', 'All-reviews label'],
['url', 'Booking URL (Calendly / Square)'], ['height', 'Height'],
];
const COLOR_PROPS: Array<[string, string]> = [
['labelColor', 'Label color'], ['valueColor', 'Value color'],
['bgColor', 'Background'], ['textColor', 'Text color'], ['cardBg', 'Card background'],
];
export const BusinessStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { setProp, setProps, setPropContinuous } = useNodeProp(selectedId);
const arrays = Object.entries(nodeProps).filter(([, v]) => Array.isArray(v));
return (
<>
<CollapsibleSection title="Content">
{nodeProps.provider !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Provider</label>
<select value={String(nodeProps.provider)} onChange={(e) => setProp('provider', e.target.value)} style={inputStyle}>
<option value="calendly">Calendly</option>
<option value="square">Square Appointments</option>
<option value="custom">Other (allowed hosts only)</option>
</select>
</div>
)}
{TEXT_PROPS.filter(([k]) => typeof nodeProps[k] === 'string').map(([k, label]) => (
<div key={k} style={sectionGap}>
<label style={labelStyle}>{label}</label>
<input type="text" value={nodeProps[k]} onChange={(e) => setPropContinuous(k, e.target.value)} style={inputStyle} />
</div>
))}
</CollapsibleSection>
{arrays.map(([key, items]) => (
<ArrayItemFieldsEditor key={key} selectedId={selectedId} propKey={key} items={items as any[]} />
))}
{COLOR_PROPS.some(([k]) => typeof nodeProps[k] === 'string') && (
<CollapsibleSection title="Colors">
{COLOR_PROPS.filter(([k]) => typeof nodeProps[k] === 'string').map(([k, label]) => (
<ColorPickerField key={k} label={label} value={nodeProps[k]} onChange={(v) => setPropContinuous(k, v)} />
))}
</CollapsibleSection>
)}
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => setProps({ animation: v.animation, animationDelay: v.animationDelay })}
/>
</CollapsibleSection>
<CollapsibleSection title="Visibility" defaultOpen={nodeProps.hideOnDesktop === true}>
<VisibilityControl
value={{ hideOnDesktop: !!nodeProps.hideOnDesktop, hideOnTablet: !!nodeProps.hideOnTablet, hideOnMobile: !!nodeProps.hideOnMobile }}
onChange={(v) => setProps({ hideOnDesktop: !!v.hideOnDesktop, hideOnTablet: !!v.hideOnTablet, hideOnMobile: !!v.hideOnMobile })}
/>
</CollapsibleSection>
</>
);
};
+1
View File
@@ -12,3 +12,4 @@ export { PricingStylePanel } from './PricingStylePanel';
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
export { GenericPropsEditor } from './GenericPropsEditor';
export { HtmlStylePanel } from './HtmlStylePanel';
export { BusinessStylePanel } from './BusinessStylePanel';