yaver-feedback-react-native 0.3.0 → 0.5.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/src/Discovery.ts CHANGED
@@ -1,4 +1,24 @@
1
- import AsyncStorage from '@react-native-async-storage/async-storage';
1
+ // AsyncStorage is an optional peer dep — gracefully degrade if missing.
2
+ // Resolved lazily on each call so unit-test mocks of
3
+ // `@react-native-async-storage/async-storage` are picked up even when
4
+ // Discovery.ts is imported before the mock is applied.
5
+ type AsyncStorageLike = {
6
+ getItem: (key: string) => Promise<string | null>;
7
+ setItem: (key: string, value: string) => Promise<void>;
8
+ removeItem: (key: string) => Promise<void>;
9
+ };
10
+ function getAsyncStorage(): AsyncStorageLike | null {
11
+ try {
12
+ const mod = require('@react-native-async-storage/async-storage');
13
+ const candidate = mod?.default ?? mod;
14
+ if (candidate && typeof candidate.getItem === 'function') {
15
+ return candidate as AsyncStorageLike;
16
+ }
17
+ return null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
2
22
 
3
23
  const STORAGE_KEY = 'yaver_feedback_agent';
4
24
  const DEFAULT_PORT = 18080;
@@ -123,9 +143,116 @@ export class YaverDiscovery {
123
143
 
124
144
  if (!target?.quicHost) return null;
125
145
 
146
+ // Try direct connection first (same LAN)
126
147
  const port = target.httpPort ?? DEFAULT_PORT;
127
- const url = `http://${target.quicHost}:${port}`;
128
- return await YaverDiscovery.probe(url);
148
+ const directUrl = `http://${target.quicHost}:${port}`;
149
+ const directResult = await YaverDiscovery.probe(directUrl);
150
+ if (directResult) return directResult;
151
+
152
+ // Direct connection failed — try via HTTP relay (off-LAN)
153
+ const relayResult = await YaverDiscovery.discoverViaRelay(
154
+ base, authToken, target.deviceId,
155
+ );
156
+ if (relayResult) return relayResult;
157
+
158
+ return null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Discover agent via relay HTTP proxy.
166
+ * Fetches relay server list from Convex platformConfig, then probes
167
+ * `{relayHttpUrl}/d/{deviceId}/health` to reach the agent over the internet.
168
+ */
169
+ static async discoverViaRelay(
170
+ convexUrl: string,
171
+ authToken: string,
172
+ deviceId: string,
173
+ ): Promise<DiscoveryResult | null> {
174
+ try {
175
+ // Fetch relay server list from user settings first, then platform config
176
+ const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
177
+ headers: { Authorization: `Bearer ${authToken}` },
178
+ });
179
+ let relayUrl: string | undefined;
180
+ let relayPassword: string | undefined;
181
+
182
+ if (settingsRes.ok) {
183
+ const settingsData = await settingsRes.json();
184
+ relayUrl = settingsData.relayUrl;
185
+ relayPassword = settingsData.relayPassword;
186
+ }
187
+
188
+ // If no user-level relay, fetch platform relay servers
189
+ if (!relayUrl) {
190
+ const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
191
+ if (configRes.ok) {
192
+ const configData = await configRes.json();
193
+ const servers = typeof configData.value === 'string'
194
+ ? JSON.parse(configData.value)
195
+ : configData.value;
196
+ if (Array.isArray(servers) && servers.length > 0) {
197
+ // Pick the first (highest priority) relay with an httpUrl
198
+ const relay = servers.find((s: { httpUrl?: string }) => s.httpUrl);
199
+ if (relay) {
200
+ relayUrl = relay.httpUrl;
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ if (!relayUrl) return null;
207
+
208
+ // Probe agent through relay: {relayHttpUrl}/d/{deviceId}/health
209
+ const relayBase = `${relayUrl.replace(/\/$/, '')}/d/${deviceId}`;
210
+ const result = await YaverDiscovery.probeWithHeaders(relayBase, {
211
+ 'X-Relay-Password': relayPassword || '',
212
+ });
213
+ return result;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Probe with extra headers (e.g. relay password).
221
+ */
222
+ static async probeWithHeaders(
223
+ url: string,
224
+ headers: Record<string, string>,
225
+ ): Promise<DiscoveryResult | null> {
226
+ const base = url.replace(/\/$/, '');
227
+ const start = Date.now();
228
+
229
+ try {
230
+ const controller = new AbortController();
231
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS + 3000); // relay adds latency
232
+
233
+ const response = await fetch(`${base}/health`, {
234
+ method: 'GET',
235
+ headers,
236
+ signal: controller.signal,
237
+ });
238
+
239
+ clearTimeout(timeoutId);
240
+
241
+ if (!response.ok) return null;
242
+
243
+ const latency = Date.now() - start;
244
+ let hostname = 'Unknown';
245
+ let version = 'unknown';
246
+
247
+ try {
248
+ const data = await response.json();
249
+ hostname = data.hostname ?? data.name ?? 'Unknown';
250
+ version = data.version ?? 'unknown';
251
+ } catch {
252
+ // Health endpoint might return plain text
253
+ }
254
+
255
+ return { url: base, hostname, version, latency };
129
256
  } catch {
130
257
  return null;
131
258
  }
@@ -185,10 +312,12 @@ export class YaverDiscovery {
185
312
  return result;
186
313
  }
187
314
 
188
- /** Get the cached agent connection from AsyncStorage. */
315
+ /** Get the cached agent connection from storage. */
189
316
  static async getStored(): Promise<{ url: string; hostname: string } | null> {
317
+ const storage = getAsyncStorage();
318
+ if (!storage) return null;
190
319
  try {
191
- const raw = await AsyncStorage.getItem(STORAGE_KEY);
320
+ const raw = await storage.getItem(STORAGE_KEY);
192
321
  if (!raw) return null;
193
322
  const parsed = JSON.parse(raw);
194
323
  if (parsed && typeof parsed.url === 'string') {
@@ -200,10 +329,12 @@ export class YaverDiscovery {
200
329
  }
201
330
  }
202
331
 
203
- /** Store a successful discovery result in AsyncStorage. */
332
+ /** Store a successful discovery result. */
204
333
  static async store(result: DiscoveryResult): Promise<void> {
334
+ const storage = getAsyncStorage();
335
+ if (!storage) return;
205
336
  try {
206
- await AsyncStorage.setItem(
337
+ await storage.setItem(
207
338
  STORAGE_KEY,
208
339
  JSON.stringify({ url: result.url, hostname: result.hostname }),
209
340
  );
@@ -214,8 +345,10 @@ export class YaverDiscovery {
214
345
 
215
346
  /** Clear the stored agent connection. */
216
347
  static async clear(): Promise<void> {
348
+ const storage = getAsyncStorage();
349
+ if (!storage) return;
217
350
  try {
218
- await AsyncStorage.removeItem(STORAGE_KEY);
351
+ await storage.removeItem(STORAGE_KEY);
219
352
  } catch {
220
353
  // Storage failure is non-fatal
221
354
  }
@@ -16,6 +16,7 @@ import { BlackBox } from './BlackBox';
16
16
  import { captureScreenshot, startAudioRecording, stopAudioRecording } from './capture';
17
17
  import { uploadFeedback } from './upload';
18
18
  import { TimelineEvent, DeviceInfo, FeedbackBundle, AgentCommentary } from './types';
19
+ import { AuthOverlay } from './AuthOverlay';
19
20
 
20
21
  type FeedbackMode = 'live' | 'narrated' | 'batch';
21
22
 
@@ -327,9 +328,13 @@ export const FeedbackModal: React.FC = () => {
327
328
  [],
328
329
  );
329
330
 
330
- if (!visible) return null;
331
-
331
+ // The AuthOverlay must stay mounted so it can respond to login / picker
332
+ // events even when the feedback modal itself isn't visible. The wrapping
333
+ // fragment keeps the original return shape intact for the modal branch.
332
334
  return (
335
+ <>
336
+ <AuthOverlay />
337
+ {visible && (
333
338
  <Modal
334
339
  visible={visible}
335
340
  animationType="slide"
@@ -474,6 +479,8 @@ export const FeedbackModal: React.FC = () => {
474
479
  </View>
475
480
  </View>
476
481
  </Modal>
482
+ )}
483
+ </>
477
484
  );
478
485
  };
479
486
 
@@ -119,9 +119,13 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
119
119
  const testPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
120
120
  const outputScrollRef = useRef<ScrollView>(null);
121
121
 
122
- // Resolve agent URL and token
122
+ // Resolve agent URL and token — re-read from config each render
123
+ // because discoverAgent() may set agentUrl asynchronously after init.
124
+ const [resolvedAgentUrl, setResolvedAgentUrl] = useState<string | undefined>(
125
+ agentUrlProp || YaverFeedback.getConfig()?.agentUrl,
126
+ );
123
127
  const config = YaverFeedback.getConfig();
124
- const agentUrl = agentUrlProp || config?.agentUrl;
128
+ const agentUrl = resolvedAgentUrl;
125
129
  const authToken = authTokenProp || config?.authToken;
126
130
  const panelBg = panelBackgroundColor || config?.panelBackgroundColor || DEFAULT_PANEL_BG;
127
131
 
@@ -129,19 +133,27 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
129
133
  setOutput((prev) => [...prev.slice(-20), line]);
130
134
  }, []);
131
135
 
132
- // Connection health polling
136
+ // Connection health polling — also picks up agentUrl from config when
137
+ // it becomes available after background discovery completes.
133
138
  useEffect(() => {
134
- if (!healthCheckInterval || !agentUrl) return;
139
+ if (!healthCheckInterval) return;
135
140
 
136
141
  const check = async () => {
142
+ // Re-read config in case discoverAgent() resolved since last check
143
+ const latestUrl = agentUrlProp || YaverFeedback.getConfig()?.agentUrl;
144
+ if (latestUrl && latestUrl !== resolvedAgentUrl) {
145
+ setResolvedAgentUrl(latestUrl);
146
+ }
147
+ if (!latestUrl) return;
148
+
137
149
  try {
138
150
  const client = YaverFeedback.getP2PClient();
139
151
  if (client) {
140
152
  setIsConnected(await client.health());
141
- } else if (agentUrl) {
153
+ } else {
142
154
  const controller = new AbortController();
143
155
  const timeout = setTimeout(() => controller.abort(), 3000);
144
- const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/health`, {
156
+ const resp = await fetch(`${latestUrl.replace(/\/$/, '')}/health`, {
145
157
  signal: controller.signal,
146
158
  });
147
159
  clearTimeout(timeout);
@@ -155,7 +167,7 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
155
167
  check();
156
168
  const interval = setInterval(check, healthCheckInterval);
157
169
  return () => clearInterval(interval);
158
- }, [agentUrl, healthCheckInterval]);
170
+ }, [agentUrlProp, healthCheckInterval, resolvedAgentUrl]);
159
171
 
160
172
  const panResponder = useRef(
161
173
  PanResponder.create({
@@ -0,0 +1,395 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ ActivityIndicator,
9
+ SafeAreaView,
10
+ ScrollView,
11
+ Linking,
12
+ Platform,
13
+ } from 'react-native';
14
+ import {
15
+ loginWithEmail,
16
+ signupWithEmail,
17
+ pollDeviceCode,
18
+ startDeviceCode,
19
+ validateToken,
20
+ saveToken,
21
+ saveUser,
22
+ OAuthProvider,
23
+ DeviceCodeStart,
24
+ } from './auth';
25
+
26
+ export interface YaverLoginScreenProps {
27
+ /** Invoked once a session token is issued and the user is loaded. */
28
+ onLoggedIn: (token: string) => void;
29
+ /** Optional cancel button shown in header. */
30
+ onCancel?: () => void;
31
+ }
32
+
33
+ type Mode = 'device' | 'email';
34
+
35
+ const PROVIDERS: { id: OAuthProvider; label: string; emoji: string }[] = [
36
+ { id: 'apple', label: 'Apple', emoji: '' },
37
+ { id: 'google', label: 'Google', emoji: 'G' },
38
+ { id: 'github', label: 'GitHub', emoji: '' },
39
+ { id: 'gitlab', label: 'GitLab', emoji: '' },
40
+ { id: 'microsoft', label: 'Microsoft', emoji: 'M' },
41
+ ];
42
+
43
+ /**
44
+ * Full-screen in-SDK login. Device-code is the default flow — users sign in
45
+ * with any OAuth provider (Apple/Google/GitHub/GitLab/Microsoft) or email on
46
+ * yaver.io and the SDK polls for the issued session token. Email/password is
47
+ * available as an inline fallback for headless environments.
48
+ */
49
+ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
50
+ onLoggedIn,
51
+ onCancel,
52
+ }) => {
53
+ const [mode, setMode] = useState<Mode>('device');
54
+
55
+ const [code, setCode] = useState<DeviceCodeStart | null>(null);
56
+ const [codeError, setCodeError] = useState<string | null>(null);
57
+ const [starting, setStarting] = useState(false);
58
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
59
+ const expiredTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+
61
+ const [emailMode, setEmailMode] = useState<'login' | 'signup'>('login');
62
+ const [fullName, setFullName] = useState('');
63
+ const [email, setEmail] = useState('');
64
+ const [password, setPassword] = useState('');
65
+ const [emailBusy, setEmailBusy] = useState(false);
66
+ const [emailError, setEmailError] = useState<string | null>(null);
67
+
68
+ useEffect(() => {
69
+ if (mode === 'device' && !code && !starting) {
70
+ void beginDeviceCode(undefined);
71
+ }
72
+ return () => {
73
+ stopPolling();
74
+ };
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [mode]);
77
+
78
+ const stopPolling = () => {
79
+ if (pollRef.current) {
80
+ clearInterval(pollRef.current);
81
+ pollRef.current = null;
82
+ }
83
+ if (expiredTimerRef.current) {
84
+ clearTimeout(expiredTimerRef.current);
85
+ expiredTimerRef.current = null;
86
+ }
87
+ };
88
+
89
+ const beginDeviceCode = async (preferredProvider?: OAuthProvider) => {
90
+ setStarting(true);
91
+ setCodeError(null);
92
+ stopPolling();
93
+ try {
94
+ const result = await startDeviceCode({
95
+ platform: Platform.OS,
96
+ machineName: `feedback-sdk-${Platform.OS}`,
97
+ preferredProvider,
98
+ });
99
+ setCode(result);
100
+
101
+ pollRef.current = setInterval(async () => {
102
+ const poll = await pollDeviceCode(result.deviceCode);
103
+ if (poll.status === 'authorized') {
104
+ stopPolling();
105
+ const user = await validateToken(poll.token);
106
+ await saveToken(poll.token);
107
+ if (user) await saveUser(user);
108
+ onLoggedIn(poll.token);
109
+ } else if (poll.status === 'expired') {
110
+ stopPolling();
111
+ setCodeError('Kod doldu — tekrar başlat.');
112
+ setCode(null);
113
+ }
114
+ }, 3_000);
115
+
116
+ expiredTimerRef.current = setTimeout(() => {
117
+ stopPolling();
118
+ setCode(null);
119
+ setCodeError('Kod süresi doldu.');
120
+ }, Math.max(0, result.expiresAt - Date.now()));
121
+ } catch (err) {
122
+ setCodeError(err instanceof Error ? err.message : String(err));
123
+ } finally {
124
+ setStarting(false);
125
+ }
126
+ };
127
+
128
+ const openVerification = () => {
129
+ if (!code) return;
130
+ Linking.openURL(code.verificationUrl).catch(() => {
131
+ setCodeError('Tarayıcı açılamadı — URL’yi elle aç.');
132
+ });
133
+ };
134
+
135
+ const handleEmailSubmit = async () => {
136
+ setEmailError(null);
137
+ if (!email.trim() || !password) {
138
+ setEmailError('E-posta ve parola zorunlu.');
139
+ return;
140
+ }
141
+ setEmailBusy(true);
142
+ try {
143
+ const result =
144
+ emailMode === 'signup'
145
+ ? await signupWithEmail(fullName.trim() || email.trim(), email.trim(), password)
146
+ : await loginWithEmail(email.trim(), password);
147
+ const user = await validateToken(result.token);
148
+ await saveToken(result.token);
149
+ if (user) await saveUser(user);
150
+ onLoggedIn(result.token);
151
+ } catch (err) {
152
+ setEmailError(err instanceof Error ? err.message : String(err));
153
+ } finally {
154
+ setEmailBusy(false);
155
+ }
156
+ };
157
+
158
+ return (
159
+ <SafeAreaView style={styles.container}>
160
+ <ScrollView
161
+ contentContainerStyle={styles.content}
162
+ keyboardShouldPersistTaps="handled"
163
+ >
164
+ <View style={styles.header}>
165
+ <Text style={styles.title}>Yaver Girişi</Text>
166
+ {onCancel && (
167
+ <TouchableOpacity onPress={onCancel} style={styles.cancel}>
168
+ <Text style={styles.cancelText}>İptal</Text>
169
+ </TouchableOpacity>
170
+ )}
171
+ </View>
172
+
173
+ <View style={styles.tabRow}>
174
+ <TouchableOpacity
175
+ style={[styles.tab, mode === 'device' && styles.tabActive]}
176
+ onPress={() => setMode('device')}
177
+ >
178
+ <Text
179
+ style={[styles.tabText, mode === 'device' && styles.tabTextActive]}
180
+ >
181
+ Hızlı Giriş (OAuth)
182
+ </Text>
183
+ </TouchableOpacity>
184
+ <TouchableOpacity
185
+ style={[styles.tab, mode === 'email' && styles.tabActive]}
186
+ onPress={() => setMode('email')}
187
+ >
188
+ <Text
189
+ style={[styles.tabText, mode === 'email' && styles.tabTextActive]}
190
+ >
191
+ E-posta
192
+ </Text>
193
+ </TouchableOpacity>
194
+ </View>
195
+
196
+ {mode === 'device' && (
197
+ <View style={styles.section}>
198
+ {starting ? (
199
+ <ActivityIndicator color="#6366f1" style={{ marginVertical: 40 }} />
200
+ ) : code ? (
201
+ <>
202
+ <Text style={styles.hint}>
203
+ Tarayıcıda yaver.io/auth/device aç ve aşağıdaki kodu gir.
204
+ Sağlayıcıyla giriş yaptığında buraya otomatik dönecek.
205
+ </Text>
206
+ <View style={styles.codeBox}>
207
+ <Text style={styles.codeText}>{code.userCode}</Text>
208
+ </View>
209
+ <TouchableOpacity style={styles.primaryButton} onPress={openVerification}>
210
+ <Text style={styles.primaryButtonText}>Tarayıcıda Aç</Text>
211
+ </TouchableOpacity>
212
+
213
+ <Text style={[styles.hint, { marginTop: 20 }]}>
214
+ Sağlayıcıyı önceden seçmek istersen:
215
+ </Text>
216
+ <View style={styles.providerRow}>
217
+ {PROVIDERS.map((p) => (
218
+ <TouchableOpacity
219
+ key={p.id}
220
+ style={styles.providerButton}
221
+ onPress={() => beginDeviceCode(p.id)}
222
+ >
223
+ <Text style={styles.providerButtonText}>{p.label}</Text>
224
+ </TouchableOpacity>
225
+ ))}
226
+ </View>
227
+ </>
228
+ ) : (
229
+ <TouchableOpacity
230
+ style={styles.primaryButton}
231
+ onPress={() => beginDeviceCode(undefined)}
232
+ >
233
+ <Text style={styles.primaryButtonText}>Tekrar Başlat</Text>
234
+ </TouchableOpacity>
235
+ )}
236
+ {codeError && <Text style={styles.error}>{codeError}</Text>}
237
+ </View>
238
+ )}
239
+
240
+ {mode === 'email' && (
241
+ <View style={styles.section}>
242
+ <View style={styles.tabRow}>
243
+ <TouchableOpacity
244
+ style={[styles.subTab, emailMode === 'login' && styles.subTabActive]}
245
+ onPress={() => setEmailMode('login')}
246
+ >
247
+ <Text style={styles.subTabText}>Giriş</Text>
248
+ </TouchableOpacity>
249
+ <TouchableOpacity
250
+ style={[styles.subTab, emailMode === 'signup' && styles.subTabActive]}
251
+ onPress={() => setEmailMode('signup')}
252
+ >
253
+ <Text style={styles.subTabText}>Kayıt</Text>
254
+ </TouchableOpacity>
255
+ </View>
256
+
257
+ {emailMode === 'signup' && (
258
+ <>
259
+ <Text style={styles.label}>Ad Soyad</Text>
260
+ <TextInput
261
+ style={styles.input}
262
+ value={fullName}
263
+ onChangeText={setFullName}
264
+ placeholder="Adın Soyadın"
265
+ placeholderTextColor="#666"
266
+ autoCapitalize="words"
267
+ />
268
+ </>
269
+ )}
270
+
271
+ <Text style={styles.label}>E-posta</Text>
272
+ <TextInput
273
+ style={styles.input}
274
+ value={email}
275
+ onChangeText={setEmail}
276
+ placeholder="you@example.com"
277
+ placeholderTextColor="#666"
278
+ keyboardType="email-address"
279
+ autoCapitalize="none"
280
+ autoCorrect={false}
281
+ />
282
+
283
+ <Text style={styles.label}>Parola</Text>
284
+ <TextInput
285
+ style={styles.input}
286
+ value={password}
287
+ onChangeText={setPassword}
288
+ placeholder="••••••••"
289
+ placeholderTextColor="#666"
290
+ secureTextEntry
291
+ autoCapitalize="none"
292
+ />
293
+
294
+ <TouchableOpacity
295
+ style={styles.primaryButton}
296
+ onPress={handleEmailSubmit}
297
+ disabled={emailBusy}
298
+ >
299
+ {emailBusy ? (
300
+ <ActivityIndicator color="#fff" />
301
+ ) : (
302
+ <Text style={styles.primaryButtonText}>
303
+ {emailMode === 'signup' ? 'Kayıt Ol' : 'Giriş Yap'}
304
+ </Text>
305
+ )}
306
+ </TouchableOpacity>
307
+
308
+ {emailError && <Text style={styles.error}>{emailError}</Text>}
309
+ </View>
310
+ )}
311
+ </ScrollView>
312
+ </SafeAreaView>
313
+ );
314
+ };
315
+
316
+ const styles = StyleSheet.create({
317
+ container: { flex: 1, backgroundColor: '#1a1a2e' },
318
+ content: { padding: 24, paddingTop: 16 },
319
+ header: {
320
+ flexDirection: 'row',
321
+ alignItems: 'center',
322
+ justifyContent: 'space-between',
323
+ marginBottom: 20,
324
+ },
325
+ title: { fontSize: 22, fontWeight: '700', color: '#e0e0e0' },
326
+ cancel: { padding: 8 },
327
+ cancelText: { color: '#9ca3af', fontSize: 14 },
328
+ tabRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
329
+ tab: {
330
+ flex: 1,
331
+ paddingVertical: 12,
332
+ borderRadius: 10,
333
+ backgroundColor: 'rgba(255,255,255,0.05)',
334
+ alignItems: 'center',
335
+ },
336
+ tabActive: { backgroundColor: 'rgba(99,102,241,0.25)' },
337
+ tabText: { color: '#9ca3af', fontSize: 14, fontWeight: '600' },
338
+ tabTextActive: { color: '#e0e0e0' },
339
+ subTab: {
340
+ flex: 1,
341
+ paddingVertical: 8,
342
+ borderRadius: 8,
343
+ alignItems: 'center',
344
+ backgroundColor: 'rgba(255,255,255,0.05)',
345
+ },
346
+ subTabActive: { backgroundColor: 'rgba(99,102,241,0.2)' },
347
+ subTabText: { color: '#e0e0e0', fontSize: 13 },
348
+ section: { marginTop: 4 },
349
+ hint: { color: '#9ca3af', fontSize: 13, lineHeight: 18 },
350
+ codeBox: {
351
+ backgroundColor: 'rgba(99,102,241,0.15)',
352
+ borderWidth: 1,
353
+ borderColor: 'rgba(99,102,241,0.35)',
354
+ borderRadius: 14,
355
+ paddingVertical: 24,
356
+ alignItems: 'center',
357
+ marginTop: 16,
358
+ marginBottom: 16,
359
+ },
360
+ codeText: {
361
+ color: '#e0e7ff',
362
+ fontSize: 36,
363
+ fontWeight: '800',
364
+ letterSpacing: 6,
365
+ fontVariant: ['tabular-nums'],
366
+ },
367
+ primaryButton: {
368
+ backgroundColor: '#6366f1',
369
+ borderRadius: 12,
370
+ paddingVertical: 14,
371
+ alignItems: 'center',
372
+ marginTop: 8,
373
+ },
374
+ primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: 15 },
375
+ providerRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
376
+ providerButton: {
377
+ backgroundColor: 'rgba(255,255,255,0.08)',
378
+ borderRadius: 10,
379
+ paddingVertical: 10,
380
+ paddingHorizontal: 14,
381
+ },
382
+ providerButtonText: { color: '#e0e0e0', fontSize: 13, fontWeight: '600' },
383
+ label: { color: '#9ca3af', fontSize: 12, marginTop: 14, marginBottom: 6 },
384
+ input: {
385
+ backgroundColor: 'rgba(255,255,255,0.08)',
386
+ borderWidth: 1,
387
+ borderColor: 'rgba(255,255,255,0.15)',
388
+ borderRadius: 10,
389
+ paddingHorizontal: 14,
390
+ paddingVertical: 12,
391
+ color: '#e0e0e0',
392
+ fontSize: 15,
393
+ },
394
+ error: { color: '#ef4444', fontSize: 13, marginTop: 12 },
395
+ });