@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,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { useStorefront } from '../provider';
|
|
5
|
+
import { useUserStore } from '../stores/user-store';
|
|
6
|
+
import { useAuthModalStore } from '../stores/auth-modal-store';
|
|
7
|
+
import { getQueekClient } from '../sdk/queek-client';
|
|
8
|
+
|
|
9
|
+
interface FollowData {
|
|
10
|
+
following: boolean;
|
|
11
|
+
shared_contact: boolean;
|
|
12
|
+
followers_count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DISMISS_KEY = 'queek_follow_card_dismissed';
|
|
16
|
+
|
|
17
|
+
function getDismissedVendors(): string[] {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]');
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function persistDismiss(vendorId: string) {
|
|
26
|
+
try {
|
|
27
|
+
const list = getDismissedVendors();
|
|
28
|
+
if (!list.includes(vendorId)) {
|
|
29
|
+
list.push(vendorId);
|
|
30
|
+
localStorage.setItem(DISMISS_KEY, JSON.stringify(list));
|
|
31
|
+
}
|
|
32
|
+
} catch { /* noop */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const POSITION_CLASS: Record<string, string> = {
|
|
36
|
+
'bottom-right': 'core-follow-card--bottom-right',
|
|
37
|
+
'bottom-left': 'core-follow-card--bottom-left',
|
|
38
|
+
'center-right': 'core-follow-card--center-right',
|
|
39
|
+
'center-left': 'core-follow-card--center-left',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function FollowCard({ display }: { display?: 'floating' | 'inline' } = {}): JSX.Element | null {
|
|
43
|
+
const { config, vendor } = useStorefront();
|
|
44
|
+
const followConfig = config.apps?.follow_card;
|
|
45
|
+
const resolvedDisplay = display ?? followConfig?.display ?? 'floating';
|
|
46
|
+
const user = useUserStore((s) => s.user);
|
|
47
|
+
const openAuth = useAuthModalStore((s) => s.open);
|
|
48
|
+
|
|
49
|
+
const [isFollowing, setIsFollowing] = useState(false);
|
|
50
|
+
const [followersCount, setFollowersCount] = useState(config.followers_count ?? 0);
|
|
51
|
+
const [shareContact, setShareContact] = useState(true);
|
|
52
|
+
const [loading, setLoading] = useState(false);
|
|
53
|
+
const [dismissed, setDismissed] = useState(false);
|
|
54
|
+
const [checked, setChecked] = useState(false);
|
|
55
|
+
|
|
56
|
+
// Check follow status when user is logged in
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!user || !vendor.slug) {
|
|
59
|
+
setChecked(true);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const client = getQueekClient(vendor.slug);
|
|
63
|
+
client.get<FollowData>('/client/store/follow/status')
|
|
64
|
+
.then((res) => {
|
|
65
|
+
setIsFollowing(res.data?.following ?? false);
|
|
66
|
+
setFollowersCount(res.data?.followers_count ?? followersCount);
|
|
67
|
+
})
|
|
68
|
+
.catch(() => {})
|
|
69
|
+
.finally(() => setChecked(true));
|
|
70
|
+
}, [user, vendor.slug]);
|
|
71
|
+
|
|
72
|
+
// Check persistent dismiss
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (vendor.id && getDismissedVendors().includes(vendor.id)) {
|
|
75
|
+
setDismissed(true);
|
|
76
|
+
}
|
|
77
|
+
}, [vendor.id]);
|
|
78
|
+
|
|
79
|
+
const handleFollow = useCallback(async () => {
|
|
80
|
+
if (!user) {
|
|
81
|
+
openAuth('login');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setLoading(true);
|
|
86
|
+
try {
|
|
87
|
+
const client = getQueekClient(vendor.slug ?? undefined);
|
|
88
|
+
const res = await client.post<FollowData>('/client/store/follow', {
|
|
89
|
+
share_contact: shareContact,
|
|
90
|
+
});
|
|
91
|
+
setIsFollowing(res.data?.following ?? true);
|
|
92
|
+
setFollowersCount(res.data?.followers_count ?? followersCount + 1);
|
|
93
|
+
} catch {
|
|
94
|
+
// Silently fail
|
|
95
|
+
} finally {
|
|
96
|
+
setLoading(false);
|
|
97
|
+
}
|
|
98
|
+
}, [user, vendor.slug, openAuth, shareContact]);
|
|
99
|
+
|
|
100
|
+
const handleUnfollow = useCallback(async () => {
|
|
101
|
+
if (!user) return;
|
|
102
|
+
|
|
103
|
+
setLoading(true);
|
|
104
|
+
try {
|
|
105
|
+
const client = getQueekClient(vendor.slug ?? undefined);
|
|
106
|
+
const res = await client.post<FollowData>('/client/store/follow?_method=DELETE', {});
|
|
107
|
+
setIsFollowing(res.data?.following ?? false);
|
|
108
|
+
setFollowersCount(res.data?.followers_count ?? Math.max(0, followersCount - 1));
|
|
109
|
+
} catch {
|
|
110
|
+
// Silently fail
|
|
111
|
+
} finally {
|
|
112
|
+
setLoading(false);
|
|
113
|
+
}
|
|
114
|
+
}, [user, vendor.slug]);
|
|
115
|
+
|
|
116
|
+
const handleDismiss = useCallback(() => {
|
|
117
|
+
setDismissed(true);
|
|
118
|
+
if (vendor.id) persistDismiss(vendor.id);
|
|
119
|
+
}, [vendor.id]);
|
|
120
|
+
|
|
121
|
+
if (!followConfig?.enabled || dismissed || !checked) return null;
|
|
122
|
+
// Don't show to users already following
|
|
123
|
+
if (isFollowing) return null;
|
|
124
|
+
|
|
125
|
+
const isFloating = resolvedDisplay === 'floating';
|
|
126
|
+
const posClass = isFloating
|
|
127
|
+
? POSITION_CLASS[followConfig.position ?? 'bottom-right'] ?? POSITION_CLASS['bottom-right']
|
|
128
|
+
: '';
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
className={`core-follow-card ${isFloating ? `core-follow-card--floating ${posClass}` : 'core-follow-card--inline'}`}
|
|
133
|
+
role="complementary"
|
|
134
|
+
aria-label="Follow this store"
|
|
135
|
+
>
|
|
136
|
+
{isFloating && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className="core-follow-card__dismiss"
|
|
140
|
+
onClick={handleDismiss}
|
|
141
|
+
aria-label="Don't show again"
|
|
142
|
+
title="Don't show again"
|
|
143
|
+
>
|
|
144
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="12" height="12">
|
|
145
|
+
<path d="M3 3l10 10M13 3L3 13" />
|
|
146
|
+
</svg>
|
|
147
|
+
</button>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<div className="core-follow-card__body">
|
|
151
|
+
{vendor.logo && (
|
|
152
|
+
<img
|
|
153
|
+
src={vendor.logo}
|
|
154
|
+
alt={vendor.name ?? ''}
|
|
155
|
+
className="core-follow-card__logo"
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
<div className="core-follow-card__text">
|
|
159
|
+
<p className="core-follow-card__title">
|
|
160
|
+
Follow {vendor.name ?? 'this store'}
|
|
161
|
+
</p>
|
|
162
|
+
<p className="core-follow-card__sub">
|
|
163
|
+
Get notified about new arrivals and exclusive offers.
|
|
164
|
+
</p>
|
|
165
|
+
{followersCount > 0 && (
|
|
166
|
+
<p className="core-follow-card__count">
|
|
167
|
+
{followersCount.toLocaleString()} {followersCount === 1 ? 'follower' : 'followers'}
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<label className="core-follow-card__consent">
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
checked={shareContact}
|
|
177
|
+
onChange={(e) => setShareContact(e.target.checked)}
|
|
178
|
+
className="core-follow-card__checkbox"
|
|
179
|
+
/>
|
|
180
|
+
<span>Share my contact info with this store</span>
|
|
181
|
+
</label>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="core-follow-card__btn"
|
|
185
|
+
onClick={handleFollow}
|
|
186
|
+
disabled={loading}
|
|
187
|
+
>
|
|
188
|
+
{loading ? 'Following...' : 'Follow'}
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
package/apps/gtag.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window {
|
|
5
|
+
dataLayer?: unknown[];
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pushes straight to `dataLayer` rather than calling `window.gtag()` directly —
|
|
11
|
+
* this queues correctly even before gtag.js has finished loading (the same
|
|
12
|
+
* mechanism the inline bootstrap snippet in analytics.tsx relies on), so
|
|
13
|
+
* callers never need to guard on load order.
|
|
14
|
+
*/
|
|
15
|
+
export function gtagPush(...args: unknown[]): void {
|
|
16
|
+
if (typeof window === 'undefined') return;
|
|
17
|
+
window.dataLayer = window.dataLayer || [];
|
|
18
|
+
window.dataLayer.push(args);
|
|
19
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useId, useState } from 'react';
|
|
4
|
+
import { useStorefront } from '../provider';
|
|
5
|
+
import { getQueekClient } from '../sdk/queek-client';
|
|
6
|
+
import type { SubscribeFormProps } from '../types/theme';
|
|
7
|
+
|
|
8
|
+
type Status = 'idle' | 'saving' | 'done' | 'error';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Newsletter capture — the storefront's only way to hand a vendor an email
|
|
12
|
+
* without forcing a full account signup. `FollowCard` requires an account
|
|
13
|
+
* (it opens the auth modal), so before this the cheapest path into a
|
|
14
|
+
* vendor's campaign audience was a whole registration flow.
|
|
15
|
+
*
|
|
16
|
+
* Backed by `POST /client/store/subscribe`, which is public by design: no
|
|
17
|
+
* auth, one field. It answers identically whether the address is new,
|
|
18
|
+
* already subscribed, or previously opted out, so this form can never be
|
|
19
|
+
* used to probe who shops here.
|
|
20
|
+
*
|
|
21
|
+
* The `subscribe` app's `inline` variant (the theme-declared default) — a
|
|
22
|
+
* theme's own `getSubscribeForm` dispatch renders this directly, passing
|
|
23
|
+
* heading/tagline/cta straight through with no gate of its own; gating on
|
|
24
|
+
* `config.apps?.subscribe?.enabled` happens once, centrally, in whichever
|
|
25
|
+
* variant dispatch mounts it (see vendor-shell.tsx / a theme's footer).
|
|
26
|
+
*/
|
|
27
|
+
export function SubscribeForm({
|
|
28
|
+
heading = 'Stay in the loop',
|
|
29
|
+
tagline = 'New arrivals and offers, straight to your inbox.',
|
|
30
|
+
cta = 'Subscribe',
|
|
31
|
+
layout = 'stacked',
|
|
32
|
+
onSuccess,
|
|
33
|
+
}: SubscribeFormProps & { layout?: 'stacked' | 'inline'; onSuccess?: () => void } = {}): JSX.Element {
|
|
34
|
+
const { vendor } = useStorefront();
|
|
35
|
+
const emailId = useId();
|
|
36
|
+
const nameId = useId();
|
|
37
|
+
|
|
38
|
+
const [name, setName] = useState('');
|
|
39
|
+
const [email, setEmail] = useState('');
|
|
40
|
+
const [status, setStatus] = useState<Status>('idle');
|
|
41
|
+
const [message, setMessage] = useState('');
|
|
42
|
+
|
|
43
|
+
const handleSubmit = useCallback(
|
|
44
|
+
async (event: React.FormEvent) => {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
if (status === 'saving' || email.trim() === '') return;
|
|
47
|
+
|
|
48
|
+
setStatus('saving');
|
|
49
|
+
try {
|
|
50
|
+
const res = await getQueekClient(vendor.slug ?? undefined).post<{ message?: string }>(
|
|
51
|
+
'/client/store/subscribe',
|
|
52
|
+
{ email: email.trim(), name: name.trim() || undefined },
|
|
53
|
+
);
|
|
54
|
+
setStatus('done');
|
|
55
|
+
setMessage(res.data?.message ?? "You're subscribed.");
|
|
56
|
+
setName('');
|
|
57
|
+
setEmail('');
|
|
58
|
+
onSuccess?.();
|
|
59
|
+
} catch {
|
|
60
|
+
setStatus('error');
|
|
61
|
+
setMessage('That didn’t go through. Please check the address and try again.');
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[name, email, status, vendor.slug, onSuccess],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (status === 'done') {
|
|
68
|
+
return (
|
|
69
|
+
<div className="core-subscribe core-subscribe--done" role="status">
|
|
70
|
+
<p className="core-subscribe__done">{message}</p>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={`core-subscribe core-subscribe--${layout}`}>
|
|
77
|
+
{heading ? <p className="core-subscribe__heading">{heading}</p> : null}
|
|
78
|
+
{tagline ? <p className="core-subscribe__tagline">{tagline}</p> : null}
|
|
79
|
+
|
|
80
|
+
<form className="core-subscribe__form" onSubmit={handleSubmit} noValidate>
|
|
81
|
+
<label className="core-subscribe__label" htmlFor={nameId}>
|
|
82
|
+
Name
|
|
83
|
+
</label>
|
|
84
|
+
<input
|
|
85
|
+
id={nameId}
|
|
86
|
+
className="core-subscribe__input"
|
|
87
|
+
type="text"
|
|
88
|
+
name="name"
|
|
89
|
+
autoComplete="name"
|
|
90
|
+
placeholder="Name (optional)"
|
|
91
|
+
value={name}
|
|
92
|
+
onChange={(e) => setName(e.target.value)}
|
|
93
|
+
/>
|
|
94
|
+
<label className="core-subscribe__label" htmlFor={emailId}>
|
|
95
|
+
Email address
|
|
96
|
+
</label>
|
|
97
|
+
<input
|
|
98
|
+
id={emailId}
|
|
99
|
+
className="core-subscribe__input"
|
|
100
|
+
type="email"
|
|
101
|
+
name="email"
|
|
102
|
+
autoComplete="email"
|
|
103
|
+
inputMode="email"
|
|
104
|
+
placeholder="you@example.com"
|
|
105
|
+
value={email}
|
|
106
|
+
onChange={(e) => {
|
|
107
|
+
setEmail(e.target.value);
|
|
108
|
+
if (status === 'error') setStatus('idle');
|
|
109
|
+
}}
|
|
110
|
+
required
|
|
111
|
+
/>
|
|
112
|
+
<button className="core-subscribe__button" type="submit" disabled={status === 'saving'}>
|
|
113
|
+
{status === 'saving' ? 'Subscribing…' : cta}
|
|
114
|
+
</button>
|
|
115
|
+
</form>
|
|
116
|
+
|
|
117
|
+
{status === 'error' ? (
|
|
118
|
+
<p className="core-subscribe__error" role="alert">
|
|
119
|
+
{message}
|
|
120
|
+
</p>
|
|
121
|
+
) : null}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
package/client.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The client half of the public theme surface — hooks, stores and components.
|
|
5
|
+
* See ./index.ts for why this is split.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from './provider';
|
|
9
|
+
export * from './page-renderer';
|
|
10
|
+
export * from './edit-mode';
|
|
11
|
+
export * from './theme-variant-resolver';
|
|
12
|
+
export * from './shared-blocks';
|
|
13
|
+
export * from './components/blog';
|
|
14
|
+
export * from './components/image';
|
|
15
|
+
export * from './components/product-media-frame';
|
|
16
|
+
export * from './components/variant-picker';
|
|
17
|
+
export * from './components/auth/auth-flow';
|
|
18
|
+
export * from './components/cart/default-cart-shell';
|
|
19
|
+
export * from './components/cart/cart-panel-controller';
|
|
20
|
+
export * from './components/checkout/default-checkout-shell';
|
|
21
|
+
export * from './components/checkout/checkout-contact';
|
|
22
|
+
export * from './components/checkout/policy-links';
|
|
23
|
+
export * from './apps/follow-card';
|
|
24
|
+
export * from './apps/subscribe-form';
|
|
25
|
+
export * from './api/collections';
|
|
26
|
+
export * from './api/orders';
|
|
27
|
+
export * from './hooks/use-href';
|
|
28
|
+
export * from './hooks/use-auth';
|
|
29
|
+
export * from './hooks/use-cart';
|
|
30
|
+
export * from './hooks/use-shop-cart';
|
|
31
|
+
export * from './hooks/use-products';
|
|
32
|
+
export * from './hooks/use-product-detail';
|
|
33
|
+
export * from './hooks/use-product-sections';
|
|
34
|
+
export * from './hooks/use-related-products';
|
|
35
|
+
export * from './hooks/use-categories';
|
|
36
|
+
export * from './hooks/use-collection-params';
|
|
37
|
+
export * from './hooks/use-shop';
|
|
38
|
+
export * from './hooks/use-shop-params';
|
|
39
|
+
export * from './hooks/use-search';
|
|
40
|
+
export * from './hooks/use-posts';
|
|
41
|
+
export * from './hooks/use-reviews';
|
|
42
|
+
export * from './hooks/use-variant-selection';
|
|
43
|
+
export * from './hooks/use-address-autocomplete';
|
|
44
|
+
export * from './hooks/use-resolve-coordinates';
|
|
45
|
+
export * from './stores/cart-panel-store';
|
|
46
|
+
export * from './stores/product-modal-store';
|
|
47
|
+
export * from './stores/search-modal-store';
|
|
48
|
+
export * from './stores/orders-sheet-store';
|
|
49
|
+
export * from './stores/location-store';
|
|
50
|
+
export * from './stores/address-modal-store';
|
|
51
|
+
export * from './stores/auth-modal-store';
|
|
52
|
+
export * from './stores/cart-store';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useAuthFlow } from '../../hooks/use-auth-flow';
|
|
3
|
+
|
|
4
|
+
export function AuthEmailLoginStep(): JSX.Element {
|
|
5
|
+
const { email, setEmail, password, setPassword, submitEmailLogin, loading, error, goToEmailRegister, goToEmailOtp } = useAuthFlow();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="core-auth__step core-auth__step--email-login">
|
|
9
|
+
<h2 className="core-auth__title">Sign in</h2>
|
|
10
|
+
<p className="core-auth__sub">Enter your email and password</p>
|
|
11
|
+
<input
|
|
12
|
+
type="email"
|
|
13
|
+
className="core-input"
|
|
14
|
+
placeholder="Email address"
|
|
15
|
+
value={email}
|
|
16
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
17
|
+
autoComplete="email"
|
|
18
|
+
autoFocus
|
|
19
|
+
/>
|
|
20
|
+
<input
|
|
21
|
+
type="password"
|
|
22
|
+
className="core-input"
|
|
23
|
+
placeholder="Password"
|
|
24
|
+
value={password}
|
|
25
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
26
|
+
onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailLogin(); }}
|
|
27
|
+
autoComplete="current-password"
|
|
28
|
+
/>
|
|
29
|
+
{error ? <p className="core-auth__error">{error}</p> : null}
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
className="core-submit-btn"
|
|
33
|
+
onClick={() => void submitEmailLogin()}
|
|
34
|
+
disabled={loading || !email.trim() || !password}
|
|
35
|
+
>
|
|
36
|
+
{loading ? 'Signing in…' : 'Sign in'}
|
|
37
|
+
</button>
|
|
38
|
+
<button type="button" className="core-auth__link" onClick={goToEmailRegister}>
|
|
39
|
+
Don't have an account? Create one
|
|
40
|
+
</button>
|
|
41
|
+
<button type="button" className="core-auth__link" onClick={goToEmailOtp}>
|
|
42
|
+
Use an email code instead
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useAuthFlow } from '../../hooks/use-auth-flow';
|
|
4
|
+
|
|
5
|
+
export function AuthEmailOtpStep(): JSX.Element {
|
|
6
|
+
const { email, setEmail, submitEmailRequestOtp, loading, error, initiateGoogleAuth, goToEmailLogin } = useAuthFlow();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="core-auth__step core-auth__step--email-otp">
|
|
10
|
+
<h2 className="core-auth__title">Sign in</h2>
|
|
11
|
+
<p className="core-auth__sub">Enter your email to continue</p>
|
|
12
|
+
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
className="core-auth__google-btn"
|
|
16
|
+
onClick={() => void initiateGoogleAuth()}
|
|
17
|
+
disabled={loading}
|
|
18
|
+
>
|
|
19
|
+
<svg className="core-auth__google-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
20
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
|
21
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
22
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
|
23
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
24
|
+
</svg>
|
|
25
|
+
Continue with Google
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
<div className="core-auth__divider"><span>or</span></div>
|
|
29
|
+
|
|
30
|
+
<input
|
|
31
|
+
type="email"
|
|
32
|
+
className="core-input"
|
|
33
|
+
placeholder="your@email.com"
|
|
34
|
+
value={email}
|
|
35
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
36
|
+
onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailRequestOtp(); }}
|
|
37
|
+
autoComplete="email"
|
|
38
|
+
autoFocus
|
|
39
|
+
/>
|
|
40
|
+
{error ? <p className="core-auth__error">{error}</p> : null}
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="core-submit-btn"
|
|
44
|
+
onClick={() => void submitEmailRequestOtp()}
|
|
45
|
+
disabled={loading || !email.trim()}
|
|
46
|
+
>
|
|
47
|
+
{loading ? 'Sending code…' : 'Continue'}
|
|
48
|
+
</button>
|
|
49
|
+
<button type="button" className="core-auth__link" onClick={goToEmailLogin}>
|
|
50
|
+
Have a password? Sign in with it instead
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { useAuthFlow } from '../../hooks/use-auth-flow';
|
|
5
|
+
|
|
6
|
+
const OTP_LENGTH = 6;
|
|
7
|
+
|
|
8
|
+
export function AuthEmailOtpVerifyStep(): JSX.Element {
|
|
9
|
+
const { email, otp, setOtp, submitEmailVerifyOtp, submitEmailRequestOtp, goToEmailOtp, loading, error } = useAuthFlow();
|
|
10
|
+
const refs = useRef<(HTMLInputElement | null)[]>([]);
|
|
11
|
+
|
|
12
|
+
const focus = (i: number): void => {
|
|
13
|
+
refs.current[i]?.focus();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const handleChange = (i: number, raw: string): void => {
|
|
17
|
+
const char = raw.replace(/\D/g, '').slice(-1);
|
|
18
|
+
const next = otp.split('');
|
|
19
|
+
next[i] = char;
|
|
20
|
+
const joined = next.join('').slice(0, OTP_LENGTH);
|
|
21
|
+
setOtp(joined);
|
|
22
|
+
if (char && i < OTP_LENGTH - 1) focus(i + 1);
|
|
23
|
+
if (joined.length === OTP_LENGTH) void submitEmailVerifyOtp();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const handleKeyDown = (i: number, e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
27
|
+
if (e.key === 'Backspace') {
|
|
28
|
+
if (otp[i]) {
|
|
29
|
+
const next = otp.split('');
|
|
30
|
+
next[i] = '';
|
|
31
|
+
setOtp(next.join(''));
|
|
32
|
+
} else if (i > 0) {
|
|
33
|
+
focus(i - 1);
|
|
34
|
+
}
|
|
35
|
+
} else if (e.key === 'Enter' && otp.length === OTP_LENGTH) {
|
|
36
|
+
void submitEmailVerifyOtp();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="core-auth__step core-auth__step--email-otp-verify">
|
|
42
|
+
<h2 className="core-auth__title">Enter code</h2>
|
|
43
|
+
<p className="core-auth__sub">
|
|
44
|
+
{OTP_LENGTH}-digit code sent to <strong>{email}</strong>
|
|
45
|
+
</p>
|
|
46
|
+
<div className="core-auth-otp">
|
|
47
|
+
{Array.from({ length: OTP_LENGTH }).map((_, i) => (
|
|
48
|
+
<input
|
|
49
|
+
key={i}
|
|
50
|
+
ref={(el) => { refs.current[i] = el; }}
|
|
51
|
+
className="core-auth-otp__box"
|
|
52
|
+
type="text"
|
|
53
|
+
inputMode="numeric"
|
|
54
|
+
maxLength={1}
|
|
55
|
+
value={otp[i] ?? ''}
|
|
56
|
+
onChange={(e) => handleChange(i, e.target.value)}
|
|
57
|
+
onKeyDown={(e) => handleKeyDown(i, e)}
|
|
58
|
+
onFocus={(e) => e.target.select()}
|
|
59
|
+
disabled={loading}
|
|
60
|
+
autoComplete={i === 0 ? 'one-time-code' : 'off'}
|
|
61
|
+
aria-label={`Digit ${i + 1}`}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
{error ? <p className="core-auth__error">{error}</p> : null}
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="core-submit-btn"
|
|
69
|
+
onClick={() => void submitEmailVerifyOtp()}
|
|
70
|
+
disabled={loading || otp.length < OTP_LENGTH}
|
|
71
|
+
>
|
|
72
|
+
{loading ? 'Verifying…' : 'Verify'}
|
|
73
|
+
</button>
|
|
74
|
+
<button type="button" className="core-auth__link" onClick={() => void submitEmailRequestOtp()}>
|
|
75
|
+
Resend code
|
|
76
|
+
</button>
|
|
77
|
+
<button type="button" className="core-auth__link" onClick={goToEmailOtp}>
|
|
78
|
+
← Change email
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useAuthFlow } from '../../hooks/use-auth-flow';
|
|
3
|
+
|
|
4
|
+
export function AuthEmailRegisterStep(): JSX.Element {
|
|
5
|
+
const { firstName, setFirstName, lastName, setLastName, email, setEmail, password, setPassword, submitEmailRegister, loading, error, goToEmailLogin } = useAuthFlow();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="core-auth__step core-auth__step--email-register">
|
|
9
|
+
<h2 className="core-auth__title">Create account</h2>
|
|
10
|
+
<p className="core-auth__sub">Fill in your details to get started</p>
|
|
11
|
+
<input
|
|
12
|
+
type="text"
|
|
13
|
+
className="core-input"
|
|
14
|
+
placeholder="First name"
|
|
15
|
+
value={firstName}
|
|
16
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
17
|
+
autoComplete="given-name"
|
|
18
|
+
autoFocus
|
|
19
|
+
/>
|
|
20
|
+
<input
|
|
21
|
+
type="text"
|
|
22
|
+
className="core-input"
|
|
23
|
+
placeholder="Last name (optional)"
|
|
24
|
+
value={lastName}
|
|
25
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
26
|
+
autoComplete="family-name"
|
|
27
|
+
/>
|
|
28
|
+
<input
|
|
29
|
+
type="email"
|
|
30
|
+
className="core-input"
|
|
31
|
+
placeholder="Email address"
|
|
32
|
+
value={email}
|
|
33
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
34
|
+
autoComplete="email"
|
|
35
|
+
/>
|
|
36
|
+
<input
|
|
37
|
+
type="password"
|
|
38
|
+
className="core-input"
|
|
39
|
+
placeholder="Password (min 8 characters)"
|
|
40
|
+
value={password}
|
|
41
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
42
|
+
onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailRegister(); }}
|
|
43
|
+
autoComplete="new-password"
|
|
44
|
+
/>
|
|
45
|
+
{error ? <p className="core-auth__error">{error}</p> : null}
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="core-submit-btn"
|
|
49
|
+
onClick={() => void submitEmailRegister()}
|
|
50
|
+
disabled={loading || !firstName.trim() || !email.trim() || password.length < 8}
|
|
51
|
+
>
|
|
52
|
+
{loading ? 'Creating account…' : 'Create account'}
|
|
53
|
+
</button>
|
|
54
|
+
<button type="button" className="core-auth__link" onClick={goToEmailLogin}>
|
|
55
|
+
Already have an account? Sign in
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AuthFlowProvider, useAuthFlow } from '../../hooks/use-auth-flow';
|
|
4
|
+
import { AuthEmailOtpStep } from './auth-email-otp-step';
|
|
5
|
+
import { AuthEmailOtpVerifyStep } from './auth-email-otp-verify-step';
|
|
6
|
+
import { AuthSignupStep } from './auth-signup-step';
|
|
7
|
+
import { AuthEmailLoginStep } from './auth-email-login-step';
|
|
8
|
+
import { AuthEmailRegisterStep } from './auth-email-register-step';
|
|
9
|
+
|
|
10
|
+
function AuthFlowInner(): JSX.Element | null {
|
|
11
|
+
const { step } = useAuthFlow();
|
|
12
|
+
if (step === 'email-otp') return <AuthEmailOtpStep />;
|
|
13
|
+
if (step === 'email-otp-verify') return <AuthEmailOtpVerifyStep />;
|
|
14
|
+
if (step === 'signup') return <AuthSignupStep />;
|
|
15
|
+
if (step === 'email-login') return <AuthEmailLoginStep />;
|
|
16
|
+
if (step === 'email-register') return <AuthEmailRegisterStep />;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function AuthFlow(): JSX.Element {
|
|
21
|
+
return (
|
|
22
|
+
<AuthFlowProvider>
|
|
23
|
+
<AuthFlowInner />
|
|
24
|
+
</AuthFlowProvider>
|
|
25
|
+
);
|
|
26
|
+
}
|