@fullgrainsoftware/portal 0.9.0 → 0.11.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
 
@@ -47,6 +47,8 @@ export default defineSite({
47
47
  });
48
48
  ```
49
49
 
50
+ A repeatable section whose items are mostly a picture can take photos in bulk from the panel: set `batchUpload: true` (the section has exactly one image field) or `batchUpload: "<image field key>"`. Each picked image becomes one item with every other field empty, so no other field of that section may be `required`.
51
+
50
52
  ## Read content in your pages
51
53
 
52
54
  ```tsx
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") {
@@ -18,6 +18,13 @@ export interface ManifestSection {
18
18
  title: string;
19
19
  kind: "repeatable" | "singleton";
20
20
  allowGroupCreation?: boolean;
21
+ /**
22
+ * Batch photo uploads in the panel ("Add photos": one item per picked
23
+ * image, every other field empty). `true` when the section has exactly one
24
+ * image field, else that field's key. Repeatable sections only; no other
25
+ * field may be `required`.
26
+ */
27
+ batchUpload?: true | string;
21
28
  fields: Record<string, ManifestField>;
22
29
  seed?: readonly ManifestSeedItem[];
23
30
  }
@@ -41,6 +48,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
41
48
  title: z.ZodString;
42
49
  kind: z.ZodEnum<["repeatable", "singleton"]>;
43
50
  allowGroupCreation: z.ZodOptional<z.ZodBoolean>;
51
+ batchUpload: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<true>, z.ZodString]>>;
44
52
  fields: z.ZodRecord<z.ZodString, z.ZodObject<{
45
53
  label: z.ZodString;
46
54
  type: z.ZodEnum<["text", "image", "color", "audio", "video"]>;
@@ -90,6 +98,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
90
98
  generateMp4?: boolean | undefined;
91
99
  }>;
92
100
  allowGroupCreation?: boolean | undefined;
101
+ batchUpload?: string | true | undefined;
93
102
  seed?: {
94
103
  values: Record<string, string>;
95
104
  name: string;
@@ -108,6 +117,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
108
117
  generateMp4?: boolean | undefined;
109
118
  }>;
110
119
  allowGroupCreation?: boolean | undefined;
120
+ batchUpload?: string | true | undefined;
111
121
  seed?: {
112
122
  values: Record<string, string>;
113
123
  name: string;
@@ -126,6 +136,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
126
136
  generateMp4?: boolean | undefined;
127
137
  }>;
128
138
  allowGroupCreation?: boolean | undefined;
139
+ batchUpload?: string | true | undefined;
129
140
  seed?: {
130
141
  values: Record<string, string>;
131
142
  name: string;
@@ -144,6 +155,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
144
155
  generateMp4?: boolean | undefined;
145
156
  }>;
146
157
  allowGroupCreation?: boolean | undefined;
158
+ batchUpload?: string | true | undefined;
147
159
  seed?: {
148
160
  values: Record<string, string>;
149
161
  name: string;
@@ -166,6 +178,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
166
178
  generateMp4?: boolean | undefined;
167
179
  }>;
168
180
  allowGroupCreation?: boolean | undefined;
181
+ batchUpload?: string | true | undefined;
169
182
  seed?: {
170
183
  values: Record<string, string>;
171
184
  name: string;
@@ -188,6 +201,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
188
201
  generateMp4?: boolean | undefined;
189
202
  }>;
190
203
  allowGroupCreation?: boolean | undefined;
204
+ batchUpload?: string | true | undefined;
191
205
  seed?: {
192
206
  values: Record<string, string>;
193
207
  name: string;
@@ -213,6 +227,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
213
227
  generateMp4?: boolean | undefined;
214
228
  }>;
215
229
  allowGroupCreation?: boolean | undefined;
230
+ batchUpload?: string | true | undefined;
216
231
  seed?: {
217
232
  values: Record<string, string>;
218
233
  name: string;
@@ -238,6 +253,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
238
253
  generateMp4?: boolean | undefined;
239
254
  }>;
240
255
  allowGroupCreation?: boolean | undefined;
256
+ batchUpload?: string | true | undefined;
241
257
  seed?: {
242
258
  values: Record<string, string>;
243
259
  name: string;
@@ -263,6 +279,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
263
279
  generateMp4?: boolean | undefined;
264
280
  }>;
265
281
  allowGroupCreation?: boolean | undefined;
282
+ batchUpload?: string | true | undefined;
266
283
  seed?: {
267
284
  values: Record<string, string>;
268
285
  name: string;
@@ -288,6 +305,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
288
305
  generateMp4?: boolean | undefined;
289
306
  }>;
290
307
  allowGroupCreation?: boolean | undefined;
308
+ batchUpload?: string | true | undefined;
291
309
  seed?: {
292
310
  values: Record<string, string>;
293
311
  name: string;
package/dist/manifest.js CHANGED
@@ -29,6 +29,7 @@ const sectionSchema = z
29
29
  title: z.string().trim().min(1),
30
30
  kind: z.enum(["repeatable", "singleton"]),
31
31
  allowGroupCreation: z.boolean().optional(),
32
+ batchUpload: z.union([z.literal(true), z.string().trim().min(1)]).optional(),
32
33
  fields: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, "field keys are lower-kebab"), fieldSchema),
33
34
  seed: z.array(seedItemSchema).optional(),
34
35
  })
@@ -64,6 +65,48 @@ const sectionSchema = z
64
65
  }
65
66
  }
66
67
  }
68
+ // Batch uploads: the panel fills ONE image field per picked file and
69
+ // leaves every other field empty, so the target must be an image field
70
+ // of a repeatable section and nothing else may be required.
71
+ if (section.batchUpload !== undefined) {
72
+ if (section.kind !== "repeatable") {
73
+ ctx.addIssue({
74
+ code: z.ZodIssueCode.custom,
75
+ message: `batchUpload on section "${section.title}" needs kind: "repeatable"`,
76
+ });
77
+ }
78
+ const imageKeys = Object.entries(section.fields)
79
+ .filter(([, field]) => field.type === "image")
80
+ .map(([key]) => key);
81
+ let target = null;
82
+ if (section.batchUpload === true) {
83
+ if (imageKeys.length === 1)
84
+ target = imageKeys[0];
85
+ else {
86
+ ctx.addIssue({
87
+ code: z.ZodIssueCode.custom,
88
+ message: `batchUpload: true on section "${section.title}" needs exactly one image field (found ${imageKeys.length}) — name the field key instead`,
89
+ });
90
+ }
91
+ }
92
+ else if (imageKeys.includes(section.batchUpload)) {
93
+ target = section.batchUpload;
94
+ }
95
+ else {
96
+ ctx.addIssue({
97
+ code: z.ZodIssueCode.custom,
98
+ message: `batchUpload on section "${section.title}" names "${section.batchUpload}", which is not an image field of that section`,
99
+ });
100
+ }
101
+ for (const [key, field] of Object.entries(section.fields)) {
102
+ if (key !== target && field.required) {
103
+ ctx.addIssue({
104
+ code: z.ZodIssueCode.custom,
105
+ message: `batchUpload on section "${section.title}" can't fill required field "${key}" — a batch-made item leaves every other field empty`,
106
+ });
107
+ }
108
+ }
109
+ }
67
110
  });
68
111
  const pageSchema = z
69
112
  .object({
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.11.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",