@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 +12 -0
- package/README.md +61 -0
- package/demo-server.d.ts +21 -4
- package/dist/demo-server.cjs +53 -7
- package/dist/demo-server.js +48 -7
- package/dist/index.cjs +1065 -52
- package/dist/index.js +1065 -52
- package/dist/schema.cjs +728 -0
- package/dist/schema.js +676 -0
- package/index.d.ts +29 -0
- package/package.json +11 -3
- package/schema.d.ts +96 -0
- package/styles.css +122 -0
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;
|
package/dist/demo-server.cjs
CHANGED
|
@@ -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
|
|
51
|
-
if (!
|
|
52
|
-
const list = clean(
|
|
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
|
-
|
|
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 }
|
|
66
|
-
|
|
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
|
});
|
package/dist/demo-server.js
CHANGED
|
@@ -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
|
|
24
|
-
if (!
|
|
25
|
-
const list = clean(
|
|
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
|
-
|
|
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 }
|
|
39
|
-
|
|
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
|
};
|