@dreamtree-org/landing-kit 0.8.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/dist/schema.js CHANGED
@@ -267,8 +267,8 @@ var MAX_SECTIONS = 20;
267
267
  function sectionDefaults(type) {
268
268
  const def = SECTION_TYPES[type];
269
269
  if (!def) throw new Error(`Unknown section type "${type}"`);
270
- const fill = (fields2) => Object.fromEntries(fields2.map((f) => [f.key, f.kind === "list" ? (f.default || []).map((item) => ({ ...fill(f.of), ...item })) : f.default]));
271
- return fill(def.fields);
270
+ const fill2 = (fields2) => Object.fromEntries(fields2.map((f) => [f.key, f.kind === "list" ? (f.default || []).map((item) => ({ ...fill2(f.of), ...item })) : f.default]));
271
+ return fill2(def.fields);
272
272
  }
273
273
  var seq = 0;
274
274
  function newSectionId(type) {
@@ -298,7 +298,7 @@ var decode = (s) => s.replace(/&(amp|quot|#39|lt|gt|#x[0-9a-f]+|#\d+);/gi, (m, e
298
298
  });
299
299
  function safeHref(raw) {
300
300
  const v = decode(String(raw || "")).replace(/[\u0000- \u007f-\u009f]/g, "");
301
- if (!v) return null;
301
+ if (!v || v.includes("\\")) return null;
302
302
  if (/^(https?:\/\/|mailto:|tel:)/i.test(v)) return v;
303
303
  if (/^\/(?!\/)/.test(v) || v.startsWith("#")) return v;
304
304
  return null;
@@ -422,6 +422,9 @@ function field(f, value, path, errors) {
422
422
  throw new Error(`Unknown field kind "${f.kind}" (${path})`);
423
423
  }
424
424
  }
425
+ function validateFields(defs, obj, path, errors) {
426
+ return fields(defs, obj, path, errors);
427
+ }
425
428
  function fields(defs, obj, path, errors) {
426
429
  const src = obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {};
427
430
  if (obj != null && src !== obj) errors.push({ path, message: "must be an object" });
@@ -645,24 +648,318 @@ function themeVars(input) {
645
648
  }
646
649
  };
647
650
  }
651
+
652
+ // src/schema/store.js
653
+ var STORE_VERSION = 2;
654
+ var MAX_BLOCKS = 12;
655
+ var ID_RE2 = /^[a-z0-9][a-z0-9-]{0,39}$/;
656
+ var opt = (...pairs) => pairs.map(([value, label]) => ({ value, label }));
657
+ var sel = (key, label, options, d) => ({ key, kind: "select", label, options, default: d });
658
+ var bool = (key, label, d) => ({ key, kind: "bool", label, default: d });
659
+ var heading = (d, max = 90) => ({ key: "title", kind: "text", label: "Heading", max, default: d });
660
+ var CONTENT_BLOCKS = ["promo_banner", "usp_strip", "trust_badges", "cta_cards", "faq", "rich_text", "testimonials", "brand_strip", "gallery"];
661
+ var LISTING_SLOTS = {
662
+ 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)] },
663
+ toolbar: { label: "Toolbar and sort", description: "Result count, sort and the filters in use.", fields: [] },
664
+ filters: { label: "Filters", description: "Price, brand and spec filters.", required: true, fields: [] },
665
+ results: { label: "Results grid", description: "The products, with paging.", required: true, fields: [] }
666
+ };
667
+ var PRODUCT_SLOTS = {
668
+ gallery: { label: "Gallery", description: "Photos of the product.", required: true, fields: [] },
669
+ 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)] },
670
+ highlights: { label: "Highlights", description: "The product's headline numbers and selling points.", fields: [heading("Highlights")] },
671
+ delivery: { label: "Delivery check", description: "PIN code delivery estimate.", fields: [] },
672
+ seller: { label: "Seller", description: "Who sells and services the product.", fields: [] },
673
+ inspection: { label: "Inspection report", description: "Checks, grade and condition notes.", fields: [heading("Inspection report"), bool("expanded", "Open by default", false)] },
674
+ battery: { label: "Battery", description: "Battery health, where the product has one.", fields: [] },
675
+ included: { label: "In the box", description: "What ships with the product.", fields: [] },
676
+ specs: { label: "Specifications", description: "The full specification table.", fields: [heading("Specifications")] },
677
+ policies: { label: "Warranty and returns", description: "The store's warranty and return terms.", fields: [heading("Warranty and returns")] },
678
+ 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")] }
679
+ };
680
+ var SORTS = opt(["relevance", "Recommended"], ["newest", "Newest first"], ["price_asc", "Price: low to high"], ["price_desc", "Price: high to low"], ["discount", "Biggest discount"]);
681
+ var STORE_PAGES = {
682
+ listing: {
683
+ label: "Listing page",
684
+ slots: LISTING_SLOTS,
685
+ settings: [
686
+ sel("columns", "Columns on desktop", opt(["3", "3"], ["4", "4"]), "3"),
687
+ sel("columns_mobile", "Columns on phones", opt(["1", "1"], ["2", "2"]), "2"),
688
+ sel("page_size", "Products per page", opt(["12", "12"], ["24", "24"], ["36", "36"]), "12"),
689
+ sel("default_sort", "Default sort", SORTS, "relevance"),
690
+ sel("filters", "Filters show as", opt(["sidebar", "Sidebar"], ["topbar", "Bar above the results"], ["drawer", "Button that opens a panel"]), "sidebar"),
691
+ bool("active_chips", "Show the filters in use as chips", true),
692
+ sel("paging", "Paging", opt(["pages", "Page numbers"], ["load_more", "Load more button"]), "pages")
693
+ ],
694
+ templates: {
695
+ classic: { label: "Classic", description: "Filters in a sidebar beside a product grid.", settings: {}, blocks: ["collection_header", "toolbar", "filters", "results"] },
696
+ 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"] },
697
+ 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"] }
698
+ },
699
+ default: "classic"
700
+ },
701
+ product: {
702
+ label: "Product page",
703
+ slots: PRODUCT_SLOTS,
704
+ settings: [
705
+ sel("gallery", "Gallery", opt(["thumbs_side", "Thumbnails beside the photo"], ["thumbs_below", "Thumbnails below the photo"], ["mosaic", "Full-width mosaic"]), "thumbs_side"),
706
+ bool("sticky_buy_bar", "Sticky buy bar on phones", true),
707
+ sel("spec_tiles", "Key spec tiles", opt(["0", "None"], ["4", "4"], ["6", "6"]), "4"),
708
+ bool("breadcrumbs", "Show breadcrumbs", true)
709
+ ],
710
+ templates: {
711
+ 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"] },
712
+ 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"] },
713
+ 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"] }
714
+ },
715
+ default: "classic"
716
+ }
717
+ };
718
+ var STORE_PAGE_KEYS = Object.keys(STORE_PAGES);
719
+ var link = [
720
+ { key: "label", kind: "text", label: "Label", max: 30, required: true, default: "" },
721
+ { key: "href", kind: "url", label: "Link", required: true, default: "" }
722
+ ];
723
+ var CHROME_FIELDS = [
724
+ {
725
+ key: "announcement",
726
+ kind: "group",
727
+ label: "Announcement bar",
728
+ fields: [
729
+ bool("enabled", "Show the announcement bar", false),
730
+ { key: "text", kind: "text", label: "Message", max: 120, default: "" },
731
+ { key: "href", kind: "url", label: "Link", default: "" },
732
+ sel("tone", "Colour", opt(["brand", "Brand"], ["accent", "Accent"], ["dark", "Dark"]), "brand")
733
+ ]
734
+ },
735
+ {
736
+ key: "header",
737
+ kind: "group",
738
+ label: "Header",
739
+ fields: [
740
+ { key: "utility_text", kind: "text", label: "Strip above the header", max: 80, default: "" },
741
+ bool("show_search", "Search box", true),
742
+ bool("category_nav", "Menu of the categories you sell", true),
743
+ { key: "nav_links", kind: "list", label: "Extra menu links", max: 8, itemLabel: "label", of: link, default: [] },
744
+ bool("sticky", "Keep the header on screen while scrolling", true)
745
+ ]
746
+ },
747
+ {
748
+ key: "footer",
749
+ kind: "group",
750
+ label: "Footer",
751
+ fields: [
752
+ { key: "about", kind: "textarea", label: "About the store", max: 240, default: "" },
753
+ {
754
+ key: "columns",
755
+ kind: "list",
756
+ label: "Link columns",
757
+ max: 4,
758
+ itemLabel: "title",
759
+ 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: [] }],
760
+ default: []
761
+ },
762
+ {
763
+ key: "socials",
764
+ kind: "list",
765
+ label: "Social profiles",
766
+ max: 6,
767
+ itemLabel: "network",
768
+ 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: "" }],
769
+ default: []
770
+ },
771
+ { key: "note", kind: "text", label: "Small print (GSTIN, address)", max: 160, default: "" }
772
+ ]
773
+ }
774
+ ];
775
+ var CARD_FIELDS = [
776
+ sel("image_ratio", "Photo shape", opt(["4:3", "Landscape 4:3"], ["1:1", "Square"], ["3:4", "Portrait 3:4"]), "4:3"),
777
+ bool("badges", "Condition and discount badges", true),
778
+ bool("spec_line", "Key specs line", true),
779
+ bool("compare", "Compare checkbox", true),
780
+ bool("quick_add", "Add to cart button", true),
781
+ bool("mrp", "Show the MRP struck through", true),
782
+ sel("hover", "On hover", opt(["none", "Nothing"], ["zoom", "Zoom the photo"], ["second_image", "Show the second photo"]), "zoom")
783
+ ];
784
+ function validateGroups(defs, obj, path, errors) {
785
+ const src = obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {};
786
+ if (obj != null && src !== obj) errors.push({ path, message: "must be an object" });
787
+ 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]]));
788
+ }
789
+ var fill = (defs) => Object.fromEntries(defs.map((f) => [f.key, f.kind === "group" ? fill(f.fields) : f.kind === "list" ? [...f.default || []] : f.default]));
790
+ var defaultChrome = () => fill(CHROME_FIELDS);
791
+ var defaultCard = () => fill(CARD_FIELDS);
792
+ function blockDef(pageKey, type) {
793
+ const page = own(STORE_PAGES, pageKey) ? STORE_PAGES[pageKey] : null;
794
+ if (!page) return null;
795
+ if (own(page.slots, type)) return { ...page.slots[type], slot: true };
796
+ if (CONTENT_BLOCKS.includes(type) && own(SECTION_TYPES, type)) return { ...SECTION_TYPES[type], slot: false };
797
+ return null;
798
+ }
799
+ var own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
800
+ var blockId = (type) => type.replace(/_/g, "-");
801
+ var blockDefaults = (pageKey, type) => {
802
+ const def = blockDef(pageKey, type);
803
+ return def.slot ? fill(def.fields) : sectionDefaults(type);
804
+ };
805
+ function newBlock(pageKey, type) {
806
+ const def = blockDef(pageKey, type);
807
+ if (!def) throw new Error(`Unknown block "${type}" on the ${pageKey} page`);
808
+ return { id: def.slot ? blockId(type) : newSectionId(type), type, enabled: true, props: blockDefaults(pageKey, type) };
809
+ }
810
+ function templatePage(pageKey, template) {
811
+ const page = STORE_PAGES[pageKey];
812
+ const key = own(page.templates, template) ? template : page.default;
813
+ const tpl = page.templates[key];
814
+ const settings = { ...fill(page.settings), ...tpl.settings };
815
+ const blocks = tpl.blocks.map((type) => ({ id: blockId(type), type, enabled: true, props: blockDefaults(pageKey, type) }));
816
+ return { template: key, settings, blocks };
817
+ }
818
+ function validateTemplatePage(pageKey, input, path, errors) {
819
+ var _a, _b;
820
+ const page = STORE_PAGES[pageKey];
821
+ const src = input && typeof input === "object" && !Array.isArray(input) ? input : {};
822
+ let template = (_a = src.template) != null ? _a : page.default;
823
+ if (!own(page.templates, template)) {
824
+ errors.push({ path: `${path}.template`, message: `must be one of ${Object.keys(page.templates).join(", ")}` });
825
+ template = page.default;
826
+ }
827
+ const tpl = page.templates[template];
828
+ const settingDefs = page.settings.map((f) => tpl.settings[f.key] === void 0 ? f : { ...f, default: tpl.settings[f.key] });
829
+ const settings = validateFields(settingDefs, (_b = src.settings) != null ? _b : {}, `${path}.settings`, errors);
830
+ const list = Array.isArray(src.blocks) ? src.blocks : null;
831
+ if (src.blocks != null && !list) errors.push({ path: `${path}.blocks`, message: "must be a list" });
832
+ const raw = list != null ? list : templatePage(pageKey, template).blocks;
833
+ if (raw.length > MAX_BLOCKS) errors.push({ path: `${path}.blocks`, message: `can have at most ${MAX_BLOCKS}` });
834
+ const ids = /* @__PURE__ */ new Set();
835
+ const slotsSeen = /* @__PURE__ */ new Set();
836
+ const blocks = [];
837
+ raw.slice(0, MAX_BLOCKS).forEach((b, i) => {
838
+ var _a2, _b2;
839
+ const at = `${path}.blocks[${i}]`;
840
+ if (!b || typeof b !== "object") {
841
+ errors.push({ path: at, message: "must be an object" });
842
+ return;
843
+ }
844
+ const def = blockDef(pageKey, b.type);
845
+ if (!def) {
846
+ errors.push({ path: `${at}.type`, message: `unknown block "${b.type}" on the ${page.label.toLowerCase()}` });
847
+ return;
848
+ }
849
+ const id = String((_a2 = b.id) != null ? _a2 : "");
850
+ if (!ID_RE2.test(id)) errors.push({ path: `${at}.id`, message: "must be 1\u201340 lowercase letters, digits or dashes" });
851
+ else if (ids.has(id)) errors.push({ path: `${at}.id`, message: `duplicate id "${id}"` });
852
+ ids.add(id);
853
+ if (def.slot) {
854
+ if (slotsSeen.has(b.type)) {
855
+ errors.push({ path: `${at}.type`, message: `"${def.label}" can appear only once` });
856
+ return;
857
+ }
858
+ slotsSeen.add(b.type);
859
+ }
860
+ let enabled = b.enabled === void 0 ? true : b.enabled;
861
+ if (typeof enabled !== "boolean") {
862
+ errors.push({ path: `${at}.enabled`, message: "must be true or false" });
863
+ enabled = true;
864
+ }
865
+ if (def.required && !enabled) {
866
+ errors.push({ path: `${at}.enabled`, message: `"${def.label}" is required and cannot be hidden` });
867
+ enabled = true;
868
+ }
869
+ blocks.push({ id, type: b.type, enabled, props: validateFields(def.fields, (_b2 = b.props) != null ? _b2 : {}, `${at}.props`, errors) });
870
+ });
871
+ for (const [type, def] of Object.entries(page.slots)) {
872
+ if (def.required && !slotsSeen.has(type)) {
873
+ errors.push({ path: `${path}.blocks`, message: `"${def.label}" is required` });
874
+ blocks.push({ id: ids.has(blockId(type)) ? `${blockId(type)}-1` : blockId(type), type, enabled: true, props: blockDefaults(pageKey, type) });
875
+ }
876
+ }
877
+ return { template, settings, blocks };
878
+ }
879
+ function migrateStore(input) {
880
+ const home = input && typeof input === "object" && Array.isArray(input.sections) ? input : null;
881
+ return {
882
+ version: STORE_VERSION,
883
+ pages: { home: home ? { version: 1, sections: home.sections } : defaultPage(), listing: templatePage("listing"), product: templatePage("product") },
884
+ chrome: defaultChrome(),
885
+ card: defaultCard()
886
+ };
887
+ }
888
+ var isLegacyPage = (doc) => !!doc && typeof doc === "object" && doc.version !== STORE_VERSION && Array.isArray(doc.sections);
889
+ function validateStore(input) {
890
+ var _a, _b, _c;
891
+ const errors = [];
892
+ const src0 = isLegacyPage(input) ? migrateStore(input) : input;
893
+ const src = src0 && typeof src0 === "object" && !Array.isArray(src0) ? src0 : null;
894
+ if (!src) {
895
+ const doc2 = migrateStore(null);
896
+ return { ok: false, errors: [{ path: "doc", message: "must be an object" }], doc: doc2 };
897
+ }
898
+ if (src.version != null && src.version !== STORE_VERSION) errors.push({ path: "version", message: `must be ${STORE_VERSION}` });
899
+ const pages = src.pages && typeof src.pages === "object" ? src.pages : {};
900
+ const home = validatePage((_a = pages.home) != null ? _a : defaultPage());
901
+ home.errors.forEach((e) => errors.push({ path: `pages.home.${e.path}`, message: e.message }));
902
+ const doc = {
903
+ version: STORE_VERSION,
904
+ pages: {
905
+ home: home.page,
906
+ ...Object.fromEntries(STORE_PAGE_KEYS.map((k) => [k, validateTemplatePage(k, pages[k], `pages.${k}`, errors)]))
907
+ },
908
+ chrome: validateGroups(CHROME_FIELDS, (_b = src.chrome) != null ? _b : {}, "chrome", errors),
909
+ card: validateFields(CARD_FIELDS, (_c = src.card) != null ? _c : {}, "card", errors)
910
+ };
911
+ if (doc.chrome.announcement.enabled && !doc.chrome.announcement.text) errors.push({ path: "chrome.announcement.text", message: "is required when the bar is shown" });
912
+ return { ok: errors.length === 0, errors, doc };
913
+ }
914
+ function defaultStore({ name, tagline } = {}) {
915
+ return { ...migrateStore(defaultPage({ name, tagline })) };
916
+ }
917
+ function storeMediaRefs(doc) {
918
+ const out = [];
919
+ const walk = (v) => {
920
+ if (Array.isArray(v)) v.forEach(walk);
921
+ else if (v && typeof v === "object") {
922
+ if (v.media_id != null && Object.keys(v).every((k) => k === "media_id" || k === "alt")) out.push(v);
923
+ else Object.values(v).forEach(walk);
924
+ }
925
+ };
926
+ walk(doc);
927
+ return out;
928
+ }
648
929
  export {
930
+ CARD_FIELDS,
931
+ CHROME_FIELDS,
932
+ CONTENT_BLOCKS,
649
933
  DEFAULT_THEME,
650
934
  FONT_PAIRS,
651
935
  ICON_NAMES,
936
+ LISTING_SLOTS,
937
+ MAX_BLOCKS,
652
938
  MAX_SECTIONS,
653
939
  MODES,
654
940
  PAGE_VERSION,
655
941
  PALETTE_STEPS,
942
+ PRODUCT_SLOTS,
656
943
  RADII,
657
944
  SECTION_TYPES,
658
945
  SECTION_TYPE_KEYS,
946
+ STORE_PAGES,
947
+ STORE_PAGE_KEYS,
948
+ STORE_VERSION,
659
949
  THEME_PRESETS,
950
+ blockDef,
660
951
  brandPalette,
661
952
  contrastRatio,
953
+ defaultCard,
954
+ defaultChrome,
662
955
  defaultPage,
956
+ defaultStore,
663
957
  hexToOklch,
664
958
  isHex,
959
+ isLegacyPage,
665
960
  luminance,
961
+ migrateStore,
962
+ newBlock,
666
963
  newSection,
667
964
  newSectionId,
668
965
  oklchToHex,
@@ -670,7 +967,11 @@ export {
670
967
  safeHref,
671
968
  sanitizeRichText,
672
969
  sectionDefaults,
970
+ storeMediaRefs,
971
+ templatePage,
673
972
  themeVars,
973
+ validateFields,
674
974
  validatePage,
975
+ validateStore,
675
976
  validateTheme
676
977
  };
package/index.d.ts CHANGED
@@ -299,7 +299,7 @@ export declare function createDemoIsland(props: Omit<DemoDialogProps, 'onClose'>
299
299
  // ---------------------------------------------------------------------------
300
300
  // Tenant pages: a store's own landing page as data. Validate and build pages
301
301
  // with `@dreamtree-org/landing-kit/schema`; render them with TenantPage.
302
- import type { PageDoc, Theme, MediaRef, SectionType, ValidationError, Palette, FontPair } from './schema';
302
+ import type { PageDoc, Theme, MediaRef, SectionType, ValidationError, Palette, FontPair, StoreDoc, TemplateBlock } from './schema';
303
303
 
304
304
  export interface TenantPageProps {
305
305
  /** A page document (validated again here; invalid sections are dropped). */
@@ -321,6 +321,20 @@ export function TenantPage(props: TenantPageProps): JSX.Element;
321
321
  export const TENANT_BLOCKS: Record<SectionType, (props: any) => JSX.Element | null>;
322
322
  export function HeroSlider(props: Record<string, unknown>): JSX.Element | null;
323
323
  export function NamedIcon(props: { name: string; size?: number; className?: string }): JSX.Element | null;
324
+ export interface TemplateRegionProps {
325
+ /** The blocks of a template page (StoreDoc.pages.listing/product.blocks), in order; hidden ones are skipped. */
326
+ blocks: TemplateBlock[];
327
+ /** App components for commerce slots, by block type; return null to place a slot yourself. */
328
+ slots?: Record<string, ComponentType<{ block: TemplateBlock; props: Record<string, unknown> }>>;
329
+ theme?: Partial<Theme> | null;
330
+ resolveMedia?: (ref: MediaRef, variant: string) => string | null;
331
+ linkComponent?: ComponentType<any> | null;
332
+ storeName?: string;
333
+ className?: string;
334
+ }
335
+ /** A listing or product page's blocks: app slots plus content sections rendered like TenantPage. */
336
+ export function TemplateRegion(props: TemplateRegionProps): JSX.Element;
337
+ export function validateStore(doc: unknown): { ok: boolean; errors: ValidationError[]; doc: StoreDoc };
324
338
  export function validatePage(doc: unknown): { ok: boolean; errors: ValidationError[]; page: PageDoc };
325
339
  export function validateTheme(theme: unknown): { ok: boolean; errors: ValidationError[]; warnings: ValidationError[]; theme: Theme };
326
340
  export function themeVars(theme: unknown): { theme: Theme; font: FontPair; palette: { primary: Palette; accent: Palette }; style: Record<string, string> };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamtree-org/landing-kit",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "The shared marketing site, auth frame (AuthShell + brands) and Platform admin console for every Dreamtree product: content-driven landing pages, login/signup shell, platform console client, Express routes, prerender and SEO files.",
5
5
  "author": {
6
6
  "name": "Partha Preetham Krishna",
package/platform.d.ts CHANGED
@@ -5,7 +5,7 @@ export const PLATFORM_API_BASE: '/api/platform';
5
5
  export const PLATFORM_LOGIN_PATH: '/platform/login';
6
6
  export const STEP_UP_REQUIRED: 'STEP_UP_REQUIRED';
7
7
 
8
- export type PlatformFeature = 'overview' | 'companies' | 'jobs' | 'plans' | 'audit' | 'support' | 'settings' | 'admins';
8
+ export type PlatformFeature = 'overview' | 'companies' | 'jobs' | 'plans' | 'audit' | 'support' | 'settings' | 'domains' | 'admins';
9
9
  /** Every console feature, in nav order. */
10
10
  export const PLATFORM_FEATURES: readonly PlatformFeature[];
11
11
  /** Features a server without `GET /me` serves. */
@@ -91,7 +91,7 @@ export interface PlatformMe {
91
91
  permissions: string[];
92
92
  /** `console` false = enrollment-only session (enroll an authenticator first). */
93
93
  access?: { console: boolean; stepUpFresh: boolean };
94
- features: Partial<Record<PlatformFeature | 'domains', boolean>>;
94
+ features: Partial<Record<PlatformFeature, boolean>>;
95
95
  app: { key: string; name: string; companyLoginPath?: string };
96
96
  }
97
97
 
@@ -187,28 +187,35 @@ export interface Plan {
187
187
  seats?: number | null;
188
188
  limits?: Record<string, number | null>;
189
189
  features?: string[];
190
+ sort?: number;
190
191
  }
191
192
 
192
193
  export interface Subscription {
193
194
  id: string;
194
195
  companyId: string;
195
196
  planId: string;
197
+ planCode?: string | null;
198
+ planName?: string | null;
196
199
  status: 'trial' | 'active' | 'past_due' | 'cancelled' | 'expired';
197
200
  startsAt: Iso;
198
201
  endsAt: Iso | null;
199
202
  seats?: number | null;
200
203
  notes?: string | null;
204
+ createdBy?: string | null;
205
+ createdAt?: Iso;
201
206
  }
202
207
 
203
208
  export interface SettingsField {
204
209
  key: string;
205
210
  label: string;
206
211
  type: 'text' | 'number' | 'boolean' | 'select' | 'secret' | 'email' | 'url';
207
- options?: string[];
212
+ options?: Array<{ value: string; label: string }>;
208
213
  /** Secret values come back masked (`set: true`), never in clear. */
209
214
  value?: unknown;
210
215
  set?: boolean;
211
216
  help?: string;
217
+ /** Where the value comes from. */
218
+ source?: 'branch' | 'company' | 'default' | null;
212
219
  }
213
220
  export interface SettingsFamily {
214
221
  family: string;
@@ -216,24 +223,37 @@ export interface SettingsFamily {
216
223
  scope: 'platform' | 'company';
217
224
  fields: SettingsField[];
218
225
  }
226
+ /** GET settings?company= */
227
+ export interface SettingsResponse {
228
+ families: SettingsFamily[];
229
+ /** Send back with PUT settings/:family; a stale version answers 409. */
230
+ version: number;
231
+ }
219
232
 
220
233
  export interface SupportGrant {
221
234
  id: string;
222
235
  companyId: string;
223
236
  companyName?: string;
224
237
  requestedBy: string;
238
+ requestedById?: string;
225
239
  approvedBy?: string | null;
226
240
  reason: string;
241
+ /** Length of the access window, counted from approval. */
242
+ minutes?: number | null;
227
243
  status: 'requested' | 'active' | 'expired' | 'revoked';
228
244
  expiresAt: Iso | null;
245
+ approvedAt?: Iso | null;
246
+ revokedAt?: Iso | null;
229
247
  createdAt: Iso;
230
248
  }
231
249
 
232
250
  export interface CompanyDomain {
233
251
  id: string;
234
252
  host: string;
253
+ status?: 'pending' | 'verified';
235
254
  primary: boolean;
236
255
  verifiedAt: Iso | null;
256
+ createdAt?: Iso;
237
257
  verification?: { type: 'TXT'; name: string; value: string };
238
258
  }
239
259
 
@@ -327,6 +347,8 @@ export function usePlatform(): {
327
347
  href(rel?: string): string;
328
348
  navigate(href: string): void;
329
349
  can(permission: string): boolean;
350
+ /** Features this console shows (requested ∩ advertised by GET /me). */
351
+ features: PlatformFeature[];
330
352
  companyLoginPath(slug: string): string;
331
353
  companyLoginUrl(slug: string): string;
332
354
  };
@@ -340,3 +362,15 @@ export const CopyButton: ComponentType<{ value: string; label?: string }>;
340
362
  export const OneTimeSecret: ComponentType<{ title?: string; loginUrl?: string; email: string; password: string; onDone?: () => void }>;
341
363
  export const InspectResult: ComponentType<{ report: Record<string, any> }>;
342
364
  export function describeAction(action: string): string;
365
+ /** "₹999.00 / month" from minor units. */
366
+ export function formatPrice(priceMinor: number, currency?: string, interval?: Plan['interval']): string;
367
+
368
+ /** Built-in v1.1 views, exported for products that compose their own console. They render inside <PlatformConsole>. */
369
+ export const PlansView: ComponentType;
370
+ export const SupportAccessView: ComponentType;
371
+ export const SupportGrants: ComponentType<{ companyId?: string; companyName?: string }>;
372
+ type CompanyTabProps = { company: { id: string; displayName?: string } & Record<string, any>; setFlash(flash: { kind: 'success' | 'error' | 'warning' | 'info'; msg: string } | null): void };
373
+ export const SubscriptionTab: ComponentType<CompanyTabProps>;
374
+ export const DomainsTab: ComponentType<CompanyTabProps>;
375
+ export const SettingsTab: ComponentType<CompanyTabProps>;
376
+ export const SupportTab: ComponentType<CompanyTabProps>;
package/schema.d.ts CHANGED
@@ -94,3 +94,53 @@ export function hexToOklch(hex: string): { l: number; c: number; h: number };
94
94
  export function oklchToHex(c: { l: number; c: number; h: number }): string;
95
95
  export function isHex(v: unknown): boolean;
96
96
  export const PALETTE_STEPS: number[];
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Store documents (version 2): the landing page plus listing and product
100
+ // template pages, the header/footer and the product card.
101
+
102
+ export type StorePageKey = 'listing' | 'product';
103
+ export type ListingSlot = 'collection_header' | 'toolbar' | 'filters' | 'results';
104
+ export type ProductSlot = 'gallery' | 'buy_box' | 'highlights' | 'delivery' | 'seller' | 'inspection' | 'battery' | 'included' | 'specs' | 'policies' | 'similar';
105
+ export type ContentBlock = 'promo_banner' | 'usp_strip' | 'trust_badges' | 'cta_cards' | 'faq' | 'rich_text' | 'testimonials' | 'brand_strip' | 'gallery';
106
+ export interface SlotDef { label: string; description: string; required?: boolean; fields: FieldDef[] }
107
+ export interface TemplateDef { label: string; description: string; /** Setting values that differ from the page's defaults. */ settings: Record<string, unknown>; /** Default block order. */ blocks: string[] }
108
+ export interface StorePageDef { label: string; slots: Record<string, SlotDef>; settings: FieldDef[]; templates: Record<string, TemplateDef>; default: string }
109
+ export interface TemplateBlock { id: string; type: string; enabled: boolean; props: Record<string, unknown> }
110
+ export interface TemplatePage { template: string; settings: Record<string, unknown>; blocks: TemplateBlock[] }
111
+ export interface GroupDef { key: string; kind: 'group'; label: string; fields: FieldDef[] }
112
+ export interface StoreLink { label: string; href: string }
113
+ export interface StoreChrome {
114
+ announcement: { enabled: boolean; text: string; href: string; tone: 'brand' | 'accent' | 'dark' };
115
+ header: { utility_text: string; show_search: boolean; category_nav: boolean; nav_links: StoreLink[]; sticky: boolean };
116
+ footer: { about: string; columns: { title: string; links: StoreLink[] }[]; socials: { network: string; href: string }[]; note: string };
117
+ }
118
+ export interface StoreCard { image_ratio: '4:3' | '1:1' | '3:4'; badges: boolean; spec_line: boolean; compare: boolean; quick_add: boolean; mrp: boolean; hover: 'none' | 'zoom' | 'second_image' }
119
+ export interface StoreDoc { version: 2; pages: { home: PageDoc; listing: TemplatePage; product: TemplatePage }; chrome: StoreChrome; card: StoreCard }
120
+
121
+ export const STORE_VERSION: 2;
122
+ export const MAX_BLOCKS: number;
123
+ export const STORE_PAGES: Record<StorePageKey, StorePageDef>;
124
+ export const STORE_PAGE_KEYS: StorePageKey[];
125
+ export const LISTING_SLOTS: Record<ListingSlot, SlotDef>;
126
+ export const PRODUCT_SLOTS: Record<ProductSlot, SlotDef>;
127
+ export const CONTENT_BLOCKS: ContentBlock[];
128
+ export const CHROME_FIELDS: GroupDef[];
129
+ export const CARD_FIELDS: FieldDef[];
130
+ /** Normalise a store document (a version-1 landing page is migrated first) and report every problem by path. Required slots cannot be hidden or removed. */
131
+ export function validateStore(doc: unknown): { ok: boolean; errors: ValidationError[]; doc: StoreDoc };
132
+ /** A version-1 landing page (or nothing) wrapped into a store document with defaults. */
133
+ export function migrateStore(page: unknown): StoreDoc;
134
+ export function isLegacyPage(doc: unknown): boolean;
135
+ export function defaultStore(store?: { name?: string; tagline?: string }): StoreDoc;
136
+ /** A template page with that template's settings and default blocks (unknown template → the page default). */
137
+ export function templatePage(page: StorePageKey, template?: string): TemplatePage;
138
+ export function newBlock(page: StorePageKey, type: string): TemplateBlock;
139
+ /** A slot or content section's definition on a page (slot: true for app-rendered blocks), or null. */
140
+ export function blockDef(page: StorePageKey, type: string): ((SlotDef | SectionTypeDef) & { slot: boolean }) | null;
141
+ export function defaultChrome(): StoreChrome;
142
+ export function defaultCard(): StoreCard;
143
+ /** Every uploaded-image reference in a document. */
144
+ export function storeMediaRefs(doc: unknown): MediaRef[];
145
+ /** Validate an object against a field list with the section rules; problems are pushed onto `errors`. */
146
+ export function validateFields(defs: FieldDef[], obj: unknown, path: string, errors: ValidationError[]): Record<string, unknown>;