@dreamtree-org/landing-kit 0.8.0 → 0.9.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/MIGRATION.md CHANGED
@@ -159,3 +159,17 @@ Nothing changes for products that don't use it. The server half, `createPlatform
159
159
  - Credential-custodian "Reveal URL" with a reason, audited.
160
160
 
161
161
  **Not in the console yet:** plans and subscriptions, platform settings, support access and domains. Their server endpoints come first (tenancy). The nav shows them only when `GET /me` advertises them.
162
+
163
+ ## 0.9.0 — store documents (additive)
164
+
165
+ A store's landing, listing and product pages, its header and footer, and its product card are now one
166
+ version-2 document (`validateStore` from `/schema`). Nothing changes for apps that keep version-1 tenant pages.
167
+
168
+ - To adopt it, run each stored v1 page through `migrateStore(page)`. It becomes `pages.home`, and the listing
169
+ and product pages get their default templates. `validateStore` migrates a v1 page itself, so an app can
170
+ switch over on its next save.
171
+ - Listing and product pages are templates whose blocks are either app **slots** (`filters`, `results`,
172
+ `gallery`, `buy_box`…, rendered by the app through `TemplateRegion`'s `slots`) or ordinary content sections.
173
+ Required slots cannot be hidden or removed.
174
+ - `safeHref` now rejects links containing a backslash, and registry lookups (`blockDef`, `templatePage`) ignore
175
+ inherited keys, so `__proto__` or `constructor` is an unknown type.
package/README.md CHANGED
@@ -202,6 +202,32 @@ import '@dreamtree-org/landing-kit/styles.css';
202
202
  - `newSection(type)` / `sectionDefaults(type)` start a section with valid defaults; `defaultPage({ name, tagline })`
203
203
  is a neutral starting page.
204
204
 
205
+ ### Store documents (0.9.0): landing, listing and product pages as one document
206
+
207
+ A store's whole storefront design is one version-2 document, validated with `validateStore` from `/schema`:
208
+
209
+ ```js
210
+ { version: 2,
211
+ pages: { home: PageDoc /* the landing page above */,
212
+ listing: { template: 'classic' | 'topbar' | 'editorial', settings, blocks },
213
+ product: { template: 'classic' | 'immersive' | 'compact', settings, blocks } },
214
+ chrome: { announcement, header, footer }, // CHROME_FIELDS
215
+ card: { image_ratio, badges, spec_line, compare, quick_add, mrp, hover } } // CARD_FIELDS
216
+ ```
217
+
218
+ - `STORE_PAGES[page]` lists each page's `settings` (field defs, so a builder generates the form), `templates`
219
+ (setting values + default block order) and `slots`: the commerce blocks the app renders (results, filters,
220
+ gallery, buy box…). `required` slots can be moved, never removed or hidden, so a page always sells.
221
+ - Between the slots a page may place content sections from `CONTENT_BLOCKS` (promo banner, FAQ, USP strip…),
222
+ validated with the same rules as landing-page sections. At most `MAX_BLOCKS` (12) blocks per page.
223
+ - A version-1 landing page passed to `validateStore` is migrated (`migrateStore`): it becomes `pages.home`
224
+ and everything else takes its defaults. `isLegacyPage(doc)` tells them apart.
225
+ - Render a template page with `<TemplateRegion blocks slots theme resolveMedia />`: `slots[type]` is the app's
226
+ component for a commerce block (`{ block, props }`; return null to place it yourself), content sections render
227
+ like `TenantPage`.
228
+ - Helpers: `templatePage(page, template)`, `newBlock(page, type)`, `blockDef(page, type)`, `defaultStore`,
229
+ `defaultChrome`, `defaultCard`, `storeMediaRefs(doc)` (every `{ media_id }` to check before storing), `validateFields`.
230
+
205
231
  ### `<AuthShell>` & `@dreamtree-org/landing-kit/auth`
206
232
 
207
233
  The shared auth frame for login, signup, password reset, consent and platform-admin sign-in (`desktop [ brand panel 40% | form card ]`, `mobile [ logo · form card · footer · legal links ]`). Import from `@dreamtree-org/landing-kit` or the lightweight `@dreamtree-org/landing-kit/auth` subpath (with `@dreamtree-org/landing-kit/auth.css` or `styles.css`).
package/dist/index.cjs CHANGED
@@ -51,6 +51,7 @@ __export(index_exports, {
51
51
  SectionIntro: () => SectionIntro,
52
52
  SmartLink: () => SmartLink,
53
53
  TENANT_BLOCKS: () => TENANT_BLOCKS,
54
+ TemplateRegion: () => TemplateRegion,
54
55
  TenantPage: () => TenantPage,
55
56
  TextLink: () => TextLink,
56
57
  Wordmark: () => Wordmark,
@@ -69,6 +70,7 @@ __export(index_exports, {
69
70
  useLanding: () => useLanding,
70
71
  useTapReveal: () => useTapReveal,
71
72
  validatePage: () => validatePage,
73
+ validateStore: () => validateStore,
72
74
  validateTheme: () => validateTheme
73
75
  });
74
76
  module.exports = __toCommonJS(index_exports);
@@ -349,9 +351,9 @@ function Wordmark({ name }) {
349
351
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "dt-lp-wordmark-hi", children: m[2] })
350
352
  ] });
351
353
  }
352
- function BrandLockup({ brand: own, byline = "by Dreamtree-Org", href = "/" }) {
354
+ function BrandLockup({ brand: own2, byline = "by Dreamtree-Org", href = "/" }) {
353
355
  const ctx = useLanding();
354
- const brand = own || ctx.brand;
356
+ const brand = own2 || ctx.brand;
355
357
  if (!brand) return null;
356
358
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("a", { href, className: "dt-lp-brand", "aria-label": `${brand.name}${byline ? ` ${byline}` : ""}, home`, children: [
357
359
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(BrandMark2, { brand }),
@@ -478,10 +480,10 @@ function DreamtreeFooter({
478
480
  tagline,
479
481
  legal = [],
480
482
  year = (/* @__PURE__ */ new Date()).getFullYear(),
481
- brand: own
483
+ brand: own2
482
484
  }) {
483
485
  const ctx = useLanding();
484
- const brand = own || ctx.brand;
486
+ const brand = own2 || ctx.brand;
485
487
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("footer", { className: "dt-lp-footer", children: [
486
488
  /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(Wrap, { className: `dt-lp-footer-grid dt-lp-footer-grid--${Math.min(columns.length, 3)}`, children: [
487
489
  /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "dt-lp-footer-about", children: [
@@ -648,7 +650,7 @@ function FeatureSection(props) {
648
650
  ] })
649
651
  );
650
652
  case "intro": {
651
- const { subtitle, detail, note, glance = [], glanceTitle = "At a glance", link } = props;
653
+ const { subtitle, detail, note, glance = [], glanceTitle = "At a glance", link: link2 } = props;
652
654
  return shell(
653
655
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "dt-lp-intro-grid", children: [
654
656
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { children: [
@@ -662,7 +664,7 @@ function FeatureSection(props) {
662
664
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { id: `${hid2}-glance`, className: "dt-lp-eyebrow", children: glanceTitle }),
663
665
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Checklist, { items: glance }),
664
666
  props.glanceNote ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "dt-lp-note", children: props.glanceNote }) : null,
665
- link ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(TextLink, { href: link.href, className: "dt-lp-mt", children: link.label }) : null
667
+ link2 ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(TextLink, { href: link2.href, className: "dt-lp-mt", children: link2.label }) : null
666
668
  ] }) : null
667
669
  ] })
668
670
  );
@@ -823,7 +825,7 @@ function Faq({ id = "faq", eyebrow, title: title2 = "Frequently asked questions"
823
825
 
824
826
  // src/DemoCta.jsx
825
827
  var import_jsx_runtime16 = require("react/jsx-runtime");
826
- function DemoCta({ id = "demo", eyebrow, title: title2, body, label = "Book a demo", chips, chipsLabel = "Business types", note, link }) {
828
+ function DemoCta({ id = "demo", eyebrow, title: title2, body, label = "Book a demo", chips, chipsLabel = "Business types", note, link: link2 }) {
827
829
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("section", { id, "aria-labelledby": `${id}-title`, className: "dt-lp-section dt-lp-cta", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Wrap, { children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "dt-lp-cta-panel", children: [
828
830
  eyebrow ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "dt-lp-eyebrow dt-lp-eyebrow--inverse", children: eyebrow }) : null,
829
831
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h2", { id: `${id}-title`, className: "dt-lp-h2 dt-lp-h2--cta", children: title2 }),
@@ -834,7 +836,7 @@ function DemoCta({ id = "demo", eyebrow, title: title2, body, label = "Book a de
834
836
  note.title ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h3", { className: "dt-lp-h3", children: note.title }) : null,
835
837
  note.body ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { children: note.body }) : null
836
838
  ] }) : null,
837
- link ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "dt-lp-cta-link", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(SmartLink, { href: link.href, reload: link.reload, children: link.label }) }) : null
839
+ link2 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "dt-lp-cta-link", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(SmartLink, { href: link2.href, reload: link2.reload, children: link2.label }) }) : null
838
840
  ] }) }) });
839
841
  }
840
842
 
@@ -897,17 +899,17 @@ function LandingPage({ brand, content, preview = null, slots = {}, linkComponent
897
899
  var import_jsx_runtime18 = require("react/jsx-runtime");
898
900
  function PreviewFrame({
899
901
  id = "product",
900
- heading,
902
+ heading: heading2,
901
903
  caption,
902
904
  meta,
903
905
  badge = "Demo data",
904
- brand: own,
906
+ brand: own2,
905
907
  children
906
908
  }) {
907
909
  const ctx = useLanding();
908
- const brand = own || ctx.brand;
910
+ const brand = own2 || ctx.brand;
909
911
  return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("section", { id, "aria-labelledby": `${id}-title`, className: "dt-lp-preview", children: [
910
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("h2", { id: `${id}-title`, className: "dt-lp-sr-only", children: heading || `${brand ? brand.name : "Product"} overview` }),
912
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("h2", { id: `${id}-title`, className: "dt-lp-sr-only", children: heading2 || `${brand ? brand.name : "Product"} overview` }),
911
913
  /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Wrap, { children: /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("figure", { className: "dt-lp-preview-frame", children: [
912
914
  /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "dt-lp-preview-bar", children: [
913
915
  /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "dt-lp-preview-title", children: [
@@ -1557,6 +1559,12 @@ var SECTION_TYPES = {
1557
1559
  var SECTION_TYPE_KEYS = Object.keys(SECTION_TYPES);
1558
1560
  var PAGE_VERSION = 1;
1559
1561
  var MAX_SECTIONS = 20;
1562
+ function sectionDefaults(type) {
1563
+ const def = SECTION_TYPES[type];
1564
+ if (!def) throw new Error(`Unknown section type "${type}"`);
1565
+ const fill2 = (fields2) => Object.fromEntries(fields2.map((f) => [f.key, f.kind === "list" ? (f.default || []).map((item) => ({ ...fill2(f.of), ...item })) : f.default]));
1566
+ return fill2(def.fields);
1567
+ }
1560
1568
 
1561
1569
  // src/schema/richtext.js
1562
1570
  var ALLOWED = /* @__PURE__ */ new Set(["p", "br", "h2", "h3", "h4", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a", "blockquote"]);
@@ -1577,7 +1585,7 @@ var decode = (s) => s.replace(/&(amp|quot|#39|lt|gt|#x[0-9a-f]+|#\d+);/gi, (m, e
1577
1585
  });
1578
1586
  function safeHref(raw) {
1579
1587
  const v = decode(String(raw || "")).replace(/[\u0000- \u007f-\u009f]/g, "");
1580
- if (!v) return null;
1588
+ if (!v || v.includes("\\")) return null;
1581
1589
  if (/^(https?:\/\/|mailto:|tel:)/i.test(v)) return v;
1582
1590
  if (/^\/(?!\/)/.test(v) || v.startsWith("#")) return v;
1583
1591
  return null;
@@ -1701,6 +1709,9 @@ function field(f, value, path, errors) {
1701
1709
  throw new Error(`Unknown field kind "${f.kind}" (${path})`);
1702
1710
  }
1703
1711
  }
1712
+ function validateFields(defs, obj, path, errors) {
1713
+ return fields(defs, obj, path, errors);
1714
+ }
1704
1715
  function fields(defs, obj, path, errors) {
1705
1716
  const src = obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {};
1706
1717
  if (obj != null && src !== obj) errors.push({ path, message: "must be an object" });
@@ -1738,6 +1749,18 @@ function validatePage(input) {
1738
1749
  });
1739
1750
  return { ok: errors.length === 0, errors, page: { version: PAGE_VERSION, sections } };
1740
1751
  }
1752
+ function defaultPage({ name = "Our store", tagline = "" } = {}) {
1753
+ const hero = sectionDefaults("hero_slider");
1754
+ hero.slides = [{ ...hero.slides[0], heading: String(name).slice(0, 90), text: String(tagline || "").slice(0, 220), cta_label: "Shop now", cta_href: "/" }];
1755
+ return validatePage({
1756
+ version: PAGE_VERSION,
1757
+ sections: [
1758
+ { id: "hero", type: "hero_slider", props: { ...hero, autoplay: false } },
1759
+ { id: "featured", type: "featured", props: sectionDefaults("featured") },
1760
+ { id: "categories", type: "categories", props: sectionDefaults("categories") }
1761
+ ]
1762
+ }).page;
1763
+ }
1741
1764
 
1742
1765
  // src/schema/color.js
1743
1766
  var HEX_RE = /^#([0-9a-f]{6})$/i;
@@ -2112,12 +2135,12 @@ function SlotSection({ id, title: title2, intro: intro2, tone: tone2, slot, ...p
2112
2135
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "dt-lp-slot-body", children: body })
2113
2136
  ] });
2114
2137
  }
2115
- function PromoBanner({ id, eyebrow, heading, text, cta_label: label, cta_href: href, image, style = "brand", media }) {
2138
+ function PromoBanner({ id, eyebrow, heading: heading2, text, cta_label: label, cta_href: href, image, style = "brand", media }) {
2116
2139
  const src = media(image, "wide");
2117
2140
  return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("section", { id, "aria-labelledby": hid(id), className: "dt-lp-section dt-lp-promo-wrap", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "dt-lp-wrap", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: `dt-lp-promo dt-lp-promo--${style}${src ? " dt-lp-promo--image" : ""}`, children: [
2118
2141
  /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "dt-lp-promo-copy", children: [
2119
2142
  eyebrow ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("p", { className: "dt-lp-eyebrow dt-lp-eyebrow--inverse", children: eyebrow }) : null,
2120
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("h2", { id: hid(id), className: "dt-lp-h2 dt-lp-promo-heading", children: heading }),
2143
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("h2", { id: hid(id), className: "dt-lp-h2 dt-lp-promo-heading", children: heading2 }),
2121
2144
  text ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("p", { className: "dt-lp-promo-text", children: text }) : null,
2122
2145
  label && href ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(SmartLink, { href, className: "dt-lp-btn dt-lp-btn--lg dt-lp-btn--inverse", children: label }) : null
2123
2146
  ] }),
@@ -2293,3 +2316,288 @@ function TenantPage({
2293
2316
  return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(SectionBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(Block, { ...s.props, id: `s-${s.id}`, first: i === 0 && headingStart === 1, media, slot: slots[s.type], onSubscribe }) }, s.id);
2294
2317
  }) }) });
2295
2318
  }
2319
+
2320
+ // src/tenant/TemplateRegion.jsx
2321
+ var import_react12 = require("react");
2322
+ var import_jsx_runtime26 = require("react/jsx-runtime");
2323
+ var noMedia2 = () => null;
2324
+ function TemplateRegion({
2325
+ blocks = [],
2326
+ slots = {},
2327
+ theme,
2328
+ resolveMedia = noMedia2,
2329
+ linkComponent = null,
2330
+ storeName = "",
2331
+ className = ""
2332
+ }) {
2333
+ const vars = (0, import_react12.useMemo)(() => themeVars(theme), [theme]);
2334
+ const ctx = (0, import_react12.useMemo)(() => ({ brand: { id: "tenant", name: storeName, primary: vars.theme.primary }, linkComponent, onBookDemo: null, home: false }), [storeName, vars.theme.primary, linkComponent]);
2335
+ const media = (ref, variant) => ref && ref.media_id != null ? resolveMedia(ref, variant) : null;
2336
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_react12.Fragment, { children: blocks.filter((b) => b && b.enabled !== false).map((b) => {
2337
+ const Slot = slots[b.type];
2338
+ if (Slot) {
2339
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(SectionBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(Slot, { block: b, props: b.props || {} }) }, b.id);
2340
+ }
2341
+ const Block = TENANT_BLOCKS[b.type];
2342
+ if (!Block || Block === TENANT_BLOCKS.featured) return null;
2343
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(SectionBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(LandingProvider, { value: ctx, children: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("div", { className: `dt-landing dt-lp-tenant dt-lp-region ${className}`.trim(), style: vars.style, "data-block": b.type, children: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(Block, { ...b.props, id: `b-${b.id}`, first: false, media, slot: null, onSubscribe: null }) }) }) }, b.id);
2344
+ }) });
2345
+ }
2346
+
2347
+ // src/schema/store.js
2348
+ var STORE_VERSION = 2;
2349
+ var MAX_BLOCKS = 12;
2350
+ var ID_RE2 = /^[a-z0-9][a-z0-9-]{0,39}$/;
2351
+ var opt = (...pairs) => pairs.map(([value, label]) => ({ value, label }));
2352
+ var sel = (key, label, options, d) => ({ key, kind: "select", label, options, default: d });
2353
+ var bool = (key, label, d) => ({ key, kind: "bool", label, default: d });
2354
+ var heading = (d, max = 90) => ({ key: "title", kind: "text", label: "Heading", max, default: d });
2355
+ var CONTENT_BLOCKS = ["promo_banner", "usp_strip", "trust_badges", "cta_cards", "faq", "rich_text", "testimonials", "brand_strip", "gallery"];
2356
+ var LISTING_SLOTS = {
2357
+ collection_header: { label: "Collection header", description: "Title, description and quick links for the category or search.", fields: [bool("show_description", "Show the description", true), bool("show_quick_links", "Show quick links", true)] },
2358
+ toolbar: { label: "Toolbar and sort", description: "Result count, sort and the filters in use.", fields: [] },
2359
+ filters: { label: "Filters", description: "Price, brand and spec filters.", required: true, fields: [] },
2360
+ results: { label: "Results grid", description: "The products, with paging.", required: true, fields: [] }
2361
+ };
2362
+ var PRODUCT_SLOTS = {
2363
+ gallery: { label: "Gallery", description: "Photos of the product.", required: true, fields: [] },
2364
+ buy_box: { label: "Buy box", description: "Title, price, key specs and the buy buttons.", required: true, fields: [bool("show_compare", "Show Add to compare", true), bool("show_quote", "Show bulk quote link", true)] },
2365
+ highlights: { label: "Highlights", description: "The product's headline numbers and selling points.", fields: [heading("Highlights")] },
2366
+ delivery: { label: "Delivery check", description: "PIN code delivery estimate.", fields: [] },
2367
+ seller: { label: "Seller", description: "Who sells and services the product.", fields: [] },
2368
+ inspection: { label: "Inspection report", description: "Checks, grade and condition notes.", fields: [heading("Inspection report"), bool("expanded", "Open by default", false)] },
2369
+ battery: { label: "Battery", description: "Battery health, where the product has one.", fields: [] },
2370
+ included: { label: "In the box", description: "What ships with the product.", fields: [] },
2371
+ specs: { label: "Specifications", description: "The full specification table.", fields: [heading("Specifications")] },
2372
+ policies: { label: "Warranty and returns", description: "The store's warranty and return terms.", fields: [heading("Warranty and returns")] },
2373
+ similar: { label: "Similar products", description: "More products like this one.", fields: [heading("Similar products"), sel("limit", "How many", opt(["3", "3"], ["4", "4"], ["6", "6"], ["8", "8"]), "4")] }
2374
+ };
2375
+ var SORTS = opt(["relevance", "Recommended"], ["newest", "Newest first"], ["price_asc", "Price: low to high"], ["price_desc", "Price: high to low"], ["discount", "Biggest discount"]);
2376
+ var STORE_PAGES = {
2377
+ listing: {
2378
+ label: "Listing page",
2379
+ slots: LISTING_SLOTS,
2380
+ settings: [
2381
+ sel("columns", "Columns on desktop", opt(["3", "3"], ["4", "4"]), "3"),
2382
+ sel("columns_mobile", "Columns on phones", opt(["1", "1"], ["2", "2"]), "2"),
2383
+ sel("page_size", "Products per page", opt(["12", "12"], ["24", "24"], ["36", "36"]), "12"),
2384
+ sel("default_sort", "Default sort", SORTS, "relevance"),
2385
+ sel("filters", "Filters show as", opt(["sidebar", "Sidebar"], ["topbar", "Bar above the results"], ["drawer", "Button that opens a panel"]), "sidebar"),
2386
+ bool("active_chips", "Show the filters in use as chips", true),
2387
+ sel("paging", "Paging", opt(["pages", "Page numbers"], ["load_more", "Load more button"]), "pages")
2388
+ ],
2389
+ templates: {
2390
+ classic: { label: "Classic", description: "Filters in a sidebar beside a product grid.", settings: {}, blocks: ["collection_header", "toolbar", "filters", "results"] },
2391
+ topbar: { label: "Top bar", description: "Filter chips above a wide grid, more room for products.", settings: { columns: "4", filters: "topbar", paging: "load_more" }, blocks: ["collection_header", "filters", "toolbar", "results"] },
2392
+ editorial: { label: "Editorial", description: "A story-led collection header, then the grid behind a filter button.", settings: { columns: "4", filters: "drawer", active_chips: false }, blocks: ["collection_header", "rich_text", "toolbar", "filters", "results"] }
2393
+ },
2394
+ default: "classic"
2395
+ },
2396
+ product: {
2397
+ label: "Product page",
2398
+ slots: PRODUCT_SLOTS,
2399
+ settings: [
2400
+ sel("gallery", "Gallery", opt(["thumbs_side", "Thumbnails beside the photo"], ["thumbs_below", "Thumbnails below the photo"], ["mosaic", "Full-width mosaic"]), "thumbs_side"),
2401
+ bool("sticky_buy_bar", "Sticky buy bar on phones", true),
2402
+ sel("spec_tiles", "Key spec tiles", opt(["0", "None"], ["4", "4"], ["6", "6"]), "4"),
2403
+ bool("breadcrumbs", "Show breadcrumbs", true)
2404
+ ],
2405
+ templates: {
2406
+ classic: { label: "Classic", description: "Photos beside the buy box, details below.", settings: { gallery: "thumbs_side" }, blocks: ["gallery", "buy_box", "delivery", "seller", "inspection", "battery", "included", "specs", "policies", "similar"] },
2407
+ immersive: { label: "Immersive", description: "A full-width photo mosaic first, a floating buy panel, the story below.", settings: { gallery: "mosaic" }, blocks: ["gallery", "buy_box", "highlights", "inspection", "specs", "policies", "similar"] },
2408
+ compact: { label: "Compact", description: "One column, phone first, with a sticky buy bar.", settings: { gallery: "thumbs_below", spec_tiles: "0", breadcrumbs: false }, blocks: ["gallery", "buy_box", "delivery", "inspection", "specs", "policies", "similar"] }
2409
+ },
2410
+ default: "classic"
2411
+ }
2412
+ };
2413
+ var STORE_PAGE_KEYS = Object.keys(STORE_PAGES);
2414
+ var link = [
2415
+ { key: "label", kind: "text", label: "Label", max: 30, required: true, default: "" },
2416
+ { key: "href", kind: "url", label: "Link", required: true, default: "" }
2417
+ ];
2418
+ var CHROME_FIELDS = [
2419
+ {
2420
+ key: "announcement",
2421
+ kind: "group",
2422
+ label: "Announcement bar",
2423
+ fields: [
2424
+ bool("enabled", "Show the announcement bar", false),
2425
+ { key: "text", kind: "text", label: "Message", max: 120, default: "" },
2426
+ { key: "href", kind: "url", label: "Link", default: "" },
2427
+ sel("tone", "Colour", opt(["brand", "Brand"], ["accent", "Accent"], ["dark", "Dark"]), "brand")
2428
+ ]
2429
+ },
2430
+ {
2431
+ key: "header",
2432
+ kind: "group",
2433
+ label: "Header",
2434
+ fields: [
2435
+ { key: "utility_text", kind: "text", label: "Strip above the header", max: 80, default: "" },
2436
+ bool("show_search", "Search box", true),
2437
+ bool("category_nav", "Menu of the categories you sell", true),
2438
+ { key: "nav_links", kind: "list", label: "Extra menu links", max: 8, itemLabel: "label", of: link, default: [] },
2439
+ bool("sticky", "Keep the header on screen while scrolling", true)
2440
+ ]
2441
+ },
2442
+ {
2443
+ key: "footer",
2444
+ kind: "group",
2445
+ label: "Footer",
2446
+ fields: [
2447
+ { key: "about", kind: "textarea", label: "About the store", max: 240, default: "" },
2448
+ {
2449
+ key: "columns",
2450
+ kind: "list",
2451
+ label: "Link columns",
2452
+ max: 4,
2453
+ itemLabel: "title",
2454
+ of: [{ key: "title", kind: "text", label: "Column heading", max: 40, required: true, default: "" }, { key: "links", kind: "list", label: "Links", max: 8, itemLabel: "label", of: link, default: [] }],
2455
+ default: []
2456
+ },
2457
+ {
2458
+ key: "socials",
2459
+ kind: "list",
2460
+ label: "Social profiles",
2461
+ max: 6,
2462
+ itemLabel: "network",
2463
+ of: [sel("network", "Network", opt(["instagram", "Instagram"], ["facebook", "Facebook"], ["youtube", "YouTube"], ["x", "X"], ["linkedin", "LinkedIn"], ["whatsapp", "WhatsApp"]), "instagram"), { key: "href", kind: "url", label: "Profile link", required: true, default: "" }],
2464
+ default: []
2465
+ },
2466
+ { key: "note", kind: "text", label: "Small print (GSTIN, address)", max: 160, default: "" }
2467
+ ]
2468
+ }
2469
+ ];
2470
+ var CARD_FIELDS = [
2471
+ sel("image_ratio", "Photo shape", opt(["4:3", "Landscape 4:3"], ["1:1", "Square"], ["3:4", "Portrait 3:4"]), "4:3"),
2472
+ bool("badges", "Condition and discount badges", true),
2473
+ bool("spec_line", "Key specs line", true),
2474
+ bool("compare", "Compare checkbox", true),
2475
+ bool("quick_add", "Add to cart button", true),
2476
+ bool("mrp", "Show the MRP struck through", true),
2477
+ sel("hover", "On hover", opt(["none", "Nothing"], ["zoom", "Zoom the photo"], ["second_image", "Show the second photo"]), "zoom")
2478
+ ];
2479
+ function validateGroups(defs, obj, path, errors) {
2480
+ const src = obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {};
2481
+ if (obj != null && src !== obj) errors.push({ path, message: "must be an object" });
2482
+ return Object.fromEntries(defs.map((g) => [g.key, g.kind === "group" ? validateFields(g.fields, src[g.key], `${path}.${g.key}`, errors) : validateFields([g], src, path, errors)[g.key]]));
2483
+ }
2484
+ var fill = (defs) => Object.fromEntries(defs.map((f) => [f.key, f.kind === "group" ? fill(f.fields) : f.kind === "list" ? [...f.default || []] : f.default]));
2485
+ var defaultChrome = () => fill(CHROME_FIELDS);
2486
+ var defaultCard = () => fill(CARD_FIELDS);
2487
+ function blockDef(pageKey, type) {
2488
+ const page = own(STORE_PAGES, pageKey) ? STORE_PAGES[pageKey] : null;
2489
+ if (!page) return null;
2490
+ if (own(page.slots, type)) return { ...page.slots[type], slot: true };
2491
+ if (CONTENT_BLOCKS.includes(type) && own(SECTION_TYPES, type)) return { ...SECTION_TYPES[type], slot: false };
2492
+ return null;
2493
+ }
2494
+ var own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
2495
+ var blockId = (type) => type.replace(/_/g, "-");
2496
+ var blockDefaults = (pageKey, type) => {
2497
+ const def = blockDef(pageKey, type);
2498
+ return def.slot ? fill(def.fields) : sectionDefaults(type);
2499
+ };
2500
+ function templatePage(pageKey, template) {
2501
+ const page = STORE_PAGES[pageKey];
2502
+ const key = own(page.templates, template) ? template : page.default;
2503
+ const tpl = page.templates[key];
2504
+ const settings = { ...fill(page.settings), ...tpl.settings };
2505
+ const blocks = tpl.blocks.map((type) => ({ id: blockId(type), type, enabled: true, props: blockDefaults(pageKey, type) }));
2506
+ return { template: key, settings, blocks };
2507
+ }
2508
+ function validateTemplatePage(pageKey, input, path, errors) {
2509
+ var _a, _b;
2510
+ const page = STORE_PAGES[pageKey];
2511
+ const src = input && typeof input === "object" && !Array.isArray(input) ? input : {};
2512
+ let template = (_a = src.template) != null ? _a : page.default;
2513
+ if (!own(page.templates, template)) {
2514
+ errors.push({ path: `${path}.template`, message: `must be one of ${Object.keys(page.templates).join(", ")}` });
2515
+ template = page.default;
2516
+ }
2517
+ const tpl = page.templates[template];
2518
+ const settingDefs = page.settings.map((f) => tpl.settings[f.key] === void 0 ? f : { ...f, default: tpl.settings[f.key] });
2519
+ const settings = validateFields(settingDefs, (_b = src.settings) != null ? _b : {}, `${path}.settings`, errors);
2520
+ const list = Array.isArray(src.blocks) ? src.blocks : null;
2521
+ if (src.blocks != null && !list) errors.push({ path: `${path}.blocks`, message: "must be a list" });
2522
+ const raw = list != null ? list : templatePage(pageKey, template).blocks;
2523
+ if (raw.length > MAX_BLOCKS) errors.push({ path: `${path}.blocks`, message: `can have at most ${MAX_BLOCKS}` });
2524
+ const ids = /* @__PURE__ */ new Set();
2525
+ const slotsSeen = /* @__PURE__ */ new Set();
2526
+ const blocks = [];
2527
+ raw.slice(0, MAX_BLOCKS).forEach((b, i) => {
2528
+ var _a2, _b2;
2529
+ const at = `${path}.blocks[${i}]`;
2530
+ if (!b || typeof b !== "object") {
2531
+ errors.push({ path: at, message: "must be an object" });
2532
+ return;
2533
+ }
2534
+ const def = blockDef(pageKey, b.type);
2535
+ if (!def) {
2536
+ errors.push({ path: `${at}.type`, message: `unknown block "${b.type}" on the ${page.label.toLowerCase()}` });
2537
+ return;
2538
+ }
2539
+ const id = String((_a2 = b.id) != null ? _a2 : "");
2540
+ if (!ID_RE2.test(id)) errors.push({ path: `${at}.id`, message: "must be 1\u201340 lowercase letters, digits or dashes" });
2541
+ else if (ids.has(id)) errors.push({ path: `${at}.id`, message: `duplicate id "${id}"` });
2542
+ ids.add(id);
2543
+ if (def.slot) {
2544
+ if (slotsSeen.has(b.type)) {
2545
+ errors.push({ path: `${at}.type`, message: `"${def.label}" can appear only once` });
2546
+ return;
2547
+ }
2548
+ slotsSeen.add(b.type);
2549
+ }
2550
+ let enabled = b.enabled === void 0 ? true : b.enabled;
2551
+ if (typeof enabled !== "boolean") {
2552
+ errors.push({ path: `${at}.enabled`, message: "must be true or false" });
2553
+ enabled = true;
2554
+ }
2555
+ if (def.required && !enabled) {
2556
+ errors.push({ path: `${at}.enabled`, message: `"${def.label}" is required and cannot be hidden` });
2557
+ enabled = true;
2558
+ }
2559
+ blocks.push({ id, type: b.type, enabled, props: validateFields(def.fields, (_b2 = b.props) != null ? _b2 : {}, `${at}.props`, errors) });
2560
+ });
2561
+ for (const [type, def] of Object.entries(page.slots)) {
2562
+ if (def.required && !slotsSeen.has(type)) {
2563
+ errors.push({ path: `${path}.blocks`, message: `"${def.label}" is required` });
2564
+ blocks.push({ id: ids.has(blockId(type)) ? `${blockId(type)}-1` : blockId(type), type, enabled: true, props: blockDefaults(pageKey, type) });
2565
+ }
2566
+ }
2567
+ return { template, settings, blocks };
2568
+ }
2569
+ function migrateStore(input) {
2570
+ const home = input && typeof input === "object" && Array.isArray(input.sections) ? input : null;
2571
+ return {
2572
+ version: STORE_VERSION,
2573
+ pages: { home: home ? { version: 1, sections: home.sections } : defaultPage(), listing: templatePage("listing"), product: templatePage("product") },
2574
+ chrome: defaultChrome(),
2575
+ card: defaultCard()
2576
+ };
2577
+ }
2578
+ var isLegacyPage = (doc) => !!doc && typeof doc === "object" && doc.version !== STORE_VERSION && Array.isArray(doc.sections);
2579
+ function validateStore(input) {
2580
+ var _a, _b, _c;
2581
+ const errors = [];
2582
+ const src0 = isLegacyPage(input) ? migrateStore(input) : input;
2583
+ const src = src0 && typeof src0 === "object" && !Array.isArray(src0) ? src0 : null;
2584
+ if (!src) {
2585
+ const doc2 = migrateStore(null);
2586
+ return { ok: false, errors: [{ path: "doc", message: "must be an object" }], doc: doc2 };
2587
+ }
2588
+ if (src.version != null && src.version !== STORE_VERSION) errors.push({ path: "version", message: `must be ${STORE_VERSION}` });
2589
+ const pages = src.pages && typeof src.pages === "object" ? src.pages : {};
2590
+ const home = validatePage((_a = pages.home) != null ? _a : defaultPage());
2591
+ home.errors.forEach((e) => errors.push({ path: `pages.home.${e.path}`, message: e.message }));
2592
+ const doc = {
2593
+ version: STORE_VERSION,
2594
+ pages: {
2595
+ home: home.page,
2596
+ ...Object.fromEntries(STORE_PAGE_KEYS.map((k) => [k, validateTemplatePage(k, pages[k], `pages.${k}`, errors)]))
2597
+ },
2598
+ chrome: validateGroups(CHROME_FIELDS, (_b = src.chrome) != null ? _b : {}, "chrome", errors),
2599
+ card: validateFields(CARD_FIELDS, (_c = src.card) != null ? _c : {}, "card", errors)
2600
+ };
2601
+ if (doc.chrome.announcement.enabled && !doc.chrome.announcement.text) errors.push({ path: "chrome.announcement.text", message: "is required when the bar is shown" });
2602
+ return { ok: errors.length === 0, errors, doc };
2603
+ }