@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 +1 -1
- package/dist/bookings.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/cms-client.js +1 -1
- package/dist/commerce.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/inquiries.js +1 -1
- package/dist/preview.d.ts +15 -0
- package/dist/preview.js +121 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @fullgrainsoftware/portal
|
|
2
2
|
|
|
3
|
-
Site SDK for the [Fullgrain Portal](https://
|
|
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://
|
|
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://
|
|
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 {
|
package/dist/cms-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const DEFAULT_BASE_URL = "https://
|
|
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://
|
|
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://
|
|
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
|
|
39
|
-
//
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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