@ohhwells/bridge 0.1.23 → 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");
@@ -571,7 +564,9 @@ function CalendarFoldIcon() {
571
564
  }
572
565
  );
573
566
  }
574
- 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;
575
570
  const [schedule, setSchedule] = (0, import_react2.useState)(null);
576
571
  const [loading, setLoading] = (0, import_react2.useState)(true);
577
572
  const [inEditor, setInEditor] = (0, import_react2.useState)(false);
@@ -601,88 +596,99 @@ function SchedulingWidget({ notifyOnConnect = false, initialScheduleId, insertAf
601
596
  if (typeof window === "undefined") return;
602
597
  const isInEditor = window.self !== window.top;
603
598
  setInEditor(isInEditor);
604
- if (isInEditor) {
605
- const startEmpty = initialScheduleId === null;
606
- if (startEmpty) setLoading(false);
607
- let initialized = startEmpty;
608
- const timer = !startEmpty ? setTimeout(() => {
609
- 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;
610
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);
611
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 {
612
663
  }
613
- }, 5e3) : null;
614
- const handler = (e) => {
615
- if (e.data?.type === "ow:clear-scheduling-widget") {
616
- if (!e.data.insertAfter || e.data.insertAfter !== insertAfter) return;
617
- setSchedule(null);
618
- setLoading(false);
619
- return;
620
- }
621
- if (e.data?.type === "ow:switch-schedule") {
622
- if (!e.data.insertAfter || e.data.insertAfter !== insertAfter) return;
623
- switchScheduleIdRef.current = e.data.scheduleId ?? null;
624
- initialized = false;
625
- setLoading(true);
626
- window.parent.postMessage({ type: "ow:request-user-schedules", insertAfter }, "*");
627
- return;
628
- }
629
- if (e.data?.type !== "ow:user-schedules-response") return;
630
- if (e.data.insertAfter && e.data.insertAfter !== insertAfter) return;
631
- if (initialized && switchScheduleIdRef.current === null) return;
632
- initialized = true;
633
- if (timer) clearTimeout(timer);
634
- const schedules = e.data.schedules ?? [];
635
- const isSwitching = switchScheduleIdRef.current !== null;
636
- 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;
637
- switchScheduleIdRef.current = null;
638
- setSchedule(target);
639
664
  setLoading(false);
640
- if (notifyOnConnect && target && !isSwitching) {
641
- window.parent.postMessage({
642
- type: "ow:schedule-connected",
643
- schedule: { id: target.id, name: target.name },
644
- insertAfter
645
- }, "*");
646
- 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);
647
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);
648
682
  };
649
- window.addEventListener("message", handler);
650
- 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 }, "*");
651
685
  return () => {
652
- window.removeEventListener("message", handler);
653
- if (timer) clearTimeout(timer);
686
+ window.removeEventListener("message", configHandler);
687
+ clearTimeout(timer);
688
+ innerCleanup?.();
654
689
  };
655
690
  }
656
- if (initialScheduleId === null) {
657
- setLoading(false);
658
- return;
659
- }
660
- ;
661
- (async () => {
662
- try {
663
- if (initialScheduleId) {
664
- const res = await fetch(`${API_URL}/api/schedule/id/${initialScheduleId}`);
665
- const data = await res.json();
666
- setSchedule(data?.id ? data : null);
667
- } else {
668
- const domain = getApiDomain();
669
- const sitemapRes = await fetch(`${API_URL}/api/schedule/sitemap/${domain}`);
670
- const slugs = await sitemapRes.json();
671
- if (!Array.isArray(slugs) || !slugs.length) {
672
- setLoading(false);
673
- return;
674
- }
675
- const { slug } = [...slugs].sort(
676
- (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
677
- )[0];
678
- const res = await fetch(`${API_URL}/api/schedule/${domain}/${slug}`);
679
- const data = await res.json();
680
- setSchedule(data?.id ? data : null);
681
- }
682
- } catch {
683
- }
684
- setLoading(false);
685
- })();
691
+ return loadWithId(initialScheduleId) ?? void 0;
686
692
  }, []);
687
693
  const timezoneLabel = schedule?.timezone ? (() => {
688
694
  try {
@@ -4382,10 +4388,14 @@ function initSectionsFromContent(content, removeExisting = false) {
4382
4388
  const inEditor = typeof window !== "undefined" && window.self !== window.top;
4383
4389
  if (inEditor) getSectionsTracker().textContent = raw;
4384
4390
  const pageEntries = getPageSchedulingEntries(raw);
4391
+ const preExisting = pageEntries.filter((e) => !isSchedulingWidgetMissing(e));
4385
4392
  const notifyForEntry = inEditor ? (e) => !e.scheduleId : false;
4386
4393
  mountSchedulingEntries(pageEntries, notifyForEntry);
4387
4394
  requestAnimationFrame(() => retryMissingSchedulingMounts(pageEntries, notifyForEntry));
4388
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
+ }
4389
4399
  } catch {
4390
4400
  }
4391
4401
  }
@@ -4839,6 +4849,8 @@ function OhhwellsBridge() {
4839
4849
  });
4840
4850
  const postToParentRef = (0, import_react3.useRef)(postToParent);
4841
4851
  postToParentRef.current = postToParent;
4852
+ const sectionsLoadedRef = (0, import_react3.useRef)(false);
4853
+ const pendingScheduleConfigRequests = (0, import_react3.useRef)([]);
4842
4854
  const [toolbarRect, setToolbarRect] = (0, import_react3.useState)(null);
4843
4855
  const [toggleState, setToggleState] = (0, import_react3.useState)(null);
4844
4856
  const [maxBadge, setMaxBadge] = (0, import_react3.useState)(null);
@@ -4871,6 +4883,29 @@ function OhhwellsBridge() {
4871
4883
  const refreshStateRules = (0, import_react3.useCallback)(() => {
4872
4884
  editStylesRef.current?.forceHover && (editStylesRef.current.forceHover.textContent = collectStateRules());
4873
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]);
4874
4909
  const deactivate = (0, import_react3.useCallback)(() => {
4875
4910
  const el = activeElRef.current;
4876
4911
  if (!el) return;
@@ -4942,6 +4977,8 @@ function OhhwellsBridge() {
4942
4977
  });
4943
4978
  }
4944
4979
  initSectionsFromContent(content, true);
4980
+ sectionsLoadedRef.current = true;
4981
+ pendingScheduleConfigRequests.current.splice(0).forEach(processConfigRequest);
4945
4982
  return imageLoads.length > 0 ? Promise.all(imageLoads).then(() => {
4946
4983
  }) : Promise.resolve();
4947
4984
  };
@@ -5591,6 +5628,8 @@ function OhhwellsBridge() {
5591
5628
  }
5592
5629
  if (sectionsJson) {
5593
5630
  initSectionsFromContent({ __ohw_sections: sectionsJson }, true);
5631
+ sectionsLoadedRef.current = true;
5632
+ pendingScheduleConfigRequests.current.splice(0).forEach(processConfigRequest);
5594
5633
  }
5595
5634
  postToParentRef.current({ type: "ow:hydrate-done" });
5596
5635
  };
@@ -5854,6 +5893,22 @@ function OhhwellsBridge() {
5854
5893
  if (imageShowTimerRef.current) clearTimeout(imageShowTimerRef.current);
5855
5894
  };
5856
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]);
5857
5912
  (0, import_react3.useEffect)(() => {
5858
5913
  if (!isEditMode) return;
5859
5914
  document.querySelectorAll("[data-ohw-active-state]").forEach((el) => {