@ohhwells/bridge 0.1.22 → 0.1.24

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/README.md CHANGED
@@ -177,6 +177,64 @@ Add `data-ohw-editable` and `data-ohw-key` to any element the studio owner shoul
177
177
 
178
178
  ---
179
179
 
180
+ ## SchedulingWidget (vibe-coder placement)
181
+
182
+ `SchedulingWidget` lets a template builder embed a scheduling/booking section directly in their template JSX, without going through the "Add Section" flow. The canvas editor treats it exactly like a dynamically-inserted scheduling widget — the studio owner can connect a schedule, switch it, and clear it.
183
+
184
+ ### Basic usage
185
+
186
+ ```tsx
187
+ import { SchedulingWidget } from '@ohhwells/bridge'
188
+
189
+ export default function ClassesPage() {
190
+ return (
191
+ <>
192
+ <PageHeader ... />
193
+ <ClassLibrary ... />
194
+ <SchedulingWidget />
195
+ <WordmarkBand ... />
196
+ </>
197
+ )
198
+ }
199
+ ```
200
+
201
+ No props are required. The widget auto-generates a stable identity via React's `useId()` so the bridge can track which schedule is connected to it across saves.
202
+
203
+ ### Props
204
+
205
+ | Prop | Type | Default | Description |
206
+ |---|---|---|---|
207
+ | `insertAfter` | `string` | auto | Stable identifier used as the tracker key. Only set this manually if you need two `SchedulingWidget`s on the same page. |
208
+ | `initialScheduleId` | `string \| null` | `undefined` | Set by the bridge internally after hydration. Do not pass this yourself. |
209
+ | `notifyOnConnect` | `boolean` | `false` | Set by the bridge internally. Do not pass this yourself. |
210
+
211
+ ### How it works
212
+
213
+ 1. The widget renders immediately with a loading skeleton.
214
+ 2. It sends `ow:request-schedule-config` to the bridge (running in the same window).
215
+ 3. The bridge looks up the tracker for a saved `scheduleId` for this widget:
216
+ - **Found** → responds with `ow:schedule-config { scheduleId }` → widget loads that schedule.
217
+ - **Not found (first time)** → bridge adopts the widget (adds it to the tracker, notifies the canvas editor), responds with `scheduleId: null` → widget shows empty state with an "Add Schedule" button.
218
+ 4. In the canvas editor, the studio owner clicks "Add Schedule" → selects a schedule → the widget updates and the connection is saved.
219
+ 5. On the live site, the widget fetches the saved schedule by ID and renders it.
220
+
221
+ ### Multiple widgets on one page
222
+
223
+ Each `SchedulingWidget` must have a unique `insertAfter` to be tracked independently:
224
+
225
+ ```tsx
226
+ <SchedulingWidget insertAfter="classes-morning" />
227
+ <SchedulingWidget insertAfter="classes-evening" />
228
+ ```
229
+
230
+ If you omit `insertAfter` on both, `useId()` auto-generates different stable IDs for each, so they are tracked separately anyway.
231
+
232
+ ### Empty state on live site
233
+
234
+ If the studio owner has never connected a schedule to this widget, it renders nothing on the live site (no empty placeholder is shown to visitors).
235
+
236
+ ---
237
+
180
238
  ## Editable states (advanced)
181
239
 
182
240
  For elements with multiple display states (e.g. a contact form with default/success/error views), wrap each state in a `data-ohw-state-view` and mark the container with `data-ohw-editable-state`:
package/dist/index.cjs CHANGED
@@ -204,13 +204,6 @@ function EmailCaptureModal({ title, subtitle, onSubmit, onClose }) {
204
204
  var import_jsx_runtime2 = require("react/jsx-runtime");
205
205
  var API_URL = process.env.NEXT_PUBLIC_FLOWOPS_API_URL ?? "https://flowops-backend-staging-2yfdh7pwpq-as.a.run.app";
206
206
  var DAY_ABBR = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
207
- function getApiDomain() {
208
- const h = window.location.hostname;
209
- for (const base of ["ohhwells.site", "theflowops-staging.com", "theflowops.com"]) {
210
- if (h.endsWith(`.${base}`)) return h.slice(0, -(base.length + 1));
211
- }
212
- return h;
213
- }
214
207
  function classRunsOnDate(cls, date, type) {
215
208
  if (type === "FIXED") {
216
209
  const d = String(date.getDate()).padStart(2, "0");
@@ -484,18 +477,21 @@ function ScheduleView({ schedule, dates, selectedIdx, onSelectDate, onOpenModal
484
477
  const booked = getBookingsOnDate(cls, selectedDate);
485
478
  const available = cls.maxParticipants - booked;
486
479
  const isFull = available <= 0;
480
+ const isPrivate = cls.maxParticipants === 1;
487
481
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
488
482
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex gap-4 py-4 sm:items-center sm:gap-[60px] box-border", children: [
489
483
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex flex-col gap-2 shrink-0 sm:contents", children: [
490
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "sm:w-[120px] sm:shrink-0", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "font-body text-base font-bold text-(--color-dark,#200C02) block", children: formatClassTime(cls) }) }),
491
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
492
- "span",
493
- {
494
- className: "inline-flex items-center px-2 py-0.5 rounded-full border text-xs font-medium sm:hidden",
495
- style: { borderColor: "#0885FE", color: "#0885FE" },
496
- children: "GROUP"
497
- }
498
- ),
484
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex flex-col gap-2 sm:w-[120px] sm:shrink-0", children: [
485
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "font-body text-base font-bold text-(--color-dark,#200C02) block", children: formatClassTime(cls) }),
486
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
487
+ "span",
488
+ {
489
+ className: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium w-fit",
490
+ style: isPrivate ? { background: "#DDFBE3", color: "#199130", border: "1px solid #199130", fontWeight: 500 } : { background: "#EEEDFF", color: "#5953FF", border: "1px solid #5953FF", fontWeight: 500 },
491
+ children: isPrivate ? "PRIVATE" : "GROUP"
492
+ }
493
+ )
494
+ ] }),
499
495
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "sm:hidden flex flex-col gap-px", children: isFull ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "font-body text-sm text-(--color-dark,#200C02) opacity-80", children: "Full" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
500
496
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "font-body text-sm text-(--color-dark,#200C02) opacity-80", children: [
501
497
  available,
@@ -568,7 +564,9 @@ function CalendarFoldIcon() {
568
564
  }
569
565
  );
570
566
  }
571
- function SchedulingWidget({ notifyOnConnect = false, initialScheduleId, insertAfter }) {
567
+ function SchedulingWidget({ notifyOnConnect = false, initialScheduleId, insertAfter: insertAfterProp }) {
568
+ const autoId = (0, import_react2.useId)();
569
+ const insertAfter = insertAfterProp ?? autoId;
572
570
  const [schedule, setSchedule] = (0, import_react2.useState)(null);
573
571
  const [loading, setLoading] = (0, import_react2.useState)(true);
574
572
  const [inEditor, setInEditor] = (0, import_react2.useState)(false);
@@ -598,88 +596,99 @@ function SchedulingWidget({ notifyOnConnect = false, initialScheduleId, insertAf
598
596
  if (typeof window === "undefined") return;
599
597
  const isInEditor = window.self !== window.top;
600
598
  setInEditor(isInEditor);
601
- if (isInEditor) {
602
- const startEmpty = initialScheduleId === null;
603
- if (startEmpty) setLoading(false);
604
- let initialized = startEmpty;
605
- const timer = !startEmpty ? setTimeout(() => {
606
- if (!initialized) {
599
+ function loadWithId(effectiveId) {
600
+ if (isInEditor) {
601
+ const startEmpty = effectiveId === null;
602
+ if (startEmpty) setLoading(false);
603
+ let initialized = startEmpty;
604
+ const timer = !startEmpty ? setTimeout(() => {
605
+ if (!initialized) {
606
+ initialized = true;
607
+ setLoading(false);
608
+ }
609
+ }, 5e3) : null;
610
+ const handler = (e) => {
611
+ if (e.data?.type === "ow:clear-scheduling-widget") {
612
+ if (!e.data.insertAfter || e.data.insertAfter !== insertAfter) return;
613
+ setSchedule(null);
614
+ setLoading(false);
615
+ return;
616
+ }
617
+ if (e.data?.type === "ow:switch-schedule") {
618
+ if (!e.data.insertAfter || e.data.insertAfter !== insertAfter) return;
619
+ switchScheduleIdRef.current = e.data.scheduleId ?? null;
620
+ initialized = false;
621
+ setLoading(true);
622
+ window.parent.postMessage({ type: "ow:request-user-schedules", insertAfter }, "*");
623
+ return;
624
+ }
625
+ if (e.data?.type !== "ow:user-schedules-response") return;
626
+ if (e.data.insertAfter && e.data.insertAfter !== insertAfter) return;
627
+ if (initialized && switchScheduleIdRef.current === null) return;
607
628
  initialized = true;
629
+ if (timer) clearTimeout(timer);
630
+ const schedules = e.data.schedules ?? [];
631
+ const isSwitching = switchScheduleIdRef.current !== null;
632
+ const target = isSwitching ? schedules.find((s) => s.id === switchScheduleIdRef.current) ?? schedules[0] ?? null : effectiveId ? schedules.find((s) => s.id === effectiveId) ?? schedules[0] ?? null : schedules[0] ?? null;
633
+ switchScheduleIdRef.current = null;
634
+ setSchedule(target);
608
635
  setLoading(false);
636
+ if (notifyOnConnect && target && !isSwitching) {
637
+ window.parent.postMessage({
638
+ type: "ow:schedule-connected",
639
+ schedule: { id: target.id, name: target.name },
640
+ insertAfter
641
+ }, "*");
642
+ window.postMessage({ type: "ow:schedule-linked", scheduleId: target.id, insertAfter }, "*");
643
+ }
644
+ };
645
+ window.addEventListener("message", handler);
646
+ if (!startEmpty) window.parent.postMessage({ type: "ow:request-user-schedules", insertAfter }, "*");
647
+ return () => {
648
+ window.removeEventListener("message", handler);
649
+ if (timer) clearTimeout(timer);
650
+ };
651
+ }
652
+ if (!effectiveId) {
653
+ setLoading(false);
654
+ return;
655
+ }
656
+ ;
657
+ (async () => {
658
+ try {
659
+ const res = await fetch(`${API_URL}/api/schedule/id/${effectiveId}`);
660
+ const data = await res.json();
661
+ setSchedule(data?.id ? data : null);
662
+ } catch {
609
663
  }
610
- }, 5e3) : null;
611
- const handler = (e) => {
612
- if (e.data?.type === "ow:clear-scheduling-widget") {
613
- if (!e.data.insertAfter || e.data.insertAfter !== insertAfter) return;
614
- setSchedule(null);
615
- setLoading(false);
616
- return;
617
- }
618
- if (e.data?.type === "ow:switch-schedule") {
619
- if (!e.data.insertAfter || e.data.insertAfter !== insertAfter) return;
620
- switchScheduleIdRef.current = e.data.scheduleId ?? null;
621
- initialized = false;
622
- setLoading(true);
623
- window.parent.postMessage({ type: "ow:request-user-schedules", insertAfter }, "*");
624
- return;
625
- }
626
- if (e.data?.type !== "ow:user-schedules-response") return;
627
- if (e.data.insertAfter && e.data.insertAfter !== insertAfter) return;
628
- if (initialized && switchScheduleIdRef.current === null) return;
629
- initialized = true;
630
- if (timer) clearTimeout(timer);
631
- const schedules = e.data.schedules ?? [];
632
- const isSwitching = switchScheduleIdRef.current !== null;
633
- const target = isSwitching ? schedules.find((s) => s.id === switchScheduleIdRef.current) ?? schedules[0] ?? null : initialScheduleId ? schedules.find((s) => s.id === initialScheduleId) ?? schedules[0] ?? null : schedules[0] ?? null;
634
- switchScheduleIdRef.current = null;
635
- setSchedule(target);
636
664
  setLoading(false);
637
- if (notifyOnConnect && target && !isSwitching) {
638
- window.parent.postMessage({
639
- type: "ow:schedule-connected",
640
- schedule: { id: target.id, name: target.name },
641
- insertAfter
642
- }, "*");
643
- window.postMessage({ type: "ow:schedule-linked", scheduleId: target.id, insertAfter }, "*");
665
+ })();
666
+ }
667
+ if (initialScheduleId === void 0) {
668
+ let done = false;
669
+ let innerCleanup;
670
+ const timer = setTimeout(() => {
671
+ if (!done) {
672
+ done = true;
673
+ setLoading(false);
644
674
  }
675
+ }, 2e3);
676
+ const configHandler = (e) => {
677
+ if (e.data?.type !== "ow:schedule-config" || e.data.insertAfter !== insertAfter || done) return;
678
+ done = true;
679
+ clearTimeout(timer);
680
+ window.removeEventListener("message", configHandler);
681
+ innerCleanup = loadWithId(e.data.scheduleId ?? null);
645
682
  };
646
- window.addEventListener("message", handler);
647
- if (!startEmpty) window.parent.postMessage({ type: "ow:request-user-schedules", insertAfter }, "*");
683
+ window.addEventListener("message", configHandler);
684
+ window.postMessage({ type: "ow:request-schedule-config", insertAfter }, "*");
648
685
  return () => {
649
- window.removeEventListener("message", handler);
650
- if (timer) clearTimeout(timer);
686
+ window.removeEventListener("message", configHandler);
687
+ clearTimeout(timer);
688
+ innerCleanup?.();
651
689
  };
652
690
  }
653
- if (initialScheduleId === null) {
654
- setLoading(false);
655
- return;
656
- }
657
- ;
658
- (async () => {
659
- try {
660
- if (initialScheduleId) {
661
- const res = await fetch(`${API_URL}/api/schedule/id/${initialScheduleId}`);
662
- const data = await res.json();
663
- setSchedule(data?.id ? data : null);
664
- } else {
665
- const domain = getApiDomain();
666
- const sitemapRes = await fetch(`${API_URL}/api/schedule/sitemap/${domain}`);
667
- const slugs = await sitemapRes.json();
668
- if (!Array.isArray(slugs) || !slugs.length) {
669
- setLoading(false);
670
- return;
671
- }
672
- const { slug } = [...slugs].sort(
673
- (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
674
- )[0];
675
- const res = await fetch(`${API_URL}/api/schedule/${domain}/${slug}`);
676
- const data = await res.json();
677
- setSchedule(data?.id ? data : null);
678
- }
679
- } catch {
680
- }
681
- setLoading(false);
682
- })();
691
+ return loadWithId(initialScheduleId) ?? void 0;
683
692
  }, []);
684
693
  const timezoneLabel = schedule?.timezone ? (() => {
685
694
  try {
@@ -4379,10 +4388,14 @@ function initSectionsFromContent(content, removeExisting = false) {
4379
4388
  const inEditor = typeof window !== "undefined" && window.self !== window.top;
4380
4389
  if (inEditor) getSectionsTracker().textContent = raw;
4381
4390
  const pageEntries = getPageSchedulingEntries(raw);
4391
+ const preExisting = pageEntries.filter((e) => !isSchedulingWidgetMissing(e));
4382
4392
  const notifyForEntry = inEditor ? (e) => !e.scheduleId : false;
4383
4393
  mountSchedulingEntries(pageEntries, notifyForEntry);
4384
4394
  requestAnimationFrame(() => retryMissingSchedulingMounts(pageEntries, notifyForEntry));
4385
4395
  setTimeout(() => retryMissingSchedulingMounts(pageEntries, notifyForEntry), 250);
4396
+ for (const entry of preExisting) {
4397
+ window.postMessage({ type: "ow:schedule-config", insertAfter: entry.insertAfter, scheduleId: entry.scheduleId ?? null }, "*");
4398
+ }
4386
4399
  } catch {
4387
4400
  }
4388
4401
  }
@@ -4836,6 +4849,8 @@ function OhhwellsBridge() {
4836
4849
  });
4837
4850
  const postToParentRef = (0, import_react3.useRef)(postToParent);
4838
4851
  postToParentRef.current = postToParent;
4852
+ const sectionsLoadedRef = (0, import_react3.useRef)(false);
4853
+ const pendingScheduleConfigRequests = (0, import_react3.useRef)([]);
4839
4854
  const [toolbarRect, setToolbarRect] = (0, import_react3.useState)(null);
4840
4855
  const [toggleState, setToggleState] = (0, import_react3.useState)(null);
4841
4856
  const [maxBadge, setMaxBadge] = (0, import_react3.useState)(null);
@@ -4868,6 +4883,29 @@ function OhhwellsBridge() {
4868
4883
  const refreshStateRules = (0, import_react3.useCallback)(() => {
4869
4884
  editStylesRef.current?.forceHover && (editStylesRef.current.forceHover.textContent = collectStateRules());
4870
4885
  }, []);
4886
+ const processConfigRequest = (0, import_react3.useCallback)((insertAfterVal) => {
4887
+ const tracker = getSectionsTracker();
4888
+ let entries = [];
4889
+ try {
4890
+ entries = JSON.parse(tracker.textContent || "[]");
4891
+ } catch {
4892
+ }
4893
+ const path = window.location.pathname;
4894
+ const entry = entries.find(
4895
+ (e) => e.type === "scheduling" && e.insertAfter === insertAfterVal && (!e.pagePath || e.pagePath === path)
4896
+ );
4897
+ if (entry) {
4898
+ window.postMessage({ type: "ow:schedule-config", insertAfter: insertAfterVal, scheduleId: entry.scheduleId ?? null }, "*");
4899
+ return;
4900
+ }
4901
+ const newEntry = { type: "scheduling", insertAfter: insertAfterVal, pagePath: path };
4902
+ entries.push(newEntry);
4903
+ tracker.textContent = JSON.stringify(entries);
4904
+ if (isEditMode) {
4905
+ postToParentRef.current({ type: "ow:change", nodes: [{ key: "__ohw_sections", text: tracker.textContent }] });
4906
+ }
4907
+ window.postMessage({ type: "ow:schedule-config", insertAfter: insertAfterVal, scheduleId: null }, "*");
4908
+ }, [isEditMode]);
4871
4909
  const deactivate = (0, import_react3.useCallback)(() => {
4872
4910
  const el = activeElRef.current;
4873
4911
  if (!el) return;
@@ -4939,6 +4977,8 @@ function OhhwellsBridge() {
4939
4977
  });
4940
4978
  }
4941
4979
  initSectionsFromContent(content, true);
4980
+ sectionsLoadedRef.current = true;
4981
+ pendingScheduleConfigRequests.current.splice(0).forEach(processConfigRequest);
4942
4982
  return imageLoads.length > 0 ? Promise.all(imageLoads).then(() => {
4943
4983
  }) : Promise.resolve();
4944
4984
  };
@@ -5588,6 +5628,8 @@ function OhhwellsBridge() {
5588
5628
  }
5589
5629
  if (sectionsJson) {
5590
5630
  initSectionsFromContent({ __ohw_sections: sectionsJson }, true);
5631
+ sectionsLoadedRef.current = true;
5632
+ pendingScheduleConfigRequests.current.splice(0).forEach(processConfigRequest);
5591
5633
  }
5592
5634
  postToParentRef.current({ type: "ow:hydrate-done" });
5593
5635
  };
@@ -5851,6 +5893,22 @@ function OhhwellsBridge() {
5851
5893
  if (imageShowTimerRef.current) clearTimeout(imageShowTimerRef.current);
5852
5894
  };
5853
5895
  }, [isEditMode, refreshStateRules]);
5896
+ (0, import_react3.useEffect)(() => {
5897
+ const handler = (e) => {
5898
+ if (e.data?.type !== "ow:request-schedule-config") return;
5899
+ const insertAfterVal = e.data.insertAfter;
5900
+ if (!insertAfterVal) return;
5901
+ if (!sectionsLoadedRef.current) {
5902
+ if (!pendingScheduleConfigRequests.current.includes(insertAfterVal)) {
5903
+ pendingScheduleConfigRequests.current.push(insertAfterVal);
5904
+ }
5905
+ return;
5906
+ }
5907
+ processConfigRequest(insertAfterVal);
5908
+ };
5909
+ window.addEventListener("message", handler);
5910
+ return () => window.removeEventListener("message", handler);
5911
+ }, [processConfigRequest]);
5854
5912
  (0, import_react3.useEffect)(() => {
5855
5913
  if (!isEditMode) return;
5856
5914
  document.querySelectorAll("[data-ohw-active-state]").forEach((el) => {