@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.
Files changed (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. 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';