fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
282
mobile/App.js
Normal file
282
mobile/App.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
BackHandler,
|
||||
Linking,
|
||||
StatusBar,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import Pushy from 'pushy-react-native';
|
||||
|
||||
const APP_URL = 'https://iistwin.ru';
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
|
||||
// Injected before every page load:
|
||||
// 1. sessionStorage → localStorage so session survives app restarts.
|
||||
// 2. Intercept setItem('accessToken') → login event for SPA login detection.
|
||||
// 3. Intercept removeItem('accessToken') → logout event.
|
||||
const BASE_INJECTED_JS = `
|
||||
(function() {
|
||||
try {
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
get: function() { return window.localStorage; },
|
||||
configurable: true
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
try {
|
||||
var _origSet = localStorage.setItem.bind(localStorage);
|
||||
var _origRemove = localStorage.removeItem.bind(localStorage);
|
||||
|
||||
localStorage.setItem = function(key, value) {
|
||||
_origSet(key, value);
|
||||
if (key === 'accessToken' && value && window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
JSON.stringify({ type: 'login', token: value })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.removeItem = function(key) {
|
||||
if (key === 'accessToken') {
|
||||
var tok = localStorage.getItem('accessToken');
|
||||
if (tok && window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
JSON.stringify({ type: 'logout', token: tok })
|
||||
);
|
||||
}
|
||||
}
|
||||
return _origRemove(key);
|
||||
};
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`;
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
if (Platform.OS !== 'android') return;
|
||||
if (Platform.Version >= 33) {
|
||||
await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
|
||||
{
|
||||
title: 'Разрешение на уведомления',
|
||||
message: 'iistwin хочет отправлять вам уведомления о задачах и сообщениях.',
|
||||
buttonPositive: 'Разрешить',
|
||||
buttonNegative: 'Не сейчас',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const webViewRef = useRef(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [pushyToken, setPushyToken] = useState(null);
|
||||
// true once the WebView fires onLoadEnd at least once
|
||||
const [webViewReady, setWebViewReady] = useState(false);
|
||||
// Auth token received via login event before pushyToken was available (race guard)
|
||||
const [pendingAuthToken, setPendingAuthToken] = useState(null);
|
||||
// URL from notification tap while app was killed (cold-start)
|
||||
const [pendingUrl, setPendingUrl] = useState(null);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Register using an auth token passed explicitly (login event / replay)
|
||||
const registerDevice = useCallback((deviceToken, authToken) => {
|
||||
if (!deviceToken || !authToken || !webViewRef.current) return;
|
||||
const dt = JSON.stringify(deviceToken);
|
||||
const at = JSON.stringify(authToken);
|
||||
webViewRef.current.injectJavaScript(`
|
||||
(function() {
|
||||
try {
|
||||
fetch('/api/devices/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} },
|
||||
body: JSON.stringify({ token: ${dt}, platform: 'android' })
|
||||
}).catch(function() {});
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`);
|
||||
}, []);
|
||||
|
||||
// Register by reading accessToken from localStorage (existing sessions / page reload)
|
||||
const registerFromStorage = useCallback((deviceToken) => {
|
||||
if (!deviceToken || !webViewRef.current) return;
|
||||
const dt = JSON.stringify(deviceToken);
|
||||
webViewRef.current.injectJavaScript(`
|
||||
(function() {
|
||||
try {
|
||||
var at = localStorage.getItem('accessToken');
|
||||
if (at) {
|
||||
fetch('/api/devices/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + at },
|
||||
body: JSON.stringify({ token: ${dt}, platform: 'android' })
|
||||
}).catch(function() {});
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`);
|
||||
}, []);
|
||||
|
||||
const unregisterDevice = useCallback((deviceToken, authToken) => {
|
||||
if (!deviceToken || !authToken || !webViewRef.current) return;
|
||||
const dt = JSON.stringify(deviceToken);
|
||||
const at = JSON.stringify(authToken);
|
||||
webViewRef.current.injectJavaScript(`
|
||||
(function() {
|
||||
try {
|
||||
fetch('/api/devices/unregister', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} },
|
||||
body: JSON.stringify({ token: ${dt} })
|
||||
}).catch(function() {});
|
||||
} catch(e) {}
|
||||
})();
|
||||
true;
|
||||
`);
|
||||
}, []);
|
||||
|
||||
const navigateTo = useCallback((path) => {
|
||||
if (!path || !webViewRef.current) return;
|
||||
const url = path.startsWith('http') ? path : APP_URL + path;
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.location.href = ${JSON.stringify(url)}; true;`
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Coordination effect: fires whenever pushyToken or webViewReady changes ──
|
||||
// Ensures registration always happens once BOTH sides are ready,
|
||||
// regardless of which arrives first.
|
||||
useEffect(() => {
|
||||
if (!pushyToken || !webViewReady) return;
|
||||
|
||||
if (pendingAuthToken) {
|
||||
// Login event arrived before pushyToken – replay it now
|
||||
registerDevice(pushyToken, pendingAuthToken);
|
||||
setPendingAuthToken(null);
|
||||
} else {
|
||||
// Normal start: read token from existing session in localStorage
|
||||
registerFromStorage(pushyToken);
|
||||
}
|
||||
}, [pushyToken, webViewReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── One-time effect: Pushy setup ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
requestNotificationPermission();
|
||||
|
||||
Pushy.register()
|
||||
.then((token) => {
|
||||
console.log('Pushy device token:', token);
|
||||
setPushyToken(token);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Pushy registration error:', err.message);
|
||||
});
|
||||
|
||||
Pushy.setNotificationHandler((data, callback) => {
|
||||
const title = data.title || 'iistwin';
|
||||
const body = data.body || '';
|
||||
if (body) {
|
||||
Alert.alert(title, body, [
|
||||
{ text: 'Открыть', onPress: () => { if (data.url) navigateTo(data.url); } },
|
||||
{ text: 'Закрыть', style: 'cancel' },
|
||||
]);
|
||||
}
|
||||
callback();
|
||||
});
|
||||
|
||||
Pushy.setNotificationClickListener((data) => {
|
||||
if (data.url) {
|
||||
if (webViewRef.current) {
|
||||
navigateTo(data.url);
|
||||
} else {
|
||||
setPendingUrl(data.url);
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Back button ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onBackPress = () => {
|
||||
if (canGoBack && webViewRef.current) {
|
||||
webViewRef.current.goBack();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||
return () => sub.remove();
|
||||
}, [canGoBack]);
|
||||
|
||||
// ── WebView callbacks ─────────────────────────────────────────────────────
|
||||
|
||||
const handleLoadEnd = () => {
|
||||
setWebViewReady(true); // triggers coordination effect above
|
||||
if (pendingUrl) {
|
||||
navigateTo(pendingUrl);
|
||||
setPendingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = useCallback((event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.nativeEvent.data);
|
||||
|
||||
if (msg.type === 'login' && msg.token) {
|
||||
if (pushyToken) {
|
||||
// Both pieces ready — register immediately
|
||||
registerDevice(pushyToken, msg.token);
|
||||
} else {
|
||||
// pushyToken not yet available — cache for coordination effect
|
||||
setPendingAuthToken(msg.token);
|
||||
}
|
||||
} else if (msg.type === 'logout' && msg.token && pushyToken) {
|
||||
unregisterDevice(pushyToken, msg.token);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}, [pushyToken, registerDevice, unregisterDevice]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar backgroundColor="#3b82f6" barStyle="light-content" translucent={false} />
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: APP_URL }}
|
||||
style={styles.webview}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
sharedCookiesEnabled={true}
|
||||
thirdPartyCookiesEnabled={true}
|
||||
injectedJavaScriptBeforeContentLoaded={BASE_INJECTED_JS}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onMessage={handleMessage}
|
||||
onNavigationStateChange={(navState) => setCanGoBack(navState.canGoBack)}
|
||||
onPermissionRequest={(request) => { request.grant(request.resources); }}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
try {
|
||||
if (new URL(request.url).origin === APP_ORIGIN) return true;
|
||||
} catch {}
|
||||
Linking.openURL(request.url);
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#3b82f6' },
|
||||
webview: { flex: 1 },
|
||||
});
|
||||
Reference in New Issue
Block a user