import { CameraView, useCameraPermissions } from 'expo-camera'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, AppState, Dimensions, Linking, Pressable, ScrollView, StyleSheet, Text, Vibration, View, Platform } from 'react-native'; import sha256 from 'crypto-js/sha256'; import ChooseCarForChargingRow from '../../../../component/global/chooseCarForChargingRow'; import { CrossLogoWhiteSvg, QuestionSvg } from '../../../../component/global/SVG'; import { router, useFocusEffect } from 'expo-router'; import { chargeStationService } from '../../../../service/chargeStationService'; import { authenticationService } from '../../../../service/authService'; import { walletService } from '../../../../service/walletService'; import useUserInfoStore from '../../../../providers/userinfo_store'; import Modal from 'react-native-modal'; import NormalButton from '../../../../component/global/normal_button'; import { ceil } from 'lodash'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useChargingStore } from '../../../../providers/scan_qr_payload_store'; const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); //reminder: scan qr code page, ic call should be false const ScanQrPage = () => { const { userID, currentPrice, setCurrentPrice } = useUserInfoStore(); const [currentPriceFetchedWhenScanQr, setCurrentPriceFetchedWhenScanQr] = useState(0); const { scanned_qr_code, setScannedQrCode, stationID, setStationId } = useChargingStore(); const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); const viewRef = useRef(null); const [scannedResult, setScannedResult] = useState(''); const [selectedCar, setSelectedCar] = useState(''); const now = new Date(); const [loading, setLoading] = useState(true); const [loading2, setLoading2] = useState(false); const [loading3, setLoading3] = useState(false); const [carData, setCarData] = useState([]); const [isModalVisible, setModalVisible] = useState(false); const [isConfirmLoading, setIsConfirmLoading] = useState(false); const [availableSlots, setAvailableSlots] = useState({ // 3: false, 25: false, 30: false, 40: false, 45: false, full: false }); const [selectedDuration, setSelectedDuration] = useState(null); const appState = useRef(AppState.currentState); const [paymentStatus, setPaymentStatus] = useState(null); const [isExpectingPayment, setIsExpectingPayment] = useState(false); const paymentInitiatedTime = useRef(null); const PAYMENT_CHECK_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds const [outTradeNo, setOutTradeNo] = useState(''); const [totalFee, setTotalFee] = useState(0); const [walletBalance, setWalletBalance] = useState(0); // Effect for requesting camera permissions useEffect(() => { (async () => { const { status } = await requestPermission(); if (status !== 'granted') { alert( '我們需要相機權限來掃描機器上的二維碼,以便識別並啟動充電機器。我們不會儲存或共享任何掃描到的資訊。 請前往設定開啟相機權限' ); } })(); }, []); useFocusEffect( useCallback(() => { // When screen comes into focus, enable scanning setScanned(false); return () => { // When screen loses focus, disable scanning setScanned(true); }; }, []) ); useEffect(() => { const fetchDefaultCar = async () => { try { const response = await chargeStationService.getUserDefaultCars(); if (response) { // console.log('default car', response.data.id); setSelectedCar(response.data.id); } } catch (error) { } finally { setLoading(false); } }; fetchDefaultCar(); }, []); useEffect(() => { const getWalletBalance = async () => { try { const response = await walletService.getWalletBalance(); if (response) { // console.log('walletBalance setting up', response); setWalletBalance(response); } } catch (error) { console.log(error); } }; getWalletBalance(); }, []); // Function to handle barcode scanning const handleBarCodeScanned = async ({ bounds, data, type }: { bounds?: any; data: any; type: any }) => { if ( !bounds || typeof bounds.origin?.x !== 'number' || typeof bounds.origin?.y !== 'number' || typeof bounds.size?.width !== 'number' || typeof bounds.size?.height !== 'number' ) { setScanned(true); setScannedQrCode(data); Vibration.vibrate(100); //after scanning, immediately fetch the correct station id and push to optionPage try { const stationId = await chargeStationService.noImagefetchChargeStationIdByScannedConnectorId(data); if (!stationId) { Alert.alert('錯誤', '無法找到充電站,請稍後再嘗試'); setTimeout(() => { setScanned(false); }, 2000); return; } setStationId(stationId); router.push('/optionPage'); } catch (error) { console.error('Error fetching station ID:', error); Alert.alert('錯誤', '無法找到充電站,請稍後再試'); setTimeout(() => { setScanned(false); }, 2000); return; } return; } // ----------------------------------------------------------------------------------------------------- const { origin, size } = bounds; // Calculate the size of the square transparent area const transparentAreaSize = Math.min(screenWidth * 0.6, screenHeight * 0.3); const transparentAreaX = (screenWidth - transparentAreaSize) / 2; const transparentAreaY = (screenHeight - transparentAreaSize) / 2; // Check if the barcode is within the transparent area // 在iOS上检查二维码是否在扫描框内,在安卓上跳过位置检查 const isIOS = Platform.OS === 'ios'; const isWithinScanArea = isIOS ? origin.x >= transparentAreaX && origin.y >= transparentAreaY && origin.x + size.width <= transparentAreaX + transparentAreaSize && origin.y + size.height <= transparentAreaY + transparentAreaSize : true; if (isWithinScanArea) { setScanned(true); setScannedQrCode(data); Vibration.vibrate(100); //after scanning, immediately fetch the correct station id and push to optionPage try { const stationId = await chargeStationService.noImagefetchChargeStationIdByScannedConnectorId(data); if (!stationId) { Alert.alert('錯誤', '無法找到充電站,請稍後再嘗試'); setTimeout(() => { setScanned(false); }, 2000); return; } setStationId(stationId); router.push('/optionPage'); } catch (error) { console.error('Error fetching station ID:', error); Alert.alert('錯誤', '無法找到充電站,請稍後再試'); setTimeout(() => { setScanned(false); }, 2000); return; } return; } }; useEffect(() => { const subscription = AppState.addEventListener('change', (nextAppState) => { if ( appState.current.match(/inactive|background/) && nextAppState === 'active' && isExpectingPayment && // outTradeNo && paymentInitiatedTime.current ) { const currentTime = new Date().getTime(); if (currentTime - paymentInitiatedTime.current < PAYMENT_CHECK_TIMEOUT) { checkPaymentStatus(); } else { // Payment check timeout reached setIsExpectingPayment(false); setOutTradeNo(''); paymentInitiatedTime.current = null; Alert.alert( 'Payment Timeout', 'The payment status check has timed out. Please check your payment history.' ); } } appState.current = nextAppState; }); return () => { subscription.remove(); }; }, [outTradeNo, isExpectingPayment]); const checkPaymentStatus = async () => { try { // console.log('outTradeNo in scanQR Page checkpaymentstatus ', outTradeNo); const result = await walletService.checkPaymentStatus(outTradeNo); setPaymentStatus(result); // console.log('checkPaymentStatus from scan QR checkpaymentStatus', result); if (result && !result.some((item) => item.errmsg?.includes('處理中'))) { // Payment successful // console.log('totalFee', totalFee); Alert.alert( '付款已成功', `你已成功增值HKD $${ Number.isInteger(totalFee) ? totalFee : totalFee.toFixed(1) }。請重新掃描去啟動充電槍。`, [ { text: '確認', onPress: async () => { setModalVisible(false); router.dismiss(); } } ] ); } else { Alert.alert('付款失敗', '請再試一次。', [ { text: '確定', onPress: () => { setModalVisible(false); router.dismiss(); } } ]); } setIsExpectingPayment(false); setOutTradeNo(''); paymentInitiatedTime.current = null; } catch (error) { console.error('Failed to check payment status:', error); Alert.alert('Error', 'Failed to check payment status. Please check your payment history.'); } }; function formatTime(utcTimeString) { // Parse the UTC time string const date = new Date(utcTimeString); // Add 8 hours date.setHours(date.getHours()); // Format the date const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); // Return the formatted string return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } const oneTimeCharging = async (inputAmount) => { try { const response = await walletService.getOutTradeNo(); // console.log('outtradeno in oneTimeCharging', response); if (response) { setOutTradeNo(response); setIsExpectingPayment(true); paymentInitiatedTime.current = new Date().getTime(); const now = new Date(); const formattedTime = formatTime(now); let amount = inputAmount * 100; const origin = 'https://openapi-hk.qfapi.com/checkstand/#/?'; const obj = { // appcode: '6937EF25DF6D4FA78BB2285441BC05E9', appcode: '636E234FB30D43598FC8F0140A1A7282', goods_name: 'Crazy Charge 錢包增值', out_trade_no: response, paysource: 'crazycharge_checkout', return_url: 'https://crazycharge.com.hk/completed', failed_url: 'https://crazycharge.com.hk/failed', notify_url: 'https://api.crazycharge.com.hk/api/v1/clients/qfpay/webhook', sign_type: 'sha256', txamt: amount, txcurrcd: 'HKD', txdtm: formattedTime }; const paramStringify = (json, flag?) => { let str = ''; let keysArr = Object.keys(json); keysArr.sort().forEach((val) => { if (!json[val]) return; str += `${val}=${flag ? encodeURIComponent(json[val]) : json[val]}&`; }); return str.slice(0, -1); }; // const api_key = '8F59E31F6ADF4D2894365F2BB6D2FF2C'; const api_key = '3E2727FBA2DA403EA325E73F36B07824'; const params = paramStringify(obj); const sign = sha256(`${params}${api_key}`).toString(); const url = `${origin}${paramStringify(obj, true)}&sign=${sign}`; try { // console.log(url); const supported = await Linking.canOpenURL(url); if (supported) { await Linking.openURL(url); } else { Alert.alert('錯誤', '請稍後再試'); } } catch (error) { console.error('Top-up failed:', error); Alert.alert('Error', '一次性付款失敗,請稍後再試'); } } else { } } catch (error) {} }; const startCharging = async (dataForSubmission) => { try { //before i start below logic, i need to check if the user has penalty unpaid. //i will call fetchReservationHistories. and the api will return an array of object, within the object there is a field called "penalty_fee". //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. const reservationHistories = await chargeStationService.fetchReservationHistories(); const unpaidPenalties = reservationHistories.filter( (reservation) => reservation.penalty_fee > 0 && reservation.penalty_paid_status === false ); const mostRecentUnpaidReservation = unpaidPenalties.reduce((mostRecent, current) => { return new Date(mostRecent.created_at) > new Date(current.created_at) ? mostRecent : current; }, unpaidPenalties[0]); if (unpaidPenalties.length > 0) { Alert.alert( '未付罰款', '您有未支付的罰款。請先支付罰款後再開始充電。', [ { text: '查看詳情', onPress: () => { // Navigate to a page showing penalty details setModalVisible(false); setLoading(false); router.push({ pathname: '(auth)/(tabs)/(home)/penaltyPaymentPage', params: { book_time: mostRecentUnpaidReservation.book_time, end_time: mostRecentUnpaidReservation.end_time, actual_end_time: mostRecentUnpaidReservation.actual_end_time, penalty_fee: mostRecentUnpaidReservation.penalty_fee, format_order_id: mostRecentUnpaidReservation.format_order_id, id: mostRecentUnpaidReservation.id, stationName: mostRecentUnpaidReservation.connector.EquipmentID.StationID.snapshot .StationName, address: mostRecentUnpaidReservation.connector.EquipmentID.StationID.snapshot.Address } }); } }, { text: '返回', onPress: () => { setModalVisible(false); if (router.canGoBack()) { router.back(); } else { router.push('/mainPage'); } } } ], { cancelable: false } ); return; } const wallet = await walletService.getWalletBalance(); if (wallet < dataForSubmission.total_fee) { oneTimeCharging(dataForSubmission.total_fee); // const remainingAmount = dataForSubmission.total_fee - wallet; // oneTimeCharging(remainingAmount); return; } const response = await walletService.submitPayment( dataForSubmission.stationID, dataForSubmission.connector, dataForSubmission.user, dataForSubmission.book_time, dataForSubmission.end_time, dataForSubmission.total_power, dataForSubmission.total_fee, dataForSubmission.promotion_code, dataForSubmission.car, dataForSubmission.type, dataForSubmission.is_ic_call ); if (response.status === 200 || response.status === 201) { setSelectedDuration(null); setIsConfirmLoading(false); await AsyncStorage.setItem('chargingStarted', 'true'); Alert.alert('啟動成功', '請按下確認並等待頁面稍後自動跳轉至充電介面', [ { text: 'OK', onPress: async () => { setModalVisible(false); setLoading(true); // Wait for 2 seconds await new Promise((resolve) => setTimeout(resolve, 2000)); // Hide loading spinner and navigate setLoading(false); router.navigate('(auth)/(tabs)/(home)/mainPage'); router.push('(auth)/(tabs)/(charging)/chargingPage'); } } ]); } else if (response.status === 400) { Alert.alert('餘額不足', '掃描失敗 請稍後再試。'); } else { Alert.alert('掃描失敗 請稍後再試。', response); } } catch (error) {} }; return ( {!permission ? ( ) : !permission.granted ? ( 我們需要相機權限來掃描機器上的二維碼,以便識別並啟動充電機器。我們不會儲存或共享任何掃描到的資訊。 請前往設定開啟相機權限 ) : loading ? ( ) : ( { if (router.canGoBack()) { router.back(); } else { router.push('/mainPage'); } }} > 請掃瞄充電座上的二維碼 router.push('assistancePage')}> 需要協助? )} ); }; const styles = StyleSheet.create({ container: { flex: 1 }, camera: { flex: 1 }, overlay: { flex: 1, width: '100%', height: '100%', position: 'absolute' }, topOverlay: { flex: 35, alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.5)' }, centerRow: { flex: 30, flexDirection: 'row' }, leftOverlay: { flex: 20, backgroundColor: 'rgba(0,0,0,0.5)' }, transparentArea: { flex: 60, aspectRatio: 1, position: 'relative' }, rightOverlay: { flex: 20, backgroundColor: 'rgba(0,0,0,0.5)' }, bottomOverlay: { flex: 35, backgroundColor: 'rgba(0,0,0,0.5)' }, closeButton: { position: 'absolute', top: 40, left: 20, zIndex: 1 }, modalContent: { backgroundColor: 'white', padding: 22, alignItems: 'center', borderRadius: 4, borderColor: 'rgba(0, 0, 0, 0.1)' }, durationButton: { margin: 5 }, confirmButton: { marginTop: 20, width: '100%' }, cancelButton: { marginTop: 20, width: '100%', backgroundColor: 'white', borderColor: 'black', borderWidth: 1, color: 'black' } }); export default ScanQrPage;