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/LICENSE +201 -0
- package/README.md +29 -11
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java +188 -0
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadPackage.java +33 -0
- package/app.plugin.js +263 -5
- package/ios/YaverHotReload.m +16 -0
- package/ios/YaverHotReload.swift +112 -0
- package/package.json +24 -7
- package/src/AuthOverlay.tsx +97 -0
- package/src/BlackBox.ts +41 -2
- package/src/Discovery.ts +141 -8
- package/src/FeedbackModal.tsx +9 -2
- package/src/FloatingButton.tsx +19 -7
- package/src/LoginScreen.tsx +395 -0
- package/src/MachinePickerScreen.tsx +196 -0
- package/src/P2PClient.ts +110 -0
- package/src/YaverFeedback.ts +363 -14
- package/src/YaverUpdates.ts +334 -0
- package/src/__tests__/Discovery.test.ts +8 -2
- package/src/__tests__/YaverFeedback.test.ts +4 -1
- package/src/auth.ts +338 -0
- package/src/index.ts +36 -0
- package/src/types.ts +21 -2
package/src/Discovery.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
|
|
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
|
|
128
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
351
|
+
await storage.removeItem(STORAGE_KEY);
|
|
219
352
|
} catch {
|
|
220
353
|
// Storage failure is non-fatal
|
|
221
354
|
}
|
package/src/FeedbackModal.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/FloatingButton.tsx
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
153
|
+
} else {
|
|
142
154
|
const controller = new AbortController();
|
|
143
155
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
144
|
-
const resp = await fetch(`${
|
|
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
|
-
}, [
|
|
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
|
+
});
|