@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,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
|
+
}
|