@dreamtree-org/landing-kit
v0.7.0
Published
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.
Downloads
700
Maintainers
Readme
@dreamtree-org/landing-kit
The shared marketing landing pages and authentication frame (<AuthShell>, brands, getBrand) for every Dreamtree product: replaces the landing setup copied across billpro, taskpro and venturepro (page chrome, sections, "Book a demo" dialog, SEO files, Express routes and prerender) and the login/signup/reset/platform-admin frame in one package.
- Two facades, both taking the product's brand from its own repo (
dreamtree.brand.json, twreact-ui's brand format) throughtoLandingBrand():<LandingPage brand={brand} content={content} />— a product's landing page is a content file.<AuthShell brand={brand} title="Welcome back">…</AuthShell>(also available at@dreamtree-org/landing-kit/auth+@dreamtree-org/landing-kit/auth.css).- The built-in
brands/getBrand(id)registry is deprecated (warns once, removed in 0.5.0).
- Plain CSS (
--dt-*custom properties). It looks the same under Tailwind 3, Tailwind 4 (preflight or not) or no Tailwind. Dark mode via[data-theme="dark"]or.dark. - Built for prerendered, un-hydrated pages (fast and crawlable):
enhanceLanding()wires the menu and the demo button in a few hundred bytes. It also works hydrated in an SPA or Next.js ('use client',onBookDemo,linkComponent). - Responsive from 320px up. The nav folds into a menu below 1024px. Checked with Playwright at 320/375/768/1024/1440, light and dark.
60-second start
npm install @dreamtree-org/landing-kit// Landing.jsx
import { LandingPage, toLandingBrand } from '@dreamtree-org/landing-kit';
import brandFile from '../dreamtree.brand.json'; // the product's brand, kept in its repo
import '@dreamtree-org/landing-kit/styles.css';
import { content } from './content';
export default function Landing() {
return <LandingPage brand={toLandingBrand(brandFile, { headline: 'Billing, inventory and GST — done right.' })} content={content} />;
}// content.js: plain data (icons are any component, e.g. lucide-react)
import { FileText, Barcode } from 'lucide-react';
export const content = {
nav: [{ label: 'Billing', href: '#billing' }, { label: 'FAQ', href: '#faq' }],
hero: {
eyebrow: 'GST billing software · Inventory',
title: ['GST Billing & Inventory Software', 'for Indian Businesses'], // 2nd half is underlined
lead: 'Create GST invoices, manage stock and prepare GST summaries in one app.',
chips: ['Retail shops', 'Wholesalers'],
secondary: { label: 'See it in action', href: '#product' },
},
sections: [
{ id: 'billing', eyebrow: 'Billing', title: 'GST Billing Software', body: '…',
items: [{ title: 'GST tax invoices', body: '…', icon: FileText }, { title: 'Barcode billing', body: '…', icon: Barcode }],
cta: { label: 'Book a billing demo', demo: true } },
{ id: 'gst', variant: 'highlight', title: 'GSTR-1 & GSTR-3B reports', note: 'Summaries, not portal filing.', items: ['GSTR-1', 'GSTR-3B'] },
{ use: 'audiences' }, // place a tail block here instead of at the end
],
audiences: { id: 'industries', title: 'Who it is for', items: [{ id: 'retail', title: 'Retail shops', body: '…', href: '/solutions/retail' }] },
steps: { title: 'How it works', steps: [{ name: 'Set up', text: '…' }, { name: 'Bill', text: '…' }, { name: 'Review', text: '…' }] },
faq: { title: 'Frequently asked questions', items: [{ q: 'What is BillPro?', a: '…' }] },
cta: { title: 'See BillPro with your business', body: '…', chips: ['Retail', 'Wholesale'] },
footer: { blurb: '…', tagline: 'Bill fast.', columns: [{ title: 'Product', links: [{ label: 'Log in', href: '/login', reload: true }] }] },
};Then prerender it and serve it (Express recipe below), or render it in your SPA / Next page.
Reference
toLandingBrand(file, copy?)
Maps the product's dreamtree.brand.json (create it with npx @dreamtree-org/twreact-ui brand init) to a Brand, so the landing and sign-in pages share the app's colours:
| Brand field | From the brand file |
|---|---|
| id, name | id, name |
| primary | marketing.primary, else color.primary.600 |
| accent | marketing.accent, else color.primary.300 |
| panel | marketing.panel, else role.shell |
| logoSrc | logo.mark |
copy is the product's content (headline, pitch, points, category, platformHeadline, platformPitch). It stays in product code, not the brand file.
<LandingPage>
| Prop | Type | Default | Meaning |
|---|---|---|---|
| brand | Brand | required | getBrand(id) (auth-kit's brand objects) or your own { id, name, primary, accent?, logoSrc? } |
| content | LandingContent | required | { nav, login, hero, sections[], audiences, steps, faq, cta, footer }; only hero is required |
| preview | node | | e.g. <PreviewFrame>…your app screen in HTML…</PreviewFrame>, which rides over the hero |
| slots | { [name]: node } | | a product's own sections, placed by { use: '<name>' } in content.sections |
| linkComponent | component | <a> | react-router Link / next/link for in-app paths; gets to and href |
| onBookDemo | (button) => void | | when hydrated; un-hydrated pages use enhanceLanding |
| home | boolean | true | false on sub-pages: #faq links become /#faq |
content.sections[] render in order after the hero. Each one is FeatureSection props, or
{ use: 'audiences' | 'steps' | 'faq' | 'cta' | 'preview' | <slot name> }, or a React element. audiences, steps, faq and
cta that are not placed with use follow the sections in that order. login: null hides "Log in".
Blocks (all exported, all usable on their own)
| Block | Purpose |
|---|---|
| MarketingLayout | skip link + sticky Header + <main> + DreamtreeFooter; sets brand colours and context. Use it for sub-pages |
| Header | brand lockup, nav, "Log in", "Book a demo", phone menu (data-menu-toggle, #mobile-nav) |
| Hero / PageHero + Breadcrumbs | the page's one h1 (#hero-title); PageHero adds breadcrumbs and #answer for sub-pages |
| PreviewFrame | window chrome + "Demo data" badge + hidden heading/caption around your product screen |
| FeatureSection | variant: cards (default) · split · list · intro · highlight (dark band + warning note) · band · compare · links · detail; tone: plain · tinted · muted · dark |
| Audiences | who-it-is-for cards with anchors and links |
| HowItWorks | numbered steps (feed the same steps to the HowTo JSON-LD) |
| Faq | native <details>, every answer in the HTML |
| DemoCta | the closing brand panel |
| DreamtreeFooter | Dreamtree-Org mark, blurb, link columns, copyright bar |
| DemoDialog | native <dialog> demo form: your fields, honeypot, consent + privacy line, onSubmit(data) |
| createDemoIsland(props) | mounts DemoDialog on demand for un-hydrated pages; returns { openDemo } |
| BookDemoButton, BrandLockup, Section, SectionIntro, TextLink, SmartLink | building blocks |
Tenant pages (0.6.0): a store's own landing page as data
For multi-tenant apps where each customer edits their own page (a store front, a clinic, a franchise). The page is a JSON document; the app stores it, the package validates and renders it.
// Server (CommonJS is fine): validate before storing
const { validatePage, validateTheme, sanitizeRichText } = require('@dreamtree-org/landing-kit/schema');
const page = validatePage(req.body.page); // { ok, errors: [{ path, message }], page }
const theme = validateTheme(req.body.theme); // { ok, errors, warnings, theme }
if (!page.ok || !theme.ok) return res.status(422).json({ errors: [...page.errors, ...theme.errors] });// Browser: render
import { TenantPage } from '@dreamtree-org/landing-kit';
import '@dreamtree-org/landing-kit/styles.css';
<TenantPage
page={doc} theme={theme} storeName="TechNext"
slots={{ featured: <FeaturedProducts />, categories: (props) => <CategoryTiles title={props.title} /> }}
resolveMedia={(ref, variant) => `/api/public-media/${ref.media_id}`}
linkComponent={RouterLink}
onSubscribe={({ email, consent }) => api.subscribe(email, consent)}
/>- Document:
{ version: 1, sections: [{ id, type, enabled, props }] }, at most 20 sections, any order, a type may repeat. - Section types (
SECTION_TYPES, each a list of typed fields a builder can turn into a form):hero_slider,featured†,categories†,promo_banner,trust_badges,cta_cards,testimonials,faq,rich_text,usp_strip,brand_strip,newsletter,gallery. † rendered from the app'sslots. - Safe by construction: links must be http(s),
mailto:,tel:,/pathor#fragment; icons are names (ICON_NAMES, drawn from the package's own SVGs); images are{ media_id }refs the app resolves; rich text keeps an allowlist (sanitizeRichText) on save and on render. - Theme:
{ preset, primary, accent, font, radius, mode, logo, favicon }.themeVars(theme)returns the CSS variables for the page root pluspalette.primary/palette.accent(50…950 from one colour, the colour itself at 600) to map onto the app's own tokens.validateThemewarns when button text or links would fall under WCAG contrast. SixTHEME_PRESETSand sixFONT_PAIRS(with the stylesheet to load). - HeroSlider: swipe, arrow keys, always-visible 44px controls, pause/play; autoplay stops on hover,
focus and
prefers-reduced-motion; the first slide's heading is the page's onlyh1. newSection(type)/sectionDefaults(type)start a section with valid defaults;defaultPage({ name, tagline })is a neutral starting page.
<AuthShell> & @dreamtree-org/landing-kit/auth
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).
import { AuthShell, getBrand } from '@dreamtree-org/landing-kit/auth';
import '@dreamtree-org/landing-kit/auth.css';
export default function Login() {
return (
<AuthShell
brand={getBrand('venturepro')}
linkComponent={Link}
title="Welcome back"
subtitle="Sign in to manage your ventures and units."
footer={<>New here? <Link to="/register">Register now</Link></>}
>
{/* your form */}
</AuthShell>
);
}| Prop | Type | Default | Meaning |
|---|---|---|---|
| brand | Brand | required | from getBrand(id) (billpro, taskpro, venturepro, campaignpro, firepro, procurementpro, fitweavepro, aiagentbuilder, runloompro, appforge) or your own object |
| title | node | required | card heading |
| subtitle | node | | under the heading |
| footer | node | | below the card (e.g. "Register now") |
| variant | 'tenant' \| 'platform' | 'tenant' | 'platform' renders a darker panel + "Platform" badge |
| legalLinks | {label, href}[] | Privacy, Terms, Data Policy | [] hides them |
| linkComponent | component | <a> | receives both to and href |
| demoAccounts | { load, onSelect, taps? } | | hidden demo-accounts panel (below) |
Hidden demo accounts (every product, same behaviour)
Tapping the card heading ("Sign in") 4 times quickly reveals a Demo accounts row of role chips under the
form; a chip fills the form (it never submits). Credentials are never bundled: load asks the product's API,
which answers 404 unless the deployment sets DEMO_ACCOUNTS=1, and a 404 renders nothing.
import { AuthShell, fetchDemoAccounts } from '@dreamtree-org/landing-kit/auth';
<AuthShell
brand={brand}
title="Sign in"
demoAccounts={{
load: fetchDemoAccounts, // GET /api/auth/demo-accounts (or fetchDemoAccounts('/other/url'))
onSelect: (a) => { setEmail(a.identifier); setPassword(a.password); if (a.company) setCompany(a.company); },
}}
>Server (@dreamtree-org/landing-kit/demo-server, no dependencies):
const { demoAccountsExpress } = require('@dreamtree-org/landing-kit/demo-server');
app.get('/api/auth/demo-accounts', demoAccountsExpress({
// read passwords from the same env vars the seed script uses, so they never drift
accounts: (env) => [{ label: 'Admin', identifier: '[email protected]', password: env.SEED_PASSWORD || 'demo1234', company: 'demo' }],
}));
// Next.js: app/api/auth/demo-accounts/route.ts
export const GET = () => demoAccountsResponse({ accounts: [...] });| Env | Meaning |
|---|---|
| DEMO_ACCOUNTS=1 | turn the endpoint on (off by default; leave it off in production) |
| DEMO_ACCOUNTS_JSON | [{ label, identifier, password, company? }], replaces the built-in list (accounts a deployment created itself) |
The default demo company (every pro app)
Every pro app seeds a company with slug default — its branches and one user per role —
so the whole product can be demoed end to end, and the chips list exactly those users:
const { demoUsers, demoAccountsExpress, requireDemoPassword } = require('@dreamtree-org/landing-kit/demo-server');
const manifest = [{ role: 'tenant-admin', label: 'Admin' }, { role: 'viewer', label: 'Viewer' }];
// demo:seed (idempotent): create `default`, upsert each user, password = requireDemoPassword()
// endpoint: one chip per role, emails demoEmail(product, role) → [email protected]
app.get('/api/auth/demo-accounts', demoAccountsExpress({ accounts: (env) => demoUsers(manifest, { product: 'taskpro', env }) }));| Env | Meaning |
|---|---|
| DEMO_PASSWORD | the demo users' password (≥ 12 chars); demo:seed resets every demo user to it, the chips show it. Unset → no chips |
Company-first logins pick the chip on the company step: call stashDemoFill(account) there and
takeDemoFill() when the password step mounts (sessionStorage, read once). Outside AuthShell, render
<DemoAccounts load onSelect /> behind useTapReveal() yourself.
@dreamtree-org/landing-kit/enhance
enhanceLanding(root = document, { openDemo | loadDemo, warmDemo, hash = '#book-demo' }): wires
[data-menu-toggle], [data-scroll-to] and [data-book-demo] on a prerendered page. Returns unwire().
@dreamtree-org/landing-kit/seo (pure: browser, Node, Next)
| Function | Returns |
|---|---|
| buildJsonLd({ site, product, org, page, faq, howTo, breadcrumbs, parts, keywords, speakable, lastmod }) | schema.org graph: Organization, WebSite, SoftwareApplication, WebPage, BreadcrumbList, FAQPage, HowTo |
| contentFacts(content) | { faq, howTo, features, sections } from a LandingPage content object, so page and JSON-LD cannot drift |
| buildHead({ site, page, product, jsonLd }) | <title>, description, canonical, OG/Twitter, llms alternate, JSON-LD script |
| buildRobots({ privatePaths, crawlers }) · buildSitemap({ pages, lastmod }) · buildLlmsTxt(…) · buildLlmsFullTxt(…) | crawler files |
| jsonLdScript(graph) · pageUrl(path, site) · escapeHtml | helpers |
site defaults to '__SITE_URL__' (filled in at serve time). Pass a real origin when you have one, or null
to emit a graph without URLs. No ratings, reviews or prices are ever generated; an Offer (with no price) only appears with product.offers.
@dreamtree-org/landing-kit/server (Express, CommonJS-safe, includes everything in /seo)
| Function | Meaning |
|---|---|
| landingRoutes(app, { dist, isPublicHost, hasSession, redirects, onSession, onPrivateHost, trailingSlash, rawFiles, manifest, cacheControl, transformPage }) | serves / (visitors only, Vary: Cookie), every page in the manifest (default landing-pages.json; its paths are relative to its own directory), robots/sitemap/llms with the origin filled in, verification meta tags from env; 404s the raw files (rawFiles: 'redirect' 301s them to their page instead); /x/ → 301 /x (trailingSlash: 'serve' answers both); isPublicHost may be async; a non-public host gets Disallow: / and, at /, next() or the app shell (onPrivateHost: 'app-shell'); transformPage(body, { req, route, file, kind }) may rewrite a page or crawler file per request (e.g. live prices) and falls back to the prerendered body on null or a throw |
| fillPage(template, page) | one page poured into the template, as a string: what prerenderPages writes. Pair it with transformPage to re-render a page with live data |
| prerenderPages({ dist, pages, files, homeFile, pagesDir, manifest }) | build step: fills %TITLE% %DESCRIPTION% %PATH% %JSONLD% %HEAD% <!--landing--> per page, writes landing.html, landing-pages/…, the manifest and crawler files; throws on an unfilled slot before writing anything |
| sessionCheck({ cookie: 'JWT_TOKEN', bearer, verify }) | hasSession(req) from your own JWT check |
| siteUrl(req) · verificationTags(env) · readCookie · fillSite | helpers |
CSS variables
Set by brand: --dt-brand, --dt-brand-accent, --dt-brand-panel. Override on .dt-landing:
--dt-lp-bg, --dt-lp-surface, --dt-lp-muted-bg, --dt-lp-border, --dt-lp-text, --dt-lp-body, --dt-lp-muted,
--dt-lp-radius, --dt-lp-max, --dt-lp-font, --dt-lp-header-h.
Product extension points (0.1.1)
Keep a product's own pictures and sections without forking markup:
| Where | Prop | What it takes |
|---|---|---|
| FeatureSection split · band · highlight | media, reverse | your node (preview, figure, illustration) on the visual side; items become an icon row under the text; reverse puts the visual first |
| FeatureSection | cta | one CtaSpec or an array (side by side) |
| FeatureSection split / detail | bullets | a checklist under the text |
| FeatureSection intro | cta, glanceNote | a CTA under the text; a note inside the "at a glance" card |
| card items | label | a small kicker above the title ("01 · Visibility") |
| Hero | backdrop | a decorative node behind the copy (aria-hidden) |
| Audiences items | media, features | an illustration beside the heading; a short icon list |
| HowItWorks | cta | a CTA under the steps |
| DemoCta | note, link | a "what to tell us" box; a quiet link (e.g. Log in) |
| DemoDialog | validate | (form) => ({ field: message }), inline errors before submit; an Error thrown by onSubmit with fields shows them too |
| landingRoutes | isPublicHost async, onPrivateHost, trailingSlash, rawFiles | see /server above |
Recipes
Express + Vite, prerendered and un-hydrated (billpro / taskpro / venturepro).
// client/src/landing/main.jsx (landing.html's script)
import '@dreamtree-org/landing-kit/styles.css';
import { enhanceLanding } from '@dreamtree-org/landing-kit/enhance';
enhanceLanding(document, { loadDemo: () => import('./demoIsland.jsx') });
// client/src/landing/demoIsland.jsx
import { createDemoIsland, getBrand } from '@dreamtree-org/landing-kit';
export const { openDemo } = createDemoIsland({ brand: getBrand('billpro'), fields, onSubmit: postDemoRequest });
// client/scripts/prerender-landing.mjs, after vite builds landing.html + the SSR entry
const { prerenderPages } = await import('@dreamtree-org/landing-kit/server');
prerenderPages({ dist, pages: mod.renderPages(today), files: { 'robots.txt': robots, 'sitemap.xml': map, 'llms.txt': llms } });
// server.js (before express.static)
const { landingRoutes, sessionCheck } = require('@dreamtree-org/landing-kit/server');
landingRoutes(app, {
dist: path.join(__dirname, 'client/dist'),
isPublicHost: (req) => req.context?.domainKind === 'shared' && req.context?.domainCompanyId == null,
hasSession: sessionCheck({ verify: (t) => tokenService.verify(t).success === true }),
redirects: { '/solutions': '/#solutions' },
});SPA, hydrated. <LandingPage brand={brand} content={content} linkComponent={Link} onBookDemo={() => setOpen(true)} />
and render <DemoDialog open={open} onClose={…} onSubmit={…} brand={brand} />.
Next.js (App Router). The components are 'use client'. For SEO, build in server code:
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(buildJsonLd({ site: origin, … })) }} />,
and in app/robots.ts / app/sitemap.ts use buildRobots / buildSitemap with site: origin, or return Next's metadata objects from the same data.
What it deliberately does NOT do
- No product copy, prices, ratings or customer counts. Every word comes from your content file.
- No product screenshots.
PreviewFrameframes the HTML preview you write (and may style with Tailwind). - No HTTP for the demo form.
onSubmitis yours (kormsubmitDemoRequest, fetch, anything). It does not store consent either. - No Vite/Next build plugin. You keep your own landing.html, SSR entry and build; the package fills and writes the files.
- No icon library. Pass lucide (or any) components.
License
MIT
