@usequeek/theme-kit 0.1.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/README.md +37 -0
- package/api/collections.ts +93 -0
- package/api/orders.ts +23 -0
- package/apps/analytics-events.ts +32 -0
- package/apps/apps.css +613 -0
- package/apps/follow-card.tsx +192 -0
- package/apps/gtag.ts +19 -0
- package/apps/subscribe-form.tsx +124 -0
- package/client.ts +52 -0
- package/components/auth/auth-email-login-step.tsx +46 -0
- package/components/auth/auth-email-otp-step.tsx +54 -0
- package/components/auth/auth-email-otp-verify-step.tsx +82 -0
- package/components/auth/auth-email-register-step.tsx +59 -0
- package/components/auth/auth-flow.tsx +26 -0
- package/components/auth/auth-signup-step.tsx +53 -0
- package/components/blog/category-filter.tsx +92 -0
- package/components/blog/index.ts +12 -0
- package/components/blog/pagination.tsx +73 -0
- package/components/blog/post-categories.tsx +42 -0
- package/components/blog/post-meta.tsx +49 -0
- package/components/blog/related-posts.tsx +54 -0
- package/components/blog/share-buttons.tsx +89 -0
- package/components/cart/cart-panel-controller.tsx +67 -0
- package/components/cart/default-cart-shell.tsx +207 -0
- package/components/checkout/checkout-contact.tsx +84 -0
- package/components/checkout/default-checkout-shell.tsx +177 -0
- package/components/checkout/policy-links.tsx +98 -0
- package/components/image.tsx +149 -0
- package/components/markdown.tsx +54 -0
- package/components/product-media-frame.tsx +56 -0
- package/components/variant-picker.css +84 -0
- package/components/variant-picker.tsx +107 -0
- package/design-token-preview.tsx +81 -0
- package/edit-mode-active.tsx +324 -0
- package/edit-mode.tsx +80 -0
- package/hooks/use-address-autocomplete.ts +62 -0
- package/hooks/use-auth-flow.tsx +346 -0
- package/hooks/use-auth.ts +17 -0
- package/hooks/use-cart.ts +14 -0
- package/hooks/use-categories.ts +112 -0
- package/hooks/use-collection-params.ts +67 -0
- package/hooks/use-href.ts +15 -0
- package/hooks/use-posts.ts +75 -0
- package/hooks/use-product-detail.ts +69 -0
- package/hooks/use-product-questions.ts +77 -0
- package/hooks/use-product-sections.ts +165 -0
- package/hooks/use-products.ts +216 -0
- package/hooks/use-related-products.ts +56 -0
- package/hooks/use-resolve-coordinates.ts +48 -0
- package/hooks/use-reviews.ts +167 -0
- package/hooks/use-search.ts +55 -0
- package/hooks/use-shop-cart.ts +31 -0
- package/hooks/use-shop-params.ts +68 -0
- package/hooks/use-shop.ts +186 -0
- package/hooks/use-variant-selection.ts +98 -0
- package/index.ts +49 -0
- package/package.json +41 -0
- package/page-renderer.tsx +365 -0
- package/provider.tsx +91 -0
- package/sdk/client.ts +221 -0
- package/sdk/error-handler.ts +37 -0
- package/sdk/platform.ts +78 -0
- package/sdk/queek-client.ts +104 -0
- package/sdk/reauth-guard.ts +27 -0
- package/sdk/server-store-client.ts +165 -0
- package/shared-blocks/button.tsx +41 -0
- package/shared-blocks/callout.tsx +35 -0
- package/shared-blocks/content-default.tsx +23 -0
- package/shared-blocks/core-blocks.css +1792 -0
- package/shared-blocks/divider.tsx +17 -0
- package/shared-blocks/embed.tsx +40 -0
- package/shared-blocks/faq.tsx +44 -0
- package/shared-blocks/image.tsx +29 -0
- package/shared-blocks/index.ts +24 -0
- package/shared-blocks/manifest.ts +76 -0
- package/shared-blocks/product-qa.tsx +203 -0
- package/shared-blocks/quote.tsx +20 -0
- package/shared-blocks/reviews.tsx +436 -0
- package/shared-blocks/table.tsx +45 -0
- package/shared-blocks/video.tsx +60 -0
- package/stores/address-modal-store.ts +15 -0
- package/stores/auth-modal-store.ts +17 -0
- package/stores/cart-panel-store.ts +17 -0
- package/stores/cart-store.ts +172 -0
- package/stores/checkout-store.ts +167 -0
- package/stores/edit-preview-store.ts +61 -0
- package/stores/location-store.ts +41 -0
- package/stores/orders-sheet-store.ts +21 -0
- package/stores/policy-modal-store.ts +15 -0
- package/stores/product-modal-store.ts +18 -0
- package/stores/search-modal-store.ts +17 -0
- package/stores/toast-store.ts +46 -0
- package/stores/user-store.ts +88 -0
- package/theme-context.tsx +28 -0
- package/theme-variant-resolver.ts +44 -0
- package/types/block.ts +292 -0
- package/types/cart.ts +21 -0
- package/types/media.ts +55 -0
- package/types/menu.ts +27 -0
- package/types/order.ts +119 -0
- package/types/page.ts +86 -0
- package/types/product.ts +279 -0
- package/types/theme.ts +380 -0
- package/types/vendor.ts +243 -0
- package/utils/addon-selection.ts +31 -0
- package/utils/benefit-icon.tsx +145 -0
- package/utils/brand-palette.ts +64 -0
- package/utils/brand.ts +518 -0
- package/utils/contact-location.ts +44 -0
- package/utils/format.ts +64 -0
- package/utils/hero-content.ts +35 -0
- package/utils/image-srcset.ts +157 -0
- package/utils/markdown.tsx +237 -0
- package/utils/match-variant.ts +31 -0
- package/utils/menu-link.ts +41 -0
- package/utils/placeholder-resolver.ts +184 -0
- package/utils/product-media.ts +77 -0
- package/utils/product-normalizer.ts +323 -0
- package/utils/vendor-host.ts +173 -0
- package/utils/video-embed.ts +38 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useStorefront } from '../provider';
|
|
5
|
+
import { createBrowserClient } from '../sdk/client';
|
|
6
|
+
import type { ReviewItem, ReviewsBlockData, ReviewsSummary } from '../types/block';
|
|
7
|
+
|
|
8
|
+
interface ReviewsResponse {
|
|
9
|
+
status: string;
|
|
10
|
+
message: string;
|
|
11
|
+
data: {
|
|
12
|
+
scope: string;
|
|
13
|
+
vendor_id: string;
|
|
14
|
+
summary: ReviewsSummary;
|
|
15
|
+
reviews: ReviewItem[];
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY_SUMMARY: ReviewsSummary = {
|
|
20
|
+
average_rating: 0,
|
|
21
|
+
total_reviews: 0,
|
|
22
|
+
rating_breakdown: { '5': 0, '4': 0, '3': 0, '2': 0, '1': 0 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Preview-mode resolution, mirroring `resolvePreviewProducts` in use-products:
|
|
28
|
+
* apply the block's own filters to seeded reviews so a preview shows what the
|
|
29
|
+
* configured section would really show.
|
|
30
|
+
*/
|
|
31
|
+
function resolvePreviewReviews(reviews: ReviewItem[], options: ReviewsBlockData): ReviewItem[] {
|
|
32
|
+
let next = [...reviews];
|
|
33
|
+
|
|
34
|
+
if (options.product_slug) next = next.filter((review) => review.product?.slug === options.product_slug);
|
|
35
|
+
if (options.ids && options.ids.length > 0) next = next.filter((review) => options.ids?.includes(review.id));
|
|
36
|
+
if (options.min_rating) next = next.filter((review) => review.rating >= options.min_rating!);
|
|
37
|
+
if (options.verified_only) next = next.filter((review) => review.is_verified_purchase);
|
|
38
|
+
if (options.with_media) next = next.filter((review) => (review.media?.length ?? 0) > 0);
|
|
39
|
+
|
|
40
|
+
if (options.sort === 'highest') next.sort((left, right) => right.rating - left.rating);
|
|
41
|
+
else if (options.sort === 'newest') next.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
|
42
|
+
|
|
43
|
+
return typeof options.limit === 'number' ? next.slice(0, options.limit) : next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarize(reviews: ReviewItem[]): ReviewsSummary {
|
|
47
|
+
if (reviews.length === 0) return EMPTY_SUMMARY;
|
|
48
|
+
|
|
49
|
+
const breakdown = { ...EMPTY_SUMMARY.rating_breakdown };
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (const review of reviews) {
|
|
52
|
+
total += review.rating;
|
|
53
|
+
const key = String(Math.round(review.rating)) as keyof typeof breakdown;
|
|
54
|
+
if (key in breakdown) breakdown[key] += 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
average_rating: Number((total / reviews.length).toFixed(1)),
|
|
59
|
+
total_reviews: reviews.length,
|
|
60
|
+
rating_breakdown: breakdown,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useReviews(options: ReviewsBlockData): {
|
|
65
|
+
reviews: ReviewItem[];
|
|
66
|
+
summary: ReviewsSummary;
|
|
67
|
+
isLoading: boolean;
|
|
68
|
+
} {
|
|
69
|
+
const { vendor, previewData } = useStorefront();
|
|
70
|
+
const seeded = options._preview?.reviews ?? (previewData?.reviews ? resolvePreviewReviews(previewData.reviews, options) : []);
|
|
71
|
+
const [reviews, setReviews] = useState<ReviewItem[]>(seeded);
|
|
72
|
+
const [summary, setSummary] = useState<ReviewsSummary>(options._preview?.summary ?? summarize(seeded));
|
|
73
|
+
const [isLoading, setIsLoading] = useState(!options._preview && !previewData);
|
|
74
|
+
|
|
75
|
+
const {
|
|
76
|
+
scope,
|
|
77
|
+
product_slug,
|
|
78
|
+
ids,
|
|
79
|
+
min_rating,
|
|
80
|
+
verified_only,
|
|
81
|
+
with_media,
|
|
82
|
+
limit,
|
|
83
|
+
sort,
|
|
84
|
+
} = options;
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
// Preview mode: use inline preview data if provided, otherwise empty.
|
|
88
|
+
if (previewData) {
|
|
89
|
+
if (options._preview) {
|
|
90
|
+
setReviews(options._preview.reviews ?? []);
|
|
91
|
+
setSummary(options._preview.summary ?? EMPTY_SUMMARY);
|
|
92
|
+
} else if (previewData.reviews) {
|
|
93
|
+
const resolved = resolvePreviewReviews(previewData.reviews, options);
|
|
94
|
+
setReviews(resolved);
|
|
95
|
+
setSummary(summarize(resolved));
|
|
96
|
+
}
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
const client = createBrowserClient(vendor.slug ?? undefined);
|
|
103
|
+
|
|
104
|
+
const query: Record<string, string | number> = {};
|
|
105
|
+
if (scope) query.scope = scope;
|
|
106
|
+
if (product_slug) query.product_slug = product_slug;
|
|
107
|
+
if (min_rating) query.min_rating = min_rating;
|
|
108
|
+
if (verified_only === false) query.verified_only = 0;
|
|
109
|
+
if (with_media) query.with_media = 1;
|
|
110
|
+
if (limit) query.limit = limit;
|
|
111
|
+
if (sort) query.sort = sort;
|
|
112
|
+
|
|
113
|
+
async function run(): Promise<void> {
|
|
114
|
+
setIsLoading(true);
|
|
115
|
+
try {
|
|
116
|
+
let path = '/reviews';
|
|
117
|
+
if (ids && ids.length > 0) {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
for (const [key, value] of Object.entries(query)) {
|
|
120
|
+
params.set(key, String(value));
|
|
121
|
+
}
|
|
122
|
+
ids.forEach((id) => params.append('ids[]', id));
|
|
123
|
+
path = `/reviews?${params.toString()}`;
|
|
124
|
+
const response = await client.get<ReviewsResponse>(path);
|
|
125
|
+
if (!cancelled) {
|
|
126
|
+
setReviews(response.data.reviews ?? []);
|
|
127
|
+
setSummary(response.data.summary ?? EMPTY_SUMMARY);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const response = await client.get<ReviewsResponse>('/reviews', query);
|
|
133
|
+
if (!cancelled) {
|
|
134
|
+
setReviews(response.data.reviews ?? []);
|
|
135
|
+
setSummary(response.data.summary ?? EMPTY_SUMMARY);
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
if (!cancelled) {
|
|
139
|
+
setReviews([]);
|
|
140
|
+
setSummary(EMPTY_SUMMARY);
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
if (!cancelled) setIsLoading(false);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
void run();
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
cancelled = true;
|
|
151
|
+
};
|
|
152
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
153
|
+
}, [
|
|
154
|
+
vendor.slug,
|
|
155
|
+
previewData,
|
|
156
|
+
scope,
|
|
157
|
+
product_slug,
|
|
158
|
+
JSON.stringify(ids ?? []),
|
|
159
|
+
min_rating,
|
|
160
|
+
verified_only,
|
|
161
|
+
with_media,
|
|
162
|
+
limit,
|
|
163
|
+
sort,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
return { reviews, summary, isLoading };
|
|
167
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { Product } from '../types/product';
|
|
5
|
+
import type { Collection } from '../types/page';
|
|
6
|
+
|
|
7
|
+
export interface SearchResults {
|
|
8
|
+
products: Product[];
|
|
9
|
+
categories: Array<[slug: string, name: string]>;
|
|
10
|
+
collections: Collection[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useSearch(
|
|
14
|
+
query: string,
|
|
15
|
+
products: Product[],
|
|
16
|
+
collections: Collection[],
|
|
17
|
+
): SearchResults {
|
|
18
|
+
const filteredProducts = useMemo(() => {
|
|
19
|
+
if (!query.trim()) return products.slice(0, 5);
|
|
20
|
+
const q = query.toLowerCase();
|
|
21
|
+
return products
|
|
22
|
+
.filter(
|
|
23
|
+
(p) =>
|
|
24
|
+
p.title.toLowerCase().includes(q) ||
|
|
25
|
+
p.categories.some((c) => c.name.toLowerCase().includes(q)),
|
|
26
|
+
)
|
|
27
|
+
.slice(0, 5);
|
|
28
|
+
}, [query, products]);
|
|
29
|
+
|
|
30
|
+
const filteredCategories = useMemo(() => {
|
|
31
|
+
const map = new Map<string, string>();
|
|
32
|
+
products.forEach((p) =>
|
|
33
|
+
p.categories.forEach((c) => {
|
|
34
|
+
if (c.slug && !map.has(c.slug)) map.set(c.slug, c.name);
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
if (!query.trim()) return Array.from(map.entries()).slice(0, 4);
|
|
38
|
+
const q = query.toLowerCase();
|
|
39
|
+
return Array.from(map.entries())
|
|
40
|
+
.filter(([, name]) => name.toLowerCase().includes(q))
|
|
41
|
+
.slice(0, 4);
|
|
42
|
+
}, [query, products]);
|
|
43
|
+
|
|
44
|
+
const filteredCollections = useMemo(() => {
|
|
45
|
+
if (!query.trim()) return [];
|
|
46
|
+
const q = query.toLowerCase();
|
|
47
|
+
return collections.filter((c) => c.name.toLowerCase().includes(q)).slice(0, 2);
|
|
48
|
+
}, [query, collections]);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
products: filteredProducts,
|
|
52
|
+
categories: filteredCategories,
|
|
53
|
+
collections: filteredCollections,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useCartStore } from '../stores/cart-store';
|
|
5
|
+
|
|
6
|
+
export function useShopCart(shopId: string | null | undefined): {
|
|
7
|
+
items: ReturnType<typeof useCartStore.getState>['items'];
|
|
8
|
+
count: number;
|
|
9
|
+
total: number;
|
|
10
|
+
setQuantity: ReturnType<typeof useCartStore.getState>['setQuantity'];
|
|
11
|
+
removeItem: ReturnType<typeof useCartStore.getState>['removeItem'];
|
|
12
|
+
clearShopCart: ReturnType<typeof useCartStore.getState>['clearShopCart'];
|
|
13
|
+
} {
|
|
14
|
+
const resolvedShopId = shopId ?? '';
|
|
15
|
+
const allItems = useCartStore((state) => state.items);
|
|
16
|
+
const items = useMemo(
|
|
17
|
+
() => allItems.filter((entry) => entry.shop_id === resolvedShopId),
|
|
18
|
+
[allItems, resolvedShopId],
|
|
19
|
+
);
|
|
20
|
+
const count = items.reduce((sum, entry) => sum + entry.quantity, 0);
|
|
21
|
+
const total = items.reduce((sum, entry) => sum + entry.unit_price * entry.quantity, 0);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
items,
|
|
25
|
+
count,
|
|
26
|
+
total,
|
|
27
|
+
setQuantity: useCartStore((state) => state.setQuantity),
|
|
28
|
+
removeItem: useCartStore((state) => state.removeItem),
|
|
29
|
+
clearShopCart: useCartStore((state) => state.clearShopCart),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import type { ShopSort } from './use-shop';
|
|
6
|
+
|
|
7
|
+
const SORTS: ShopSort[] = ['latest', 'popular', 'price_low', 'price_high'];
|
|
8
|
+
|
|
9
|
+
export interface ShopParams {
|
|
10
|
+
categorySlug: string | null;
|
|
11
|
+
keyword: string;
|
|
12
|
+
sort: ShopSort;
|
|
13
|
+
page: number;
|
|
14
|
+
setCategory: (slug: string | null) => void;
|
|
15
|
+
setKeyword: (keyword: string) => void;
|
|
16
|
+
setSort: (sort: ShopSort) => void;
|
|
17
|
+
setPage: (page: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shop filter/search/sort/page state, persisted in the URL query string so it
|
|
22
|
+
* survives refresh and is shareable. Pair with `useShop` for the catalogue data.
|
|
23
|
+
*
|
|
24
|
+
* Query keys: `category` (slug), `q` (keyword), `sort`, `page`.
|
|
25
|
+
* Changing category, keyword or sort resets to page 1.
|
|
26
|
+
*/
|
|
27
|
+
export function useShopParams(): ShopParams {
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const pathname = usePathname();
|
|
30
|
+
const searchParams = useSearchParams();
|
|
31
|
+
|
|
32
|
+
const categorySlug = searchParams.get('category');
|
|
33
|
+
const keyword = searchParams.get('q') ?? '';
|
|
34
|
+
const sortRaw = searchParams.get('sort');
|
|
35
|
+
const sort: ShopSort = SORTS.includes(sortRaw as ShopSort) ? (sortRaw as ShopSort) : 'latest';
|
|
36
|
+
const pageRaw = Number(searchParams.get('page'));
|
|
37
|
+
const page = Number.isFinite(pageRaw) && pageRaw > 1 ? Math.floor(pageRaw) : 1;
|
|
38
|
+
|
|
39
|
+
const apply = useCallback(
|
|
40
|
+
(next: { category?: string | null; q?: string | null; sort?: ShopSort; page?: number }) => {
|
|
41
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
42
|
+
const set = (key: string, value: string | null | undefined): void => {
|
|
43
|
+
if (value === null || value === undefined || value === '') params.delete(key);
|
|
44
|
+
else params.set(key, value);
|
|
45
|
+
};
|
|
46
|
+
if ('category' in next) set('category', next.category);
|
|
47
|
+
if ('q' in next) set('q', next.q);
|
|
48
|
+
if ('sort' in next) set('sort', next.sort === 'latest' ? null : next.sort);
|
|
49
|
+
if ('page' in next) set('page', next.page && next.page > 1 ? String(next.page) : null);
|
|
50
|
+
const qs = params.toString();
|
|
51
|
+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
|
52
|
+
},
|
|
53
|
+
[router, pathname, searchParams],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const setCategory = useCallback(
|
|
57
|
+
(slug: string | null) => apply({ category: slug, q: null, page: 1 }),
|
|
58
|
+
[apply],
|
|
59
|
+
);
|
|
60
|
+
const setKeyword = useCallback(
|
|
61
|
+
(value: string) => apply({ q: value.trim() || null, category: null, page: 1 }),
|
|
62
|
+
[apply],
|
|
63
|
+
);
|
|
64
|
+
const setSort = useCallback((value: ShopSort) => apply({ sort: value, page: 1 }), [apply]);
|
|
65
|
+
const setPage = useCallback((value: number) => apply({ page: value }), [apply]);
|
|
66
|
+
|
|
67
|
+
return { categorySlug, keyword, sort, page, setCategory, setKeyword, setSort, setPage };
|
|
68
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useStorefront } from '../provider';
|
|
5
|
+
import type { Product, ApiResponse, LegacyProductLike } from '../types/product';
|
|
6
|
+
import { createBrowserClient } from '../sdk/client';
|
|
7
|
+
import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
|
|
8
|
+
|
|
9
|
+
export type ShopSort = 'latest' | 'popular' | 'price_low' | 'price_high';
|
|
10
|
+
|
|
11
|
+
export interface ShopPagination {
|
|
12
|
+
currentPage: number;
|
|
13
|
+
lastPage: number;
|
|
14
|
+
perPage: number;
|
|
15
|
+
total: number;
|
|
16
|
+
from: number | null;
|
|
17
|
+
to: number | null;
|
|
18
|
+
hasMore: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EMPTY_PAGINATION: ShopPagination = {
|
|
22
|
+
currentPage: 1,
|
|
23
|
+
lastPage: 1,
|
|
24
|
+
perPage: 24,
|
|
25
|
+
total: 0,
|
|
26
|
+
from: null,
|
|
27
|
+
to: null,
|
|
28
|
+
hasMore: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function sortProducts(list: Product[], sort: ShopSort): Product[] {
|
|
32
|
+
const copy = [...list];
|
|
33
|
+
switch (sort) {
|
|
34
|
+
case 'price_low':
|
|
35
|
+
return copy.sort((a, b) => a.pricing.sale_amount - b.pricing.sale_amount);
|
|
36
|
+
case 'price_high':
|
|
37
|
+
return copy.sort((a, b) => b.pricing.sale_amount - a.pricing.sale_amount);
|
|
38
|
+
case 'popular':
|
|
39
|
+
return copy.sort(
|
|
40
|
+
(a, b) => b.review_summary.review_count - a.review_summary.review_count,
|
|
41
|
+
);
|
|
42
|
+
default:
|
|
43
|
+
return copy;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function filterProducts(
|
|
48
|
+
all: Product[],
|
|
49
|
+
categorySlug: string | null,
|
|
50
|
+
keyword: string | null,
|
|
51
|
+
): Product[] {
|
|
52
|
+
let list = all;
|
|
53
|
+
if (categorySlug) {
|
|
54
|
+
list = list.filter((product) =>
|
|
55
|
+
product.categories.some((category) => category.slug === categorySlug),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const term = keyword?.trim().toLowerCase();
|
|
59
|
+
if (term) {
|
|
60
|
+
list = list.filter(
|
|
61
|
+
(product) =>
|
|
62
|
+
product.title.toLowerCase().includes(term) ||
|
|
63
|
+
(product.excerpt ?? '').toLowerCase().includes(term),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return list;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shop catalogue data source. Hits the live products endpoint in production.
|
|
71
|
+
* In preview (when the provider carries `previewData.products`) it filters,
|
|
72
|
+
* sorts and paginates the demo catalogue in-memory so themes render real cards.
|
|
73
|
+
*
|
|
74
|
+
* Page is controlled by the caller — pair with `useShopParams` for URL sync.
|
|
75
|
+
*/
|
|
76
|
+
export function useShop(options?: {
|
|
77
|
+
categorySlug?: string | null;
|
|
78
|
+
keyword?: string | null;
|
|
79
|
+
sort?: ShopSort;
|
|
80
|
+
page?: number;
|
|
81
|
+
perPage?: number;
|
|
82
|
+
}): {
|
|
83
|
+
products: Product[];
|
|
84
|
+
isLoading: boolean;
|
|
85
|
+
pagination: ShopPagination;
|
|
86
|
+
} {
|
|
87
|
+
const { vendor, previewData } = useStorefront();
|
|
88
|
+
|
|
89
|
+
const perPage = options?.perPage ?? 24;
|
|
90
|
+
const categorySlug = options?.categorySlug ?? null;
|
|
91
|
+
const keyword = options?.keyword ?? null;
|
|
92
|
+
const sort = options?.sort ?? 'latest';
|
|
93
|
+
const page = options?.page && options.page > 0 ? options.page : 1;
|
|
94
|
+
|
|
95
|
+
const previewProducts = previewData?.products;
|
|
96
|
+
const isPreview = Array.isArray(previewProducts);
|
|
97
|
+
|
|
98
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
99
|
+
const [isLoading, setIsLoading] = useState(!isPreview);
|
|
100
|
+
const [pagination, setPagination] = useState<ShopPagination>({
|
|
101
|
+
...EMPTY_PAGINATION,
|
|
102
|
+
perPage,
|
|
103
|
+
});
|
|
104
|
+
const cancelRef = useRef<boolean>(false);
|
|
105
|
+
|
|
106
|
+
// Preview: derive everything synchronously from the demo catalogue.
|
|
107
|
+
const previewResult = useMemo(() => {
|
|
108
|
+
if (!isPreview) return null;
|
|
109
|
+
const filtered = sortProducts(
|
|
110
|
+
filterProducts(previewProducts as Product[], categorySlug, keyword),
|
|
111
|
+
sort,
|
|
112
|
+
);
|
|
113
|
+
const total = filtered.length;
|
|
114
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
115
|
+
const safePage = Math.min(Math.max(1, page), lastPage);
|
|
116
|
+
const start = (safePage - 1) * perPage;
|
|
117
|
+
const slice = filtered.slice(start, start + perPage);
|
|
118
|
+
return {
|
|
119
|
+
products: slice,
|
|
120
|
+
pagination: {
|
|
121
|
+
currentPage: safePage,
|
|
122
|
+
lastPage,
|
|
123
|
+
perPage,
|
|
124
|
+
total,
|
|
125
|
+
from: total === 0 ? null : start + 1,
|
|
126
|
+
to: total === 0 ? null : start + slice.length,
|
|
127
|
+
hasMore: safePage < lastPage,
|
|
128
|
+
} satisfies ShopPagination,
|
|
129
|
+
};
|
|
130
|
+
}, [isPreview, previewProducts, categorySlug, keyword, sort, page, perPage]);
|
|
131
|
+
|
|
132
|
+
// Production: fetch the live catalogue.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (isPreview) return undefined;
|
|
135
|
+
|
|
136
|
+
cancelRef.current = false;
|
|
137
|
+
setIsLoading(true);
|
|
138
|
+
|
|
139
|
+
const client = createBrowserClient(vendor.slug ?? undefined);
|
|
140
|
+
const query: Record<string, string | number> = { per_page: perPage, page };
|
|
141
|
+
if (categorySlug) query.category_slug = categorySlug;
|
|
142
|
+
if (keyword?.trim()) query.keyword = keyword.trim();
|
|
143
|
+
if (sort) query.sort = sort;
|
|
144
|
+
|
|
145
|
+
async function run(): Promise<void> {
|
|
146
|
+
try {
|
|
147
|
+
const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>(
|
|
148
|
+
'/products',
|
|
149
|
+
query,
|
|
150
|
+
);
|
|
151
|
+
if (cancelRef.current) return;
|
|
152
|
+
|
|
153
|
+
const meta = (response.meta ?? {}) as Record<string, unknown>;
|
|
154
|
+
const nestedMeta = (meta.pagination ?? meta) as Record<string, unknown>;
|
|
155
|
+
setPagination({
|
|
156
|
+
currentPage: (nestedMeta.current_page as number) ?? page,
|
|
157
|
+
lastPage: (nestedMeta.last_page as number) ?? 1,
|
|
158
|
+
perPage: (nestedMeta.per_page as number) ?? perPage,
|
|
159
|
+
total: (nestedMeta.total as number) ?? 0,
|
|
160
|
+
from: (nestedMeta.from as number | null) ?? null,
|
|
161
|
+
to: (nestedMeta.to as number | null) ?? null,
|
|
162
|
+
hasMore: (nestedMeta.has_more as boolean) ?? false,
|
|
163
|
+
});
|
|
164
|
+
setProducts(
|
|
165
|
+
extractProducts(response.data).map((item) =>
|
|
166
|
+
normalizeProduct(item as LegacyProductLike),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
} finally {
|
|
170
|
+
if (!cancelRef.current) setIsLoading(false);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
void run();
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
cancelRef.current = true;
|
|
178
|
+
};
|
|
179
|
+
}, [isPreview, vendor.slug, categorySlug, keyword, sort, perPage, page]);
|
|
180
|
+
|
|
181
|
+
if (isPreview && previewResult) {
|
|
182
|
+
return { products: previewResult.products, isLoading: false, pagination: previewResult.pagination };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { products, isLoading, pagination };
|
|
186
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
4
|
+
import type { Product, ProductVariant } from '../types/product';
|
|
5
|
+
import { matchVariant, productRequiresVariant } from '../utils/match-variant';
|
|
6
|
+
|
|
7
|
+
export interface VariantSelection {
|
|
8
|
+
/** Current option name → value map, seeded from a real variant. */
|
|
9
|
+
selectedOptions: Record<string, string>;
|
|
10
|
+
/** Pick one option value. Pass to a theme's own swatch/button markup. */
|
|
11
|
+
selectOption: (optionName: string, value: string) => void;
|
|
12
|
+
/** The variant the current selection resolves to, or null if none matches. */
|
|
13
|
+
matchedVariant: ProductVariant | null;
|
|
14
|
+
/**
|
|
15
|
+
* THE ADD GATE. False while a variant-required product has no resolved
|
|
16
|
+
* variant. Every add/buy button must include `!variantSatisfied` in its
|
|
17
|
+
* disabled expression — a variant-required line with no variant fails the
|
|
18
|
+
* cart save server-side.
|
|
19
|
+
*/
|
|
20
|
+
variantSatisfied: boolean;
|
|
21
|
+
activePrice: number;
|
|
22
|
+
activeCompare: number;
|
|
23
|
+
activeDiscount: number;
|
|
24
|
+
activeInStock: boolean;
|
|
25
|
+
activeHasDiscount: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The option-selection state machine every theme's product page used to
|
|
30
|
+
* hand-roll — six near-identical copies, all of which gated their add button
|
|
31
|
+
* on stock and addons but never on a resolved variant.
|
|
32
|
+
*
|
|
33
|
+
* Two things it fixes that the copies got wrong:
|
|
34
|
+
*
|
|
35
|
+
* 1. It seeds from a real variant (first in-stock, else first) rather than
|
|
36
|
+
* from the first value of each option independently. On a sparse matrix —
|
|
37
|
+
* Red/S is not stocked but Red/M and Blue/S are — first-value-of-each
|
|
38
|
+
* produces a combination that matches nothing, so the page opened with an
|
|
39
|
+
* unresolvable selection.
|
|
40
|
+
* 2. It exposes `variantSatisfied`, so a selection that resolves to nothing
|
|
41
|
+
* (mid-change, or that sparse seed) disables the add instead of sending a
|
|
42
|
+
* line the backend rejects.
|
|
43
|
+
*
|
|
44
|
+
* Themes keep their own option markup and call `selectOption` — core owns the
|
|
45
|
+
* state, the theme owns the design.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* The opening selection. Seeded from a variant that actually exists, so the
|
|
49
|
+
* initial state is always resolvable — preferring one in stock, since opening
|
|
50
|
+
* on a sold-out variant reads as "unavailable" for a product that is not.
|
|
51
|
+
*/
|
|
52
|
+
export function seedVariantOptions(product: Product): Record<string, string> {
|
|
53
|
+
const variants = product.variants ?? [];
|
|
54
|
+
const seed = variants.find((variant) => variant.inventory?.in_stock) ?? variants[0];
|
|
55
|
+
if (seed) {
|
|
56
|
+
return Object.fromEntries(seed.option_values.map((ov) => [ov.option_name, ov.value]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// No variants (or none loaded yet): fall back to the first value of each
|
|
60
|
+
// declared option so a picker still renders something selected.
|
|
61
|
+
const initial: Record<string, string> = {};
|
|
62
|
+
for (const option of product.options ?? []) {
|
|
63
|
+
if (option.values.length > 0) initial[option.name] = option.values[0].value;
|
|
64
|
+
}
|
|
65
|
+
return initial;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Everything derived from a selection. Pure, so the rules are testable directly. */
|
|
69
|
+
export function resolveVariantState(
|
|
70
|
+
product: Product,
|
|
71
|
+
selectedOptions: Record<string, string>,
|
|
72
|
+
): Omit<VariantSelection, 'selectedOptions' | 'selectOption'> {
|
|
73
|
+
const matchedVariant = matchVariant(product.variants ?? [], selectedOptions);
|
|
74
|
+
const activePrice = matchedVariant?.pricing.sale_amount ?? product.pricing.sale_amount;
|
|
75
|
+
const activeCompare = matchedVariant?.pricing.compare_at_amount ?? product.pricing.compare_at_amount;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
matchedVariant,
|
|
79
|
+
variantSatisfied: !productRequiresVariant(product) || matchedVariant !== null,
|
|
80
|
+
activePrice,
|
|
81
|
+
activeCompare,
|
|
82
|
+
activeDiscount: matchedVariant?.pricing.discount_percent ?? product.pricing.discount_percent,
|
|
83
|
+
activeInStock: matchedVariant ? matchedVariant.inventory.in_stock : product.inventory.in_stock,
|
|
84
|
+
activeHasDiscount: activeCompare > activePrice,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useVariantSelection(product: Product): VariantSelection {
|
|
89
|
+
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>(() => seedVariantOptions(product));
|
|
90
|
+
|
|
91
|
+
const selectOption = useCallback((optionName: string, value: string): void => {
|
|
92
|
+
setSelectedOptions((previous) => ({ ...previous, [optionName]: value }));
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const derived = useMemo(() => resolveVariantState(product, selectedOptions), [product, selectedOptions]);
|
|
96
|
+
|
|
97
|
+
return { selectedOptions, selectOption, ...derived };
|
|
98
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface a theme is allowed to build against.
|
|
3
|
+
*
|
|
4
|
+
* The package is `@usequeek/theme-kit`: what a theme is built AGAINST, named for
|
|
5
|
+
* the job rather than for our internals — "storefront core" describes where the
|
|
6
|
+
* code lives here, which is meaningless to someone who has never seen this repo.
|
|
7
|
+
*
|
|
8
|
+
* Themes currently import core through deep paths (`@/lib/core/hooks/use-cart`).
|
|
9
|
+
* These two barrels name which of those paths are API — the set that would ship
|
|
10
|
+
* as `@usequeek/theme-kit` — so the boundary is written down before it is
|
|
11
|
+
* published and frozen. Anything NOT re-exported here is core's own plumbing and
|
|
12
|
+
* may change without notice.
|
|
13
|
+
*
|
|
14
|
+
* Two entries because the split is real, not cosmetic: this one is types and
|
|
15
|
+
* pure functions, importable from anywhere including node tooling; `client.ts`
|
|
16
|
+
* is React state and components and needs a client boundary at the consumer.
|
|
17
|
+
* Core also holds one `next/headers` caller, which belongs in neither and is
|
|
18
|
+
* deliberately unreachable from both.
|
|
19
|
+
*
|
|
20
|
+
* THESE ARE A DECLARATION, NOT AN IMPORT PATH. Do not migrate themes onto them.
|
|
21
|
+
* Measured 9/9/26: pointing one theme's 28 files at `client.ts` fails the build
|
|
22
|
+
* outright — `theme:generate-registry` imports theme components under tsx, and
|
|
23
|
+
* through the barrel a gallery block transitively pulls the SDK, which does not
|
|
24
|
+
* resolve outside a bundler. It is the same reason a barrel would bloat every
|
|
25
|
+
* theme chunk: the stores call `create()` at import time and package.json
|
|
26
|
+
* declares no `sideEffects`, so webpack must treat every re-export as live and
|
|
27
|
+
* cannot drop the unused ones. Themes keep importing the exact module they use;
|
|
28
|
+
* when core is published it should expose SUBPATH exports
|
|
29
|
+
* (`@usequeek/theme-kit/hooks/use-cart`) and these barrels stay the
|
|
30
|
+
* machine-checkable statement of what is in the contract.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export * from './types/theme';
|
|
34
|
+
export * from './types/block';
|
|
35
|
+
export * from './types/product';
|
|
36
|
+
export * from './types/cart';
|
|
37
|
+
export * from './types/order';
|
|
38
|
+
export * from './types/page';
|
|
39
|
+
export * from './types/menu';
|
|
40
|
+
export * from './utils/format';
|
|
41
|
+
export * from './utils/markdown';
|
|
42
|
+
export * from './utils/menu-link';
|
|
43
|
+
export * from './utils/product-media';
|
|
44
|
+
export * from './utils/hero-content';
|
|
45
|
+
export * from './utils/addon-selection';
|
|
46
|
+
export * from './utils/contact-location';
|
|
47
|
+
export * from './utils/benefit-icon';
|
|
48
|
+
export * from './utils/video-embed';
|
|
49
|
+
export * from './utils/match-variant';
|