zopassport 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 (110) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +407 -0
  3. package/app/.env.example +15 -0
  4. package/app/README.md +28 -0
  5. package/app/package.json +24 -0
  6. package/app/reanimated-mock.js +102 -0
  7. package/app/reanimated-mock.jsx +97 -0
  8. package/app/src/App.tsx +331 -0
  9. package/app/src/components/FounderBadge.tsx +26 -0
  10. package/app/src/components/OTPInput.tsx +149 -0
  11. package/app/src/components/PhoneInput.tsx +109 -0
  12. package/app/src/components/ZoAuth.tsx +320 -0
  13. package/app/src/components/ZoAvatar.tsx +87 -0
  14. package/app/src/components/ZoLanding.tsx +231 -0
  15. package/app/src/components/ZoOnboarding.tsx +524 -0
  16. package/app/src/components/ZoPassportCard.tsx +183 -0
  17. package/app/src/components/ZoProgressRing.tsx +57 -0
  18. package/app/src/components/index.ts +16 -0
  19. package/app/src/components/wallet/MovingShine.tsx +43 -0
  20. package/app/src/components/wallet/TransactionItem.tsx +84 -0
  21. package/app/src/components/wallet/TransactionList.tsx +65 -0
  22. package/app/src/components/wallet/WalletCard.tsx +152 -0
  23. package/app/src/components/wallet/WalletScreen.tsx +190 -0
  24. package/app/src/components/wallet/ZoToken.tsx +69 -0
  25. package/app/src/components/wallet/index.ts +8 -0
  26. package/app/src/components/wallet/styles/index.ts +4 -0
  27. package/app/src/components/wallet/styles/walletStyles.ts +210 -0
  28. package/app/src/sdk/ZoPassportSDK.ts +277 -0
  29. package/app/src/sdk/lib/api/auth.ts +223 -0
  30. package/app/src/sdk/lib/api/avatar.ts +155 -0
  31. package/app/src/sdk/lib/api/client.ts +135 -0
  32. package/app/src/sdk/lib/api/index.ts +8 -0
  33. package/app/src/sdk/lib/api/profile.ts +80 -0
  34. package/app/src/sdk/lib/api/wallet.ts +59 -0
  35. package/app/src/sdk/lib/types/auth.ts +78 -0
  36. package/app/src/sdk/lib/types/avatar.ts +22 -0
  37. package/app/src/sdk/lib/types/index.ts +8 -0
  38. package/app/src/sdk/lib/types/profile.ts +18 -0
  39. package/app/src/sdk/lib/types/wallet.ts +103 -0
  40. package/app/src/sdk/lib/types.ts +205 -0
  41. package/app/src/sdk/lib/utils/index.ts +6 -0
  42. package/app/src/sdk/lib/utils/phone.ts +71 -0
  43. package/app/src/sdk/lib/utils/storage.ts +116 -0
  44. package/app/src/sdk/lib/utils/wallet.ts +73 -0
  45. package/app/src/sdk/types.ts +205 -0
  46. package/app/src/styles.css +154 -0
  47. package/app/svg-mock.js +125 -0
  48. package/app/svg-mock.jsx +120 -0
  49. package/app/vite.config.ts +70 -0
  50. package/assets/ASSETS_MANIFEST.md +124 -0
  51. package/assets/bae.png +0 -0
  52. package/assets/bro.png +0 -0
  53. package/assets/cultural-stickers/Business.png +0 -0
  54. package/assets/cultural-stickers/Default (2).jpg +0 -0
  55. package/assets/cultural-stickers/Design.png +0 -0
  56. package/assets/cultural-stickers/FollowYourHeart.png +0 -0
  57. package/assets/cultural-stickers/Food.png +0 -0
  58. package/assets/cultural-stickers/Game.png +0 -0
  59. package/assets/cultural-stickers/Health&Fitness.png +0 -0
  60. package/assets/cultural-stickers/Home&Lifestyle.png +0 -0
  61. package/assets/cultural-stickers/Law.png +0 -0
  62. package/assets/cultural-stickers/Literature&Stories.png +0 -0
  63. package/assets/cultural-stickers/Music&Entertainment.png +0 -0
  64. package/assets/cultural-stickers/Nature&Wildlife.png +0 -0
  65. package/assets/cultural-stickers/Photography.png +0 -0
  66. package/assets/cultural-stickers/Science&Technology.png +0 -0
  67. package/assets/cultural-stickers/Spiritual.png +0 -0
  68. package/assets/cultural-stickers/Sport.png +0 -0
  69. package/assets/cultural-stickers/Stories&Journal.png +0 -0
  70. package/assets/cultural-stickers/Television&Cinema.png +0 -0
  71. package/assets/cultural-stickers/Travel&Adventure.png +0 -0
  72. package/assets/cultural-stickers/z.jpg (1).jpg +0 -0
  73. package/assets/figma-assets/landing-zo-logo.png +0 -0
  74. package/assets/images/rank1.jpeg +0 -0
  75. package/assets/index.ts +76 -0
  76. package/assets/lotties/loader.json +1216 -0
  77. package/assets/lotties/spinner.json +1 -0
  78. package/assets/videos/loading-screen-background.mp4 +0 -0
  79. package/assets/videos/opening-disks.mp4 +0 -0
  80. package/assets/wallet/constants.ts +38 -0
  81. package/assets/zo-coin.gif +0 -0
  82. package/assets/zo-fallback.png +0 -0
  83. package/dist/assets/index.d.mts +136 -0
  84. package/dist/assets/index.d.ts +136 -0
  85. package/dist/assets/index.js +133 -0
  86. package/dist/assets/index.js.map +1 -0
  87. package/dist/assets/index.mjs +100 -0
  88. package/dist/assets/index.mjs.map +1 -0
  89. package/dist/index.d.mts +789 -0
  90. package/dist/index.d.ts +789 -0
  91. package/dist/index.js +1118 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/index.mjs +1060 -0
  94. package/dist/index.mjs.map +1 -0
  95. package/dist/react-native.d.mts +537 -0
  96. package/dist/react-native.d.ts +537 -0
  97. package/dist/react-native.js +1617 -0
  98. package/dist/react-native.js.map +1 -0
  99. package/dist/react-native.mjs +1588 -0
  100. package/dist/react-native.mjs.map +1 -0
  101. package/dist/react.d.mts +824 -0
  102. package/dist/react.d.ts +824 -0
  103. package/dist/react.js +3856 -0
  104. package/dist/react.js.map +1 -0
  105. package/dist/react.mjs +3801 -0
  106. package/dist/react.mjs.map +1 -0
  107. package/package.json +112 -0
  108. package/scripts/init.js +196 -0
  109. package/scripts/postinstall.js +174 -0
  110. package/scripts/verify-build.js +121 -0
@@ -0,0 +1,331 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { ZoPassportSDK } from 'zopassport';
3
+ import { ZoLanding, ZoPassportPage, WalletFullPage } from 'zopassport/react';
4
+ import type { ZoUser } from 'zopassport';
5
+
6
+ // Get client key from environment
7
+ const CLIENT_KEY = import.meta.env.VITE_ZO_CLIENT_KEY || '';
8
+ const API_URL = import.meta.env.VITE_ZO_API_URL;
9
+
10
+ // Validate configuration
11
+ if (!CLIENT_KEY || CLIENT_KEY === 'your-client-key-here') {
12
+ console.error(`
13
+ ╔════════════════════════════════════════════════════════════╗
14
+ ║ ⚠️ CONFIGURATION ERROR ║
15
+ ╠════════════════════════════════════════════════════════════╣
16
+ ║ Missing ZO_CLIENT_KEY! ║
17
+ ║ ║
18
+ ║ 1. Copy .env.example to .env ║
19
+ ║ cp .env.example .env ║
20
+ ║ ║
21
+ ║ 2. Edit .env and add your client key ║
22
+ ║ VITE_ZO_CLIENT_KEY=your-actual-key ║
23
+ ║ ║
24
+ ║ Get your key at: https://zo.xyz/developers ║
25
+ ╚════════════════════════════════════════════════════════════╝
26
+ `);
27
+ }
28
+
29
+ // Initialize SDK
30
+ const sdk = new ZoPassportSDK({
31
+ clientKey: CLIENT_KEY,
32
+ baseUrl: API_URL,
33
+ autoRefresh: true,
34
+ });
35
+
36
+ type Screen = 'landing' | 'passport' | 'wallet';
37
+
38
+ function App() {
39
+ const [screen, setScreen] = useState<Screen>('landing');
40
+ const [user, setUser] = useState<ZoUser | null>(null);
41
+ const [isLoading, setIsLoading] = useState(true);
42
+ const [walletBalance, setWalletBalance] = useState(0);
43
+ const [walletOpen, setWalletOpen] = useState(false);
44
+ const [isLoadingBalance, setIsLoadingBalance] = useState(false);
45
+
46
+ // Fetch wallet balance
47
+ const fetchWalletBalance = async () => {
48
+ setIsLoadingBalance(true);
49
+ try {
50
+ const balance = await sdk.getWalletBalance();
51
+ console.log('[App] Wallet balance fetched:', balance);
52
+ setWalletBalance(balance);
53
+ } catch (error) {
54
+ console.warn('[App] Could not fetch wallet balance, using default:', error);
55
+ // Don't throw - just keep the existing balance
56
+ } finally {
57
+ setIsLoadingBalance(false);
58
+ }
59
+ };
60
+
61
+ // Check for existing session on mount
62
+ useEffect(() => {
63
+ const checkSession = async () => {
64
+ setIsLoading(true);
65
+ try {
66
+ if (sdk.isAuthenticated) {
67
+ const profile = await sdk.getProfile();
68
+ if (profile) {
69
+ setUser(profile);
70
+ setScreen('passport');
71
+ // Fetch wallet balance
72
+ await fetchWalletBalance();
73
+ }
74
+ }
75
+ } catch (error) {
76
+ console.error('Session check failed:', error);
77
+ } finally {
78
+ setIsLoading(false);
79
+ }
80
+ };
81
+ checkSession();
82
+ }, []);
83
+
84
+ // Refresh balance when wallet opens
85
+ useEffect(() => {
86
+ if (walletOpen) {
87
+ fetchWalletBalance();
88
+ }
89
+ }, [walletOpen]);
90
+
91
+ // Auth handlers
92
+ const handleSendOTP = async (countryCode: string, phoneNumber: string) => {
93
+ return sdk.auth.sendOTP(countryCode, phoneNumber);
94
+ };
95
+
96
+ const handleVerifyOTP = async (countryCode: string, phoneNumber: string, otp: string) => {
97
+ const result = await sdk.loginWithPhone(countryCode, phoneNumber, otp);
98
+ if (result.success && result.user) {
99
+ return { success: true, user: result.user };
100
+ }
101
+ return { success: false, error: result.error };
102
+ };
103
+
104
+ const handleAuthSuccess = async (userId: string, userData: ZoUser) => {
105
+ setUser(userData);
106
+ setScreen('passport');
107
+ // Fetch wallet balance
108
+ try {
109
+ const balance = await sdk.getWalletBalance();
110
+ setWalletBalance(balance);
111
+ } catch (e) {
112
+ console.error('Failed to fetch wallet:', e);
113
+ }
114
+ };
115
+
116
+ const handleLogout = async () => {
117
+ await sdk.logout();
118
+ setUser(null);
119
+ setScreen('landing');
120
+ setWalletBalance(0);
121
+ };
122
+
123
+ // Loading screen
124
+ if (isLoading) {
125
+ return (
126
+ <div style={styles.loadingContainer}>
127
+ <div style={styles.loader} />
128
+ <p style={styles.loadingText}>Loading Zo Passport...</p>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // Config error screen
134
+ if (!CLIENT_KEY || CLIENT_KEY === 'your-client-key-here') {
135
+ return (
136
+ <div style={styles.errorContainer}>
137
+ <div style={styles.errorCard}>
138
+ <div style={styles.errorIcon}>⚠️</div>
139
+ <h1 style={styles.errorTitle}>Configuration Required</h1>
140
+ <p style={styles.errorText}>
141
+ Your Zo Passport needs a client key to connect to the Zo World.
142
+ </p>
143
+ <div style={styles.codeBlock}>
144
+ <p style={styles.codeComment}># 1. Copy the example config</p>
145
+ <code>cp .env.example .env</code>
146
+ <p style={styles.codeComment}># 2. Edit .env with your key</p>
147
+ <code>VITE_ZO_CLIENT_KEY=your-key</code>
148
+ <p style={styles.codeComment}># 3. Restart the dev server</p>
149
+ <code>npm run dev</code>
150
+ </div>
151
+ <a
152
+ href="https://zo.xyz/developers"
153
+ target="_blank"
154
+ rel="noopener noreferrer"
155
+ style={styles.getKeyButton}
156
+ >
157
+ Get Your Client Key →
158
+ </a>
159
+ </div>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ // Landing / Auth screen
165
+ if (screen === 'landing') {
166
+ return (
167
+ <ZoLanding
168
+ onAuthSuccess={handleAuthSuccess}
169
+ sendOTP={handleSendOTP}
170
+ verifyOTP={handleVerifyOTP}
171
+ />
172
+ );
173
+ }
174
+
175
+ // Main Passport screen
176
+ return (
177
+ <div style={styles.container}>
178
+ {/* Header */}
179
+ <header style={styles.header}>
180
+ <img
181
+ src="/figma-assets/landing-zo-logo.png"
182
+ alt="Zo"
183
+ style={styles.logo}
184
+ />
185
+ <button onClick={handleLogout} style={styles.logoutButton}>
186
+ Logout
187
+ </button>
188
+ </header>
189
+
190
+ {/* Full Passport Page */}
191
+ <ZoPassportPage
192
+ user={user}
193
+ balance={walletBalance}
194
+ completion={{ done: 8, total: 10 }}
195
+ walletOpen={walletOpen}
196
+ onWalletToggle={() => setWalletOpen(!walletOpen)}
197
+ />
198
+
199
+ {/* Wallet Full Screen */}
200
+ {walletOpen && (
201
+ <WalletFullPage
202
+ user={user}
203
+ balance={walletBalance}
204
+ onClose={() => setWalletOpen(false)}
205
+ isLoading={isLoadingBalance}
206
+ onRefresh={fetchWalletBalance}
207
+ />
208
+ )}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ const styles: { [key: string]: React.CSSProperties } = {
214
+ // Loading
215
+ loadingContainer: {
216
+ display: 'flex',
217
+ flexDirection: 'column',
218
+ alignItems: 'center',
219
+ justifyContent: 'center',
220
+ height: '100vh',
221
+ background: '#000',
222
+ },
223
+ loader: {
224
+ width: '48px',
225
+ height: '48px',
226
+ border: '3px solid rgba(255,255,255,0.1)',
227
+ borderTopColor: '#fff',
228
+ borderRadius: '50%',
229
+ animation: 'spin 1s linear infinite',
230
+ },
231
+ loadingText: {
232
+ marginTop: '16px',
233
+ color: 'rgba(255,255,255,0.6)',
234
+ fontSize: '14px',
235
+ },
236
+ // Error
237
+ errorContainer: {
238
+ display: 'flex',
239
+ alignItems: 'center',
240
+ justifyContent: 'center',
241
+ minHeight: '100vh',
242
+ background: 'linear-gradient(180deg, #000 0%, #1a1a2e 100%)',
243
+ padding: '24px',
244
+ },
245
+ errorCard: {
246
+ background: 'rgba(255,255,255,0.05)',
247
+ border: '1px solid rgba(255,255,255,0.1)',
248
+ borderRadius: '24px',
249
+ padding: '48px',
250
+ maxWidth: '500px',
251
+ textAlign: 'center' as const,
252
+ },
253
+ errorIcon: {
254
+ fontSize: '64px',
255
+ marginBottom: '24px',
256
+ },
257
+ errorTitle: {
258
+ fontFamily: 'Syne, sans-serif',
259
+ fontSize: '28px',
260
+ fontWeight: 800,
261
+ marginBottom: '16px',
262
+ },
263
+ errorText: {
264
+ color: 'rgba(255,255,255,0.6)',
265
+ fontSize: '16px',
266
+ lineHeight: 1.6,
267
+ marginBottom: '24px',
268
+ },
269
+ codeBlock: {
270
+ background: 'rgba(0,0,0,0.5)',
271
+ borderRadius: '12px',
272
+ padding: '20px',
273
+ textAlign: 'left' as const,
274
+ fontFamily: 'monospace',
275
+ fontSize: '14px',
276
+ marginBottom: '24px',
277
+ },
278
+ codeComment: {
279
+ color: 'rgba(255,255,255,0.4)',
280
+ margin: '8px 0 4px 0',
281
+ fontSize: '12px',
282
+ },
283
+ getKeyButton: {
284
+ display: 'inline-block',
285
+ background: '#fff',
286
+ color: '#000',
287
+ padding: '16px 32px',
288
+ borderRadius: '12px',
289
+ textDecoration: 'none',
290
+ fontWeight: 600,
291
+ fontSize: '16px',
292
+ transition: 'transform 0.2s',
293
+ },
294
+ // Main container
295
+ container: {
296
+ minHeight: '100%',
297
+ background: '#000',
298
+ },
299
+ // Header
300
+ header: {
301
+ display: 'flex',
302
+ justifyContent: 'space-between',
303
+ alignItems: 'center',
304
+ padding: '12px 16px',
305
+ borderBottom: '1px solid rgba(255,255,255,0.1)',
306
+ background: '#000',
307
+ position: 'sticky' as const,
308
+ top: 0,
309
+ zIndex: 100,
310
+ flexShrink: 0,
311
+ },
312
+ logo: {
313
+ width: '36px',
314
+ height: '36px',
315
+ },
316
+ logoutButton: {
317
+ background: 'transparent',
318
+ border: '1px solid rgba(255,255,255,0.2)',
319
+ borderRadius: '8px',
320
+ padding: '8px 16px',
321
+ color: 'rgba(255,255,255,0.6)',
322
+ cursor: 'pointer',
323
+ fontSize: '14px',
324
+ transition: 'all 0.2s',
325
+ },
326
+ };
327
+
328
+ export default App;
329
+
330
+
331
+
@@ -0,0 +1,26 @@
1
+ // src/components/FounderBadge.tsx
2
+ // Founder badge SVG icon
3
+
4
+ import React from 'react';
5
+
6
+ export interface FounderBadgeProps {
7
+ /** Size in pixels (default: 32) */
8
+ size?: number;
9
+ }
10
+
11
+ export const FounderBadge: React.FC<FounderBadgeProps> = ({ size = 32 }) => (
12
+ <svg width={size} height={size} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
13
+ <path
14
+ d="M12.0117 3.15234C13.1449 2.14828 14.8551 2.14828 15.9883 3.15234L16.0996 3.25684L17.7715 4.89453L20.1123 4.91895L20.2646 4.92383C21.7758 5.01516 22.9848 6.22423 23.0762 7.73535L23.0811 7.8877L23.1045 10.2275L24.7432 11.9004L24.8477 12.0117C25.8517 13.1449 25.8517 14.8551 24.8477 15.9883L24.7432 16.0996L23.1045 17.7715L23.0811 20.1123C23.0646 21.6938 21.8262 22.9818 20.2646 23.0762L20.1123 23.0811L17.7715 23.1045L16.0996 24.7432C14.9697 25.8498 13.1826 25.8852 12.0117 24.8477L11.9004 24.7432L10.2275 23.1045L7.8877 23.0811C6.30625 23.0646 5.01821 21.8262 4.92383 20.2646L4.91895 20.1123L4.89453 17.7715L3.25684 16.0996C2.11446 14.9333 2.11446 13.0667 3.25684 11.9004L4.89453 10.2275L4.91895 7.8877L4.92383 7.73535C5.01821 6.17382 6.30624 4.93536 7.8877 4.91895L10.2275 4.89453L11.9004 3.25684L12.0117 3.15234Z"
15
+ fill="#FF2F8E"
16
+ stroke="#111111"
17
+ strokeWidth="4"
18
+ strokeLinejoin="round"
19
+ />
20
+ <path
21
+ d="M13.5008 16.1741H15.8997C16.4443 16.1741 16.8858 16.6156 16.8858 17.1602C16.8858 17.7048 16.4443 18.1463 15.8997 18.1463H12.2286C11.4558 18.1463 10.8293 17.5199 10.8293 16.7471C10.8293 16.4219 10.9425 16.1069 11.1495 15.8562L14.0743 12.3137H11.8434C11.2988 12.3137 10.8573 11.8722 10.8573 11.3276C10.8573 10.783 11.2988 10.3415 11.8434 10.3415H15.4226C16.1921 10.3415 16.8158 10.9652 16.8158 11.7347C16.8158 12.0634 16.6996 12.3816 16.4876 12.6329L13.5008 16.1741Z"
22
+ fill="white"
23
+ />
24
+ </svg>
25
+ );
26
+
@@ -0,0 +1,149 @@
1
+ // src/components/OTPInput.tsx
2
+ // 6-digit OTP input component
3
+
4
+ import React, { useRef, useEffect } from 'react';
5
+
6
+ export interface OTPInputProps {
7
+ /** Current OTP value array */
8
+ value: string[];
9
+ /** Callback when OTP changes */
10
+ onChange: (otp: string[]) => void;
11
+ /** Callback when all 6 digits entered */
12
+ onComplete?: (otp: string) => void;
13
+ /** Number of digits (default: 6) */
14
+ length?: number;
15
+ /** Disabled state */
16
+ disabled?: boolean;
17
+ /** Error message */
18
+ error?: string;
19
+ /** Auto-focus first input */
20
+ autoFocus?: boolean;
21
+ /** Additional CSS class */
22
+ className?: string;
23
+ }
24
+
25
+ export const OTPInput: React.FC<OTPInputProps> = ({
26
+ value,
27
+ onChange,
28
+ onComplete,
29
+ length = 6,
30
+ disabled = false,
31
+ error,
32
+ autoFocus = true,
33
+ className = '',
34
+ }) => {
35
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
36
+
37
+ // Initialize refs array
38
+ useEffect(() => {
39
+ inputRefs.current = inputRefs.current.slice(0, length);
40
+ }, [length]);
41
+
42
+ // Auto-focus first input
43
+ useEffect(() => {
44
+ if (autoFocus && inputRefs.current[0]) {
45
+ setTimeout(() => inputRefs.current[0]?.focus(), 100);
46
+ }
47
+ }, [autoFocus]);
48
+
49
+ const handleChange = (index: number, inputValue: string) => {
50
+ // Only allow digits
51
+ if (inputValue && !/^\d$/.test(inputValue)) return;
52
+
53
+ const newOtp = [...value];
54
+ newOtp[index] = inputValue;
55
+ onChange(newOtp);
56
+
57
+ // Auto-focus next input
58
+ if (inputValue && index < length - 1) {
59
+ inputRefs.current[index + 1]?.focus();
60
+ }
61
+
62
+ // Check if complete
63
+ if (newOtp.every(digit => digit !== '') && index === length - 1) {
64
+ onComplete?.(newOtp.join(''));
65
+ }
66
+ };
67
+
68
+ const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
69
+ // Backspace: move to previous input
70
+ if (e.key === 'Backspace' && !value[index] && index > 0) {
71
+ inputRefs.current[index - 1]?.focus();
72
+ }
73
+ };
74
+
75
+ const handlePaste = (e: React.ClipboardEvent) => {
76
+ e.preventDefault();
77
+ const pastedData = e.clipboardData.getData('text').slice(0, length);
78
+ const digits = pastedData.replace(/\D/g, '').split('');
79
+
80
+ if (digits.length > 0) {
81
+ const newOtp = [...value];
82
+ digits.forEach((digit, i) => {
83
+ if (i < length) newOtp[i] = digit;
84
+ });
85
+ onChange(newOtp);
86
+
87
+ // Focus last filled input or the one after
88
+ const focusIndex = Math.min(digits.length, length - 1);
89
+ inputRefs.current[focusIndex]?.focus();
90
+
91
+ if (newOtp.every(digit => digit !== '')) {
92
+ onComplete?.(newOtp.join(''));
93
+ }
94
+ }
95
+ };
96
+
97
+ return (
98
+ <div className={`zo-otp-input ${className}`}>
99
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
100
+ {Array.from({ length }).map((_, index) => (
101
+ <input
102
+ key={index}
103
+ ref={(el) => { inputRefs.current[index] = el; }}
104
+ type="text"
105
+ inputMode="numeric"
106
+ maxLength={1}
107
+ value={value[index] || ''}
108
+ onChange={(e) => handleChange(index, e.target.value)}
109
+ onKeyDown={(e) => handleKeyDown(index, e)}
110
+ onPaste={handlePaste}
111
+ disabled={disabled}
112
+ style={{
113
+ width: '48px',
114
+ height: '56px',
115
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
116
+ border: error
117
+ ? '1px solid #ef4444'
118
+ : '1px solid rgba(255, 255, 255, 0.2)',
119
+ borderRadius: '8px',
120
+ textAlign: 'center',
121
+ color: 'white',
122
+ fontFamily: 'Rubik, system-ui, sans-serif',
123
+ fontSize: '20px',
124
+ fontWeight: 600,
125
+ cursor: disabled ? 'not-allowed' : 'text',
126
+ opacity: disabled ? 0.5 : 1,
127
+ }}
128
+ />
129
+ ))}
130
+ </div>
131
+
132
+ {/* Error Message */}
133
+ {error && (
134
+ <p
135
+ style={{
136
+ color: '#ef4444',
137
+ fontSize: '14px',
138
+ marginTop: '8px',
139
+ textAlign: 'center',
140
+ fontFamily: 'Rubik, system-ui, sans-serif',
141
+ }}
142
+ >
143
+ {error}
144
+ </p>
145
+ )}
146
+ </div>
147
+ );
148
+ };
149
+
@@ -0,0 +1,109 @@
1
+ // src/components/PhoneInput.tsx
2
+ // Phone number input with country code selector
3
+
4
+ import React, { useState } from 'react';
5
+ import { COUNTRY_CODES, parsePhoneNumber } from '../lib/utils/phone';
6
+
7
+ export interface PhoneInputProps {
8
+ /** Current phone number value */
9
+ value: string;
10
+ /** Current country code (without +) */
11
+ countryCode: string;
12
+ /** Callback when phone number changes */
13
+ onChange: (phone: string) => void;
14
+ /** Callback when country code changes */
15
+ onCountryChange: (code: string) => void;
16
+ /** Placeholder text */
17
+ placeholder?: string;
18
+ /** Disabled state */
19
+ disabled?: boolean;
20
+ /** Error message */
21
+ error?: string;
22
+ /** Additional CSS class */
23
+ className?: string;
24
+ }
25
+
26
+ export const PhoneInput: React.FC<PhoneInputProps> = ({
27
+ value,
28
+ countryCode,
29
+ onChange,
30
+ onCountryChange,
31
+ placeholder = '555-123-4567',
32
+ disabled = false,
33
+ error,
34
+ className = '',
35
+ }) => {
36
+ const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
37
+ const digits = parsePhoneNumber(e.target.value);
38
+ onChange(digits);
39
+ };
40
+
41
+ return (
42
+ <div className={`zo-phone-input ${className}`}>
43
+ <div style={{ display: 'flex', gap: '8px' }}>
44
+ {/* Country Code Selector */}
45
+ <select
46
+ value={countryCode}
47
+ onChange={(e) => onCountryChange(e.target.value)}
48
+ disabled={disabled}
49
+ style={{
50
+ flex: 1,
51
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
52
+ border: '1px solid rgba(255, 255, 255, 0.2)',
53
+ borderRadius: '8px',
54
+ padding: '12px 16px',
55
+ color: 'white',
56
+ fontFamily: 'Rubik, system-ui, sans-serif',
57
+ fontSize: '14px',
58
+ cursor: disabled ? 'not-allowed' : 'pointer',
59
+ opacity: disabled ? 0.5 : 1,
60
+ }}
61
+ >
62
+ {COUNTRY_CODES.map((country) => (
63
+ <option key={country.code} value={country.code} style={{ backgroundColor: 'black' }}>
64
+ {country.flag} +{country.code}
65
+ </option>
66
+ ))}
67
+ </select>
68
+
69
+ {/* Phone Number Input */}
70
+ <input
71
+ type="tel"
72
+ value={value}
73
+ onChange={handlePhoneChange}
74
+ placeholder={placeholder}
75
+ disabled={disabled}
76
+ style={{
77
+ flex: 2,
78
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
79
+ border: error
80
+ ? '1px solid #ef4444'
81
+ : '1px solid rgba(255, 255, 255, 0.2)',
82
+ borderRadius: '8px',
83
+ padding: '12px 16px',
84
+ color: 'white',
85
+ fontFamily: 'Rubik, system-ui, sans-serif',
86
+ fontSize: '14px',
87
+ cursor: disabled ? 'not-allowed' : 'text',
88
+ opacity: disabled ? 0.5 : 1,
89
+ }}
90
+ />
91
+ </div>
92
+
93
+ {/* Error Message */}
94
+ {error && (
95
+ <p
96
+ style={{
97
+ color: '#ef4444',
98
+ fontSize: '14px',
99
+ marginTop: '8px',
100
+ fontFamily: 'Rubik, system-ui, sans-serif',
101
+ }}
102
+ >
103
+ {error}
104
+ </p>
105
+ )}
106
+ </div>
107
+ );
108
+ };
109
+