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