@usequeek/theme-kit 0.1.1 → 0.1.2
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/client.ts +4 -0
- package/demo.ts +201 -0
- package/index.ts +3 -0
- package/package.json +1 -1
- package/theme-mount.tsx +88 -0
package/client.ts
CHANGED
|
@@ -50,3 +50,7 @@ export * from './stores/location-store';
|
|
|
50
50
|
export * from './stores/address-modal-store';
|
|
51
51
|
export * from './stores/auth-modal-store';
|
|
52
52
|
export * from './stores/cart-store';
|
|
53
|
+
|
|
54
|
+
// Mounting a theme — the piece a theme developer cannot build without.
|
|
55
|
+
export * from './theme-mount';
|
|
56
|
+
export * from './theme-context';
|
package/demo.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a theme's `demo.json` into the props its components expect.
|
|
3
|
+
*
|
|
4
|
+
* SHIPPED WITH THE KIT because without it a theme developer has demo data and
|
|
5
|
+
* no way to render it. THEME.md already requires every theme to carry a
|
|
6
|
+
* demo.json; these are the functions that resolve its block references into
|
|
7
|
+
* real products, categories, posts and collections. They lived in the
|
|
8
|
+
* storefront's own preview routes, so they existed only here — a theme could be
|
|
9
|
+
* written outside this repo and never shown running against its own data.
|
|
10
|
+
*
|
|
11
|
+
* Pure and type-only by construction: no `fs`, no `next`, no app imports. That
|
|
12
|
+
* is what made moving it a rename rather than a rewrite, and it is why these
|
|
13
|
+
* work identically in a developer's preview and in this repo's preview routes.
|
|
14
|
+
*/
|
|
15
|
+
import type { Block, ReviewItem } from './types/block';
|
|
16
|
+
import type { Menu } from './types/menu';
|
|
17
|
+
import type { Collection, Page, Post } from './types/page';
|
|
18
|
+
import type { Product } from './types/product';
|
|
19
|
+
import type { StorefrontConfig, VendorProfile } from './types/vendor';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure demo-data transforms — no filesystem access, so this file is safe to
|
|
23
|
+
* import from a 'use client' component (unlike `demo.ts`, which pulls in
|
|
24
|
+
* Node's `fs`/`path` for `loadDemoData` and will break the browser bundle if
|
|
25
|
+
* a client component imports a value from it). `demo.ts` re-exports
|
|
26
|
+
* everything here for existing server-component call sites; new client-side
|
|
27
|
+
* code should import from here directly.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type DemoCategory = { id: string; slug: string; name: string; image?: string | null };
|
|
31
|
+
|
|
32
|
+
export interface PreviewDemoData {
|
|
33
|
+
profile: VendorProfile;
|
|
34
|
+
config: StorefrontConfig;
|
|
35
|
+
menus: Menu[];
|
|
36
|
+
pages: Record<string, Page>;
|
|
37
|
+
products?: Product[];
|
|
38
|
+
categories?: DemoCategory[];
|
|
39
|
+
// Raw shape varies across themes that predate the `content: Block[]` /
|
|
40
|
+
// `cover_image_url` convention (see `normalizePost`) — kept loose here and
|
|
41
|
+
// normalized on read via `getPosts`/`getPost`.
|
|
42
|
+
posts?: Record<string, unknown>[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function enrichBlocks(blocks: Block[], demo: PreviewDemoData): Block[] {
|
|
46
|
+
return blocks.map((block) => {
|
|
47
|
+
if (block.type === 'products' && demo.products) {
|
|
48
|
+
const data = (block.data ?? {}) as Record<string, unknown>;
|
|
49
|
+
const title = (data.title as string) ?? '';
|
|
50
|
+
const limit = (data.limit as number) ?? demo.products.length;
|
|
51
|
+
const ids = data.ids as string[] | undefined;
|
|
52
|
+
let items = demo.products;
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(ids) && ids.length > 0) {
|
|
55
|
+
// Explicit ids field — preserve order from ids array for section variety
|
|
56
|
+
const byId = new Map(demo.products.map((p) => [p.id, p]));
|
|
57
|
+
items = ids.map((id) => byId.get(id)).filter((p): p is NonNullable<typeof p> => Boolean(p));
|
|
58
|
+
} else if (/new|arrival/i.test(title)) {
|
|
59
|
+
items = demo.products.filter((product) => product.flags?.is_new);
|
|
60
|
+
} else if (/featured|popular|best|focus|lookbook/i.test(title)) {
|
|
61
|
+
items = demo.products.filter((product) => product.flags?.featured);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (items.length === 0) items = demo.products;
|
|
65
|
+
return { ...block, data: { ...block.data, products: items.slice(0, limit) } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (block.type === 'categories' && demo.categories) {
|
|
69
|
+
return { ...block, data: { ...block.data, categories: demo.categories } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return block;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getEnrichedPage<T extends Page>(page: T, demo: PreviewDemoData): T {
|
|
77
|
+
return {
|
|
78
|
+
...page,
|
|
79
|
+
content: enrichBlocks(page.content ?? [], demo),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `demo.json`'s `posts` array is the documented, spec-correct source (see
|
|
85
|
+
* `themes/THEME.md` — "must mirror the vendor bootstrap shape") — a few
|
|
86
|
+
* themes authored it before the `content: Block[]` / `cover_image_url`
|
|
87
|
+
* convention settled, so this tolerates the older shapes instead of
|
|
88
|
+
* assuming every theme's data is current.
|
|
89
|
+
*/
|
|
90
|
+
function normalizePost(raw: Record<string, unknown>): Post {
|
|
91
|
+
const legacyMedia = raw.media as { image?: string; thumbnail?: string } | undefined;
|
|
92
|
+
const coverImageUrl =
|
|
93
|
+
(raw.cover_image_url as string | undefined) ?? legacyMedia?.image ?? legacyMedia?.thumbnail ?? null;
|
|
94
|
+
|
|
95
|
+
let content = raw.content;
|
|
96
|
+
if (typeof content === 'string') {
|
|
97
|
+
// Legacy raw-HTML content string — convert the handful of tags these
|
|
98
|
+
// themes actually used into one markdown content block rather than
|
|
99
|
+
// rendering literal "<p>"/"<h2>" text.
|
|
100
|
+
const markdown = content
|
|
101
|
+
.replace(/<h2>(.*?)<\/h2>/gi, '\n\n## $1\n\n')
|
|
102
|
+
.replace(/<h3>(.*?)<\/h3>/gi, '\n\n### $1\n\n')
|
|
103
|
+
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
|
|
104
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
105
|
+
.trim();
|
|
106
|
+
content = [{ type: 'content', data: { markdown } }];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...raw,
|
|
111
|
+
type: 'post',
|
|
112
|
+
content: Array.isArray(content) ? (content as Block[]) : [],
|
|
113
|
+
cover_image_url: coverImageUrl,
|
|
114
|
+
} as Post;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getPosts(demo: PreviewDemoData): Post[] {
|
|
118
|
+
return (demo.posts ?? [])
|
|
119
|
+
.map(normalizePost)
|
|
120
|
+
.sort((left, right) => {
|
|
121
|
+
const leftDate = left.published_at ? new Date(left.published_at).getTime() : 0;
|
|
122
|
+
const rightDate = right.published_at ? new Date(right.published_at).getTime() : 0;
|
|
123
|
+
return rightDate - leftDate;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Vendor-scope reviews for preview surfaces, flattened from the demo's own
|
|
129
|
+
* product reviews.
|
|
130
|
+
*
|
|
131
|
+
* Review-backed sections (beaurify's `content/testimonials`) render real vendor
|
|
132
|
+
* reviews rather than merchant-authored copy — deliberately, see its manifest.
|
|
133
|
+
* But nothing seeded them in preview, so the section returned null everywhere a
|
|
134
|
+
* vendor evaluates it: theme preview, style guide, and the section-library
|
|
135
|
+
* thumbnails. Themes already ship product reviews in demo.json; this exposes
|
|
136
|
+
* them at vendor scope instead of asking every theme to duplicate the data.
|
|
137
|
+
*
|
|
138
|
+
* Demo review records predate `ReviewItem` (`user` is a bare name, the body is
|
|
139
|
+
* `comment`), so both spellings are accepted.
|
|
140
|
+
*/
|
|
141
|
+
export function getVendorReviews(demo: PreviewDemoData): ReviewItem[] {
|
|
142
|
+
return (demo.products ?? []).flatMap((product) => {
|
|
143
|
+
const raw = (product as unknown as { reviews?: Record<string, unknown>[] }).reviews ?? [];
|
|
144
|
+
|
|
145
|
+
return raw.map((review, index): ReviewItem => {
|
|
146
|
+
const author = typeof review.user === 'string'
|
|
147
|
+
? review.user
|
|
148
|
+
: (review.customer_name as string | null) ?? 'Verified buyer';
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
id: String(review.id ?? `${product.id}-review-${index}`),
|
|
152
|
+
rating: Number(review.rating ?? 5),
|
|
153
|
+
title: (review.title as string | null) ?? null,
|
|
154
|
+
description: (review.description as string | null) ?? (review.comment as string | null) ?? null,
|
|
155
|
+
media: [],
|
|
156
|
+
is_verified_purchase: review.is_verified_purchase !== false,
|
|
157
|
+
created_at: String(review.created_at ?? ''),
|
|
158
|
+
user: { id: `${product.id}-reviewer-${index}`, name: author, avatar: null },
|
|
159
|
+
product: { id: product.id, slug: product.slug, title: product.title, thumbnail: product.media?.thumbnail ?? null },
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function getPost(demo: PreviewDemoData, slug: string): Post | null {
|
|
166
|
+
return getPosts(demo).find((post) => post.slug === slug) ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getPolicies(demo: PreviewDemoData): Page[] {
|
|
170
|
+
return Object.values(demo.pages).filter((page) => page.type === 'policy');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function getCollections(demo: PreviewDemoData): Collection[] {
|
|
174
|
+
const categories = demo.categories ?? [];
|
|
175
|
+
const products = demo.products ?? [];
|
|
176
|
+
|
|
177
|
+
return categories.map((category) => ({
|
|
178
|
+
id: category.id,
|
|
179
|
+
slug: category.slug,
|
|
180
|
+
name: category.name,
|
|
181
|
+
description: `${category.name} — curated picks from this collection.`,
|
|
182
|
+
image: category.image ?? null,
|
|
183
|
+
mode: 'manual',
|
|
184
|
+
products_count: products.filter((product) => product.categories.some((item) => item.slug === category.slug)).length,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getCollection(demo: PreviewDemoData, slug: string): { collection: Collection; products: Product[] } | null {
|
|
189
|
+
const collection = getCollections(demo).find((item) => item.slug === slug);
|
|
190
|
+
if (!collection) return null;
|
|
191
|
+
|
|
192
|
+
const products = (demo.products ?? []).filter((product) => product.categories.some((category) => category.slug === slug));
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
collection: {
|
|
196
|
+
...collection,
|
|
197
|
+
products,
|
|
198
|
+
},
|
|
199
|
+
products,
|
|
200
|
+
};
|
|
201
|
+
}
|
package/index.ts
CHANGED
|
@@ -47,3 +47,6 @@ export * from './utils/contact-location';
|
|
|
47
47
|
export * from './utils/benefit-icon';
|
|
48
48
|
export * from './utils/video-embed';
|
|
49
49
|
export * from './utils/match-variant';
|
|
50
|
+
|
|
51
|
+
// Rendering a theme's own demo.json — what makes local preview possible.
|
|
52
|
+
export * from './demo';
|
package/package.json
CHANGED
package/theme-mount.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Component, createElement, type ErrorInfo, type JSX, type ReactNode } from 'react';
|
|
4
|
+
import type { FooterProps, HeaderProps, ThemeModule } from './types/theme';
|
|
5
|
+
import { ThemeProvider } from './theme-context';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a theme. THE MINIMUM A THEME NEEDS TO BE SEEN.
|
|
9
|
+
*
|
|
10
|
+
* Without this, installing `@usequeek/theme-kit` gave a developer every hook,
|
|
11
|
+
* store, type and shared block — and no way to actually mount what they built.
|
|
12
|
+
* The component that does it, `VendorShell`, lives in the storefront app, so it
|
|
13
|
+
* was never part of what shipped. A theme could be written and typechecked from
|
|
14
|
+
* outside this repo but never rendered.
|
|
15
|
+
*
|
|
16
|
+
* DELIBERATELY SMALLER THAN VendorShell. That composes the same three pieces and
|
|
17
|
+
* then layers on the Queek storefront's own chrome — checkout header, cart and
|
|
18
|
+
* auth modals, toasts, WhatsApp chat, analytics, the merchant edit-mode bridge.
|
|
19
|
+
* None of that is a theme's concern, and pulling it in would drag the whole app
|
|
20
|
+
* into the package. What is left is the actual contract: a Layout wrapping a
|
|
21
|
+
* header, the page, and a footer.
|
|
22
|
+
*
|
|
23
|
+
* Themes render inside a boundary because a theme's header and footer are
|
|
24
|
+
* SIBLINGS of the page, so a crash in either escapes any boundary the page sets
|
|
25
|
+
* up and blanks the whole screen. A theme developer should see which part broke,
|
|
26
|
+
* not an empty document.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
class ThemeBoundary extends Component<
|
|
30
|
+
{ part: string; children: ReactNode },
|
|
31
|
+
{ error: Error | null }
|
|
32
|
+
> {
|
|
33
|
+
state: { error: Error | null } = { error: null };
|
|
34
|
+
|
|
35
|
+
static getDerivedStateFromError(error: Error): { error: Error } {
|
|
36
|
+
return { error };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
40
|
+
console.error(`[theme-kit] the theme's ${this.props.part} threw`, error, info.componentStack);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(): ReactNode {
|
|
44
|
+
if (!this.state.error) return this.props.children;
|
|
45
|
+
|
|
46
|
+
// Visible on purpose. A silent fallback during theme development means
|
|
47
|
+
// staring at a missing header wondering whether it is CSS.
|
|
48
|
+
return (
|
|
49
|
+
<div role="alert" style={{ padding: '1rem', border: '2px solid #e0492f', background: '#fff5f3', color: '#211711', font: '14px/1.5 ui-monospace, monospace' }}>
|
|
50
|
+
<strong>Theme {this.props.part} crashed.</strong>
|
|
51
|
+
<pre style={{ margin: '0.5rem 0 0', whiteSpace: 'pre-wrap' }}>{this.state.error.message}</pre>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ThemeMountProps {
|
|
58
|
+
theme: ThemeModule;
|
|
59
|
+
headerProps: HeaderProps;
|
|
60
|
+
footerProps: FooterProps;
|
|
61
|
+
/** Variant ids from the store's config; omit to render the theme's default. */
|
|
62
|
+
headerVariant?: string;
|
|
63
|
+
footerVariant?: string;
|
|
64
|
+
children: ReactNode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ThemeMount({
|
|
68
|
+
theme,
|
|
69
|
+
headerProps,
|
|
70
|
+
footerProps,
|
|
71
|
+
headerVariant,
|
|
72
|
+
footerVariant,
|
|
73
|
+
children,
|
|
74
|
+
}: ThemeMountProps): JSX.Element {
|
|
75
|
+
return (
|
|
76
|
+
<ThemeProvider theme={theme}>
|
|
77
|
+
<theme.Layout>
|
|
78
|
+
<ThemeBoundary part="header">
|
|
79
|
+
{createElement(theme.getHeader(headerVariant), headerProps)}
|
|
80
|
+
</ThemeBoundary>
|
|
81
|
+
<main className="storefront-main">{children}</main>
|
|
82
|
+
<ThemeBoundary part="footer">
|
|
83
|
+
{createElement(theme.getFooter(footerVariant), footerProps)}
|
|
84
|
+
</ThemeBoundary>
|
|
85
|
+
</theme.Layout>
|
|
86
|
+
</ThemeProvider>
|
|
87
|
+
);
|
|
88
|
+
}
|