@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,346 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, type ReactNode } from 'react';
4
+ import { QueekSdkError, type EmailVerifyOtpResponse } from '@queekai/client-sdk';
5
+ import { useStorefront } from '../provider';
6
+ import { getQueekClient, setAuthTokens, QUEEK_AUTH_URL } from '../sdk/queek-client';
7
+ import { useAuthModalStore } from '../stores/auth-modal-store';
8
+ import { useUserStore } from '../stores/user-store';
9
+
10
+ export type AuthStep =
11
+ | 'email-otp'
12
+ | 'email-otp-verify'
13
+ | 'signup'
14
+ | 'email-login'
15
+ | 'email-register';
16
+
17
+ export interface AuthFlowState {
18
+ step: AuthStep;
19
+ otp: string;
20
+ firstName: string;
21
+ lastName: string;
22
+ email: string;
23
+ password: string;
24
+ loading: boolean;
25
+ error: string | null;
26
+ }
27
+
28
+ export interface AuthFlowActions {
29
+ setOtp: (value: string) => void;
30
+ setFirstName: (value: string) => void;
31
+ setLastName: (value: string) => void;
32
+ setEmail: (value: string) => void;
33
+ setPassword: (value: string) => void;
34
+ submitEmailRequestOtp: () => Promise<void>;
35
+ submitEmailVerifyOtp: () => Promise<void>;
36
+ submitRegister: () => Promise<void>;
37
+ submitEmailLogin: () => Promise<void>;
38
+ submitEmailRegister: () => Promise<void>;
39
+ initiateGoogleAuth: () => Promise<void>;
40
+ reset: () => void;
41
+ goToEmailOtp: () => void;
42
+ goToEmailLogin: () => void;
43
+ goToEmailRegister: () => void;
44
+ }
45
+
46
+ export type AuthFlowValue = AuthFlowState & AuthFlowActions;
47
+
48
+ const EMAIL_OTP_LENGTH = 6;
49
+
50
+ interface PasswordAuthData {
51
+ access_token: string;
52
+ refresh_token: string;
53
+ user: {
54
+ id: string;
55
+ name: string;
56
+ first_name?: string | null;
57
+ last_name?: string | null;
58
+ email: string | null;
59
+ phone?: string | null;
60
+ avatar: string | null;
61
+ profile_complete?: boolean;
62
+ };
63
+ }
64
+
65
+ function mapEmailError(err: unknown): string {
66
+ if (err instanceof QueekSdkError) {
67
+ switch (err.code) {
68
+ case 'invalid_credentials':
69
+ return 'Wrong email or password. Please try again.';
70
+ case 'login_mode_mismatch':
71
+ return 'This account uses a different sign-in method (Google or phone).';
72
+ case 'account_already_exists':
73
+ return 'Email already registered. Please sign in instead.';
74
+ case 'phone_already_exists':
75
+ return 'Phone number already in use.';
76
+ case 'otp_resend_too_soon':
77
+ return 'Please wait before requesting another code.';
78
+ case 'invalid_otp':
79
+ return 'Incorrect code. Please try again.';
80
+ default:
81
+ return err.message || 'Something went wrong. Please try again.';
82
+ }
83
+ }
84
+ return 'Something went wrong. Please try again.';
85
+ }
86
+
87
+ const AuthFlowContext = createContext<AuthFlowValue | null>(null);
88
+
89
+ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Element {
90
+ const { vendor } = useStorefront();
91
+ const closeModal = useAuthModalStore((s) => s.close);
92
+ const setUser = useUserStore((s) => s.setUser);
93
+
94
+ const [step, setStep] = useState<AuthStep>('email-otp');
95
+ const [otp, setOtp] = useState('');
96
+ const [verifiedToken, setVerifiedToken] = useState(''); // email OTP flow: server-issued token
97
+ const [firstName, setFirstName] = useState('');
98
+ const [lastName, setLastName] = useState('');
99
+ const [email, setEmail] = useState('');
100
+ const [password, setPassword] = useState('');
101
+ const [loading, setLoading] = useState(false);
102
+ const [error, setError] = useState<string | null>(null);
103
+
104
+ function applyUser(u: { id: string; name: string; first_name?: string | null; last_name?: string | null; email: string | null; phone?: string | null; avatar: string | null; profile_complete?: boolean }): void {
105
+ setUser({
106
+ id: u.id,
107
+ name: u.name,
108
+ first_name: u.first_name ?? '',
109
+ last_name: u.last_name ?? '',
110
+ email: u.email,
111
+ phone: u.phone ?? '',
112
+ avatar: u.avatar,
113
+ profile_complete: u.profile_complete ?? Boolean(u.phone),
114
+ });
115
+ }
116
+
117
+ // OAuth round-trip handling lives in core-modal-layer's GoogleCallbackHandler
118
+ // — it must stay mounted whether or not the auth modal is open.
119
+
120
+ // ── Email OTP flow (primary) ──────────────────────────────────────────────
121
+
122
+ const submitEmailRequestOtp = async (): Promise<void> => {
123
+ const trimmedEmail = email.trim();
124
+ if (!trimmedEmail) return;
125
+ setError(null);
126
+ setLoading(true);
127
+ try {
128
+ const client = getQueekClient(vendor.slug ?? undefined);
129
+ await client.auth.emailRequestOtp({ email: trimmedEmail });
130
+ setOtp('');
131
+ setStep('email-otp-verify');
132
+ } catch (err) {
133
+ setError(mapEmailError(err));
134
+ } finally {
135
+ setLoading(false);
136
+ }
137
+ };
138
+
139
+ const submitEmailVerifyOtp = async (): Promise<void> => {
140
+ if (otp.length < EMAIL_OTP_LENGTH) return;
141
+ setError(null);
142
+ setLoading(true);
143
+ try {
144
+ const client = getQueekClient(vendor.slug ?? undefined);
145
+ const data: EmailVerifyOtpResponse = await client.auth.emailVerifyOtp({
146
+ email: email.trim(),
147
+ otpCode: otp,
148
+ });
149
+ if (data.needs_registration) {
150
+ setVerifiedToken(data.verified_token);
151
+ setStep('signup');
152
+ return;
153
+ }
154
+ // tokens already persisted by SDK
155
+ applyUser(data.user);
156
+ closeModal();
157
+ } catch (err) {
158
+ setError(mapEmailError(err));
159
+ setOtp('');
160
+ } finally {
161
+ setLoading(false);
162
+ }
163
+ };
164
+
165
+ // ── Signup (email OTP flow) ───────────────────────────────────────────────
166
+
167
+ const submitRegister = async (): Promise<void> => {
168
+ if (!firstName.trim()) {
169
+ setError('Please enter your first name.');
170
+ return;
171
+ }
172
+ setError(null);
173
+ setLoading(true);
174
+ try {
175
+ const client = getQueekClient(vendor.slug ?? undefined);
176
+ const res = await client.auth.emailRegisterOtp({
177
+ firstName: firstName.trim(),
178
+ lastName: lastName.trim() || undefined,
179
+ verifiedToken,
180
+ });
181
+ // tokens already persisted by SDK
182
+ applyUser(res.user);
183
+ closeModal();
184
+ } catch (err) {
185
+ setError(err instanceof QueekSdkError ? err.message : 'Registration failed. Try again.');
186
+ } finally {
187
+ setLoading(false);
188
+ }
189
+ };
190
+
191
+ // ── Email password flow ───────────────────────────────────────────────────
192
+
193
+ const submitEmailLogin = async (): Promise<void> => {
194
+ if (!email.trim() || !password) return;
195
+ setError(null);
196
+ setLoading(true);
197
+ try {
198
+ const client = getQueekClient(vendor.slug ?? undefined);
199
+ const res = await client.post<PasswordAuthData>('/client/auth/email/login', {
200
+ email: email.trim(),
201
+ password,
202
+ platform: 'client_web',
203
+ });
204
+ setAuthTokens(res.data.access_token, res.data.refresh_token);
205
+ applyUser(res.data.user);
206
+ closeModal();
207
+ } catch (err) {
208
+ setError(mapEmailError(err));
209
+ } finally {
210
+ setLoading(false);
211
+ }
212
+ };
213
+
214
+ const submitEmailRegister = async (): Promise<void> => {
215
+ if (!firstName.trim() || !email.trim() || password.length < 8) return;
216
+ setError(null);
217
+ setLoading(true);
218
+ try {
219
+ const client = getQueekClient(vendor.slug ?? undefined);
220
+ const res = await client.post<PasswordAuthData>('/client/auth/email/register-password', {
221
+ first_name: firstName.trim(),
222
+ last_name: lastName.trim() || undefined,
223
+ email: email.trim(),
224
+ password,
225
+ phone: '',
226
+ platform: 'client_web',
227
+ });
228
+ setAuthTokens(res.data.access_token, res.data.refresh_token);
229
+ applyUser(res.data.user);
230
+ closeModal();
231
+ } catch (err) {
232
+ setError(mapEmailError(err));
233
+ } finally {
234
+ setLoading(false);
235
+ }
236
+ };
237
+
238
+ // ── Google OAuth ──────────────────────────────────────────────────────────
239
+
240
+ const initiateGoogleAuth = async (): Promise<void> => {
241
+ setError(null);
242
+ setLoading(true);
243
+ try {
244
+ const returnTo = typeof window !== 'undefined' ? window.location.href : '';
245
+ const slug = vendor.slug ?? '';
246
+ const url = `${QUEEK_AUTH_URL}/oauth/google/start?channel=storefront&vendor_slug=${encodeURIComponent(slug)}&return_to=${encodeURIComponent(returnTo)}`;
247
+ // eslint-disable-next-line no-console
248
+ console.info('[google-auth] requesting redirect URL from', url);
249
+ const res = await fetch(url);
250
+ if (!res.ok) {
251
+ const text = await res.text().catch(() => '');
252
+ // eslint-disable-next-line no-console
253
+ console.warn('[google-auth] redirect endpoint returned non-2xx', res.status, text);
254
+ throw new Error(`Failed to initiate Google auth (HTTP ${res.status}).`);
255
+ }
256
+ // Laravel envelope: { status, message, data: { url } }
257
+ const body = (await res.json()) as {
258
+ data?: { url?: string; redirect_url?: string };
259
+ url?: string;
260
+ redirect_url?: string;
261
+ };
262
+ const redirectUrl =
263
+ body.data?.url ?? body.data?.redirect_url ?? body.url ?? body.redirect_url;
264
+ if (redirectUrl) {
265
+ window.location.href = redirectUrl;
266
+ } else {
267
+ // eslint-disable-next-line no-console
268
+ console.warn('[google-auth] no redirect URL in response body', body);
269
+ throw new Error('No redirect URL returned.');
270
+ }
271
+ } catch (err) {
272
+ // eslint-disable-next-line no-console
273
+ console.error('[google-auth] initiate failed', err);
274
+ setError('Could not connect to Google sign-in. Please try again.');
275
+ } finally {
276
+ setLoading(false);
277
+ }
278
+ };
279
+
280
+ // ── Navigation ────────────────────────────────────────────────────────────
281
+
282
+ const reset = (): void => {
283
+ setStep('email-otp');
284
+ setOtp('');
285
+ setVerifiedToken('');
286
+ setFirstName('');
287
+ setLastName('');
288
+ setEmail('');
289
+ setPassword('');
290
+ setError(null);
291
+ setLoading(false);
292
+ };
293
+
294
+ const goToEmailOtp = (): void => {
295
+ setStep('email-otp');
296
+ setOtp('');
297
+ setVerifiedToken('');
298
+ setError(null);
299
+ };
300
+
301
+ const goToEmailLogin = (): void => {
302
+ setStep('email-login');
303
+ setError(null);
304
+ };
305
+
306
+ const goToEmailRegister = (): void => {
307
+ setStep('email-register');
308
+ setError(null);
309
+ };
310
+
311
+ const value: AuthFlowValue = {
312
+ step,
313
+ otp,
314
+ firstName,
315
+ lastName,
316
+ email,
317
+ password,
318
+ loading,
319
+ error,
320
+ setOtp,
321
+ setFirstName,
322
+ setLastName,
323
+ setEmail,
324
+ setPassword,
325
+ submitEmailRequestOtp,
326
+ submitEmailVerifyOtp,
327
+ submitRegister,
328
+ submitEmailLogin,
329
+ submitEmailRegister,
330
+ initiateGoogleAuth,
331
+ reset,
332
+ goToEmailOtp,
333
+ goToEmailLogin,
334
+ goToEmailRegister,
335
+ };
336
+
337
+ return <AuthFlowContext.Provider value={value}>{children}</AuthFlowContext.Provider>;
338
+ }
339
+
340
+ export function useAuthFlow(): AuthFlowValue {
341
+ const ctx = useContext(AuthFlowContext);
342
+ if (!ctx) {
343
+ throw new Error('useAuthFlow must be used within an AuthFlowProvider');
344
+ }
345
+ return ctx;
346
+ }
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+
3
+ import { useAuthModalStore } from '../stores/auth-modal-store';
4
+ import { useUserStore } from '../stores/user-store';
5
+
6
+ export function useAuth() {
7
+ return {
8
+ user: useUserStore((state) => state.user),
9
+ isAuthenticated: useUserStore((state) => state.isAuthenticated),
10
+ setUser: useUserStore((state) => state.setUser),
11
+ logout: useUserStore((state) => state.logout),
12
+ openAuthModal: useAuthModalStore((state) => state.open),
13
+ closeAuthModal: useAuthModalStore((state) => state.close),
14
+ authModalOpen: useAuthModalStore((state) => state.isOpen),
15
+ authMode: useAuthModalStore((state) => state.mode),
16
+ };
17
+ }
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+
3
+ import { useCartStore } from '../stores/cart-store';
4
+
5
+ export function useCart() {
6
+ return {
7
+ items: useCartStore((state) => state.items),
8
+ addProduct: useCartStore((state) => state.addProduct),
9
+ addItemWithAddons: useCartStore((state) => state.addItemWithAddons),
10
+ setQuantity: useCartStore((state) => state.setQuantity),
11
+ removeItem: useCartStore((state) => state.removeItem),
12
+ clearShopCart: useCartStore((state) => state.clearShopCart),
13
+ };
14
+ }
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import { createBrowserClient } from '../sdk/client';
6
+
7
+ export interface Category {
8
+ id: string;
9
+ slug: string;
10
+ name: string;
11
+ image?: string | null;
12
+ products_count?: number;
13
+ children?: Category[];
14
+ }
15
+
16
+ interface CategoriesResponse {
17
+ data: Category[];
18
+ message?: string;
19
+ }
20
+
21
+ /**
22
+ * Flatten a nested category tree into a single list so `ids` filters
23
+ * can resolve collections at any depth.
24
+ */
25
+ function flattenCategories(items: Category[]): Category[] {
26
+ const out: Category[] = [];
27
+ const walk = (list: Category[]): void => {
28
+ for (const item of list) {
29
+ out.push(item);
30
+ if (Array.isArray(item.children) && item.children.length > 0) {
31
+ walk(item.children);
32
+ }
33
+ }
34
+ };
35
+ walk(items);
36
+ return out;
37
+ }
38
+
39
+ export function useCategories(options?: {
40
+ ids?: string[];
41
+ limit?: number;
42
+ }): {
43
+ categories: Category[];
44
+ isLoading: boolean;
45
+ } {
46
+ const { previewData, vendor } = useStorefront();
47
+ const [categories, setCategories] = useState<Category[]>([]);
48
+ const [isLoading, setIsLoading] = useState(true);
49
+
50
+ const idsKey = options?.ids?.join(',') ?? '';
51
+ const limit = options?.limit;
52
+
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ const previewCategories = previewData?.categories;
56
+ const client = createBrowserClient(vendor.slug ?? undefined);
57
+
58
+ async function run(): Promise<void> {
59
+ setIsLoading(true);
60
+
61
+ try {
62
+ if (Array.isArray(previewCategories)) {
63
+ let items = flattenCategories(previewCategories);
64
+
65
+ if (idsKey) {
66
+ const idSet = new Set(idsKey.split(','));
67
+ // Preserve input order for deterministic display.
68
+ const ordered = idsKey.split(',');
69
+ const byId = new Map(items.filter((c) => idSet.has(c.id)).map((c) => [c.id, c]));
70
+ items = ordered.map((id) => byId.get(id)).filter((c): c is Category => !!c);
71
+ }
72
+
73
+ if (!cancelled) {
74
+ setCategories(typeof limit === 'number' ? items.slice(0, limit) : items);
75
+ setIsLoading(false);
76
+ }
77
+ return;
78
+ }
79
+
80
+ const query: Record<string, string> = { include_empty: '1' };
81
+ const response = await client.get<CategoriesResponse>('/collections', query);
82
+ let items = flattenCategories(Array.isArray(response.data) ? response.data : []);
83
+
84
+ // Filter by specific IDs if provided — preserving input order
85
+ if (idsKey) {
86
+ const idSet = new Set(idsKey.split(','));
87
+ const ordered = idsKey.split(',');
88
+ const byId = new Map(items.filter((c) => idSet.has(c.id)).map((c) => [c.id, c]));
89
+ items = ordered.map((id) => byId.get(id)).filter((c): c is Category => !!c);
90
+ }
91
+
92
+ if (!cancelled) {
93
+ setCategories(typeof limit === 'number' ? items.slice(0, limit) : items);
94
+ setIsLoading(false);
95
+ }
96
+ } catch {
97
+ if (!cancelled) {
98
+ setCategories([]);
99
+ setIsLoading(false);
100
+ }
101
+ }
102
+ }
103
+
104
+ void run();
105
+
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, [idsKey, limit, previewData?.categories, vendor.slug]);
110
+
111
+ return { categories, isLoading };
112
+ }
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useTransition } from 'react';
4
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
+ import type { CollectionSort } from '../api/collections';
6
+
7
+ const SORTS: CollectionSort[] = ['newest', 'popular', 'price_low', 'price_high'];
8
+
9
+ export interface CollectionParams {
10
+ keyword: string;
11
+ sort: CollectionSort;
12
+ page: number;
13
+ /** True while a param change is re-fetching the server-rendered page
14
+ * (this route has no client-side data fetch of its own — every sort/
15
+ * search/page change is a real server round-trip). Themes can dim the
16
+ * grid on this the same way `useShop`'s client fetch does. */
17
+ isPending: boolean;
18
+ setKeyword: (keyword: string) => void;
19
+ setSort: (sort: CollectionSort) => void;
20
+ setPage: (page: number) => void;
21
+ }
22
+
23
+ /**
24
+ * Collection detail search/sort/page state, persisted in the URL query
25
+ * string (mirrors `useShopParams` — same mechanism, collection-scoped: no
26
+ * `category` key since the collection itself is already the scope).
27
+ *
28
+ * Query keys: `q` (keyword), `sort`, `page`. Changing keyword or sort resets
29
+ * to page 1. The matching server route reads these same keys to fetch the
30
+ * real page — this hook only ever manages the URL, never fetches.
31
+ */
32
+ export function useCollectionParams(): CollectionParams {
33
+ const router = useRouter();
34
+ const pathname = usePathname();
35
+ const searchParams = useSearchParams();
36
+ const [isPending, startTransition] = useTransition();
37
+
38
+ const keyword = searchParams.get('q') ?? '';
39
+ const sortRaw = searchParams.get('sort');
40
+ const sort: CollectionSort = SORTS.includes(sortRaw as CollectionSort) ? (sortRaw as CollectionSort) : 'newest';
41
+ const pageRaw = Number(searchParams.get('page'));
42
+ const page = Number.isFinite(pageRaw) && pageRaw > 1 ? Math.floor(pageRaw) : 1;
43
+
44
+ const apply = useCallback(
45
+ (next: { q?: string | null; sort?: CollectionSort; page?: number }) => {
46
+ const params = new URLSearchParams(searchParams.toString());
47
+ const set = (key: string, value: string | null | undefined): void => {
48
+ if (value === null || value === undefined || value === '') params.delete(key);
49
+ else params.set(key, value);
50
+ };
51
+ if ('q' in next) set('q', next.q);
52
+ if ('sort' in next) set('sort', next.sort === 'newest' ? null : next.sort);
53
+ if ('page' in next) set('page', next.page && next.page > 1 ? String(next.page) : null);
54
+ const qs = params.toString();
55
+ startTransition(() => {
56
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
57
+ });
58
+ },
59
+ [router, pathname, searchParams],
60
+ );
61
+
62
+ const setKeyword = useCallback((value: string) => apply({ q: value.trim() || null, page: 1 }), [apply]);
63
+ const setSort = useCallback((value: CollectionSort) => apply({ sort: value, page: 1 }), [apply]);
64
+ const setPage = useCallback((value: number) => apply({ page: value }), [apply]);
65
+
66
+ return { keyword, sort, page, isPending, setKeyword, setSort, setPage };
67
+ }
@@ -0,0 +1,15 @@
1
+ import { useStorefront } from '../provider';
2
+
3
+ /**
4
+ * Returns a function that builds storefront-relative hrefs.
5
+ * On subdomain: href("/shop") → "/shop"
6
+ * On path-based: href("/shop") → "/vendor-slug/shop"
7
+ */
8
+ export function useHref() {
9
+ const { basePath } = useStorefront();
10
+
11
+ return (path: string) => {
12
+ if (!path.startsWith('/')) return path;
13
+ return `${basePath}${path}`;
14
+ };
15
+ }
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import type { ApiResponse } from '../types/product';
6
+ import type { Post } from '../types/page';
7
+ import { createBrowserClient } from '../sdk/client';
8
+
9
+ /**
10
+ * Client-side blog-post listing, for blocks embedded on an arbitrary page
11
+ * (e.g. the 'blog' home-page block) — mirrors useProducts' shape/pattern.
12
+ * The blog LISTING page itself (pages/blog.tsx) gets its posts via SSR props
13
+ * instead (fetchPosts in lib/core/api/pages.ts); this hook is for anywhere
14
+ * else a theme needs a live post list without page-level SSR data.
15
+ */
16
+ export function usePosts(options?: { category?: string | null; limit?: number }): { posts: Post[]; isLoading: boolean } {
17
+ const { previewData, vendor } = useStorefront();
18
+ const [posts, setPosts] = useState<Post[]>([]);
19
+ const [isLoading, setIsLoading] = useState(true);
20
+
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ const previewPosts = previewData?.posts;
24
+
25
+ async function run(): Promise<void> {
26
+ setIsLoading(true);
27
+
28
+ try {
29
+ if (Array.isArray(previewPosts)) {
30
+ let next = [...previewPosts];
31
+
32
+ if (options?.category) {
33
+ next = next.filter((post) => post.blog_categories?.some((category) => category.slug === options.category));
34
+ }
35
+
36
+ if (typeof options?.limit === 'number') {
37
+ next = next.slice(0, options.limit);
38
+ }
39
+
40
+ if (!cancelled) {
41
+ setPosts(next);
42
+ }
43
+ return;
44
+ }
45
+
46
+ const client = createBrowserClient(vendor.slug ?? undefined);
47
+ const query: Record<string, string | number> = {};
48
+ if (options?.category) query.category = options.category;
49
+ if (options?.limit) query.per_page = options.limit;
50
+
51
+ const response = await client.get<ApiResponse<Post[]>>('/posts', query);
52
+
53
+ if (!cancelled) {
54
+ setPosts(response.data ?? []);
55
+ }
56
+ } catch {
57
+ if (!cancelled) {
58
+ setPosts([]);
59
+ }
60
+ } finally {
61
+ if (!cancelled) {
62
+ setIsLoading(false);
63
+ }
64
+ }
65
+ }
66
+
67
+ void run();
68
+
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [options?.category, options?.limit, previewData?.posts, vendor.slug]);
73
+
74
+ return { posts, isLoading };
75
+ }