@ordinatio/booking-widget 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -20,14 +20,14 @@ interface BookingWidgetProps {
20
20
  /** Appointment type slug for team mode (ROUND_ROBIN/LOAD_BALANCED). */
21
21
  typeSlug?: string;
22
22
  /**
23
- * Organization slug (e.g., "1701bespoke"), from Organization.slug. Optional —
23
+ * Organization slug (e.g., "acme"), from Organization.slug. Optional —
24
24
  * omitting it falls back to the legacy bare-slug directory lookup (works only
25
25
  * while the calendar/type's slug is unclaimed by another org). New embed code
26
26
  * always sets this; it exists as an optional prop only for backward
27
27
  * compatibility with widget instances embedded before org-qualified URLs shipped.
28
28
  */
29
29
  orgSlug?: string;
30
- /** API base URL (e.g., "https://system.1701bespoke.com"). Empty string for same-origin. */
30
+ /** API base URL (e.g., "https://booking.acme.example"). Empty string for same-origin. */
31
31
  apiBaseUrl: string;
32
32
  /**
33
33
  * Built-in color scheme preset. Overrides nothing the caller sets explicitly in `theme`.
@@ -51,6 +51,12 @@ interface BookingWidgetProps {
51
51
  redirectDelay?: number;
52
52
  /** Whether to show the built-in "confirmation sent to {email}" line on the confirmation step (default: true). */
53
53
  showContactInfo?: boolean;
54
+ /**
55
+ * Whether to load the organization's brand (name, logo, colours, font, footer) from the server and apply it
56
+ * (default: true). A `theme` value you set yourself always wins over the brand's. Servers without branding answer
57
+ * with no brand and the widget looks as it always has.
58
+ */
59
+ showBrand?: boolean;
54
60
  }
55
61
  interface BookingWidgetTheme {
56
62
  primaryColor?: string;
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  // src/BookingWidget.tsx
4
- import { useRef as useRef4, useEffect as useEffect6 } from "react";
4
+ import { useRef as useRef4, useEffect as useEffect7 } from "react";
5
5
  import { createRoot } from "react-dom/client";
6
6
 
7
7
  // src/BookingFlowInner.tsx
8
- import { useRef as useRef3, useEffect as useEffect5 } from "react";
8
+ import { useRef as useRef3, useEffect as useEffect6 } from "react";
9
9
 
10
10
  // src/hooks/useBookingFlow.ts
11
11
  import { useState, useCallback, useRef } from "react";
@@ -232,6 +232,32 @@ async function getWidgetInit(apiBaseUrl, calendarSlug, orgSlug) {
232
232
  const response = await safeFetch(url);
233
233
  return handleResponse(response);
234
234
  }
235
+ async function getBrand(apiBaseUrl, orgSlug) {
236
+ try {
237
+ const query = orgSlug ? `?orgSlug=${encodeURIComponent(orgSlug)}` : "";
238
+ const response = await fetch(buildUrl(apiBaseUrl, `/api/scheduling/brand${query}`));
239
+ if (!response.ok) {
240
+ if (response.status !== 404 && response.status !== 501) console.warn(`[booking-widget] could not load the brand (HTTP ${response.status})`);
241
+ return null;
242
+ }
243
+ const body = await response.json();
244
+ const brand = body?.brand;
245
+ if (!brand || typeof brand.displayName !== "string" || brand.displayName.length === 0) return null;
246
+ const text = (value) => typeof value === "string" && value.length > 0 ? value : null;
247
+ return {
248
+ displayName: brand.displayName,
249
+ logoUrl: text(brand.logoUrl),
250
+ primaryColor: text(brand.primaryColor),
251
+ accentColor: text(brand.accentColor),
252
+ backgroundColor: text(brand.backgroundColor),
253
+ textColor: text(brand.textColor),
254
+ fontFamily: text(brand.fontFamily),
255
+ footerText: text(brand.footerText)
256
+ };
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
235
261
  async function getAvailability(apiBaseUrl, calendarId, date, typeId, timezone, orgSlug) {
236
262
  const params = new URLSearchParams({
237
263
  calendarId,
@@ -1525,6 +1551,61 @@ function StaffPickerRow({ staff, value, onChange }) {
1525
1551
  ] });
1526
1552
  }
1527
1553
 
1554
+ // src/hooks/use-brand.ts
1555
+ import { useEffect as useEffect5, useState as useState6 } from "react";
1556
+ function useBrand(apiBaseUrl, orgSlug, enabled) {
1557
+ const [brand, setBrand] = useState6(null);
1558
+ useEffect5(() => {
1559
+ if (!enabled) return;
1560
+ let cancelled = false;
1561
+ (async () => {
1562
+ try {
1563
+ const loaded = await getBrand(apiBaseUrl, orgSlug);
1564
+ if (!cancelled) setBrand(loaded);
1565
+ } catch {
1566
+ }
1567
+ })();
1568
+ return () => {
1569
+ cancelled = true;
1570
+ };
1571
+ }, [apiBaseUrl, orgSlug, enabled]);
1572
+ return enabled ? brand : null;
1573
+ }
1574
+
1575
+ // src/sanitize-css-value.ts
1576
+ var DANGEROUS_PATTERN = /[;{}`@\n\r]|url\s*\(|expression\s*\(/i;
1577
+ function sanitizeCSSValue(value) {
1578
+ const trimmed = value.trim();
1579
+ if (trimmed.length === 0) return null;
1580
+ if (DANGEROUS_PATTERN.test(trimmed)) return null;
1581
+ return trimmed;
1582
+ }
1583
+
1584
+ // src/utils/brand-style.ts
1585
+ var HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
1586
+ var FONT = /^[A-Za-z0-9 ,-]{1,100}$/;
1587
+ var VARIABLE = {
1588
+ primaryColor: "--obw-primary",
1589
+ accentColor: "--obw-accent",
1590
+ backgroundColor: "--obw-bg",
1591
+ textColor: "--obw-text",
1592
+ fontFamily: "--obw-font"
1593
+ };
1594
+ function brandStyle(brand, theme) {
1595
+ const style = {};
1596
+ if (!brand) return style;
1597
+ for (const key of Object.keys(VARIABLE)) {
1598
+ const value = brand[key];
1599
+ if (!value || theme?.[key]) continue;
1600
+ const safe = sanitizeCSSValue(value);
1601
+ if (safe && (key === "fontFamily" ? FONT.test(safe) : HEX.test(safe))) style[VARIABLE[key]] = safe;
1602
+ }
1603
+ return style;
1604
+ }
1605
+ function safeLogoUrl(brand) {
1606
+ return brand?.logoUrl && /^https:\/\//i.test(brand.logoUrl) ? brand.logoUrl : null;
1607
+ }
1608
+
1528
1609
  // src/BookingFlowInner.tsx
1529
1610
  import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
1530
1611
  var STEP_LABELS = {
@@ -1545,13 +1626,16 @@ function BookingFlowInner(props) {
1545
1626
  onBooked: props.onBooked,
1546
1627
  onError: props.onError
1547
1628
  });
1629
+ const brand = useBrand(props.apiBaseUrl, props.orgSlug, props.showBrand !== false);
1630
+ const brandVariables = brandStyle(brand, props.theme);
1631
+ const logoUrl = safeLogoUrl(brand);
1548
1632
  const staffList = state.teamMode && state.selectedType?.calendars?.length ? state.selectedType.calendars.map((c) => ({
1549
1633
  id: c.calendar.id,
1550
1634
  name: c.calendar.staffName ?? c.calendar.name,
1551
1635
  avatar: c.calendar.staffAvatar
1552
1636
  })) : [];
1553
1637
  const stepRef = useRef3(null);
1554
- useEffect5(() => {
1638
+ useEffect6(() => {
1555
1639
  if (stepRef.current && !state.isLoading) {
1556
1640
  stepRef.current.focus();
1557
1641
  }
@@ -1562,7 +1646,11 @@ function BookingFlowInner(props) {
1562
1646
  if (state.error && !state.calendar && !state.selectedType) {
1563
1647
  return /* @__PURE__ */ jsx11("div", { className: "obw-container", children: /* @__PURE__ */ jsx11("div", { className: "obw-error", role: "alert", children: state.error }) });
1564
1648
  }
1565
- return /* @__PURE__ */ jsxs11("div", { className: "obw-container", children: [
1649
+ return /* @__PURE__ */ jsxs11("div", { className: "obw-container", style: brandVariables, children: [
1650
+ brand && /* @__PURE__ */ jsxs11("div", { className: "obw-brand", children: [
1651
+ logoUrl && /* @__PURE__ */ jsx11("img", { className: "obw-brand-logo", src: logoUrl, alt: "" }),
1652
+ /* @__PURE__ */ jsx11("span", { className: "obw-brand-name", children: brand.displayName })
1653
+ ] }),
1566
1654
  /* @__PURE__ */ jsx11("div", { "aria-live": "polite", "aria-atomic": "true", className: "obw-sr-only", children: state.error && state.step === "details" ? state.error : "" }),
1567
1655
  state.step !== "confirmed" && !props.typeSlug && state.calendar && /* @__PURE__ */ jsxs11("div", { className: "obw-header", children: [
1568
1656
  /* @__PURE__ */ jsx11("div", { className: "obw-title", children: state.calendar.name }),
@@ -1684,19 +1772,11 @@ function BookingFlowInner(props) {
1684
1772
  )
1685
1773
  ]
1686
1774
  }
1687
- )
1775
+ ),
1776
+ brand?.footerText && /* @__PURE__ */ jsx11("div", { className: "obw-brand-footer", children: brand.footerText })
1688
1777
  ] });
1689
1778
  }
1690
1779
 
1691
- // src/sanitize-css-value.ts
1692
- var DANGEROUS_PATTERN = /[;{}`@\n\r]|url\s*\(|expression\s*\(/i;
1693
- function sanitizeCSSValue(value) {
1694
- const trimmed = value.trim();
1695
- if (trimmed.length === 0) return null;
1696
- if (DANGEROUS_PATTERN.test(trimmed)) return null;
1697
- return trimmed;
1698
- }
1699
-
1700
1780
  // src/styles/widget.css
1701
1781
  var widget_default = `/* ===========================================
1702
1782
  @ordinatio/booking-widget \u2014 Self-contained CSS
@@ -2448,6 +2528,33 @@ var widget_default = `/* ===========================================
2448
2528
  border-radius: 50%;
2449
2529
  object-fit: cover;
2450
2530
  }
2531
+
2532
+ /* Organization brand: the logo and name above the flow, a footer line below it. */
2533
+ .obw-brand {
2534
+ display: flex;
2535
+ align-items: center;
2536
+ justify-content: center;
2537
+ gap: 0.625rem;
2538
+ margin-bottom: 1rem;
2539
+ }
2540
+
2541
+ .obw-brand-logo {
2542
+ max-height: 2.5rem;
2543
+ max-width: 10rem;
2544
+ object-fit: contain;
2545
+ }
2546
+
2547
+ .obw-brand-name {
2548
+ font-weight: 600;
2549
+ color: var(--obw-text);
2550
+ }
2551
+
2552
+ .obw-brand-footer {
2553
+ margin-top: 1rem;
2554
+ text-align: center;
2555
+ font-size: 0.75rem;
2556
+ color: var(--obw-text-muted);
2557
+ }
2451
2558
  `;
2452
2559
 
2453
2560
  // src/BookingWidget.tsx
@@ -2515,7 +2622,7 @@ function IsolatedWidget(props) {
2515
2622
  )
2516
2623
  );
2517
2624
  };
2518
- useEffect6(() => {
2625
+ useEffect7(() => {
2519
2626
  if (!hostRef.current) return;
2520
2627
  let shadow = hostRef.current.shadowRoot;
2521
2628
  if (!shadow) {
@@ -2552,7 +2659,7 @@ function IsolatedWidget(props) {
2552
2659
  };
2553
2660
  }, [props.calendarSlug, props.typeSlug, props.orgSlug, props.apiBaseUrl, themeJSON]);
2554
2661
  const hasMountedRef = useRef4(false);
2555
- useEffect6(() => {
2662
+ useEffect7(() => {
2556
2663
  if (hasMountedRef.current && rootRef.current) renderFlow();
2557
2664
  hasMountedRef.current = true;
2558
2665
  }, [props.confirmationMessage, props.redirectUrl, props.redirectDelay, props.showContactInfo]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ordinatio/booking-widget",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Embeddable booking widget for Ordinatio scheduling. Shadow DOM isolation, themeable, no iframes.",