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,
};