@dreamtree-org/landing-kit 0.4.0 → 0.6.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/index.d.ts CHANGED
@@ -295,3 +295,32 @@ export declare const LandingProvider: import('react').Provider<{ brand: Brand |
295
295
  export declare function useLanding(): { brand: Brand | null; linkComponent: ComponentType<any> | null; onBookDemo: ((t: HTMLElement) => void) | null; home: boolean };
296
296
  /** The "Book a demo" island for an un-hydrated page: React mounts on first open. */
297
297
  export declare function createDemoIsland(props: Omit<DemoDialogProps, 'onClose'> | (() => Omit<DemoDialogProps, 'onClose'>)): { openDemo(trigger?: HTMLElement | null): void; close(): void };
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Tenant pages: a store's own landing page as data. Validate and build pages
301
+ // with `@dreamtree-org/landing-kit/schema`; render them with TenantPage.
302
+ import type { PageDoc, Theme, MediaRef, SectionType, ValidationError, Palette, FontPair } from './schema';
303
+
304
+ export interface TenantPageProps {
305
+ /** A page document (validated again here; invalid sections are dropped). */
306
+ page: PageDoc | unknown;
307
+ theme?: Partial<Theme> | null;
308
+ /** App-rendered content for slot sections: a node or a function of the section's props. */
309
+ slots?: Partial<Record<'featured' | 'categories', ReactNode | ((props: Record<string, unknown>) => ReactNode)>>;
310
+ /** Uploaded image → URL ('wide' | 'narrow' | 'logo' | 'gallery'). */
311
+ resolveMedia?: (ref: MediaRef, variant: string) => string | null;
312
+ linkComponent?: ComponentType<any> | null;
313
+ /** Newsletter sign-up handler; reject with an Error to show its message. */
314
+ onSubscribe?: ((data: { email: string; consent: boolean; section: string }) => Promise<unknown>) | null;
315
+ storeName?: string;
316
+ className?: string;
317
+ /** 1 (default): the first section's heading is the page's h1. */
318
+ headingStart?: 1 | 2;
319
+ }
320
+ export function TenantPage(props: TenantPageProps): JSX.Element;
321
+ export const TENANT_BLOCKS: Record<SectionType, (props: any) => JSX.Element | null>;
322
+ export function HeroSlider(props: Record<string, unknown>): JSX.Element | null;
323
+ export function NamedIcon(props: { name: string; size?: number; className?: string }): JSX.Element | null;
324
+ export function validatePage(doc: unknown): { ok: boolean; errors: ValidationError[]; page: PageDoc };
325
+ export function validateTheme(theme: unknown): { ok: boolean; errors: ValidationError[]; warnings: ValidationError[]; theme: Theme };
326
+ 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.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "The shared marketing site and auth frame (AuthShell + brands) for every Dreamtree product: content-driven landing pages, login/signup shell, Express routes, prerender and SEO files.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,11 @@
36
36
  "import": "./dist/seo.js",
37
37
  "require": "./dist/seo.cjs"
38
38
  },
39
+ "./schema": {
40
+ "types": "./schema.d.ts",
41
+ "import": "./dist/schema.js",
42
+ "require": "./dist/schema.cjs"
43
+ },
39
44
  "./demo-server": {
40
45
  "types": "./demo-server.d.ts",
41
46
  "import": "./dist/demo-server.js",
@@ -60,6 +65,7 @@
60
65
  "auth.d.ts",
61
66
  "server.d.ts",
62
67
  "seo.d.ts",
68
+ "schema.d.ts",
63
69
  "demo-server.d.ts",
64
70
  "enhance.d.ts",
65
71
  "README.md",
@@ -70,7 +76,8 @@
70
76
  "build": "node build.mjs",
71
77
  "test": "node build.mjs && node --test test/*.test.mjs",
72
78
  "prepublishOnly": "npm test",
73
- "test:viewports": "node build.mjs && node test/viewports.mjs"
79
+ "test:viewports": "node build.mjs && node test/viewports.mjs",
80
+ "test:tenant-viewports": "node build.mjs && node test/tenant-viewports.mjs"
74
81
  },
75
82
  "peerDependencies": {
76
83
  "react": ">=18",
@@ -94,7 +101,8 @@
94
101
  "llms.txt",
95
102
  "express",
96
103
  "prerender",
97
- "dreamtree"
104
+ "dreamtree",
105
+ "multi-tenant"
98
106
  ],
99
107
  "publishConfig": {
100
108
  "access": "public"
package/schema.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ // @dreamtree-org/landing-kit/schema — tenant landing pages as data (no React, no DOM).
2
+
3
+ export type FieldKind = 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'select' | 'bool' | 'list' | 'richtext';
4
+ export interface FieldOption { value: string; label: string }
5
+ export interface FieldDef {
6
+ key: string;
7
+ kind: FieldKind;
8
+ label: string;
9
+ /** Characters for text kinds; items for lists. */
10
+ max?: number;
11
+ /** Minimum items (lists). */
12
+ min?: number;
13
+ required?: boolean;
14
+ options?: FieldOption[];
15
+ /** Fields of each list item. */
16
+ of?: FieldDef[];
17
+ /** Which item field names a list row in a builder. */
18
+ itemLabel?: string;
19
+ default?: unknown;
20
+ }
21
+ export interface SectionTypeDef {
22
+ label: string;
23
+ description: string;
24
+ /** Rendered by the app through TenantPage `slots[type]` (featured, categories). */
25
+ slot?: boolean;
26
+ fields: FieldDef[];
27
+ }
28
+ export type SectionType =
29
+ | 'hero_slider' | 'featured' | 'categories' | 'promo_banner' | 'trust_badges' | 'cta_cards' | 'testimonials'
30
+ | 'faq' | 'rich_text' | 'usp_strip' | 'brand_strip' | 'newsletter' | 'gallery';
31
+ export type IconName =
32
+ | 'shield-check' | 'badge-check' | 'truck' | 'wrench' | 'refresh' | 'battery' | 'cpu' | 'laptop' | 'star' | 'headset'
33
+ | 'rupee' | 'award' | 'clock' | 'map-pin' | 'phone' | 'check' | 'sparkles' | 'tag' | 'gift' | 'heart';
34
+ /** An uploaded image: the app resolves media_id to a URL. */
35
+ export interface MediaRef { media_id: string | number; alt?: string }
36
+
37
+ export interface PageSection { id: string; type: SectionType; enabled: boolean; props: Record<string, unknown> }
38
+ export interface PageDoc { version: 1; sections: PageSection[] }
39
+ export interface ValidationError { path: string; message: string }
40
+
41
+ export const SECTION_TYPES: Record<SectionType, SectionTypeDef>;
42
+ export const SECTION_TYPE_KEYS: SectionType[];
43
+ export const ICON_NAMES: IconName[];
44
+ export const PAGE_VERSION: 1;
45
+ export const MAX_SECTIONS: number;
46
+ export function sectionDefaults(type: SectionType): Record<string, unknown>;
47
+ export function newSectionId(type: SectionType): string;
48
+ export function newSection(type: SectionType): PageSection;
49
+
50
+ /** Normalise a page (defaults filled, unknown props dropped, rich text sanitised) and report every problem by path. */
51
+ export function validatePage(doc: unknown): { ok: boolean; errors: ValidationError[]; page: PageDoc };
52
+ /** Hero with the store's name and tagline, then the featured and category slots. */
53
+ export function defaultPage(store?: { name?: string; tagline?: string }): PageDoc;
54
+
55
+ export type FontPairKey = 'system' | 'inter' | 'poppins' | 'dmsans' | 'nunito' | 'serif';
56
+ export type RadiusKey = 'none' | 'sm' | 'md' | 'lg' | 'full';
57
+ export type ThemeMode = 'light' | 'dark' | 'auto';
58
+ export type PresetKey = 'classic' | 'forest' | 'sunset' | 'midnight' | 'slate' | 'berry';
59
+ export interface Theme {
60
+ preset: PresetKey | 'custom';
61
+ primary: string;
62
+ accent: string;
63
+ font: FontPairKey;
64
+ radius: RadiusKey;
65
+ mode: ThemeMode;
66
+ logo: MediaRef | null;
67
+ favicon: MediaRef | null;
68
+ }
69
+ export interface FontPair { label: string; heading: string; body: string; /** Stylesheet to load, or null for system fonts. */ href: string | null }
70
+ export type Palette = Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>;
71
+
72
+ export const THEME_PRESETS: Record<PresetKey, { label: string; primary: string; accent: string; font: FontPairKey; radius: RadiusKey; mode: ThemeMode }>;
73
+ export const FONT_PAIRS: Record<FontPairKey, FontPair>;
74
+ export const RADII: Record<RadiusKey, [string, string]>;
75
+ export const MODES: ThemeMode[];
76
+ export const DEFAULT_THEME: Theme;
77
+ /** Errors for malformed values; warnings for readability (contrast) the builder should show. */
78
+ export function validateTheme(theme: unknown): { ok: boolean; errors: ValidationError[]; warnings: ValidationError[]; theme: Theme };
79
+ /** CSS variables for a .dt-landing root plus the generated palettes (to map onto an app's own tokens). */
80
+ export function themeVars(theme: unknown): { theme: Theme; font: FontPair; palette: { primary: Palette; accent: Palette }; style: Record<string, string> };
81
+
82
+ /** Allowlist sanitiser (p, br, h2–h4, strong, b, em, i, u, ul, ol, li, a[href], blockquote). */
83
+ export function sanitizeRichText(html: unknown, opts?: { maxLength?: number }): string;
84
+ /** http(s), mailto:, tel:, /path or #fragment — otherwise null. */
85
+ export function safeHref(href: unknown): string | null;
86
+
87
+ /** 50…950 from one colour; the colour itself is step 600. */
88
+ export function brandPalette(hex: string): Palette;
89
+ export function contrastRatio(a: string, b: string): number;
90
+ /** '#ffffff' or '#101828', whichever reads better on `hex`. */
91
+ export function readableOn(hex: string): string;
92
+ export function luminance(hex: string): number;
93
+ export function hexToOklch(hex: string): { l: number; c: number; h: number };
94
+ export function oklchToHex(c: { l: number; c: number; h: number }): string;
95
+ export function isHex(v: unknown): boolean;
96
+ export const PALETTE_STEPS: number[];
package/styles.css CHANGED
@@ -876,3 +876,125 @@ html:has(.dt-landing) { scroll-behavior: smooth; scroll-padding-top: calc(var(--
876
876
  /* Shared helpers for form content inside the card */
877
877
  .dt-auth-link { color: var(--dt-brand); font-weight: 500; font-size: 0.875rem; text-decoration: none; }
878
878
  .dt-auth-link:hover { text-decoration: underline; }
879
+
880
+ /* ================================================================== *
881
+ * Tenant pages (TenantPage): a store's own landing page from data.
882
+ * Theme variables come from themeVars(): --dt-brand, --dt-brand-accent,
883
+ * --dt-brand-contrast, --dt-lp-font, --dt-lp-font-heading, --dt-lp-radius.
884
+ * ================================================================== */
885
+ .dt-lp-tenant { min-height: 0; --dt-brand-contrast: #fff; --dt-brand-accent-contrast: #fff; --dt-lp-font-heading: var(--dt-lp-font); }
886
+ .dt-lp-tenant :where(h1, h2, h3, .dt-lp-h2, .dt-lp-h3) { font-family: var(--dt-lp-font-heading); }
887
+ .dt-lp-tenant .dt-lp-btn--primary { color: var(--dt-brand-contrast); }
888
+ .dt-lp-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
889
+
890
+ /* Hero slider */
891
+ .dt-lp-hero-slider { position: relative; overflow: hidden; background: var(--dt-brand-panel); color: #fff; }
892
+ .dt-lp-slides { position: relative; display: grid; }
893
+ .dt-lp-slide { grid-area: 1 / 1; position: relative; display: flex; align-items: center; min-height: 22rem; opacity: 0; visibility: hidden; transition: opacity 0.5s ease, visibility 0s linear 0.5s; background: linear-gradient(120deg, var(--dt-brand-panel), var(--dt-brand)); }
894
+ .dt-lp-slide[data-active] { opacity: 1; visibility: visible; transition: opacity 0.5s ease; }
895
+ .dt-lp-hero-slider--compact .dt-lp-slide { min-height: 16rem; }
896
+ .dt-lp-hero-slider--tall .dt-lp-slide { min-height: 30rem; }
897
+ @media (min-width: 768px) {
898
+ .dt-lp-slide { min-height: 26rem; }
899
+ .dt-lp-hero-slider--compact .dt-lp-slide { min-height: 18rem; }
900
+ .dt-lp-hero-slider--tall .dt-lp-slide { min-height: 36rem; }
901
+ }
902
+ .dt-lp-slide-media, .dt-lp-slide-media img { position: absolute; inset: 0; width: 100%; height: 100%; }
903
+ .dt-lp-slide-media img { object-fit: cover; max-width: none; }
904
+ .dt-lp-slide--image::after { content: ''; position: absolute; inset: 0; pointer-events: none; }
905
+ .dt-lp-slide--overlay-light::after { background: linear-gradient(90deg, rgb(0 0 0 / 0.55), rgb(0 0 0 / 0.15)); }
906
+ .dt-lp-slide--overlay-strong::after { background: rgb(0 0 0 / 0.6); }
907
+ .dt-lp-slide--center.dt-lp-slide--overlay-light::after { background: rgb(0 0 0 / 0.4); }
908
+ .dt-lp-slide-body { position: relative; z-index: 1; width: 100%; padding-top: 3rem; padding-bottom: 4.5rem; }
909
+ .dt-lp-slide-copy { max-width: 38rem; display: grid; gap: 1rem; justify-items: start; }
910
+ .dt-lp-slide--center .dt-lp-slide-copy { margin: 0 auto; text-align: center; justify-items: center; }
911
+ .dt-lp-slide-eyebrow { color: rgb(255 255 255 / 0.85); }
912
+ .dt-lp-slide-heading { font-size: clamp(1.75rem, 4.5vw, 3rem); line-height: 1.12; font-weight: 700; letter-spacing: -0.02em; color: #fff; text-wrap: balance; }
913
+ .dt-lp-slide-text { font-size: 1.0625rem; color: rgb(255 255 255 / 0.9); max-width: 34rem; }
914
+ .dt-lp-slide-cta { margin-top: 0.5rem; }
915
+ .dt-lp-slider-controls { position: absolute; left: 0; right: 0; bottom: 1rem; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
916
+ .dt-lp-slider-btn { display: inline-flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 999px; border: 1px solid rgb(255 255 255 / 0.5); background: rgb(0 0 0 / 0.35); color: #fff; cursor: pointer; }
917
+ .dt-lp-slider-btn:hover { background: rgb(0 0 0 / 0.55); }
918
+ .dt-lp-slider-btn:focus-visible, .dt-lp-slider-dot:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
919
+ .dt-lp-slider-dots { display: flex; gap: 0.25rem; }
920
+ .dt-lp-slider-dot { width: 1.75rem; height: 2.75rem; border: 0; background: transparent; cursor: pointer; position: relative; padding: 0; }
921
+ .dt-lp-slider-dot::before { content: ''; position: absolute; left: 50%; top: 50%; width: 0.625rem; height: 0.625rem; border-radius: 999px; transform: translate(-50%, -50%); background: rgb(255 255 255 / 0.5); }
922
+ .dt-lp-slider-dot[aria-current='true']::before { background: #fff; width: 1.25rem; }
923
+ @media (prefers-reduced-motion: reduce) {
924
+ .dt-lp-slide, .dt-lp-slide[data-active] { transition: none; }
925
+ }
926
+
927
+ /* Grids and cards for tenant sections */
928
+ .dt-lp-tgrid { display: grid; gap: 1rem; margin-top: 2rem; grid-template-columns: 1fr; }
929
+ .dt-lp-intro + .dt-lp-tgrid { margin-top: 2rem; }
930
+ .dt-lp-tgrid:first-child { margin-top: 0; }
931
+ @media (min-width: 640px) { .dt-lp-tgrid--2, .dt-lp-tgrid--3, .dt-lp-tgrid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
932
+ @media (min-width: 1024px) { .dt-lp-tgrid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .dt-lp-tgrid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
933
+ .dt-lp-tcard { height: 100%; }
934
+ .dt-lp-ctacard { display: flex; gap: 1rem; align-items: flex-start; height: 100%; transition: border-color 0.15s, box-shadow 0.15s; }
935
+ .dt-lp-ctacard:hover { border-color: var(--dt-brand-line); box-shadow: 0 4px 16px rgb(16 24 40 / 0.08); }
936
+ .dt-lp-ctacard .dt-lp-icon-tile + .dt-lp-card-text { margin-top: 0; }
937
+ .dt-lp-ctacard .dt-lp-textlink { margin-top: 0.75rem; }
938
+ .dt-lp-quote { display: flex; flex-direction: column; gap: 0.75rem; }
939
+ .dt-lp-quote blockquote p { color: var(--dt-lp-text); font-size: 0.9375rem; }
940
+ .dt-lp-quote-by { font-size: 0.875rem; color: var(--dt-lp-muted); margin-top: auto; }
941
+ .dt-lp-stars { display: flex; gap: 0.125rem; font-size: 1rem; line-height: 1; }
942
+ .dt-lp-star { color: var(--dt-lp-border); }
943
+ .dt-lp-star--on { color: var(--dt-lp-warn); }
944
+
945
+ /* Promo banner */
946
+ .dt-lp-promo-wrap { padding: 2rem 0; }
947
+ .dt-lp-promo { position: relative; display: grid; gap: 1.5rem; align-items: center; padding: 2rem; border-radius: var(--dt-lp-radius-lg); overflow: hidden; color: #fff; }
948
+ .dt-lp-promo--brand { background: linear-gradient(120deg, var(--dt-brand-panel), var(--dt-brand)); }
949
+ .dt-lp-promo--accent { background: var(--dt-brand-accent); color: var(--dt-brand-accent-contrast); }
950
+ .dt-lp-promo--dark { background: var(--dt-lp-dark-bg); }
951
+ .dt-lp-promo-copy { display: grid; gap: 0.75rem; justify-items: start; }
952
+ .dt-lp-promo .dt-lp-promo-heading { color: inherit; }
953
+ .dt-lp-promo-text { opacity: 0.9; max-width: 36rem; }
954
+ .dt-lp-promo-img { width: 100%; max-height: 16rem; object-fit: cover; border-radius: var(--dt-lp-radius); }
955
+ @media (min-width: 768px) { .dt-lp-promo { padding: 2.5rem 3rem; } .dt-lp-promo--image { grid-template-columns: 3fr 2fr; } }
956
+
957
+ /* Rich text */
958
+ .dt-lp-prose { margin-top: 1rem; color: var(--dt-lp-body); }
959
+ .dt-lp-prose > * + * { margin-top: 0.875rem; }
960
+ .dt-lp-prose h2, .dt-lp-prose h3, .dt-lp-prose h4 { color: var(--dt-lp-text); font-weight: 600; line-height: 1.3; }
961
+ .dt-lp-prose h2 { font-size: 1.375rem; margin-top: 1.5rem; }
962
+ .dt-lp-prose h3 { font-size: 1.125rem; margin-top: 1.25rem; }
963
+ .dt-lp-prose ul, .dt-lp-prose ol { padding-left: 1.25rem; }
964
+ .dt-lp-prose ul { list-style: disc; }
965
+ .dt-lp-prose ol { list-style: decimal; }
966
+ .dt-lp-prose a { color: var(--dt-brand-ink); text-decoration: underline; }
967
+ .dt-lp-prose blockquote { border-left: 3px solid var(--dt-brand-line); padding-left: 1rem; color: var(--dt-lp-text); }
968
+
969
+ /* Highlights strip */
970
+ .dt-lp-usp { padding: 1rem 0; }
971
+ .dt-lp-usp-list { display: grid; gap: 0.75rem 1.5rem; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }
972
+ .dt-lp-usp-item { display: flex; align-items: flex-start; gap: 0.625rem; font-size: 0.9375rem; color: var(--dt-lp-text); }
973
+ .dt-lp-usp-icon { color: var(--dt-brand-ink); margin-top: 0.125rem; }
974
+ .dt-lp-usp-text { display: block; font-size: 0.8125rem; color: var(--dt-lp-muted); }
975
+
976
+ /* Brands */
977
+ .dt-lp-brand-list { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem 2.5rem; margin-top: 1.5rem; }
978
+ .dt-lp-brand-item { display: flex; align-items: center; min-height: 2.5rem; font-weight: 700; font-size: 1.125rem; color: var(--dt-lp-muted); letter-spacing: 0.02em; }
979
+ .dt-lp-brand-item img { max-height: 2.5rem; width: auto; filter: grayscale(1); opacity: 0.8; }
980
+
981
+ /* Newsletter */
982
+ .dt-lp-news { margin-top: 1.5rem; display: grid; gap: 0.75rem; }
983
+ .dt-lp-news-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
984
+ .dt-lp-input { flex: 1 1 14rem; min-height: 2.75rem; padding: 0.5rem 0.875rem; border: 1px solid var(--dt-lp-border); border-radius: var(--dt-lp-radius); background: var(--dt-lp-surface); color: var(--dt-lp-text); font: inherit; }
985
+ .dt-lp-input:focus-visible { outline: 2px solid var(--dt-brand); outline-offset: 1px; }
986
+ .dt-lp-news-consent { display: flex; gap: 0.5rem; align-items: flex-start; font-size: 0.8125rem; color: var(--dt-lp-body); text-align: left; }
987
+ .dt-lp-news-consent input { margin-top: 0.2rem; accent-color: var(--dt-brand); }
988
+ .dt-lp-news-done { margin-top: 1.5rem; text-align: center; font-weight: 600; color: var(--dt-brand-ink); }
989
+ .dt-lp-news-error { font-size: 0.875rem; color: #b42318; }
990
+
991
+ /* Gallery */
992
+ .dt-lp-gallery { display: grid; gap: 0.75rem; margin-top: 1.5rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
993
+ @media (min-width: 768px) { .dt-lp-gallery--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .dt-lp-gallery--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
994
+ .dt-lp-gallery figure { position: relative; border-radius: var(--dt-lp-radius); overflow: hidden; background: var(--dt-lp-muted-bg); aspect-ratio: 4 / 3; }
995
+ .dt-lp-gallery img { width: 100%; height: 100%; object-fit: cover; }
996
+ .dt-lp-gallery figcaption { position: absolute; left: 0; right: 0; bottom: 0; padding: 0.5rem 0.75rem; font-size: 0.8125rem; color: #fff; background: linear-gradient(transparent, rgb(0 0 0 / 0.6)); }
997
+
998
+ /* App-filled slots (featured, categories) */
999
+ .dt-lp-slot-body { margin-top: 1.5rem; }
1000
+ .dt-lp-intro:empty + .dt-lp-slot-body, .dt-lp-slot-body:first-child { margin-top: 0; }