From 2cb04d2537c25e9d834cc19b78e4f114dff2d2c1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 11:53:44 -0700 Subject: [PATCH] feat(site-builder): local-business block pack (hours, call bar, reviews, booking) Refs #60 --- .../business/BookingEmbed.toHtml.test.ts | 65 +++++++++ .../src/components/business/BookingEmbed.tsx | 103 ++++++++++++++ .../business/OpeningHours.toHtml.test.ts | 48 +++++++ .../src/components/business/OpeningHours.tsx | 121 +++++++++++++++++ .../business/ReviewsStrip.toHtml.test.ts | 52 +++++++ .../src/components/business/ReviewsStrip.tsx | 127 ++++++++++++++++++ .../business/StickyCallBar.toHtml.test.ts | 48 +++++++ .../src/components/business/StickyCallBar.tsx | 96 +++++++++++++ .../business/business-blocks.harness.test.tsx | 64 +++++++++ craft/src/components/resolver.ts | 8 ++ .../src/panels/left/blocks-registry.test.tsx | 12 +- craft/src/panels/left/blocks-registry.tsx | 12 ++ craft/src/panels/left/layers-virtual-rows.ts | 2 + .../right/styles/BusinessStylePanel.tsx | 84 ++++++++++++ craft/src/panels/right/styles/index.ts | 1 + 15 files changed, 837 insertions(+), 6 deletions(-) create mode 100644 craft/src/components/business/BookingEmbed.toHtml.test.ts create mode 100644 craft/src/components/business/BookingEmbed.tsx create mode 100644 craft/src/components/business/OpeningHours.toHtml.test.ts create mode 100644 craft/src/components/business/OpeningHours.tsx create mode 100644 craft/src/components/business/ReviewsStrip.toHtml.test.ts create mode 100644 craft/src/components/business/ReviewsStrip.tsx create mode 100644 craft/src/components/business/StickyCallBar.toHtml.test.ts create mode 100644 craft/src/components/business/StickyCallBar.tsx create mode 100644 craft/src/components/business/business-blocks.harness.test.tsx create mode 100644 craft/src/panels/right/styles/BusinessStylePanel.tsx diff --git a/craft/src/components/business/BookingEmbed.toHtml.test.ts b/craft/src/components/business/BookingEmbed.toHtml.test.ts new file mode 100644 index 0000000..52cf86a --- /dev/null +++ b/craft/src/components/business/BookingEmbed.toHtml.test.ts @@ -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('`), + }; + } + + // 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 + ? `

${escapeHtml(title)}

` + : ''), + }; +}; diff --git a/craft/src/components/business/OpeningHours.toHtml.test.ts b/craft/src/components/business/OpeningHours.toHtml.test.ts new file mode 100644 index 0000000..348c1d8 --- /dev/null +++ b/craft/src/components/business/OpeningHours.toHtml.test.ts @@ -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(/]*>Closed { + 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: '', + note: '">', + rows: [{ day: 'Mon', hours: '', closed: false }], + labelColor: 'red;background:url(javascript:alert(1))"', + }, '', 'n1'); + expect(html).not.toContain('', source: 'G', url: '' }] }, '', 'n1'); + expect(html).not.toContain('', phone: '+1555' }, ''); + expect(html).not.toContain('javascript:'); + expect(html).not.toContain('