Merge issue-60 (Refs #60)
This commit is contained in:
commit
48061d37ae
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&y="><script>"');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toContain('<b>Book</b>');
|
||||
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('<b>Mon</b>');
|
||||
// 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('<i>Eve</i>');
|
||||
});
|
||||
|
||||
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&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 ? ` · ${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('<script>');
|
||||
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.']);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -12,3 +12,4 @@ export { PricingStylePanel } from './PricingStylePanel';
|
||||
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
||||
export { GenericPropsEditor } from './GenericPropsEditor';
|
||||
export { HtmlStylePanel } from './HtmlStylePanel';
|
||||
export { BusinessStylePanel } from './BusinessStylePanel';
|
||||
Reference in new issue
Block a user