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.
- package/LICENSE +22 -0
- package/README.md +407 -0
- package/app/.env.example +15 -0
- package/app/README.md +28 -0
- package/app/package.json +24 -0
- package/app/reanimated-mock.js +102 -0
- package/app/reanimated-mock.jsx +97 -0
- package/app/src/App.tsx +331 -0
- package/app/src/components/FounderBadge.tsx +26 -0
- package/app/src/components/OTPInput.tsx +149 -0
- package/app/src/components/PhoneInput.tsx +109 -0
- package/app/src/components/ZoAuth.tsx +320 -0
- package/app/src/components/ZoAvatar.tsx +87 -0
- package/app/src/components/ZoLanding.tsx +231 -0
- package/app/src/components/ZoOnboarding.tsx +524 -0
- package/app/src/components/ZoPassportCard.tsx +183 -0
- package/app/src/components/ZoProgressRing.tsx +57 -0
- package/app/src/components/index.ts +16 -0
- package/app/src/components/wallet/MovingShine.tsx +43 -0
- package/app/src/components/wallet/TransactionItem.tsx +84 -0
- package/app/src/components/wallet/TransactionList.tsx +65 -0
- package/app/src/components/wallet/WalletCard.tsx +152 -0
- package/app/src/components/wallet/WalletScreen.tsx +190 -0
- package/app/src/components/wallet/ZoToken.tsx +69 -0
- package/app/src/components/wallet/index.ts +8 -0
- package/app/src/components/wallet/styles/index.ts +4 -0
- package/app/src/components/wallet/styles/walletStyles.ts +210 -0
- package/app/src/sdk/ZoPassportSDK.ts +277 -0
- package/app/src/sdk/lib/api/auth.ts +223 -0
- package/app/src/sdk/lib/api/avatar.ts +155 -0
- package/app/src/sdk/lib/api/client.ts +135 -0
- package/app/src/sdk/lib/api/index.ts +8 -0
- package/app/src/sdk/lib/api/profile.ts +80 -0
- package/app/src/sdk/lib/api/wallet.ts +59 -0
- package/app/src/sdk/lib/types/auth.ts +78 -0
- package/app/src/sdk/lib/types/avatar.ts +22 -0
- package/app/src/sdk/lib/types/index.ts +8 -0
- package/app/src/sdk/lib/types/profile.ts +18 -0
- package/app/src/sdk/lib/types/wallet.ts +103 -0
- package/app/src/sdk/lib/types.ts +205 -0
- package/app/src/sdk/lib/utils/index.ts +6 -0
- package/app/src/sdk/lib/utils/phone.ts +71 -0
- package/app/src/sdk/lib/utils/storage.ts +116 -0
- package/app/src/sdk/lib/utils/wallet.ts +73 -0
- package/app/src/sdk/types.ts +205 -0
- package/app/src/styles.css +154 -0
- package/app/svg-mock.js +125 -0
- package/app/svg-mock.jsx +120 -0
- package/app/vite.config.ts +70 -0
- package/assets/ASSETS_MANIFEST.md +124 -0
- package/assets/bae.png +0 -0
- package/assets/bro.png +0 -0
- package/assets/cultural-stickers/Business.png +0 -0
- package/assets/cultural-stickers/Default (2).jpg +0 -0
- package/assets/cultural-stickers/Design.png +0 -0
- package/assets/cultural-stickers/FollowYourHeart.png +0 -0
- package/assets/cultural-stickers/Food.png +0 -0
- package/assets/cultural-stickers/Game.png +0 -0
- package/assets/cultural-stickers/Health&Fitness.png +0 -0
- package/assets/cultural-stickers/Home&Lifestyle.png +0 -0
- package/assets/cultural-stickers/Law.png +0 -0
- package/assets/cultural-stickers/Literature&Stories.png +0 -0
- package/assets/cultural-stickers/Music&Entertainment.png +0 -0
- package/assets/cultural-stickers/Nature&Wildlife.png +0 -0
- package/assets/cultural-stickers/Photography.png +0 -0
- package/assets/cultural-stickers/Science&Technology.png +0 -0
- package/assets/cultural-stickers/Spiritual.png +0 -0
- package/assets/cultural-stickers/Sport.png +0 -0
- package/assets/cultural-stickers/Stories&Journal.png +0 -0
- package/assets/cultural-stickers/Television&Cinema.png +0 -0
- package/assets/cultural-stickers/Travel&Adventure.png +0 -0
- package/assets/cultural-stickers/z.jpg (1).jpg +0 -0
- package/assets/figma-assets/landing-zo-logo.png +0 -0
- package/assets/images/rank1.jpeg +0 -0
- package/assets/index.ts +76 -0
- package/assets/lotties/loader.json +1216 -0
- package/assets/lotties/spinner.json +1 -0
- package/assets/videos/loading-screen-background.mp4 +0 -0
- package/assets/videos/opening-disks.mp4 +0 -0
- package/assets/wallet/constants.ts +38 -0
- package/assets/zo-coin.gif +0 -0
- package/assets/zo-fallback.png +0 -0
- package/dist/assets/index.d.mts +136 -0
- package/dist/assets/index.d.ts +136 -0
- package/dist/assets/index.js +133 -0
- package/dist/assets/index.js.map +1 -0
- package/dist/assets/index.mjs +100 -0
- package/dist/assets/index.mjs.map +1 -0
- package/dist/index.d.mts +789 -0
- package/dist/index.d.ts +789 -0
- package/dist/index.js +1118 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1060 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react-native.d.mts +537 -0
- package/dist/react-native.d.ts +537 -0
- package/dist/react-native.js +1617 -0
- package/dist/react-native.js.map +1 -0
- package/dist/react-native.mjs +1588 -0
- package/dist/react-native.mjs.map +1 -0
- package/dist/react.d.mts +824 -0
- package/dist/react.d.ts +824 -0
- package/dist/react.js +3856 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +3801 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +112 -0
- package/scripts/init.js +196 -0
- package/scripts/postinstall.js +174 -0
- package/scripts/verify-build.js +121 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// src/components/ZoOnboarding.tsx
|
|
2
|
+
// Onboarding flow: nickname, body type, location, avatar generation
|
|
3
|
+
|
|
4
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
5
|
+
|
|
6
|
+
export interface ZoOnboardingProps {
|
|
7
|
+
/** Callback when onboarding is complete */
|
|
8
|
+
onComplete: (userData: OnboardingData) => void;
|
|
9
|
+
/** Update profile function (from SDK) */
|
|
10
|
+
updateProfile: (updates: { first_name?: string; body_type?: 'bro' | 'bae'; place_name?: string }) => Promise<{ success: boolean; error?: string }>;
|
|
11
|
+
/** Get profile function (from SDK) to poll for avatar */
|
|
12
|
+
getProfile: () => Promise<any>;
|
|
13
|
+
/** Video background URL */
|
|
14
|
+
videoUrl?: string;
|
|
15
|
+
/** Zo logo URL */
|
|
16
|
+
logoUrl?: string;
|
|
17
|
+
/** Bro avatar preview URL */
|
|
18
|
+
broAvatarUrl?: string;
|
|
19
|
+
/** Bae avatar preview URL */
|
|
20
|
+
baeAvatarUrl?: string;
|
|
21
|
+
/** Additional CSS class */
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OnboardingData {
|
|
26
|
+
nickname: string;
|
|
27
|
+
bodyType: 'bro' | 'bae';
|
|
28
|
+
city: string;
|
|
29
|
+
avatarUrl: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Step = 'input' | 'generating' | 'success';
|
|
33
|
+
|
|
34
|
+
export const ZoOnboarding: React.FC<ZoOnboardingProps> = ({
|
|
35
|
+
onComplete,
|
|
36
|
+
updateProfile,
|
|
37
|
+
getProfile,
|
|
38
|
+
videoUrl = '/videos/loading-screen-background.mp4',
|
|
39
|
+
logoUrl = '/figma-assets/landing-zo-logo.png',
|
|
40
|
+
broAvatarUrl = '/bro.png',
|
|
41
|
+
baeAvatarUrl = '/bae.png',
|
|
42
|
+
className = '',
|
|
43
|
+
}) => {
|
|
44
|
+
// State
|
|
45
|
+
const [step, setStep] = useState<Step>('input');
|
|
46
|
+
const [nickname, setNickname] = useState('');
|
|
47
|
+
const [bodyType, setBodyType] = useState<'bro' | 'bae'>('bro');
|
|
48
|
+
const [city, setCity] = useState('');
|
|
49
|
+
const [locationEnabled, setLocationEnabled] = useState(false);
|
|
50
|
+
const [isLoadingLocation, setIsLoadingLocation] = useState(false);
|
|
51
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
52
|
+
const [error, setError] = useState('');
|
|
53
|
+
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
|
54
|
+
|
|
55
|
+
// Refs for polling
|
|
56
|
+
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
57
|
+
const attemptsRef = useRef(0);
|
|
58
|
+
|
|
59
|
+
// Validation
|
|
60
|
+
const isNicknameValid = nickname.length >= 4 && nickname.length <= 16 && /^[a-z0-9]*$/.test(nickname);
|
|
61
|
+
const canSubmit = isNicknameValid && locationEnabled && bodyType && !isSaving;
|
|
62
|
+
|
|
63
|
+
// Cleanup polling on unmount
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
return () => {
|
|
66
|
+
if (pollingRef.current) clearTimeout(pollingRef.current);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
// Handle nickname change
|
|
71
|
+
const handleNicknameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
72
|
+
setNickname(e.target.value.toLowerCase());
|
|
73
|
+
setError('');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Handle location enable
|
|
77
|
+
const handleLocationEnable = () => {
|
|
78
|
+
if ('geolocation' in navigator) {
|
|
79
|
+
setIsLoadingLocation(true);
|
|
80
|
+
navigator.geolocation.getCurrentPosition(
|
|
81
|
+
async (position) => {
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(
|
|
84
|
+
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=en`
|
|
85
|
+
);
|
|
86
|
+
const data = await response.json();
|
|
87
|
+
const detectedCity = data.city || data.locality || data.principalSubdivision || 'Unknown City';
|
|
88
|
+
|
|
89
|
+
setCity(detectedCity);
|
|
90
|
+
setLocationEnabled(true);
|
|
91
|
+
setIsLoadingLocation(false);
|
|
92
|
+
setError('');
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error('Failed to get city:', err);
|
|
95
|
+
setError('Failed to detect location. Please try again.');
|
|
96
|
+
setLocationEnabled(false);
|
|
97
|
+
setIsLoadingLocation(false);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
(err) => {
|
|
101
|
+
console.error('Location error:', err);
|
|
102
|
+
setError('Location access denied. Please enable permissions.');
|
|
103
|
+
setLocationEnabled(false);
|
|
104
|
+
setIsLoadingLocation(false);
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
setError('Geolocation is not supported by your browser.');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Handle submit
|
|
113
|
+
const handleSubmit = async () => {
|
|
114
|
+
if (!canSubmit) return;
|
|
115
|
+
|
|
116
|
+
setIsSaving(true);
|
|
117
|
+
setError('');
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Update profile via SDK
|
|
121
|
+
const result = await updateProfile({
|
|
122
|
+
first_name: nickname,
|
|
123
|
+
body_type: bodyType,
|
|
124
|
+
place_name: city,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!result.success) {
|
|
128
|
+
throw new Error(result.error || 'Profile update failed');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Transition to generating step
|
|
132
|
+
setStep('generating');
|
|
133
|
+
|
|
134
|
+
// Start polling for avatar
|
|
135
|
+
pollForAvatar();
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
console.error('Error saving user:', err);
|
|
138
|
+
setError('Failed to save. Please try again.');
|
|
139
|
+
setIsSaving(false);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Poll for avatar
|
|
144
|
+
const pollForAvatar = async () => {
|
|
145
|
+
attemptsRef.current += 1;
|
|
146
|
+
const maxAttempts = 30;
|
|
147
|
+
|
|
148
|
+
if (attemptsRef.current > maxAttempts) {
|
|
149
|
+
// Timeout - use default avatar
|
|
150
|
+
setAvatarUrl(bodyType === 'bro' ? broAvatarUrl : baeAvatarUrl);
|
|
151
|
+
setStep('success');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const profile = await getProfile();
|
|
157
|
+
|
|
158
|
+
if (profile?.avatar?.image) {
|
|
159
|
+
setAvatarUrl(profile.avatar.image);
|
|
160
|
+
setTimeout(() => setStep('success'), 1000);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Poll again in 1s
|
|
165
|
+
pollingRef.current = setTimeout(pollForAvatar, 1000);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('Polling error:', err);
|
|
168
|
+
pollingRef.current = setTimeout(pollForAvatar, 1000);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Handle complete
|
|
173
|
+
const handleComplete = () => {
|
|
174
|
+
onComplete({
|
|
175
|
+
nickname,
|
|
176
|
+
bodyType,
|
|
177
|
+
city,
|
|
178
|
+
avatarUrl,
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const containerStyle: React.CSSProperties = {
|
|
183
|
+
position: 'fixed',
|
|
184
|
+
inset: 0,
|
|
185
|
+
display: 'flex',
|
|
186
|
+
flexDirection: 'column',
|
|
187
|
+
backgroundColor: 'black',
|
|
188
|
+
width: '100vw',
|
|
189
|
+
height: '100vh',
|
|
190
|
+
overflow: 'hidden',
|
|
191
|
+
fontFamily: 'Rubik, system-ui, sans-serif',
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className={`zo-onboarding ${className}`} style={containerStyle}>
|
|
196
|
+
{/* Video Background */}
|
|
197
|
+
<video
|
|
198
|
+
autoPlay
|
|
199
|
+
loop
|
|
200
|
+
muted
|
|
201
|
+
playsInline
|
|
202
|
+
style={{
|
|
203
|
+
position: 'absolute',
|
|
204
|
+
inset: 0,
|
|
205
|
+
width: '100%',
|
|
206
|
+
height: '100%',
|
|
207
|
+
objectFit: 'cover',
|
|
208
|
+
opacity: 0.4,
|
|
209
|
+
zIndex: 0,
|
|
210
|
+
pointerEvents: 'none',
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<source src={videoUrl} type="video/mp4" />
|
|
214
|
+
</video>
|
|
215
|
+
|
|
216
|
+
{/* Gradient Overlay */}
|
|
217
|
+
<div
|
|
218
|
+
style={{
|
|
219
|
+
position: 'absolute',
|
|
220
|
+
inset: 0,
|
|
221
|
+
background: 'linear-gradient(to bottom, transparent, transparent, black)',
|
|
222
|
+
zIndex: 0,
|
|
223
|
+
pointerEvents: 'none',
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
|
|
227
|
+
{/* Zo Logo */}
|
|
228
|
+
<div style={{ position: 'absolute', left: '24px', top: '40px', width: '40px', height: '40px', zIndex: 50 }}>
|
|
229
|
+
<img src={logoUrl} alt="Zo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Main Container */}
|
|
233
|
+
<div
|
|
234
|
+
style={{
|
|
235
|
+
position: 'relative',
|
|
236
|
+
zIndex: 10,
|
|
237
|
+
width: '100%',
|
|
238
|
+
height: '100%',
|
|
239
|
+
display: 'flex',
|
|
240
|
+
flexDirection: 'column',
|
|
241
|
+
alignItems: 'center',
|
|
242
|
+
overflowY: 'auto',
|
|
243
|
+
paddingTop: '120px',
|
|
244
|
+
paddingBottom: '40px',
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
{/* INPUT STEP */}
|
|
248
|
+
{step === 'input' && (
|
|
249
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%', maxWidth: '360px', padding: '0 24px' }}>
|
|
250
|
+
{/* Title */}
|
|
251
|
+
<h1
|
|
252
|
+
style={{
|
|
253
|
+
fontFamily: 'Syne, system-ui, sans-serif',
|
|
254
|
+
fontWeight: 800,
|
|
255
|
+
color: 'white',
|
|
256
|
+
textAlign: 'center',
|
|
257
|
+
textTransform: 'uppercase',
|
|
258
|
+
letterSpacing: '0.32px',
|
|
259
|
+
lineHeight: 1.2,
|
|
260
|
+
marginBottom: '24px',
|
|
261
|
+
fontSize: 'clamp(24px, 6vw, 48px)',
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
WHO ARE YOU?
|
|
265
|
+
</h1>
|
|
266
|
+
|
|
267
|
+
{/* Subtitle */}
|
|
268
|
+
<p
|
|
269
|
+
style={{
|
|
270
|
+
color: 'rgba(255, 255, 255, 0.6)',
|
|
271
|
+
textAlign: 'center',
|
|
272
|
+
lineHeight: 1.5,
|
|
273
|
+
marginBottom: '40px',
|
|
274
|
+
fontSize: 'clamp(14px, 2vw, 16px)',
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
A difficult question, I know. We'll get to it.
|
|
278
|
+
<br />
|
|
279
|
+
But let's start with choosing a nick.
|
|
280
|
+
</p>
|
|
281
|
+
|
|
282
|
+
{/* Nickname Input */}
|
|
283
|
+
<input
|
|
284
|
+
type="text"
|
|
285
|
+
value={nickname}
|
|
286
|
+
onChange={handleNicknameChange}
|
|
287
|
+
placeholder="samurai"
|
|
288
|
+
maxLength={16}
|
|
289
|
+
style={{
|
|
290
|
+
width: '100%',
|
|
291
|
+
height: '56px',
|
|
292
|
+
padding: '0 20px',
|
|
293
|
+
backgroundColor: 'black',
|
|
294
|
+
border: '1px solid #49494A',
|
|
295
|
+
borderRadius: '12px',
|
|
296
|
+
color: 'white',
|
|
297
|
+
fontSize: '16px',
|
|
298
|
+
marginBottom: '40px',
|
|
299
|
+
}}
|
|
300
|
+
autoFocus
|
|
301
|
+
/>
|
|
302
|
+
|
|
303
|
+
{/* Body Type Selection */}
|
|
304
|
+
<div style={{ width: '100%', marginBottom: '32px' }}>
|
|
305
|
+
<p style={{ color: 'rgba(255, 255, 255, 0.8)', fontSize: '14px', textAlign: 'center', marginBottom: '16px' }}>
|
|
306
|
+
Choose your avatar style
|
|
307
|
+
</p>
|
|
308
|
+
|
|
309
|
+
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
|
|
310
|
+
{/* Bae Option */}
|
|
311
|
+
<button
|
|
312
|
+
onClick={() => setBodyType('bae')}
|
|
313
|
+
style={{
|
|
314
|
+
display: 'flex',
|
|
315
|
+
flexDirection: 'column',
|
|
316
|
+
alignItems: 'center',
|
|
317
|
+
gap: '8px',
|
|
318
|
+
padding: '16px',
|
|
319
|
+
borderRadius: '16px',
|
|
320
|
+
border: bodyType === 'bae' ? '2px solid #CFFF50' : '2px solid rgba(255, 255, 255, 0.3)',
|
|
321
|
+
backgroundColor: bodyType === 'bae' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(255, 255, 255, 0.2)',
|
|
322
|
+
cursor: 'pointer',
|
|
323
|
+
minWidth: '120px',
|
|
324
|
+
transform: bodyType === 'bae' ? 'scale(1.05)' : 'scale(1)',
|
|
325
|
+
transition: 'all 0.3s',
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
<div style={{ width: '64px', height: '64px', borderRadius: '50%', overflow: 'hidden', backgroundColor: 'rgba(255, 255, 255, 0.1)' }}>
|
|
329
|
+
<img src={baeAvatarUrl} alt="Bae" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
330
|
+
</div>
|
|
331
|
+
<span style={{ color: 'white', fontSize: '14px', fontWeight: 500 }}>Bae</span>
|
|
332
|
+
</button>
|
|
333
|
+
|
|
334
|
+
{/* Bro Option */}
|
|
335
|
+
<button
|
|
336
|
+
onClick={() => setBodyType('bro')}
|
|
337
|
+
style={{
|
|
338
|
+
display: 'flex',
|
|
339
|
+
flexDirection: 'column',
|
|
340
|
+
alignItems: 'center',
|
|
341
|
+
gap: '8px',
|
|
342
|
+
padding: '16px',
|
|
343
|
+
borderRadius: '16px',
|
|
344
|
+
border: bodyType === 'bro' ? '2px solid #CFFF50' : '2px solid rgba(255, 255, 255, 0.3)',
|
|
345
|
+
backgroundColor: bodyType === 'bro' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(255, 255, 255, 0.2)',
|
|
346
|
+
cursor: 'pointer',
|
|
347
|
+
minWidth: '120px',
|
|
348
|
+
transform: bodyType === 'bro' ? 'scale(1.05)' : 'scale(1)',
|
|
349
|
+
transition: 'all 0.3s',
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
<div style={{ width: '64px', height: '64px', borderRadius: '50%', overflow: 'hidden', backgroundColor: 'rgba(255, 255, 255, 0.1)' }}>
|
|
353
|
+
<img src={broAvatarUrl} alt="Bro" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
354
|
+
</div>
|
|
355
|
+
<span style={{ color: 'white', fontSize: '14px', fontWeight: 500 }}>Bro</span>
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{/* Location Button */}
|
|
361
|
+
<div style={{ width: '100%', marginBottom: '24px' }}>
|
|
362
|
+
{!locationEnabled ? (
|
|
363
|
+
<button
|
|
364
|
+
onClick={handleLocationEnable}
|
|
365
|
+
disabled={isLoadingLocation}
|
|
366
|
+
style={{
|
|
367
|
+
width: '100%',
|
|
368
|
+
height: '48px',
|
|
369
|
+
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
370
|
+
border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
371
|
+
borderRadius: '12px',
|
|
372
|
+
color: 'rgba(255, 255, 255, 0.8)',
|
|
373
|
+
fontSize: '14px',
|
|
374
|
+
cursor: isLoadingLocation ? 'wait' : 'pointer',
|
|
375
|
+
display: 'flex',
|
|
376
|
+
alignItems: 'center',
|
|
377
|
+
justifyContent: 'center',
|
|
378
|
+
gap: '8px',
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
📍 {isLoadingLocation ? 'Detecting...' : 'Enable Location'}
|
|
382
|
+
</button>
|
|
383
|
+
) : (
|
|
384
|
+
<div
|
|
385
|
+
style={{
|
|
386
|
+
width: '100%',
|
|
387
|
+
height: '48px',
|
|
388
|
+
backgroundColor: 'rgba(207, 255, 80, 0.1)',
|
|
389
|
+
border: '1px solid rgba(207, 255, 80, 0.5)',
|
|
390
|
+
borderRadius: '12px',
|
|
391
|
+
color: '#CFFF50',
|
|
392
|
+
fontSize: '14px',
|
|
393
|
+
display: 'flex',
|
|
394
|
+
alignItems: 'center',
|
|
395
|
+
justifyContent: 'center',
|
|
396
|
+
gap: '8px',
|
|
397
|
+
}}
|
|
398
|
+
>
|
|
399
|
+
📍 <span style={{ color: 'white' }}>{city}</span>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
{/* Submit Button */}
|
|
405
|
+
<button
|
|
406
|
+
onClick={handleSubmit}
|
|
407
|
+
disabled={!canSubmit}
|
|
408
|
+
style={{
|
|
409
|
+
width: '100%',
|
|
410
|
+
height: '56px',
|
|
411
|
+
backgroundColor: 'white',
|
|
412
|
+
border: 'none',
|
|
413
|
+
borderRadius: '12px',
|
|
414
|
+
color: 'black',
|
|
415
|
+
fontSize: '16px',
|
|
416
|
+
fontWeight: 500,
|
|
417
|
+
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
|
418
|
+
opacity: canSubmit ? 1 : 0.5,
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
{isSaving ? 'Processing...' : 'Get Citizenship'}
|
|
422
|
+
</button>
|
|
423
|
+
|
|
424
|
+
{/* Error */}
|
|
425
|
+
{error && (
|
|
426
|
+
<p style={{ color: '#ef4444', fontSize: '14px', marginTop: '16px', textAlign: 'center' }}>
|
|
427
|
+
{error}
|
|
428
|
+
</p>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
|
|
433
|
+
{/* GENERATING STEP */}
|
|
434
|
+
{step === 'generating' && (
|
|
435
|
+
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
436
|
+
<div
|
|
437
|
+
style={{
|
|
438
|
+
width: '200px',
|
|
439
|
+
height: '200px',
|
|
440
|
+
borderRadius: '50%',
|
|
441
|
+
border: '2px solid rgba(255, 255, 255, 0.2)',
|
|
442
|
+
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
443
|
+
display: 'flex',
|
|
444
|
+
alignItems: 'center',
|
|
445
|
+
justifyContent: 'center',
|
|
446
|
+
overflow: 'hidden',
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
<img
|
|
450
|
+
src={bodyType === 'bro' ? broAvatarUrl : baeAvatarUrl}
|
|
451
|
+
alt="Generating..."
|
|
452
|
+
style={{
|
|
453
|
+
width: '100%',
|
|
454
|
+
height: '100%',
|
|
455
|
+
objectFit: 'cover',
|
|
456
|
+
opacity: 0.8,
|
|
457
|
+
animation: 'pulse 2s ease-in-out infinite',
|
|
458
|
+
}}
|
|
459
|
+
/>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
{/* SUCCESS STEP */}
|
|
465
|
+
{step === 'success' && (
|
|
466
|
+
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
467
|
+
{/* Avatar */}
|
|
468
|
+
<div
|
|
469
|
+
style={{
|
|
470
|
+
width: '200px',
|
|
471
|
+
height: '200px',
|
|
472
|
+
borderRadius: '50%',
|
|
473
|
+
border: '4px solid white',
|
|
474
|
+
boxShadow: '0 0 40px rgba(255, 255, 255, 0.6)',
|
|
475
|
+
overflow: 'hidden',
|
|
476
|
+
}}
|
|
477
|
+
>
|
|
478
|
+
<img
|
|
479
|
+
src={avatarUrl || (bodyType === 'bro' ? broAvatarUrl : baeAvatarUrl)}
|
|
480
|
+
alt="Avatar"
|
|
481
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
{/* Success Button */}
|
|
486
|
+
<button
|
|
487
|
+
onClick={handleComplete}
|
|
488
|
+
style={{
|
|
489
|
+
position: 'fixed',
|
|
490
|
+
bottom: '40px',
|
|
491
|
+
left: '50%',
|
|
492
|
+
transform: 'translateX(-50%)',
|
|
493
|
+
width: '90%',
|
|
494
|
+
maxWidth: '360px',
|
|
495
|
+
height: '56px',
|
|
496
|
+
backgroundColor: 'white',
|
|
497
|
+
border: 'none',
|
|
498
|
+
borderRadius: '12px',
|
|
499
|
+
color: 'black',
|
|
500
|
+
fontSize: '16px',
|
|
501
|
+
fontWeight: 700,
|
|
502
|
+
textTransform: 'uppercase',
|
|
503
|
+
letterSpacing: '0.05em',
|
|
504
|
+
cursor: 'pointer',
|
|
505
|
+
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
|
506
|
+
}}
|
|
507
|
+
>
|
|
508
|
+
Zo Zo Zo! Let's Go
|
|
509
|
+
</button>
|
|
510
|
+
</div>
|
|
511
|
+
)}
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
{/* CSS Animation */}
|
|
515
|
+
<style>{`
|
|
516
|
+
@keyframes pulse {
|
|
517
|
+
0%, 100% { transform: scale(1); opacity: 0.8; }
|
|
518
|
+
50% { transform: scale(1.05); opacity: 1; }
|
|
519
|
+
}
|
|
520
|
+
`}</style>
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
};
|
|
524
|
+
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// src/components/ZoPassportCard.tsx
|
|
2
|
+
// Leather passport card component
|
|
3
|
+
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { ZoProgressRing } from './ZoProgressRing';
|
|
6
|
+
import { FounderBadge } from './FounderBadge';
|
|
7
|
+
import { ZoAvatar } from './ZoAvatar';
|
|
8
|
+
|
|
9
|
+
// CDN URLs for passport backgrounds
|
|
10
|
+
const FOUNDER_BG = 'https://proxy.cdn.zo.xyz/gallery/media/images/a1659b07-94f0-4490-9b3c-3366715d9717_20250515053726.png';
|
|
11
|
+
const CITIZEN_BG = 'https://proxy.cdn.zo.xyz/gallery/media/images/bda9da5a-eefe-411d-8d90-667c80024463_20250515053805.png';
|
|
12
|
+
|
|
13
|
+
export interface ZoPassportCardProps {
|
|
14
|
+
/** User profile data */
|
|
15
|
+
profile: {
|
|
16
|
+
avatar?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
isFounder?: boolean;
|
|
19
|
+
};
|
|
20
|
+
/** Profile completion */
|
|
21
|
+
completion: {
|
|
22
|
+
done: number;
|
|
23
|
+
total: number;
|
|
24
|
+
};
|
|
25
|
+
/** Additional CSS class */
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Optional: Override founder background URL */
|
|
28
|
+
founderBgUrl?: string;
|
|
29
|
+
/** Optional: Override citizen background URL */
|
|
30
|
+
citizenBgUrl?: string;
|
|
31
|
+
/** Optional: Default avatar fallback URL */
|
|
32
|
+
defaultAvatarUrl?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const ZoPassportCard: React.FC<ZoPassportCardProps> = ({
|
|
36
|
+
profile,
|
|
37
|
+
completion,
|
|
38
|
+
className = '',
|
|
39
|
+
founderBgUrl = FOUNDER_BG,
|
|
40
|
+
citizenBgUrl = CITIZEN_BG,
|
|
41
|
+
defaultAvatarUrl = '/images/rank1.jpeg',
|
|
42
|
+
}) => {
|
|
43
|
+
const isFounder = profile?.isFounder || false;
|
|
44
|
+
const name = profile?.name || 'New Citizen';
|
|
45
|
+
const avatar = profile?.avatar || defaultAvatarUrl;
|
|
46
|
+
const done = completion?.done || 0;
|
|
47
|
+
const total = completion?.total || 1;
|
|
48
|
+
const progress = Math.min(100, Math.max(0, (done / total) * 100));
|
|
49
|
+
|
|
50
|
+
const bgImage = isFounder ? founderBgUrl : citizenBgUrl;
|
|
51
|
+
const textColor = isFounder ? 'white' : '#111111';
|
|
52
|
+
const shadowStyle = isFounder
|
|
53
|
+
? '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.1)'
|
|
54
|
+
: '0 20px 25px -5px rgba(241, 86, 63, 0.5), 0 8px 10px -6px rgba(241, 86, 63, 0.1)';
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={`zo-passport-card ${className}`}
|
|
59
|
+
style={{
|
|
60
|
+
position: 'relative',
|
|
61
|
+
width: '234px',
|
|
62
|
+
height: '300px',
|
|
63
|
+
borderRadius: '0 20px 20px 0',
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
fontFamily: 'Rubik, system-ui, sans-serif',
|
|
66
|
+
boxShadow: shadowStyle,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{/* Background Image */}
|
|
70
|
+
<div style={{ position: 'absolute', inset: 0 }}>
|
|
71
|
+
<img
|
|
72
|
+
src={bgImage}
|
|
73
|
+
alt="Passport Background"
|
|
74
|
+
style={{
|
|
75
|
+
width: '100%',
|
|
76
|
+
height: '100%',
|
|
77
|
+
objectFit: 'cover',
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{/* Circular Progress - Centered */}
|
|
83
|
+
<div
|
|
84
|
+
style={{
|
|
85
|
+
position: 'absolute',
|
|
86
|
+
inset: 0,
|
|
87
|
+
display: 'flex',
|
|
88
|
+
alignItems: 'center',
|
|
89
|
+
justifyContent: 'center',
|
|
90
|
+
top: '-10px',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<ZoProgressRing
|
|
94
|
+
progress={progress}
|
|
95
|
+
size={140}
|
|
96
|
+
strokeWidth={4}
|
|
97
|
+
primaryColor={isFounder ? '#FFFFFF' : '#111111'}
|
|
98
|
+
secondaryColor={isFounder ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Avatar - Centered */}
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
inset: 0,
|
|
107
|
+
display: 'flex',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
justifyContent: 'center',
|
|
110
|
+
top: '-10px',
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<div
|
|
114
|
+
style={{
|
|
115
|
+
width: '120px',
|
|
116
|
+
height: '120px',
|
|
117
|
+
borderRadius: '50%',
|
|
118
|
+
overflow: 'hidden',
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<ZoAvatar src={avatar} name={name} size={120} />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Founder Badge */}
|
|
125
|
+
{isFounder && (
|
|
126
|
+
<div
|
|
127
|
+
style={{
|
|
128
|
+
position: 'absolute',
|
|
129
|
+
bottom: '84px',
|
|
130
|
+
right: '60px',
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<FounderBadge size={32} />
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* Text Container - Bottom */}
|
|
139
|
+
<div
|
|
140
|
+
style={{
|
|
141
|
+
position: 'absolute',
|
|
142
|
+
left: 0,
|
|
143
|
+
right: 0,
|
|
144
|
+
bottom: '16px',
|
|
145
|
+
textAlign: 'center',
|
|
146
|
+
padding: '0 16px',
|
|
147
|
+
display: 'flex',
|
|
148
|
+
flexDirection: 'column',
|
|
149
|
+
gap: '4px',
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<p
|
|
153
|
+
style={{
|
|
154
|
+
margin: 0,
|
|
155
|
+
fontWeight: 700,
|
|
156
|
+
fontSize: '18px',
|
|
157
|
+
lineHeight: '24px',
|
|
158
|
+
color: textColor,
|
|
159
|
+
overflow: 'hidden',
|
|
160
|
+
textOverflow: 'ellipsis',
|
|
161
|
+
whiteSpace: 'nowrap',
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
{name}
|
|
165
|
+
</p>
|
|
166
|
+
<p
|
|
167
|
+
style={{
|
|
168
|
+
margin: 0,
|
|
169
|
+
fontSize: '10px',
|
|
170
|
+
lineHeight: '14px',
|
|
171
|
+
letterSpacing: '0.05em',
|
|
172
|
+
textTransform: 'uppercase',
|
|
173
|
+
color: textColor,
|
|
174
|
+
opacity: 0.7,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{isFounder ? 'Founder of Zo World' : 'Citizen of Zo World'}
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|