@dreamtree-org/landing-kit 0.7.2 → 0.9.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/platform.d.ts ADDED
@@ -0,0 +1,342 @@
1
+ // @dreamtree-org/landing-kit/platform: client for the Platform console HTTP contract.
2
+ // The contract itself (paths, bodies, step-up rules) is documented in PLATFORM-API.md.
3
+
4
+ export const PLATFORM_API_BASE: '/api/platform';
5
+ export const PLATFORM_LOGIN_PATH: '/platform/login';
6
+ export const STEP_UP_REQUIRED: 'STEP_UP_REQUIRED';
7
+
8
+ export type PlatformFeature = 'overview' | 'companies' | 'jobs' | 'plans' | 'audit' | 'support' | 'settings' | 'admins';
9
+ /** Every console feature, in nav order. */
10
+ export const PLATFORM_FEATURES: readonly PlatformFeature[];
11
+ /** Features a server without `GET /me` serves. */
12
+ export const SHIPPED_FEATURES: readonly PlatformFeature[];
13
+
14
+ export class PlatformApiError extends Error {
15
+ constructor(message: string, status: number, code?: string, body?: unknown);
16
+ /** HTTP status; 0 when the admin cancelled a step-up. */
17
+ status: number;
18
+ /** `STEP_UP_REQUIRED`, `FORBIDDEN`, `CANCELLED`, … */
19
+ code?: string | undefined;
20
+ body?: unknown;
21
+ }
22
+
23
+ export function isStepUpRequired(err: unknown): err is PlatformApiError;
24
+ /** The named cookie, or the first `<prefix>_platform_csrf` when no name is given. */
25
+ export function readPlatformCsrf(cookieString: string | null | undefined, name?: string): string | null;
26
+
27
+ export type Query = Record<string, string | number | boolean | null | undefined>;
28
+
29
+ export interface PlatformRequestInit extends Omit<RequestInit, 'body' | 'method'> {
30
+ method?: string;
31
+ json?: unknown;
32
+ query?: Query;
33
+ body?: BodyInit | null;
34
+ /** Throw on 401 without calling onUnauthorized (used by step-up confirm). */
35
+ skipAuthRedirect?: boolean;
36
+ }
37
+
38
+ export interface PlatformClientOptions {
39
+ /** Where the contract is mounted. Default `/api/platform`. */
40
+ basePath?: string;
41
+ /** Where a 401 sends the browser. Default `/platform/login`. */
42
+ loginPath?: string;
43
+ /** Exact CSRF cookie name, e.g. `pp_platform_csrf`. Default: any `*_platform_csrf` cookie. */
44
+ csrfCookie?: string;
45
+ fetch?: typeof fetch;
46
+ /** Cookie source; default `document.cookie`. */
47
+ cookies?: () => string;
48
+ /** Default: `window.location.assign(loginPath)`. */
49
+ onUnauthorized?: (err: PlatformApiError) => void;
50
+ }
51
+
52
+ export interface PlatformClient {
53
+ readonly basePath: string;
54
+ readonly loginPath: string;
55
+ url(path: string, query?: Query): string;
56
+ /** Raw Response with the CSRF header added (CSV export, downloads). */
57
+ raw(path: string, init?: PlatformRequestInit): Promise<Response>;
58
+ request<T = unknown>(path: string, init?: PlatformRequestInit): Promise<T>;
59
+ get<T = unknown>(path: string, query?: Query, init?: PlatformRequestInit): Promise<T>;
60
+ post<T = unknown>(path: string, json?: unknown, init?: PlatformRequestInit): Promise<T>;
61
+ patch<T = unknown>(path: string, json?: unknown, init?: PlatformRequestInit): Promise<T>;
62
+ put<T = unknown>(path: string, json?: unknown, init?: PlatformRequestInit): Promise<T>;
63
+ del<T = unknown>(path: string, init?: PlatformRequestInit): Promise<T>;
64
+ }
65
+
66
+ export function createPlatformClient(opts?: PlatformClientOptions): PlatformClient;
67
+
68
+ export type StepUpSecret = { password: string } | { code: string };
69
+ /**
70
+ * Wraps sensitive actions: on STEP_UP_REQUIRED calls `ask` (with the 403, or the 401 of a wrong
71
+ * secret), POSTs `auth/confirm`, retries once. `ask` resolving null cancels (code `CANCELLED`).
72
+ */
73
+ export function createStepUp(
74
+ client: PlatformClient,
75
+ ask: (reason: PlatformApiError) => Promise<StepUpSecret | null>
76
+ ): <T>(action: () => Promise<T>) => Promise<T>;
77
+
78
+ /** Features to show: those `me.features` advertises, else `SHIPPED_FEATURES`. Overview, companies, admins are always on. */
79
+ export function enabledFeatures(requested?: readonly PlatformFeature[], me?: Pick<PlatformMe, 'features'> | null): PlatformFeature[];
80
+
81
+ export function formatDate(value: string | Date | null | undefined): string;
82
+ export function timeAgo(value: string | Date | null | undefined, now?: number): string;
83
+
84
+ // ── Contract shapes (PLATFORM-API.md) ────────────────────────────────────────
85
+
86
+ export type Iso = string;
87
+ export type CompanyStatus = 'draft' | 'provisioning' | 'provisioning_failed' | 'active' | 'maintenance' | 'suspended' | 'archived';
88
+
89
+ export interface PlatformMe {
90
+ admin: { id: string; email: string; roles: string[]; totpEnrolled: boolean };
91
+ permissions: string[];
92
+ /** `console` false = enrollment-only session (enroll an authenticator first). */
93
+ access?: { console: boolean; stepUpFresh: boolean };
94
+ features: Partial<Record<PlatformFeature | 'domains', boolean>>;
95
+ app: { key: string; name: string; companyLoginPath?: string };
96
+ }
97
+
98
+ export interface LoginResponse {
99
+ ok: true;
100
+ mfa?: 'none' | 'required' | 'enroll';
101
+ ticket?: string;
102
+ needsFactorEnrollment: boolean;
103
+ }
104
+
105
+ export interface PlatformAdmin {
106
+ id: string;
107
+ email: string;
108
+ status: 'active' | 'disabled';
109
+ roles?: string[];
110
+ totpEnrolled?: boolean;
111
+ lastLoginAt: Iso | null;
112
+ createdAt: Iso;
113
+ }
114
+
115
+ export interface CompanyRow {
116
+ id: string;
117
+ slug: string;
118
+ displayName: string;
119
+ status: CompanyStatus;
120
+ createdAt: Iso;
121
+ branchCount: number;
122
+ dbMode: 'managed' | 'external' | null;
123
+ dbStatus: string | null;
124
+ schemaVersion: string | number | null;
125
+ lastCheckAt: Iso | null;
126
+ lastCheckOk: 0 | 1 | null;
127
+ }
128
+
129
+ export interface CompanyDetail {
130
+ id: string;
131
+ slug: string;
132
+ displayName: string;
133
+ status: CompanyStatus;
134
+ defaultBranchId: string | null;
135
+ configVersion: number;
136
+ createdAt: Iso;
137
+ updatedAt: Iso;
138
+ branches?: Array<{ id: string; code: string; name: string; status: string }>;
139
+ [key: string]: unknown;
140
+ }
141
+
142
+ export interface CompanyUser {
143
+ id: string;
144
+ fullName: string;
145
+ email: string;
146
+ phone?: string | null;
147
+ status: 'active' | 'disabled' | string;
148
+ lastLoginAt?: Iso | null;
149
+ }
150
+
151
+ export interface AuditEvent {
152
+ id: string;
153
+ at: Iso;
154
+ actorEmail: string | null;
155
+ action: string;
156
+ companyId: string | null;
157
+ companyName?: string | null;
158
+ target: string | null;
159
+ diff?: unknown;
160
+ }
161
+
162
+ export interface ProvisioningJob {
163
+ id: string;
164
+ companyId: string;
165
+ status: 'queued' | 'running' | 'succeeded' | 'failed' | string;
166
+ step?: string | null;
167
+ error?: string | null;
168
+ createdAt: Iso;
169
+ updatedAt?: Iso;
170
+ }
171
+
172
+ export interface OverviewResponse {
173
+ counts: { total: number; active: number; setup: number; suspended: number; archived: number; failedJobs: number; dbFailures: number };
174
+ attention: CompanyRow[];
175
+ recent: AuditEvent[];
176
+ provisioner: { ok: boolean; configured: boolean; principal?: string; error?: string };
177
+ }
178
+
179
+ export interface Plan {
180
+ id: string;
181
+ code: string;
182
+ name: string;
183
+ status: 'active' | 'archived';
184
+ priceMinor: number;
185
+ currency: string;
186
+ interval: 'month' | 'year' | 'one-time';
187
+ seats?: number | null;
188
+ limits?: Record<string, number | null>;
189
+ features?: string[];
190
+ }
191
+
192
+ export interface Subscription {
193
+ id: string;
194
+ companyId: string;
195
+ planId: string;
196
+ status: 'trial' | 'active' | 'past_due' | 'cancelled' | 'expired';
197
+ startsAt: Iso;
198
+ endsAt: Iso | null;
199
+ seats?: number | null;
200
+ notes?: string | null;
201
+ }
202
+
203
+ export interface SettingsField {
204
+ key: string;
205
+ label: string;
206
+ type: 'text' | 'number' | 'boolean' | 'select' | 'secret' | 'email' | 'url';
207
+ options?: string[];
208
+ /** Secret values come back masked (`set: true`), never in clear. */
209
+ value?: unknown;
210
+ set?: boolean;
211
+ help?: string;
212
+ }
213
+ export interface SettingsFamily {
214
+ family: string;
215
+ label: string;
216
+ scope: 'platform' | 'company';
217
+ fields: SettingsField[];
218
+ }
219
+
220
+ export interface SupportGrant {
221
+ id: string;
222
+ companyId: string;
223
+ companyName?: string;
224
+ requestedBy: string;
225
+ approvedBy?: string | null;
226
+ reason: string;
227
+ status: 'requested' | 'active' | 'expired' | 'revoked';
228
+ expiresAt: Iso | null;
229
+ createdAt: Iso;
230
+ }
231
+
232
+ export interface CompanyDomain {
233
+ id: string;
234
+ host: string;
235
+ primary: boolean;
236
+ verifiedAt: Iso | null;
237
+ verification?: { type: 'TXT'; name: string; value: string };
238
+ }
239
+
240
+ // ── React console (needs @dreamtree-org/twreact-ui ^2 and Tailwind scanning dist/platform.*) ──
241
+
242
+ import type { ComponentType, ElementType, ReactNode } from 'react';
243
+ import type { Brand, LegalLink } from './index.js';
244
+
245
+ export interface PlatformPage {
246
+ /** Below the console base path, e.g. "jobs". Sub-paths are passed to `render` as `path`. */
247
+ path: string;
248
+ label: string;
249
+ icon?: ReactNode;
250
+ /** Nav group heading. */
251
+ group?: string;
252
+ /** Hide unless GET /me advertises this feature (e.g. "jobs"). */
253
+ feature?: PlatformFeature;
254
+ /** Hide unless the admin holds this permission. */
255
+ permission?: string;
256
+ render(ctx: { client: PlatformClient; path: string; navigate(href: string): void; href(rel?: string): string; me: PlatformMe | null | undefined }): ReactNode;
257
+ }
258
+
259
+ export interface PlatformCompanyTab {
260
+ id: string;
261
+ label: string;
262
+ render(ctx: { company: CompanyDetail; reload(): Promise<void>; client: PlatformClient; sensitive: <T>(action: () => Promise<T>) => Promise<T> }): ReactNode;
263
+ }
264
+
265
+ export interface PlatformConsoleProps {
266
+ /** From toLandingBrand(dreamtree.brand.json). */
267
+ brand: Brand;
268
+ appName?: string;
269
+ /** Where the console pages live. Default `/platform`. */
270
+ basePath?: string;
271
+ /** Where the API lives. Default `/api/platform`. */
272
+ apiBasePath?: string;
273
+ client?: PlatformClient;
274
+ /** Router integration (Next: usePathname() / router.push / next/link). Without them: window.location + history. */
275
+ pathname?: string;
276
+ navigate?: (href: string) => void;
277
+ linkAs?: ElementType;
278
+ /** Features to offer; each also needs the server to advertise it (GET /me). */
279
+ features?: readonly PlatformFeature[];
280
+ /** Product pages, e.g. the background-jobs console. */
281
+ pages?: PlatformPage[];
282
+ /** Extra tabs on the company page. */
283
+ companyTabs?: PlatformCompanyTab[];
284
+ /** "/c/:slug/login" or (slug) => path. Default: GET /me app.companyLoginPath, else "/c/:slug/login". */
285
+ companyLoginPath?: string | ((slug: string) => string);
286
+ adminEmail?: string;
287
+ /** Session resolved on the server (e.g. a Next layout) so the shell renders without a flash. */
288
+ initialMe?: PlatformMe | null;
289
+ /** Default `${basePath}/login`. */
290
+ loginPath?: string;
291
+ onSignOut?: () => void;
292
+ }
293
+ export const PlatformConsole: ComponentType<PlatformConsoleProps>;
294
+ export function matchConsoleRoute(rel: string, pages?: PlatformPage[]): { view: string; id?: string; page?: PlatformPage; rest?: string };
295
+
296
+ export interface PlatformLoginProps {
297
+ brand: Brand;
298
+ basePath?: string;
299
+ client?: PlatformClient;
300
+ /** Default: window.location.assign(redirectTo). */
301
+ onSignedIn?: (res: LoginResponse) => void;
302
+ redirectTo?: string;
303
+ title?: string;
304
+ subtitle?: string;
305
+ linkComponent?: ElementType;
306
+ legalLinks?: LegalLink[];
307
+ }
308
+ export const PlatformLogin: ComponentType<PlatformLoginProps>;
309
+
310
+ export const PlatformShell: ComponentType<{
311
+ brand?: Brand;
312
+ appName?: string;
313
+ nav: Array<{ id: string; label: string; href?: string; icon?: ReactNode; active?: boolean; group?: string }>;
314
+ adminEmail?: string;
315
+ onSignOut?: () => void;
316
+ linkAs?: ElementType;
317
+ pageTitle?: string;
318
+ children?: ReactNode;
319
+ }>;
320
+ export const StepUpProvider: ComponentType<{ client: PlatformClient; totpEnrolled?: boolean; children?: ReactNode }>;
321
+ export function useSensitive(): <T>(action: () => Promise<T>) => Promise<T>;
322
+ export function usePlatform(): {
323
+ client: PlatformClient;
324
+ me: PlatformMe | null | undefined;
325
+ reloadMe(): Promise<void>;
326
+ Link: ElementType;
327
+ href(rel?: string): string;
328
+ navigate(href: string): void;
329
+ can(permission: string): boolean;
330
+ companyLoginPath(slug: string): string;
331
+ companyLoginUrl(slug: string): string;
332
+ };
333
+ export function useLoad<T = any>(path: string | null, query?: Query): { data: T | null; error: PlatformApiError | null; loading: boolean; reload(): Promise<void>; setData(data: T): void };
334
+ export const StatusPill: ComponentType<{ status?: string | null }>;
335
+ export const ModePill: ComponentType<{ mode?: string | null }>;
336
+ export const HealthDot: ComponentType<{ ok?: boolean | null }>;
337
+ export const Card: ComponentType<{ title: ReactNode; actions?: ReactNode; children?: ReactNode }>;
338
+ export const Facts: ComponentType<{ rows: Array<[string, ReactNode]> }>;
339
+ export const CopyButton: ComponentType<{ value: string; label?: string }>;
340
+ export const OneTimeSecret: ComponentType<{ title?: string; loginUrl?: string; email: string; password: string; onDone?: () => void }>;
341
+ export const InspectResult: ComponentType<{ report: Record<string, any> }>;
342
+ export function describeAction(action: string): string;
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>;