scanQrPage.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. import { CameraView, useCameraPermissions } from 'expo-camera';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import {
  4. ActivityIndicator,
  5. Alert,
  6. AppState,
  7. Dimensions,
  8. Linking,
  9. Pressable,
  10. ScrollView,
  11. StyleSheet,
  12. Text,
  13. Vibration,
  14. View,
  15. Platform
  16. } from 'react-native';
  17. import sha256 from 'crypto-js/sha256';
  18. import ChooseCarForChargingRow from '../../../../component/global/chooseCarForChargingRow';
  19. import { CrossLogoWhiteSvg, QuestionSvg } from '../../../../component/global/SVG';
  20. import { router, useFocusEffect } from 'expo-router';
  21. import { chargeStationService } from '../../../../service/chargeStationService';
  22. import { authenticationService } from '../../../../service/authService';
  23. import { walletService } from '../../../../service/walletService';
  24. import useUserInfoStore from '../../../../providers/userinfo_store';
  25. import Modal from 'react-native-modal';
  26. import NormalButton from '../../../../component/global/normal_button';
  27. import { ceil } from 'lodash';
  28. import AsyncStorage from '@react-native-async-storage/async-storage';
  29. import { useChargingStore } from '../../../../providers/scan_qr_payload_store';
  30. const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
  31. //reminder: scan qr code page, ic call should be false
  32. const ScanQrPage = () => {
  33. const { userID, currentPrice, setCurrentPrice } = useUserInfoStore();
  34. const [currentPriceFetchedWhenScanQr, setCurrentPriceFetchedWhenScanQr] = useState(0);
  35. const { scanned_qr_code, setScannedQrCode, stationID, setStationId } = useChargingStore();
  36. const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
  37. const [permission, requestPermission] = useCameraPermissions();
  38. const [scanned, setScanned] = useState(false);
  39. const viewRef = useRef(null);
  40. const [scannedResult, setScannedResult] = useState('');
  41. const [selectedCar, setSelectedCar] = useState('');
  42. const now = new Date();
  43. const [loading, setLoading] = useState(true);
  44. const [loading2, setLoading2] = useState(false);
  45. const [loading3, setLoading3] = useState(false);
  46. const [carData, setCarData] = useState([]);
  47. const [isModalVisible, setModalVisible] = useState(false);
  48. const [isConfirmLoading, setIsConfirmLoading] = useState(false);
  49. const [availableSlots, setAvailableSlots] = useState({
  50. // 3: false,
  51. 25: false,
  52. 30: false,
  53. 40: false,
  54. 45: false,
  55. full: false
  56. });
  57. const [selectedDuration, setSelectedDuration] = useState(null);
  58. const appState = useRef(AppState.currentState);
  59. const [paymentStatus, setPaymentStatus] = useState(null);
  60. const [isExpectingPayment, setIsExpectingPayment] = useState(false);
  61. const paymentInitiatedTime = useRef(null);
  62. const PAYMENT_CHECK_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
  63. const [outTradeNo, setOutTradeNo] = useState('');
  64. const [totalFee, setTotalFee] = useState(0);
  65. const [walletBalance, setWalletBalance] = useState(0);
  66. // Effect for requesting camera permissions
  67. useEffect(() => {
  68. (async () => {
  69. const { status } = await requestPermission();
  70. if (status !== 'granted') {
  71. alert(
  72. '我們需要相機權限來掃描機器上的二維碼,以便識別並啟動充電機器。我們不會儲存或共享任何掃描到的資訊。 請前往設定開啟相機權限'
  73. );
  74. }
  75. })();
  76. }, []);
  77. useFocusEffect(
  78. useCallback(() => {
  79. // When screen comes into focus, enable scanning
  80. setScanned(false);
  81. return () => {
  82. // When screen loses focus, disable scanning
  83. setScanned(true);
  84. };
  85. }, [])
  86. );
  87. useEffect(() => {
  88. const fetchDefaultCar = async () => {
  89. try {
  90. const response = await chargeStationService.getUserDefaultCars();
  91. if (response) {
  92. // console.log('default car', response.data.id);
  93. setSelectedCar(response.data.id);
  94. }
  95. } catch (error) {
  96. } finally {
  97. setLoading(false);
  98. }
  99. };
  100. fetchDefaultCar();
  101. }, []);
  102. useEffect(() => {
  103. const getWalletBalance = async () => {
  104. try {
  105. const response = await walletService.getWalletBalance();
  106. if (response) {
  107. // console.log('walletBalance setting up', response);
  108. setWalletBalance(response);
  109. }
  110. } catch (error) {
  111. console.log(error);
  112. }
  113. };
  114. getWalletBalance();
  115. }, []);
  116. // Function to handle barcode scanning
  117. const handleBarCodeScanned = async ({ bounds, data, type }: { bounds?: any; data: any; type: any }) => {
  118. if (
  119. !bounds ||
  120. typeof bounds.origin?.x !== 'number' ||
  121. typeof bounds.origin?.y !== 'number' ||
  122. typeof bounds.size?.width !== 'number' ||
  123. typeof bounds.size?.height !== 'number'
  124. ) {
  125. setScanned(true);
  126. setScannedQrCode(data);
  127. Vibration.vibrate(100);
  128. //after scanning, immediately fetch the correct station id and push to optionPage
  129. try {
  130. const stationId = await chargeStationService.noImagefetchChargeStationIdByScannedConnectorId(data);
  131. if (!stationId) {
  132. Alert.alert('錯誤', '無法找到充電站,請稍後再嘗試');
  133. setTimeout(() => {
  134. setScanned(false);
  135. }, 2000);
  136. return;
  137. }
  138. setStationId(stationId);
  139. router.push('/optionPage');
  140. } catch (error) {
  141. console.error('Error fetching station ID:', error);
  142. Alert.alert('錯誤', '無法找到充電站,請稍後再試');
  143. setTimeout(() => {
  144. setScanned(false);
  145. }, 2000);
  146. return;
  147. }
  148. return;
  149. }
  150. // -----------------------------------------------------------------------------------------------------
  151. const { origin, size } = bounds;
  152. // Calculate the size of the square transparent area
  153. const transparentAreaSize = Math.min(screenWidth * 0.6, screenHeight * 0.3);
  154. const transparentAreaX = (screenWidth - transparentAreaSize) / 2;
  155. const transparentAreaY = (screenHeight - transparentAreaSize) / 2;
  156. // Check if the barcode is within the transparent area
  157. // 在iOS上检查二维码是否在扫描框内,在安卓上跳过位置检查
  158. const isIOS = Platform.OS === 'ios';
  159. const isWithinScanArea = isIOS
  160. ? origin.x >= transparentAreaX &&
  161. origin.y >= transparentAreaY &&
  162. origin.x + size.width <= transparentAreaX + transparentAreaSize &&
  163. origin.y + size.height <= transparentAreaY + transparentAreaSize
  164. : true;
  165. if (isWithinScanArea) {
  166. setScanned(true);
  167. setScannedQrCode(data);
  168. Vibration.vibrate(100);
  169. //after scanning, immediately fetch the correct station id and push to optionPage
  170. try {
  171. const stationId = await chargeStationService.noImagefetchChargeStationIdByScannedConnectorId(data);
  172. if (!stationId) {
  173. Alert.alert('錯誤', '無法找到充電站,請稍後再嘗試');
  174. setTimeout(() => {
  175. setScanned(false);
  176. }, 2000);
  177. return;
  178. }
  179. setStationId(stationId);
  180. router.push('/optionPage');
  181. } catch (error) {
  182. console.error('Error fetching station ID:', error);
  183. Alert.alert('錯誤', '無法找到充電站,請稍後再試');
  184. setTimeout(() => {
  185. setScanned(false);
  186. }, 2000);
  187. return;
  188. }
  189. return;
  190. }
  191. };
  192. useEffect(() => {
  193. const subscription = AppState.addEventListener('change', (nextAppState) => {
  194. if (
  195. appState.current.match(/inactive|background/) &&
  196. nextAppState === 'active' &&
  197. isExpectingPayment &&
  198. // outTradeNo &&
  199. paymentInitiatedTime.current
  200. ) {
  201. const currentTime = new Date().getTime();
  202. if (currentTime - paymentInitiatedTime.current < PAYMENT_CHECK_TIMEOUT) {
  203. checkPaymentStatus();
  204. } else {
  205. // Payment check timeout reached
  206. setIsExpectingPayment(false);
  207. setOutTradeNo('');
  208. paymentInitiatedTime.current = null;
  209. Alert.alert(
  210. 'Payment Timeout',
  211. 'The payment status check has timed out. Please check your payment history.'
  212. );
  213. }
  214. }
  215. appState.current = nextAppState;
  216. });
  217. return () => {
  218. subscription.remove();
  219. };
  220. }, [outTradeNo, isExpectingPayment]);
  221. const checkPaymentStatus = async () => {
  222. try {
  223. // console.log('outTradeNo in scanQR Page checkpaymentstatus ', outTradeNo);
  224. const result = await walletService.checkPaymentStatus(outTradeNo);
  225. setPaymentStatus(result);
  226. // console.log('checkPaymentStatus from scan QR checkpaymentStatus', result);
  227. if (result && !result.some((item) => item.errmsg?.includes('處理中'))) {
  228. // Payment successful
  229. // console.log('totalFee', totalFee);
  230. Alert.alert(
  231. '付款已成功',
  232. `你已成功增值HKD $${
  233. Number.isInteger(totalFee) ? totalFee : totalFee.toFixed(1)
  234. }。請重新掃描去啟動充電槍。`,
  235. [
  236. {
  237. text: '確認',
  238. onPress: async () => {
  239. setModalVisible(false);
  240. router.dismiss();
  241. }
  242. }
  243. ]
  244. );
  245. } else {
  246. Alert.alert('付款失敗', '請再試一次。', [
  247. {
  248. text: '確定',
  249. onPress: () => {
  250. setModalVisible(false);
  251. router.dismiss();
  252. }
  253. }
  254. ]);
  255. }
  256. setIsExpectingPayment(false);
  257. setOutTradeNo('');
  258. paymentInitiatedTime.current = null;
  259. } catch (error) {
  260. console.error('Failed to check payment status:', error);
  261. Alert.alert('Error', 'Failed to check payment status. Please check your payment history.');
  262. }
  263. };
  264. function formatTime(utcTimeString) {
  265. // Parse the UTC time string
  266. const date = new Date(utcTimeString);
  267. // Add 8 hours
  268. date.setHours(date.getHours());
  269. // Format the date
  270. const year = date.getFullYear();
  271. const month = String(date.getMonth() + 1).padStart(2, '0');
  272. const day = String(date.getDate()).padStart(2, '0');
  273. const hours = String(date.getHours()).padStart(2, '0');
  274. const minutes = String(date.getMinutes()).padStart(2, '0');
  275. const seconds = String(date.getSeconds()).padStart(2, '0');
  276. // Return the formatted string
  277. return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  278. }
  279. const oneTimeCharging = async (inputAmount) => {
  280. try {
  281. const response = await walletService.getOutTradeNo();
  282. // console.log('outtradeno in oneTimeCharging', response);
  283. if (response) {
  284. setOutTradeNo(response);
  285. setIsExpectingPayment(true);
  286. paymentInitiatedTime.current = new Date().getTime();
  287. const now = new Date();
  288. const formattedTime = formatTime(now);
  289. let amount = inputAmount * 100;
  290. const origin = 'https://openapi-hk.qfapi.com/checkstand/#/?';
  291. const obj = {
  292. // appcode: '6937EF25DF6D4FA78BB2285441BC05E9',
  293. appcode: '636E234FB30D43598FC8F0140A1A7282',
  294. goods_name: 'Crazy Charge 錢包增值',
  295. out_trade_no: response,
  296. paysource: 'crazycharge_checkout',
  297. return_url: 'https://crazycharge.com.hk/completed',
  298. failed_url: 'https://crazycharge.com.hk/failed',
  299. notify_url: 'https://api.crazycharge.com.hk/api/v1/clients/qfpay/webhook',
  300. sign_type: 'sha256',
  301. txamt: amount,
  302. txcurrcd: 'HKD',
  303. txdtm: formattedTime
  304. };
  305. const paramStringify = (json, flag?) => {
  306. let str = '';
  307. let keysArr = Object.keys(json);
  308. keysArr.sort().forEach((val) => {
  309. if (!json[val]) return;
  310. str += `${val}=${flag ? encodeURIComponent(json[val]) : json[val]}&`;
  311. });
  312. return str.slice(0, -1);
  313. };
  314. // const api_key = '8F59E31F6ADF4D2894365F2BB6D2FF2C';
  315. const api_key = '3E2727FBA2DA403EA325E73F36B07824';
  316. const params = paramStringify(obj);
  317. const sign = sha256(`${params}${api_key}`).toString();
  318. const url = `${origin}${paramStringify(obj, true)}&sign=${sign}`;
  319. try {
  320. // console.log(url);
  321. const supported = await Linking.canOpenURL(url);
  322. if (supported) {
  323. await Linking.openURL(url);
  324. } else {
  325. Alert.alert('錯誤', '請稍後再試');
  326. }
  327. } catch (error) {
  328. console.error('Top-up failed:', error);
  329. Alert.alert('Error', '一次性付款失敗,請稍後再試');
  330. }
  331. } else {
  332. }
  333. } catch (error) {}
  334. };
  335. const startCharging = async (dataForSubmission) => {
  336. try {
  337. //before i start below logic, i need to check if the user has penalty unpaid.
  338. //i will call fetchReservationHistories. and the api will return an array of object, within the object there is a field called "penalty_fee".
  339. //if any reservation has penalty_fee > 0, i will show an alert to the user, and once click the alert it will takes them to a page that show the detail of the reservation.
  340. const reservationHistories = await chargeStationService.fetchReservationHistories();
  341. const unpaidPenalties = reservationHistories.filter(
  342. (reservation) => reservation.penalty_fee > 0 && reservation.penalty_paid_status === false
  343. );
  344. const mostRecentUnpaidReservation = unpaidPenalties.reduce((mostRecent, current) => {
  345. return new Date(mostRecent.created_at) > new Date(current.created_at) ? mostRecent : current;
  346. }, unpaidPenalties[0]);
  347. if (unpaidPenalties.length > 0) {
  348. Alert.alert(
  349. '未付罰款',
  350. '您有未支付的罰款。請先支付罰款後再開始充電。',
  351. [
  352. {
  353. text: '查看詳情',
  354. onPress: () => {
  355. // Navigate to a page showing penalty details
  356. setModalVisible(false);
  357. setLoading(false);
  358. router.push({
  359. pathname: '(auth)/(tabs)/(home)/penaltyPaymentPage',
  360. params: {
  361. book_time: mostRecentUnpaidReservation.book_time,
  362. end_time: mostRecentUnpaidReservation.end_time,
  363. actual_end_time: mostRecentUnpaidReservation.actual_end_time,
  364. penalty_fee: mostRecentUnpaidReservation.penalty_fee,
  365. format_order_id: mostRecentUnpaidReservation.format_order_id,
  366. id: mostRecentUnpaidReservation.id,
  367. stationName:
  368. mostRecentUnpaidReservation.connector.EquipmentID.StationID.snapshot
  369. .StationName,
  370. address:
  371. mostRecentUnpaidReservation.connector.EquipmentID.StationID.snapshot.Address
  372. }
  373. });
  374. }
  375. },
  376. {
  377. text: '返回',
  378. onPress: () => {
  379. setModalVisible(false);
  380. if (router.canGoBack()) {
  381. router.back();
  382. } else {
  383. router.push('/mainPage');
  384. }
  385. }
  386. }
  387. ],
  388. { cancelable: false }
  389. );
  390. return;
  391. }
  392. const wallet = await walletService.getWalletBalance();
  393. if (wallet < dataForSubmission.total_fee) {
  394. oneTimeCharging(dataForSubmission.total_fee);
  395. // const remainingAmount = dataForSubmission.total_fee - wallet;
  396. // oneTimeCharging(remainingAmount);
  397. return;
  398. }
  399. const response = await walletService.submitPayment(
  400. dataForSubmission.stationID,
  401. dataForSubmission.connector,
  402. dataForSubmission.user,
  403. dataForSubmission.book_time,
  404. dataForSubmission.end_time,
  405. dataForSubmission.total_power,
  406. dataForSubmission.total_fee,
  407. dataForSubmission.promotion_code,
  408. dataForSubmission.car,
  409. dataForSubmission.type,
  410. dataForSubmission.is_ic_call
  411. );
  412. if (response.status === 200 || response.status === 201) {
  413. setSelectedDuration(null);
  414. setIsConfirmLoading(false);
  415. await AsyncStorage.setItem('chargingStarted', 'true');
  416. Alert.alert('啟動成功', '請按下確認並等待頁面稍後自動跳轉至充電介面', [
  417. {
  418. text: 'OK',
  419. onPress: async () => {
  420. setModalVisible(false);
  421. setLoading(true);
  422. // Wait for 2 seconds
  423. await new Promise((resolve) => setTimeout(resolve, 2000));
  424. // Hide loading spinner and navigate
  425. setLoading(false);
  426. router.navigate('(auth)/(tabs)/(home)/mainPage');
  427. router.push('(auth)/(tabs)/(charging)/chargingPage');
  428. }
  429. }
  430. ]);
  431. } else if (response.status === 400) {
  432. Alert.alert('餘額不足', '掃描失敗 請稍後再試。');
  433. } else {
  434. Alert.alert('掃描失敗 請稍後再試。', response);
  435. }
  436. } catch (error) {}
  437. };
  438. return (
  439. <View style={styles.container} ref={viewRef}>
  440. {!permission ? (
  441. <View />
  442. ) : !permission.granted ? (
  443. <View className="flex-1 justify-center items-center">
  444. <Text style={{ textAlign: 'center' }}>
  445. 我們需要相機權限來掃描機器上的二維碼,以便識別並啟動充電機器。我們不會儲存或共享任何掃描到的資訊。
  446. 請前往設定開啟相機權限
  447. </Text>
  448. </View>
  449. ) : loading ? (
  450. <View className="flex-1 items-center justify-center">
  451. <ActivityIndicator />
  452. </View>
  453. ) : (
  454. <View className="flex-1 position-relative">
  455. <CameraView
  456. style={styles.camera}
  457. facing="back"
  458. barcodeScannerSettings={{
  459. barcodeTypes: ['qr']
  460. }}
  461. onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
  462. responsiveOrientationWhenOrientationLocked={true}
  463. ></CameraView>
  464. <View style={styles.overlay}>
  465. <View style={styles.topOverlay}>
  466. <Pressable
  467. className="absolute top-20 left-10 z-10 p-4"
  468. hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }} // Added hitSlop
  469. onPress={() => {
  470. if (router.canGoBack()) {
  471. router.back();
  472. } else {
  473. router.push('/mainPage');
  474. }
  475. }}
  476. >
  477. <View style={{ transform: [{ scale: 1.5 }] }}>
  478. <CrossLogoWhiteSvg />
  479. </View>
  480. </Pressable>
  481. </View>
  482. <View style={styles.centerRow}>
  483. <View style={styles.leftOverlay}></View>
  484. <View style={styles.transparentArea}></View>
  485. <View style={styles.rightOverlay} />
  486. </View>
  487. <View className="items-center justify-between" style={styles.bottomOverlay}>
  488. <View>
  489. <Text className="text-white text-lg font-bold mt-2 text-center">
  490. 請掃瞄充電座上的二維碼
  491. </Text>
  492. </View>
  493. <View className="flex-row space-x-2 items-center ">
  494. <QuestionSvg />
  495. <Pressable onPress={() => router.push('assistancePage')}>
  496. <Text className="text-white text-base">需要協助?</Text>
  497. </Pressable>
  498. </View>
  499. <View />
  500. </View>
  501. </View>
  502. </View>
  503. )}
  504. </View>
  505. );
  506. };
  507. const styles = StyleSheet.create({
  508. container: {
  509. flex: 1
  510. },
  511. camera: {
  512. flex: 1
  513. },
  514. overlay: {
  515. flex: 1,
  516. width: '100%',
  517. height: '100%',
  518. position: 'absolute'
  519. },
  520. topOverlay: {
  521. flex: 35,
  522. alignItems: 'center',
  523. backgroundColor: 'rgba(0,0,0,0.5)'
  524. },
  525. centerRow: {
  526. flex: 30,
  527. flexDirection: 'row'
  528. },
  529. leftOverlay: {
  530. flex: 20,
  531. backgroundColor: 'rgba(0,0,0,0.5)'
  532. },
  533. transparentArea: {
  534. flex: 60,
  535. aspectRatio: 1,
  536. position: 'relative'
  537. },
  538. rightOverlay: {
  539. flex: 20,
  540. backgroundColor: 'rgba(0,0,0,0.5)'
  541. },
  542. bottomOverlay: {
  543. flex: 35,
  544. backgroundColor: 'rgba(0,0,0,0.5)'
  545. },
  546. closeButton: {
  547. position: 'absolute',
  548. top: 40,
  549. left: 20,
  550. zIndex: 1
  551. },
  552. modalContent: {
  553. backgroundColor: 'white',
  554. padding: 22,
  555. alignItems: 'center',
  556. borderRadius: 4,
  557. borderColor: 'rgba(0, 0, 0, 0.1)'
  558. },
  559. durationButton: { margin: 5 },
  560. confirmButton: {
  561. marginTop: 20,
  562. width: '100%'
  563. },
  564. cancelButton: {
  565. marginTop: 20,
  566. width: '100%',
  567. backgroundColor: 'white',
  568. borderColor: 'black',
  569. borderWidth: 1,
  570. color: 'black'
  571. }
  572. });
  573. export default ScanQrPage;