@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/MIGRATION.md CHANGED
@@ -94,3 +94,15 @@ The built-in `brands` / `getBrand(id)` registry is deprecated: it warns once and
94
94
  2. Replace `getBrand('billpro')` with `toLandingBrand(require('./dreamtree.brand.json'), { headline, pitch, points, category })`. The copy that used to live in `brands.js` moves into your code.
95
95
  3. Run `npx @dreamtree-org/twreact-ui brand build --out app/brand.css` so the app's twreact-ui components use the same colours.
96
96
 
97
+ ## 0.6.0 — tenant pages (additive)
98
+
99
+ Nothing changes for product landing pages. New, opt-in:
100
+
101
+ - `@dreamtree-org/landing-kit/schema` (CommonJS + ESM, no React): `SECTION_TYPES`, `validatePage`,
102
+ `validateTheme`, `themeVars`, `sanitizeRichText`, `brandPalette`, `contrastRatio`, presets and fonts.
103
+ - `TenantPage`, `HeroSlider`, `NamedIcon` from the main entry, styled by `styles.css` (`.dt-lp-tenant`).
104
+ - `.dt-lp-tenant` adds `--dt-brand-contrast`, `--dt-brand-accent-contrast` and `--dt-lp-font-heading`;
105
+ `brandStyle()` is unchanged.
106
+ - Apps that stored per-tenant brand colours elsewhere can move them into a theme with
107
+ `validateTheme({ preset: 'custom', primary, accent })`.
108
+
package/README.md CHANGED
@@ -108,6 +108,49 @@ Maps the product's `dreamtree.brand.json` (create it with `npx @dreamtree-org/tw
108
108
  | `createDemoIsland(props)` | mounts `DemoDialog` on demand for un-hydrated pages; returns `{ openDemo }` |
109
109
  | `BookDemoButton`, `BrandLockup`, `Section`, `SectionIntro`, `TextLink`, `SmartLink` | building blocks |
110
110
 
111
+ ### Tenant pages (0.6.0): a store's own landing page as data
112
+
113
+ For multi-tenant apps where **each customer edits their own page** (a store front, a clinic,
114
+ a franchise). The page is a JSON document; the app stores it, the package validates and renders it.
115
+
116
+ ```js
117
+ // Server (CommonJS is fine): validate before storing
118
+ const { validatePage, validateTheme, sanitizeRichText } = require('@dreamtree-org/landing-kit/schema');
119
+ const page = validatePage(req.body.page); // { ok, errors: [{ path, message }], page }
120
+ const theme = validateTheme(req.body.theme); // { ok, errors, warnings, theme }
121
+ if (!page.ok || !theme.ok) return res.status(422).json({ errors: [...page.errors, ...theme.errors] });
122
+ ```
123
+
124
+ ```jsx
125
+ // Browser: render
126
+ import { TenantPage } from '@dreamtree-org/landing-kit';
127
+ import '@dreamtree-org/landing-kit/styles.css';
128
+
129
+ <TenantPage
130
+ page={doc} theme={theme} storeName="TechNext"
131
+ slots={{ featured: <FeaturedProducts />, categories: (props) => <CategoryTiles title={props.title} /> }}
132
+ resolveMedia={(ref, variant) => `/api/public-media/${ref.media_id}`}
133
+ linkComponent={RouterLink}
134
+ onSubscribe={({ email, consent }) => api.subscribe(email, consent)}
135
+ />
136
+ ```
137
+
138
+ - **Document:** `{ version: 1, sections: [{ id, type, enabled, props }] }`, at most 20 sections, any order, a type may repeat.
139
+ - **Section types** (`SECTION_TYPES`, each a list of typed fields a builder can turn into a form):
140
+ `hero_slider`, `featured`†, `categories`†, `promo_banner`, `trust_badges`, `cta_cards`, `testimonials`,
141
+ `faq`, `rich_text`, `usp_strip`, `brand_strip`, `newsletter`, `gallery`. † rendered from the app's `slots`.
142
+ - **Safe by construction:** links must be http(s), `mailto:`, `tel:`, `/path` or `#fragment`; icons are names
143
+ (`ICON_NAMES`, drawn from the package's own SVGs); images are `{ media_id }` refs the app resolves;
144
+ rich text keeps an allowlist (`sanitizeRichText`) on save **and** on render.
145
+ - **Theme:** `{ preset, primary, accent, font, radius, mode, logo, favicon }`. `themeVars(theme)` returns the
146
+ CSS variables for the page root plus `palette.primary` / `palette.accent` (50…950 from one colour, the
147
+ colour itself at 600) to map onto the app's own tokens. `validateTheme` warns when button text or links
148
+ would fall under WCAG contrast. Six `THEME_PRESETS` and six `FONT_PAIRS` (with the stylesheet to load).
149
+ - **HeroSlider:** swipe, arrow keys, always-visible 44px controls, pause/play; autoplay stops on hover,
150
+ focus and `prefers-reduced-motion`; the first slide's heading is the page's only `h1`.
151
+ - `newSection(type)` / `sectionDefaults(type)` start a section with valid defaults; `defaultPage({ name, tagline })`
152
+ is a neutral starting page.
153
+
111
154
  ### `<AuthShell>` & `@dreamtree-org/landing-kit/auth`
112
155
 
113
156
  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`).
@@ -179,6 +222,24 @@ export const GET = () => demoAccountsResponse({ accounts: [...] });
179
222
  | `DEMO_ACCOUNTS=1` | turn the endpoint on (off by default; leave it off in production) |
180
223
  | `DEMO_ACCOUNTS_JSON` | `[{ label, identifier, password, company? }]`, replaces the built-in list (accounts a deployment created itself) |
181
224
 
225
+ #### The `default` demo company (every pro app)
226
+
227
+ Every pro app seeds a company with slug **`default`** — its branches and **one user per role** —
228
+ so the whole product can be demoed end to end, and the chips list exactly those users:
229
+
230
+ ```js
231
+ const { demoUsers, demoAccountsExpress, requireDemoPassword } = require('@dreamtree-org/landing-kit/demo-server');
232
+ const manifest = [{ role: 'tenant-admin', label: 'Admin' }, { role: 'viewer', label: 'Viewer' }];
233
+
234
+ // demo:seed (idempotent): create `default`, upsert each user, password = requireDemoPassword()
235
+ // endpoint: one chip per role, emails demoEmail(product, role) → tenant-admin@taskpro.demo.test
236
+ app.get('/api/auth/demo-accounts', demoAccountsExpress({ accounts: (env) => demoUsers(manifest, { product: 'taskpro', env }) }));
237
+ ```
238
+
239
+ | Env | Meaning |
240
+ |---|---|
241
+ | `DEMO_PASSWORD` | the demo users' password (≥ 12 chars); `demo:seed` resets every demo user to it, the chips show it. Unset → no chips |
242
+
182
243
  Company-first logins pick the chip on the company step: call `stashDemoFill(account)` there and
183
244
  `takeDemoFill()` when the password step mounts (sessionStorage, read once). Outside AuthShell, render
184
245
  `<DemoAccounts load onSelect />` behind `useTapReveal()` yourself.
package/demo-server.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface DemoAccountsOptions {
6
6
  /** The product's own gate (e.g. not a production tenant). DEMO_ACCOUNTS=1 is required as well. */
7
7
  enabled?: boolean | undefined;
8
8
  /** Built-in list, or a function of env (read seed passwords from the same vars the seed script uses). */
9
- accounts?: DemoAccount[] | ((env: Env) => DemoAccount[]) | undefined;
9
+ accounts?: DemoAccount[] | ((env: Env) => DemoAccount[] | Promise<DemoAccount[]>) | undefined;
10
10
  env?: Env | undefined;
11
11
  }
12
12
 
@@ -16,9 +16,26 @@ export type DemoAccountsResult =
16
16
 
17
17
  export declare const DEMO_ACCOUNTS_HEADERS: Record<string, string>;
18
18
  export declare function demoAccountsEnabled(opts?: Pick<DemoAccountsOptions, 'enabled' | 'env'>): boolean;
19
- /** 404 unless enabled and DEMO_ACCOUNTS=1; DEMO_ACCOUNTS_JSON replaces `accounts`. */
20
- export declare function resolveDemoAccounts(opts?: DemoAccountsOptions): DemoAccountsResult;
19
+ /** 404 unless enabled and DEMO_ACCOUNTS=1; DEMO_ACCOUNTS_JSON replaces `accounts`. A Promise when `accounts` is async. */
20
+ export declare function resolveDemoAccounts(opts?: DemoAccountsOptions): DemoAccountsResult | Promise<DemoAccountsResult>;
21
21
  /** Express handler for `GET /api/auth/demo-accounts`. */
22
22
  export declare function demoAccountsExpress(opts?: DemoAccountsOptions): (req: any, res: any) => void;
23
23
  /** Fetch-API Response for a Next.js route handler. */
24
- export declare function demoAccountsResponse(opts?: DemoAccountsOptions): Response;
24
+ export declare function demoAccountsResponse(opts?: DemoAccountsOptions): Response | Promise<Response>;
25
+
26
+ /** One row per role in a product's demo manifest. */
27
+ export interface DemoRole {
28
+ role: string;
29
+ label: string;
30
+ /** Defaults to demoEmail(product, role). */
31
+ email?: string | undefined;
32
+ }
33
+ /** Slug of the shared demo company every pro app seeds: 'default'. */
34
+ export declare const DEMO_COMPANY: 'default';
35
+ export declare const DEMO_PASSWORD_MIN: number;
36
+ /** `${role}@${product}.demo.test` (lower-case, dashes). */
37
+ export declare function demoEmail(product: string, role: string): string;
38
+ /** Manifest → chips in the `default` company with env.DEMO_PASSWORD; [] when DEMO_PASSWORD is unset. */
39
+ export declare function demoUsers(manifest: DemoRole[], opts: { product: string; env?: Env | undefined; company?: string | undefined }): DemoAccount[];
40
+ /** Throws unless DEMO_PASSWORD is set and at least DEMO_PASSWORD_MIN characters; returns it. */
41
+ export declare function requireDemoPassword(env?: Env): string;
@@ -20,9 +20,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  var demo_server_exports = {};
21
21
  __export(demo_server_exports, {
22
22
  DEMO_ACCOUNTS_HEADERS: () => DEMO_ACCOUNTS_HEADERS,
23
+ DEMO_COMPANY: () => DEMO_COMPANY,
24
+ DEMO_PASSWORD_MIN: () => DEMO_PASSWORD_MIN,
23
25
  demoAccountsEnabled: () => demoAccountsEnabled,
24
26
  demoAccountsExpress: () => demoAccountsExpress,
25
27
  demoAccountsResponse: () => demoAccountsResponse,
28
+ demoEmail: () => demoEmail,
29
+ demoUsers: () => demoUsers,
30
+ requireDemoPassword: () => requireDemoPassword,
26
31
  resolveDemoAccounts: () => resolveDemoAccounts
27
32
  });
28
33
  module.exports = __toCommonJS(demo_server_exports);
@@ -47,29 +52,70 @@ function clean(accounts) {
47
52
  function demoAccountsEnabled({ enabled = true, env = process.env } = {}) {
48
53
  return Boolean(enabled) && env.DEMO_ACCOUNTS === "1";
49
54
  }
50
- function resolveDemoAccounts({ enabled = true, accounts = [], env = process.env } = {}) {
51
- if (!demoAccountsEnabled({ enabled, env })) return { status: 404, body: { error: "Not found" } };
52
- const list = clean(fromEnvJson(env.DEMO_ACCOUNTS_JSON) ?? (typeof accounts === "function" ? accounts(env) : accounts));
55
+ function result(accounts) {
56
+ if (!accounts) return { status: 404, body: { error: "Not found" } };
57
+ const list = clean(accounts);
53
58
  if (!list.length) return { status: 404, body: { error: "Not found" } };
54
59
  return { status: 200, body: { accounts: list } };
55
60
  }
61
+ function resolveDemoAccounts({ enabled = true, accounts = [], env = process.env } = {}) {
62
+ const on = demoAccountsEnabled({ enabled, env });
63
+ if (!on) return result(null);
64
+ const override = fromEnvJson(env.DEMO_ACCOUNTS_JSON);
65
+ if (override) return result(override);
66
+ const list = typeof accounts === "function" ? accounts(env) : accounts;
67
+ return list && typeof list.then === "function" ? list.then((l) => result(l || []), () => result(null)) : result(list || []);
68
+ }
56
69
  var DEMO_ACCOUNTS_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
57
70
  function demoAccountsExpress(opts = {}) {
58
- return (req, res) => {
59
- const { status, body } = resolveDemoAccounts(opts);
71
+ const send = (res, { status, body }) => {
60
72
  res.set(DEMO_ACCOUNTS_HEADERS);
61
73
  res.status(status).json(body);
62
74
  };
75
+ return (req, res) => {
76
+ const r = resolveDemoAccounts(opts);
77
+ if (typeof r.then === "function") return r.then((x) => send(res, x));
78
+ send(res, r);
79
+ };
63
80
  }
64
81
  function demoAccountsResponse(opts = {}) {
65
- const { status, body } = resolveDemoAccounts(opts);
66
- return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...DEMO_ACCOUNTS_HEADERS } });
82
+ const toResponse = ({ status, body }) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...DEMO_ACCOUNTS_HEADERS } });
83
+ const r = resolveDemoAccounts(opts);
84
+ return typeof r.then === "function" ? r.then(toResponse) : toResponse(r);
85
+ }
86
+ var DEMO_COMPANY = "default";
87
+ var DEMO_PASSWORD_MIN = 12;
88
+ function demoEmail(product, role) {
89
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
90
+ return `${slug(role)}@${slug(product)}.demo.test`;
91
+ }
92
+ function demoUsers(manifest, { product, env = process.env, company = DEMO_COMPANY } = {}) {
93
+ const password = env.DEMO_PASSWORD;
94
+ if (!password) return [];
95
+ return (manifest || []).map((m) => ({
96
+ label: m.label,
97
+ identifier: m.email || demoEmail(product, m.role),
98
+ password,
99
+ company,
100
+ role: m.role
101
+ }));
102
+ }
103
+ function requireDemoPassword(env = process.env) {
104
+ const p = env.DEMO_PASSWORD;
105
+ if (!p) throw new Error("DEMO_PASSWORD is not set: demo:seed gives every demo user this password (the login chips show it).");
106
+ if (p.length < DEMO_PASSWORD_MIN) throw new Error(`DEMO_PASSWORD must be at least ${DEMO_PASSWORD_MIN} characters.`);
107
+ return p;
67
108
  }
68
109
  // Annotate the CommonJS export names for ESM import in node:
69
110
  0 && (module.exports = {
70
111
  DEMO_ACCOUNTS_HEADERS,
112
+ DEMO_COMPANY,
113
+ DEMO_PASSWORD_MIN,
71
114
  demoAccountsEnabled,
72
115
  demoAccountsExpress,
73
116
  demoAccountsResponse,
117
+ demoEmail,
118
+ demoUsers,
119
+ requireDemoPassword,
74
120
  resolveDemoAccounts
75
121
  });
@@ -20,28 +20,69 @@ function clean(accounts) {
20
20
  function demoAccountsEnabled({ enabled = true, env = process.env } = {}) {
21
21
  return Boolean(enabled) && env.DEMO_ACCOUNTS === "1";
22
22
  }
23
- function resolveDemoAccounts({ enabled = true, accounts = [], env = process.env } = {}) {
24
- if (!demoAccountsEnabled({ enabled, env })) return { status: 404, body: { error: "Not found" } };
25
- const list = clean(fromEnvJson(env.DEMO_ACCOUNTS_JSON) ?? (typeof accounts === "function" ? accounts(env) : accounts));
23
+ function result(accounts) {
24
+ if (!accounts) return { status: 404, body: { error: "Not found" } };
25
+ const list = clean(accounts);
26
26
  if (!list.length) return { status: 404, body: { error: "Not found" } };
27
27
  return { status: 200, body: { accounts: list } };
28
28
  }
29
+ function resolveDemoAccounts({ enabled = true, accounts = [], env = process.env } = {}) {
30
+ const on = demoAccountsEnabled({ enabled, env });
31
+ if (!on) return result(null);
32
+ const override = fromEnvJson(env.DEMO_ACCOUNTS_JSON);
33
+ if (override) return result(override);
34
+ const list = typeof accounts === "function" ? accounts(env) : accounts;
35
+ return list && typeof list.then === "function" ? list.then((l) => result(l || []), () => result(null)) : result(list || []);
36
+ }
29
37
  var DEMO_ACCOUNTS_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
30
38
  function demoAccountsExpress(opts = {}) {
31
- return (req, res) => {
32
- const { status, body } = resolveDemoAccounts(opts);
39
+ const send = (res, { status, body }) => {
33
40
  res.set(DEMO_ACCOUNTS_HEADERS);
34
41
  res.status(status).json(body);
35
42
  };
43
+ return (req, res) => {
44
+ const r = resolveDemoAccounts(opts);
45
+ if (typeof r.then === "function") return r.then((x) => send(res, x));
46
+ send(res, r);
47
+ };
36
48
  }
37
49
  function demoAccountsResponse(opts = {}) {
38
- const { status, body } = resolveDemoAccounts(opts);
39
- return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...DEMO_ACCOUNTS_HEADERS } });
50
+ const toResponse = ({ status, body }) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...DEMO_ACCOUNTS_HEADERS } });
51
+ const r = resolveDemoAccounts(opts);
52
+ return typeof r.then === "function" ? r.then(toResponse) : toResponse(r);
53
+ }
54
+ var DEMO_COMPANY = "default";
55
+ var DEMO_PASSWORD_MIN = 12;
56
+ function demoEmail(product, role) {
57
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
58
+ return `${slug(role)}@${slug(product)}.demo.test`;
59
+ }
60
+ function demoUsers(manifest, { product, env = process.env, company = DEMO_COMPANY } = {}) {
61
+ const password = env.DEMO_PASSWORD;
62
+ if (!password) return [];
63
+ return (manifest || []).map((m) => ({
64
+ label: m.label,
65
+ identifier: m.email || demoEmail(product, m.role),
66
+ password,
67
+ company,
68
+ role: m.role
69
+ }));
70
+ }
71
+ function requireDemoPassword(env = process.env) {
72
+ const p = env.DEMO_PASSWORD;
73
+ if (!p) throw new Error("DEMO_PASSWORD is not set: demo:seed gives every demo user this password (the login chips show it).");
74
+ if (p.length < DEMO_PASSWORD_MIN) throw new Error(`DEMO_PASSWORD must be at least ${DEMO_PASSWORD_MIN} characters.`);
75
+ return p;
40
76
  }
41
77
  export {
42
78
  DEMO_ACCOUNTS_HEADERS,
79
+ DEMO_COMPANY,
80
+ DEMO_PASSWORD_MIN,
43
81
  demoAccountsEnabled,
44
82
  demoAccountsExpress,
45
83
  demoAccountsResponse,
84
+ demoEmail,
85
+ demoUsers,
86
+ requireDemoPassword,
46
87
  resolveDemoAccounts
47
88
  };