@fullgrainsoftware/portal 0.9.0 → 0.10.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @fullgrainsoftware/portal
2
2
 
3
- Site SDK for the [Fullgrain Portal](https://www.abdevpanel.com) CMS: declare your
3
+ Site SDK for the [Fullgrain Portal](https://app.fullgrainsoftware.com) CMS: declare your
4
4
  site's structure in a typed manifest, read CMS-managed content in your pages,
5
5
  and sync structural changes with one command.
6
6
 
package/dist/bookings.js CHANGED
@@ -3,7 +3,7 @@
3
3
  its manage token. Server-only — the abbk_ key is a secret — and every
4
4
  method returns a typed result instead of throwing for an expected
5
5
  outcome, so a site can never show a dead picker. */
6
- const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
6
+ const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
7
7
  const TIMEOUT_MS = 10_000;
8
8
  const versionOf = (o) => (typeof o?.version === "number" ? { version: o.version } : {});
9
9
  export function createBookings(opts) {
package/dist/cli.js CHANGED
@@ -55,7 +55,7 @@ export async function runSync(argv) {
55
55
  console.error("AB_CMS_API_KEY is not set");
56
56
  return 1;
57
57
  }
58
- const baseUrl = (process.env.AB_CMS_URL ?? "https://www.abdevpanel.com").replace(/\/$/, "");
58
+ const baseUrl = (process.env.AB_CMS_URL ?? "https://app.fullgrainsoftware.com").replace(/\/$/, "");
59
59
  const jiti = createJiti(import.meta.url);
60
60
  let loaded;
61
61
  try {
@@ -1,4 +1,4 @@
1
- const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
1
+ const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
2
2
  export async function fetchPublicSiteData(websiteId, opts) {
3
3
  const base = (opts?.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
4
4
  const timeoutMs = opts?.timeoutMs ?? 10_000;
package/dist/commerce.js CHANGED
@@ -6,7 +6,7 @@
6
6
  a `store` block; catalog reads keep serving while a store is paused
7
7
  (checkoutAvailable tells the site whether to offer buying); checkout
8
8
  returns a typed error union instead of throwing for expected outcomes. */
9
- const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
9
+ const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
10
10
  export function createCommerce(opts) {
11
11
  if (typeof window !== "undefined") {
12
12
  throw new Error("createCommerce is server-only — never expose COMMERCE_API_KEY to the browser");
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { createCommerce } from "./commerce.js";
9
9
  export type { CartPreview, CheckoutInput, CheckoutResult, Commerce, LineIssue, OrderStatus, ProductDetail, ProductSummary, StoreStatus, VariantView, } from "./commerce.js";
10
10
  export { createInquiries } from "./inquiries.js";
11
11
  export type { Inquiries, InquiryInput, InquirySubmitResult } from "./inquiries.js";
12
- export { initCmsPreview, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
12
+ export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
13
13
  export type { CmsPreviewOptions, PreviewInbound, PreviewOutbound } from "./preview.js";
14
14
  export { createBookings } from "./bookings.js";
15
15
  export type { AvailabilityDay, AvailabilityResult, BookInput, BookResult, BookingCustomer, BookingService, BookingStatus, BookingView, Bookings, GetBookingResult, ManageResult, RequestTimeInput, RequestTimeResult, ServicesResult, } from "./bookings.js";
package/dist/index.js CHANGED
@@ -4,5 +4,5 @@ export { fetchPublicSiteData } from "./cms-client.js";
4
4
  export { readReviews } from "./reviews.js";
5
5
  export { createCommerce } from "./commerce.js";
6
6
  export { createInquiries } from "./inquiries.js";
7
- export { initCmsPreview, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
7
+ export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
8
8
  export { createBookings } from "./bookings.js";
package/dist/inquiries.js CHANGED
@@ -3,7 +3,7 @@
3
3
  recipients. Server-only — the abik_ key is a secret — and `submit` never
4
4
  throws for an expected outcome: it returns a typed result the site turns
5
5
  into its own success, "try again in a few minutes", or fallback state. */
6
- const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
6
+ const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
7
7
  const TIMEOUT_MS = 10_000;
8
8
  export function createInquiries(opts) {
9
9
  if (typeof window !== "undefined") {
package/dist/preview.d.ts CHANGED
@@ -42,6 +42,21 @@ export interface CmsPreviewOptions {
42
42
  */
43
43
  allowedOrigins?: string[];
44
44
  }
45
+ /** Case- and whitespace-insensitive, the way the panel matches section titles. */
46
+ export declare function normalizeSectionTitle(title: string): string;
47
+ /**
48
+ * Among the nodes tagged with one title, the first the visitor can see, else
49
+ * the first in the tree. A site tags both its desktop and mobile renderings
50
+ * of a section (the nav, say); whichever the preview's width hides must not
51
+ * win, or the scroll lands nowhere.
52
+ */
53
+ export declare function pickSectionNode<T>(nodes: readonly T[], visible: (node: T) => boolean): T | null;
54
+ /**
55
+ * Where the page scrolls to so the section's top clears whatever fixed or
56
+ * sticky bar covers the top of the viewport, honouring the section's own
57
+ * scroll-margin-top when the site set a larger one.
58
+ */
59
+ export declare function scrollTargetTop(rectTop: number, scrollY: number, obstruction: number, scrollMargin: number): number;
45
60
  /**
46
61
  * Activate the preview bridge. Safe to call unconditionally: it no-ops on the
47
62
  * server and on the public (non-preview) site. Returns a cleanup function.
package/dist/preview.js CHANGED
@@ -35,16 +35,11 @@ export function sectionClickedMessage(title) {
35
35
  export function editItemMessage(id) {
36
36
  return { source: "ab-cms-preview", v: PREVIEW_PROTOCOL_VERSION, type: "edit-item", id };
37
37
  }
38
- // The portal's current host and the one it moves to (rebrand, 2026-10). Keep
39
- // the abdevpanel origins until no site runs an SDK that predates the move.
38
+ // The portal's host since the 2026-10 rebrand; the abdevpanel origins no
39
+ // longer serve the panel (they redirect here), so they are not trusted.
40
40
  // The "ab-cms-*" message sources below are the wire protocol, not branding:
41
41
  // renaming them would break preview against every installed SDK version.
42
- const DEFAULT_ORIGINS = [
43
- "https://www.abdevpanel.com",
44
- "https://abdevpanel.com",
45
- "https://app.abdevpanel.com",
46
- "https://app.fullgrainsoftware.com",
47
- ];
42
+ const DEFAULT_ORIGINS = ["https://app.fullgrainsoftware.com"];
48
43
  function isPreviewContext(allowed) {
49
44
  if (typeof window === "undefined")
50
45
  return false;
@@ -79,15 +74,73 @@ function collectSectionTitles() {
79
74
  });
80
75
  return titles;
81
76
  }
77
+ /** Case- and whitespace-insensitive, the way the panel matches section titles. */
78
+ export function normalizeSectionTitle(title) {
79
+ return title.trim().replace(/\s+/g, " ").toLowerCase();
80
+ }
81
+ /**
82
+ * Among the nodes tagged with one title, the first the visitor can see, else
83
+ * the first in the tree. A site tags both its desktop and mobile renderings
84
+ * of a section (the nav, say); whichever the preview's width hides must not
85
+ * win, or the scroll lands nowhere.
86
+ */
87
+ export function pickSectionNode(nodes, visible) {
88
+ if (nodes.length === 0)
89
+ return null;
90
+ return nodes.find(visible) ?? nodes[0];
91
+ }
92
+ /**
93
+ * Where the page scrolls to so the section's top clears whatever fixed or
94
+ * sticky bar covers the top of the viewport, honouring the section's own
95
+ * scroll-margin-top when the site set a larger one.
96
+ */
97
+ export function scrollTargetTop(rectTop, scrollY, obstruction, scrollMargin) {
98
+ return Math.max(0, Math.round(rectTop + scrollY - Math.max(obstruction, scrollMargin)));
99
+ }
100
+ function isVisible(el) {
101
+ if (el.getClientRects().length === 0)
102
+ return false; // display:none, or inside one
103
+ const r = el.getBoundingClientRect();
104
+ return r.width > 0 || r.height > 0;
105
+ }
106
+ function matchingNodes(title) {
107
+ const target = normalizeSectionTitle(title);
108
+ const out = [];
109
+ document.querySelectorAll("[data-cms-section]").forEach((n) => {
110
+ if (normalizeSectionTitle(n.dataset.cmsSection ?? "") === target)
111
+ out.push(n);
112
+ });
113
+ return out;
114
+ }
82
115
  function findSection(title) {
83
- const target = title.trim().toLowerCase();
84
- const nodes = document.querySelectorAll("[data-cms-section]");
85
- for (const n of nodes) {
86
- if (n.dataset.cmsSection?.trim().toLowerCase() === target)
87
- return n;
116
+ return pickSectionNode(matchingNodes(title), isVisible);
117
+ }
118
+ /** The bottom edge of any fixed or sticky element sitting at the top of the viewport (a sticky nav), excluding the target itself. */
119
+ function topObstruction(target) {
120
+ if (typeof document.elementsFromPoint !== "function")
121
+ return 0;
122
+ const x = Math.max(1, Math.floor(window.innerWidth / 2));
123
+ let bottom = 0;
124
+ for (const el of document.elementsFromPoint(x, 1)) {
125
+ if (!(el instanceof HTMLElement) || el === target || el.contains(target))
126
+ continue;
127
+ const pos = getComputedStyle(el).position;
128
+ if (pos !== "fixed" && pos !== "sticky")
129
+ continue;
130
+ bottom = Math.max(bottom, el.getBoundingClientRect().bottom);
88
131
  }
89
- return null;
132
+ return bottom;
133
+ }
134
+ function scrollToSection(el) {
135
+ const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
136
+ const top = scrollTargetTop(el.getBoundingClientRect().top, window.scrollY, topObstruction(el), margin);
137
+ const reduce = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
138
+ window.scrollTo({ top, behavior: reduce ? "auto" : "smooth" });
139
+ el.classList.add("cms-preview-flash");
140
+ window.setTimeout(() => el.classList.remove("cms-preview-flash"), 1600);
90
141
  }
142
+ /** How long a scroll request waits for a section that has not rendered yet (a client-only leaf, a lazy list). */
143
+ const LATE_SECTION_MS = 5000;
91
144
  /**
92
145
  * Activate the preview bridge. Safe to call unconditionally: it no-ops on the
93
146
  * server and on the public (non-preview) site. Returns a cleanup function.
@@ -106,7 +159,45 @@ export function initCmsPreview(options = {}) {
106
159
  }
107
160
  }
108
161
  };
109
- const announce = () => post(readyMessage(collectSectionTitles()));
162
+ let announced = "";
163
+ const announce = () => {
164
+ const titles = collectSectionTitles();
165
+ const key = titles.join("\u0000");
166
+ if (key === announced)
167
+ return; // the dashboard already has this list
168
+ announced = key;
169
+ post(readyMessage(titles));
170
+ };
171
+ // A scroll request for a section that is not in the DOM yet waits for it
172
+ // (bounded), instead of being dropped; a newer request replaces it.
173
+ let pendingScroll = null;
174
+ const scrollWhenPresent = (title) => {
175
+ pendingScroll?.();
176
+ pendingScroll = null;
177
+ const now = findSection(title);
178
+ if (now) {
179
+ scrollToSection(now);
180
+ return;
181
+ }
182
+ if (typeof MutationObserver === "undefined")
183
+ return;
184
+ const observer = new MutationObserver(() => {
185
+ const el = findSection(title);
186
+ if (!el)
187
+ return;
188
+ stop();
189
+ scrollToSection(el);
190
+ });
191
+ const timer = window.setTimeout(() => stop(), LATE_SECTION_MS);
192
+ const stop = () => {
193
+ observer.disconnect();
194
+ window.clearTimeout(timer);
195
+ if (pendingScroll === stop)
196
+ pendingScroll = null;
197
+ };
198
+ pendingScroll = stop;
199
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-cms-section", "class", "style", "hidden"] });
200
+ };
110
201
  const onMessage = (event) => {
111
202
  if (!allowed.includes(event.origin))
112
203
  return;
@@ -117,14 +208,8 @@ export function initCmsPreview(options = {}) {
117
208
  window.location.reload();
118
209
  return;
119
210
  }
120
- if (msg.type === "scroll-to-section") {
121
- const el = findSection(msg.title);
122
- if (!el)
123
- return;
124
- el.scrollIntoView({ behavior: "smooth", block: "start" });
125
- el.classList.add("cms-preview-flash");
126
- window.setTimeout(() => el.classList.remove("cms-preview-flash"), 1600);
127
- }
211
+ if (msg.type === "scroll-to-section")
212
+ scrollWhenPresent(msg.title);
128
213
  };
129
214
  const onClick = (event) => {
130
215
  const target = event.target;
@@ -157,9 +242,22 @@ export function initCmsPreview(options = {}) {
157
242
  announce();
158
243
  else
159
244
  window.addEventListener("load", announce, { once: true });
245
+ // Sections that mount after load (client leaves, lazy lists) reach the
246
+ // dashboard's list too: re-announce when the set of tagged nodes changes.
247
+ let announceTimer = 0;
248
+ const sectionObserver = typeof MutationObserver === "undefined"
249
+ ? null
250
+ : new MutationObserver(() => {
251
+ window.clearTimeout(announceTimer);
252
+ announceTimer = window.setTimeout(announce, 300);
253
+ });
254
+ sectionObserver?.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-cms-section"] });
160
255
  return () => {
161
256
  window.removeEventListener("message", onMessage);
162
257
  document.removeEventListener("click", onClick, true);
258
+ sectionObserver?.disconnect();
259
+ window.clearTimeout(announceTimer);
260
+ pendingScroll?.();
163
261
  style.remove();
164
262
  };
165
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullgrainsoftware/portal",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Fullgrain Portal site SDK: manifest, typed content reader, sync CLI, preview bridge, commerce, inquiries and bookings clients",
5
5
  "repository": {
6
6
  "type": "git",