Merge branch 'develop' into 'main'

AW-429-comp-v1-ios-ab

See merge request witapp/aura-webapp!722
This commit is contained in:
Daniil Chemerkin 2025-04-09 07:46:07 +00:00
commit a3a83980e4
8 changed files with 794 additions and 380 deletions

View File

@ -0,0 +1,391 @@
import Modal from "@/components/Modal";
import styles from "../styles.module.scss";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import Title from "@/components/Title";
import CameraModal from "@/components/CompatibilityV2/components/CameraModal";
import Loader, { LoaderColor } from "@/components/Loader";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
import routes from "@/routes";
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { actions } from "@/store";
import { useApi } from "@/api";
import { useDispatch } from "react-redux";
import { IPalmistryFinger, IPalmistryLine } from "@/api/resources/Palmistry";
import { ICompatibilityV2FingerLocal } from "@/store/compatibilityV2";
import { DataURIToBlob } from "@/services/data";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
import { checkCameraPermissionState } from "@/services/permission/permisson";
enum EToastVisible {
"try_again" = "try_again",
"try_again_or_next" = "try_again_or_next",
"no_access_camera" = "no_access_camera",
"reload_page" = "reload_page",
"upload_photo" = "upload_photo",
}
const isInstagramAndroid = window?.navigator?.userAgent?.includes("Instagram") && /Android/.test(window?.navigator?.userAgent);
function AndroidCamera() {
const api = useApi();
const dispatch = useDispatch();
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const navigate = useNavigate();
const { isReady, variant: cameraRequestModalCompatibilityV2 } = useUnleash({
flag: EUnleashFlags.cameraRequestModalCompatibilityV2
});
const isShowCameraRequestModal = cameraRequestModalCompatibilityV2 !== "hide";
const { variant: compatibilityV2ScanHand } = useUnleash({
flag: EUnleashFlags.compatibilityV2ScanHand
});
const isShowScanHand = compatibilityV2ScanHand !== "hide";
const [isCameraModalOpen, setIsCameraModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState(isShowCameraRequestModal);
const [toastVisible, setToastVisible] = useState<EToastVisible | null>(null);
const handleToScanHand = () => {
metricService.reachGoal(EGoals.SCAN_ARTIFICIAL_PHOTO, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
return navigate(routes.client.compatibilityV2ScanHand())
}
const handleToScannedPhoto = () => {
metricService.reachGoal(EGoals.CAMERA_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
return navigate(routes.client.compatibilityV2ScannedPhoto())
}
const handleCameraError = (error: string | DOMException) => {
console.log("camera error: ", error)
if (!isShowScanHand) {
return setToastVisible(EToastVisible.upload_photo)
}
return handleToScanHand()
}
const handleCameraSuccess = (photo: string) => {
onTakePhoto(photo)
}
const handleRequestCameraModalCancel = () => {
setIsRequestCameraModalOpen(false)
setToastVisible(EToastVisible.no_access_camera)
}
const handleRequestCameraModalAllow = () => {
if (isInstagramAndroid) {
return handleToScanHand()
}
setIsCameraModalOpen(true)
setIsRequestCameraModalOpen(false)
}
useEffect(() => {
if (!isShowCameraRequestModal) {
if (isInstagramAndroid) {
return handleToScanHand()
}
setIsCameraModalOpen(true)
}
}, [isShowCameraRequestModal])
useEffect(() => {
(async () => {
const permissionState = await checkCameraPermissionState();
if (permissionState === "denied") {
handleToScanHand()
}
})()
}, [])
useEffect(() => {
if (isInstagramAndroid) {
metricService.reachGoal(EGoals.CAMERA_ANDROID_INSTAGRAM, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
}
}, [isInstagramAndroid])
// LOGIC TODO: Make hook
const onTakePhoto = async (photo: string) => {
try {
const file = DataURIToBlob(photo);
const result = await getLines(file);
URL.revokeObjectURL(URL.createObjectURL(file));
dispatch(
actions.compatibilityV2.update({
photo,
})
);
if (!checkPalmistryLines(result?.lines || [])) return;
handleToScannedPhoto();
} catch (error) {
console.error('Ошибка при обработке фото:', error);
} finally {
// Принудительный запуск сборщика мусора (не гарантировано, но может помочь)
if (window.gc) {
window.gc();
}
}
};
const fingersNames = {
thumb: translate("thumb"),
index_finger: translate("index_finger"),
middle_finger: translate("middle_finger"),
ring_finger: translate("ring_finger"),
pinky: translate("pinky"),
};
const setFingersNames = (
fingers: IPalmistryFinger[]
): ICompatibilityV2FingerLocal[] => {
if (!fingers) return [];
return fingers.map((finger) => {
return {
...finger,
fingerName: fingersNames[finger.name as keyof typeof fingersNames],
};
});
};
const getLines = async (file: File | Blob) => {
setIsLoading(true);
const formData = new FormData();
formData.append("file", file);
try {
const result = await api.getPalmistryLines({ formData });
const fingers = setFingersNames(result?.fingers);
dispatch(
actions.compatibilityV2.update({
lines: result?.lines,
fingers,
})
);
return result;
} catch (error) {
dispatch(
actions.compatibilityV2.update({
lines: [],
fingers: [],
})
);
return null;
} finally {
setIsLoading(false);
}
};
const checkPalmistryLines = (lines: IPalmistryLine[]): boolean => {
if (!lines.length || lines.length < 2) {
metricService.reachGoal(EGoals.CAMERA_ERROR, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
setToastVisible(EToastVisible.try_again);
return false;
}
if (lines.length === 2) {
setToastVisible(EToastVisible.try_again_or_next);
return false;
}
return true;
};
const onSelectFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
setToastVisible(null);
if (!event.target.files || event.target.files.length === 0) return;
const result = await getLines(event.target.files[0]);
const reader = new FileReader();
reader.onloadend = () => {
dispatch(
actions.compatibilityV2.update({
photo: reader.result as string,
})
);
if (!checkPalmistryLines(result?.lines || [])) return;
handleToScannedPhoto();
};
reader.readAsDataURL(event.target.files[0]);
};
if (!isReady) return <Loader color={LoaderColor.Black} />;
return (
<>
{/* Модальное окно запроса камеры */}
<Modal
isCloseButtonVisible={false}
open={isRequestCameraModalOpen}
onClose={() => { }}
className={styles.modal}
containerClassName={styles["modal-container"]}
>
<Title variant="h4" className={styles["modal-title"]}>
{translate("/camera.modal.title")}
</Title>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={handleRequestCameraModalCancel}>
<p className={styles["modal-answer-text"]}>
{translate("/camera.modal.cancel")}
</p>
</div>
<div className={styles["modal-answer"]} onClick={handleRequestCameraModalAllow}>
<p className={styles["modal-answer-text"]}>
{translate("/camera.modal.allow")}
</p>
</div>
</div>
</Modal>
{/* Модальное окно загрузки фото */}
{/* {!isProduction && uploadMenuModalIsOpen && (
<UploadModal
onClose={() => setUploadMenuModalIsOpen(false)}
onSelectFile={onSelectFile}
onChooseCamera={() => true}
/>
)} */}
{/* Кнопка загрузки фото */}
{/* {!isProduction && (
<button
className={styles["upload-button"]}
onClick={() => setUploadMenuModalIsOpen(true)}
>
Upload
</button>
)} */}
{/* Модальное окно камеры */}
{!isLoading && <CameraModal
onClose={() => console.log("close")}
onTakePhoto={handleCameraSuccess}
onError={handleCameraError}
isCameraVisible={isCameraModalOpen}
// reinitializeKey={reinitializeCameraCount}
/>}
{/* Лоадер */}
{isLoading && (
<Loader className={styles.loader} color={LoaderColor.Black} />
)}
{/* Тост если фото плохое */}
{toastVisible === EToastVisible.try_again && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.bad_photo")}</span>
<div className={styles["toast-buttons-container"]}>
<button onClick={() => setToastVisible(null)}>
{translate("/camera.try_again")}
</button>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{/* Тост если нет доступа к камере */}
{toastVisible === EToastVisible.no_access_camera && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.no_access_camera")}</span>
<div className={styles["toast-buttons-container"]}>
<button onClick={() => {
if (isInstagramAndroid) {
return handleToScanHand()
}
setToastVisible(null)
setIsCameraModalOpen(true)
}}>
{translate("/camera.give_access")}
</button>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{/* Тост загрузки фото */}
{toastVisible === EToastVisible.upload_photo && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.no_access_camera")}</span>
<div className={styles["toast-buttons-container"]}>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{/* Тост если фото можно улучшить */}
{toastVisible === EToastVisible.try_again_or_next && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div
className={styles["toast-content"]}
style={{ flexDirection: "column" }}
>
<span>{translate("/camera.do_better")}</span>
<div className={styles["buttons-container"]}>
<button onClick={() => setToastVisible(null)}>
{translate("/camera.try_again")}
</button>
<button onClick={handleToScannedPhoto}>
{translate("/camera.next")}
</button>
</div>
</div>
</Toast>
)}
</>
)
}
export default AndroidCamera;

View File

@ -1,32 +1,7 @@
import styles from "./styles.module.scss"; import { useEffect, useMemo } from "react";
import { DataURIToBlob } from "@/services/data";
import { useApi } from "@/api";
import { IPalmistryFinger, IPalmistryLine } from "@/api/resources/Palmistry";
import { ICompatibilityV2FingerLocal } from "@/store/compatibilityV2";
import { useDispatch } from "react-redux";
import { actions } from "@/store";
import { useNavigate } from "react-router-dom";
import routes from "@/routes";
import { useEffect, useMemo, useState } from "react";
import Loader, { LoaderColor } from "@/components/Loader";
import UploadModal from "@/components/palmistry/upload-modal/upload-modal";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import CameraModal from "../../components/CameraModal";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService"; import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import Modal from "@/components/Modal"; import AndroidCamera from "./android";
import Title from "@/components/Title"; import IphoneCamera from "./iphone";
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
const isProduction = import.meta.env.MODE === "production";
enum EToastVisible {
"try_again" = "try_again",
"try_again_or_next" = "try_again_or_next",
"no_access_camera" = "no_access_camera",
"reload_page" = "reload_page",
}
function Camera() { function Camera() {
const isIphoneSafari = useMemo((): boolean => { const isIphoneSafari = useMemo((): boolean => {
@ -41,363 +16,19 @@ function Camera() {
return isIOS && isSafari; return isIOS && isSafari;
}, []); }, []);
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const navigate = useNavigate();
const api = useApi();
const dispatch = useDispatch();
const [isLoading, setIsLoading] = useState(false);
const [uploadMenuModalIsOpen, setUploadMenuModalIsOpen] = useState(false);
const [toastVisible, setToastVisible] = useState<EToastVisible | null>(null);
const [cameraKey, setCameraKey] = useState(0);
const [isCameraModalOpen, setIsCameraModalOpen] = useState(false);
const { isReady, variant: cameraRequestModalCompatibilityV2 } = useUnleash({
flag: EUnleashFlags.cameraRequestModalCompatibilityV2
});
const { variant: compatibilityV2ScanHand } = useUnleash({
flag: EUnleashFlags.compatibilityV2ScanHand
});
const isScanHand = compatibilityV2ScanHand === "show";
const isCameraRequestModal = cameraRequestModalCompatibilityV2 !== "hide";
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState((isIphoneSafari || !isCameraRequestModal) ? false : true);
const handleNext = () => {
metricService.reachGoal(EGoals.CAMERA_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
navigate(routes.client.compatibilityV2ScannedPhoto());
};
const fingersNames = {
thumb: translate("thumb"),
index_finger: translate("index_finger"),
middle_finger: translate("middle_finger"),
ring_finger: translate("ring_finger"),
pinky: translate("pinky"),
};
const setFingersNames = (
fingers: IPalmistryFinger[]
): ICompatibilityV2FingerLocal[] => {
if (!fingers) return [];
return fingers.map((finger) => {
return {
...finger,
fingerName: fingersNames[finger.name as keyof typeof fingersNames],
};
});
};
/**
* Check if the palmistry lines are valid for the next step.
* If the length of the lines is less than 2, show the "try_again" toast.
* If the length of the lines is 2, show the "try_again_or_next" toast.
* Otherwise, return true.
* @param {IPalmistryLine[]} lines - The palmistry lines.
* @returns {boolean} Whether the palmistry lines are valid for the next step.
*/
const checkPalmistryLines = (lines: IPalmistryLine[]): boolean => {
if (!lines.length || lines.length < 2) {
setToastVisible(EToastVisible.try_again);
return false;
}
if (lines.length === 2) {
setToastVisible(EToastVisible.try_again_or_next);
return false;
}
return true;
};
const getLines = async (file: File | Blob) => {
setIsLoading(true);
const formData = new FormData();
formData.append("file", file);
try {
const result = await api.getPalmistryLines({ formData });
const fingers = setFingersNames(result?.fingers);
dispatch(
actions.compatibilityV2.update({
lines: result?.lines,
fingers,
})
);
return result;
} catch (error) {
dispatch(
actions.compatibilityV2.update({
lines: [],
fingers: [],
})
);
return null;
} finally {
setIsLoading(false);
}
};
const onTakePhoto = async (photo: string) => {
setUploadMenuModalIsOpen(false);
try {
const file = DataURIToBlob(photo);
const result = await getLines(file);
URL.revokeObjectURL(URL.createObjectURL(file));
dispatch(
actions.compatibilityV2.update({
photo,
})
);
if (!checkPalmistryLines(result?.lines || [])) return;
handleNext();
} catch (error) {
console.error('Ошибка при обработке фото:', error);
} finally {
// Принудительный запуск сборщика мусора (не гарантировано, но может помочь)
if (window.gc) {
window.gc();
}
}
};
const onSelectFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
setToastVisible(null);
setUploadMenuModalIsOpen(false);
if (!event.target.files || event.target.files.length === 0) return;
const result = await getLines(event.target.files[0]);
const reader = new FileReader();
reader.onloadend = () => {
dispatch(
actions.compatibilityV2.update({
photo: reader.result as string,
})
);
if (!checkPalmistryLines(result?.lines || [])) return;
handleNext();
};
reader.readAsDataURL(event.target.files[0]);
};
useEffect(() => { useEffect(() => {
metricService.reachGoal(EGoals.CAMERA_OPEN, [EMetrics.YANDEX, EMetrics.KLAVIYO]); metricService.reachGoal(EGoals.CAMERA_OPEN, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
}, []); }, []);
useEffect(() => { // useEffect(() => {
if (toastVisible === EToastVisible.try_again) { // if (toastVisible === EToastVisible.try_again) {
metricService.reachGoal(EGoals.CAMERA_ERROR, [EMetrics.YANDEX, EMetrics.KLAVIYO]); // metricService.reachGoal(EGoals.CAMERA_ERROR, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
} // }
}, [toastVisible]); // }, [toastVisible]);
const cameraError = (error: string | DOMException) => {
console.error("Camera error", error)
if (isScanHand) {
if (!isIphoneSafari) {
return navigate(routes.client.compatibilityV2ScanHand())
}
if (isIphoneSafari && cameraKey > 1) {
return navigate(routes.client.compatibilityV2ScanHand())
}
}
if (!isIphoneSafari || !isCameraRequestModal) return;
if (error === "Video is not ready") {
return setToastVisible(EToastVisible.no_access_camera)
}
return setIsRequestCameraModalOpen(true)
}
const requestCameraPermission = async () => {
if (cameraKey > 2) {
setIsRequestCameraModalOpen(false);
setToastVisible(EToastVisible.reload_page);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
setToastVisible(null);
stream.getTracks().forEach(track => track.stop());
setCameraKey(prev => prev + 1);
} catch (error) {
setCameraKey(prev => prev + 1);
console.error("Ошибка при запросе доступа к камере:", error);
setIsRequestCameraModalOpen(true);
}
};
if (!isReady) return <Loader color={LoaderColor.Black} />;
return ( return (
<> <>
<Modal {isIphoneSafari ? <IphoneCamera /> : <AndroidCamera />}
isCloseButtonVisible={false}
open={isRequestCameraModalOpen}
onClose={() => { }}
className={styles.modal}
containerClassName={styles["modal-container"]}
>
<Title variant="h4" className={styles["modal-title"]}>
{translate("/camera.modal.title")}
</Title>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={() => {
setIsRequestCameraModalOpen(false);
setToastVisible(EToastVisible.no_access_camera);
}}>
<p className={styles["modal-answer-text"]}>
{translate("/camera.modal.cancel")}
</p>
</div>
<div className={styles["modal-answer"]} onClick={() => {
setIsRequestCameraModalOpen(false)
if (isIphoneSafari) {
requestCameraPermission()
} else {
setIsCameraModalOpen(true)
if (window.navigator.userAgent.includes("Instagram")) {
setToastVisible(EToastVisible.no_access_camera)
}
}
}}>
<p className={styles["modal-answer-text"]}>
{translate("/camera.modal.allow")}
</p>
</div>
</div>
</Modal>
{!isProduction && uploadMenuModalIsOpen && (
<UploadModal
onClose={() => setUploadMenuModalIsOpen(false)}
onSelectFile={onSelectFile}
onChooseCamera={() => true}
/>
)}
{!isProduction && (
<button
className={styles["upload-button"]}
onClick={() => setUploadMenuModalIsOpen(true)}
>
Upload
</button>
)}
{!isLoading && !uploadMenuModalIsOpen && (
// <PalmCameraModal
// onClose={() => console.log("close")}
// onTakePhoto={onTakePhoto}
// />
<CameraModal
onClose={() => console.log("close")}
onTakePhoto={onTakePhoto}
onError={cameraError}
isCameraVisible={(isIphoneSafari || isCameraRequestModal) ? true : isCameraModalOpen}
reinitializeKey={cameraKey}
/>
)}
{isLoading && (
<Loader className={styles.loader} color={LoaderColor.Black} />
)}
{toastVisible === EToastVisible.try_again && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.bad_photo")}</span>
<div className={styles["toast-buttons-container"]}>
<button onClick={() => setToastVisible(null)}>
{translate("/camera.try_again")}
</button>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload} onClick={() => {
if (!isIphoneSafari) {
return navigate(routes.client.compatibilityV2ScanHand());
}
}}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{toastVisible === EToastVisible.no_access_camera && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.no_access_camera")}</span>
<div className={styles["toast-buttons-container"]}>
<button onClick={() => {
setToastVisible(null)
if (isIphoneSafari) {
requestCameraPermission()
} else {
setIsCameraModalOpen(true)
navigate(routes.client.compatibilityV2ScanHand())
}
}}>
{translate("/camera.give_access")}
</button>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload} onClick={() => {
if (!isIphoneSafari) {
return navigate(routes.client.compatibilityV2ScanHand());
}
}}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{toastVisible === EToastVisible.reload_page && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.reload_page")}</span>
<button onClick={() => {
setToastVisible(null)
window.location.reload()
}}>
{translate("/camera.reload_page_button")}
</button>
</div>
</Toast>
)}
{toastVisible === EToastVisible.try_again_or_next && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div
className={styles["toast-content"]}
style={{ flexDirection: "column" }}
>
<span>{translate("/camera.do_better")}</span>
<div className={styles["buttons-container"]}>
<button onClick={() => setToastVisible(null)}>
{translate("/camera.try_again")}
</button>
<button onClick={() => handleNext()}>
{translate("/camera.next")}
</button>
</div>
</div>
</Toast>
)}
</> </>
); );
} }

View File

@ -0,0 +1,348 @@
import Modal from "@/components/Modal";
import styles from "../styles.module.scss";
import Title from "@/components/Title";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import Loader, { LoaderColor } from "@/components/Loader";
import { useEffect, useState } from "react";
import CameraModal from "@/components/CompatibilityV2/components/CameraModal";
import { useNavigate } from "react-router-dom";
import routes from "@/routes";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
import { DataURIToBlob } from "@/services/data";
import { actions } from "@/store";
import { IPalmistryFinger, IPalmistryLine } from "@/api/resources/Palmistry";
import { ICompatibilityV2FingerLocal } from "@/store/compatibilityV2";
import { useApi } from "@/api";
import { useDispatch } from "react-redux";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
enum EToastVisible {
"try_again" = "try_again",
"try_again_or_next" = "try_again_or_next",
"no_access_camera" = "no_access_camera",
"reload_page" = "reload_page",
}
function IphoneCamera() {
const api = useApi();
const dispatch = useDispatch();
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const navigate = useNavigate();
const { isReady, variant: cameraRequestModalCompatibilityV2 } = useUnleash({
flag: EUnleashFlags.cameraRequestModalCompatibilityV2
});
const isShowCameraRequestModal = cameraRequestModalCompatibilityV2 !== "hide";
const { variant: compatibilityV2ScanHand } = useUnleash({
flag: EUnleashFlags.compatibilityV2ScanHand
});
const isShowScanHand = compatibilityV2ScanHand !== "hide";
const [isLoading, setIsLoading] = useState(false);
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState(isShowCameraRequestModal);
const [isCameraModalOpen, setIsCameraModalOpen] = useState(false);
const [reinitializeCameraCount, setReinitializeCameraCount] = useState(0);
const [toastVisible, setToastVisible] = useState<EToastVisible | null>(null);
const handleToScanHand = () => {
metricService.reachGoal(EGoals.SCAN_ARTIFICIAL_PHOTO, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
return navigate(routes.client.compatibilityV2ScanHand())
}
const handleToScannedPhoto = () => {
metricService.reachGoal(EGoals.CAMERA_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
return navigate(routes.client.compatibilityV2ScannedPhoto())
}
const handleCameraError = (error: string | DOMException) => {
console.log("camera error: ", error)
if (reinitializeCameraCount < 2) {
setToastVisible(EToastVisible.no_access_camera)
} else {
if (!isShowScanHand) {
return setToastVisible(EToastVisible.reload_page)
}
return handleToScanHand()
}
}
const handleCameraSuccess = (photo: string) => {
onTakePhoto(photo)
}
const handleRequestCameraModalCancel = () => {
setIsRequestCameraModalOpen(false)
setToastVisible(EToastVisible.no_access_camera)
}
const handleRequestCameraModalAllow = () => {
setIsRequestCameraModalOpen(false)
handleRequestCamera()
}
const handleRequestCamera = () => {
setToastVisible(null)
if (!isCameraModalOpen) {
return setIsCameraModalOpen(true)
}
setReinitializeCameraCount(prev => prev + 1)
}
useEffect(() => {
if (!isShowCameraRequestModal) {
setIsCameraModalOpen(true)
}
}, [isShowCameraRequestModal])
// LOGIC TODO: Make hook
const onTakePhoto = async (photo: string) => {
try {
const file = DataURIToBlob(photo);
const result = await getLines(file);
URL.revokeObjectURL(URL.createObjectURL(file));
dispatch(
actions.compatibilityV2.update({
photo,
})
);
if (!checkPalmistryLines(result?.lines || [])) return;
handleToScannedPhoto();
} catch (error) {
console.error('Ошибка при обработке фото:', error);
} finally {
// Принудительный запуск сборщика мусора (не гарантировано, но может помочь)
if (window.gc) {
window.gc();
}
}
};
const fingersNames = {
thumb: translate("thumb"),
index_finger: translate("index_finger"),
middle_finger: translate("middle_finger"),
ring_finger: translate("ring_finger"),
pinky: translate("pinky"),
};
const setFingersNames = (
fingers: IPalmistryFinger[]
): ICompatibilityV2FingerLocal[] => {
if (!fingers) return [];
return fingers.map((finger) => {
return {
...finger,
fingerName: fingersNames[finger.name as keyof typeof fingersNames],
};
});
};
const getLines = async (file: File | Blob) => {
setIsLoading(true);
const formData = new FormData();
formData.append("file", file);
try {
const result = await api.getPalmistryLines({ formData });
const fingers = setFingersNames(result?.fingers);
dispatch(
actions.compatibilityV2.update({
lines: result?.lines,
fingers,
})
);
return result;
} catch (error) {
dispatch(
actions.compatibilityV2.update({
lines: [],
fingers: [],
})
);
return null;
} finally {
setIsLoading(false);
}
};
const checkPalmistryLines = (lines: IPalmistryLine[]): boolean => {
if (!lines.length || lines.length < 2) {
metricService.reachGoal(EGoals.CAMERA_ERROR, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
setToastVisible(EToastVisible.try_again);
return false;
}
if (lines.length === 2) {
setToastVisible(EToastVisible.try_again_or_next);
return false;
}
return true;
};
const onSelectFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
setToastVisible(null);
if (!event.target.files || event.target.files.length === 0) return;
const result = await getLines(event.target.files[0]);
const reader = new FileReader();
reader.onloadend = () => {
dispatch(
actions.compatibilityV2.update({
photo: reader.result as string,
})
);
if (!checkPalmistryLines(result?.lines || [])) return;
handleToScannedPhoto();
};
reader.readAsDataURL(event.target.files[0]);
};
if (!isReady) return <Loader color={LoaderColor.Black} />;
return (
<>
{/* Модальное окно запроса камеры */}
<Modal
isCloseButtonVisible={false}
open={isRequestCameraModalOpen}
onClose={() => { }}
className={styles.modal}
containerClassName={styles["modal-container"]}
>
<Title variant="h4" className={styles["modal-title"]}>
{translate("/camera.modal.title")}
</Title>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={handleRequestCameraModalCancel}>
<p className={styles["modal-answer-text"]}>
{translate("/camera.modal.cancel")}
</p>
</div>
<div className={styles["modal-answer"]} onClick={handleRequestCameraModalAllow}>
<p className={styles["modal-answer-text"]}>
{translate("/camera.modal.allow")}
</p>
</div>
</div>
</Modal>
{/* Модальное окно камеры */}
{!isLoading && (
<CameraModal
onClose={() => console.log("close")}
onTakePhoto={handleCameraSuccess}
onError={handleCameraError}
isCameraVisible={isCameraModalOpen}
reinitializeKey={reinitializeCameraCount}
/>
)}
{/* Лоадер */}
{isLoading && (
<Loader className={styles.loader} color={LoaderColor.Black} />
)}
{/* Тост если фото плохое */}
{toastVisible === EToastVisible.try_again && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.bad_photo")}</span>
<div className={styles["toast-buttons-container"]}>
<button onClick={() => setToastVisible(null)}>
{translate("/camera.try_again")}
</button>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{/* Тост если нет доступа к камере */}
{toastVisible === EToastVisible.no_access_camera && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.no_access_camera")}</span>
<div className={styles["toast-buttons-container"]}>
<button onClick={handleRequestCamera}>
{translate("/camera.give_access")}
</button>
<button className={styles.buttonUpload}>
<input
id="upload-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onSelectFile}
/>
<label htmlFor="upload-input" className={styles.labelUpload}>
{translate("/camera.upload")}
</label>
</button>
</div>
</div>
</Toast>
)}
{/* Тост если фото можно улучшить */}
{toastVisible === EToastVisible.try_again_or_next && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div
className={styles["toast-content"]}
style={{ flexDirection: "column" }}
>
<span>{translate("/camera.do_better")}</span>
<div className={styles["buttons-container"]}>
<button onClick={() => setToastVisible(null)}>
{translate("/camera.try_again")}
</button>
<button onClick={handleToScannedPhoto}>
{translate("/camera.next")}
</button>
</div>
</div>
</Toast>
)}
{/* Тост если нужно перезагрузить страницу */}
{toastVisible === EToastVisible.reload_page && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div className={styles["toast-content"]}>
<span>{translate("/camera.reload_page")}</span>
<button onClick={() => {
setToastVisible(null)
window.location.reload()
}}>
{translate("/camera.reload_page_button")}
</button>
</div>
</Toast>
)}
</>
)
}
export default IphoneCamera;

View File

@ -14,6 +14,7 @@ import { IPalmistryFinger } from "@/api/resources/Palmistry";
import { ICompatibilityV2FingerLocal } from "@/store/compatibilityV2"; import { ICompatibilityV2FingerLocal } from "@/store/compatibilityV2";
import { useApi } from "@/api"; import { useApi } from "@/api";
import { DataURIToBlob } from "@/services/data"; import { DataURIToBlob } from "@/services/data";
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
function ScanHand() { function ScanHand() {
const api = useApi(); const api = useApi();
@ -24,17 +25,25 @@ function ScanHand() {
const imageRef = useRef<HTMLImageElement>(null); const imageRef = useRef<HTMLImageElement>(null);
const gender = (useSelector(selectors.selectQuestionnaire)?.gender || "female"); const gender = (useSelector(selectors.selectQuestionnaire)?.gender || "female");
const { isReady, variant: scanHandTimeCompatibilityV2 } = useUnleash({
flag: EUnleashFlags.scanHandTimeCompatibilityV2
});
const TIME_SCAN_HAND = scanHandTimeCompatibilityV2 === "v1" ? 8000 : 6000;
const handleNext = () => { const handleNext = () => {
navigate(`${routes.client.compatibilityV2ScannedPhoto()}?fromScanHand=true`) navigate(`${routes.client.compatibilityV2ScannedPhoto()}?fromScanHand=true`)
} }
useEffect(() => { useEffect(() => {
if (!isReady) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
handleHandOn() handleHandOn()
}, 6000); }, TIME_SCAN_HAND);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, [isReady]);
const convertImageToBase64 = (img: HTMLImageElement): Promise<string> => { const convertImageToBase64 = (img: HTMLImageElement): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -129,6 +138,8 @@ function ScanHand() {
} }
} }
if (!isReady) return <Loader color={LoaderColor.Black} />;
return ( return (
<div <div
className={styles.container} className={styles.container}

View File

@ -16,6 +16,7 @@ import { useAuthentication } from "@/hooks/authentication/use-authentication";
import { ESourceAuthorization } from "@/api/resources/User"; import { ESourceAuthorization } from "@/api/resources/User";
import { getRandomArbitrary } from "@/services/random-value"; import { getRandomArbitrary } from "@/services/random-value";
import { usePreloadImages } from "@/hooks/preload/images"; import { usePreloadImages } from "@/hooks/preload/images";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
const drawElementChangeDelay = 1500; const drawElementChangeDelay = 1500;
const startDelay = 500; const startDelay = 500;
@ -106,6 +107,10 @@ function ScannedPhoto() {
] ]
}, []); }, []);
useEffect(() => {
metricService.reachGoal(EGoals.CAMERA_HAND, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
}, [])
useEffect(() => { useEffect(() => {
if (isIOSPath) { if (isIOSPath) {
(async () => { (async () => {

View File

@ -25,6 +25,7 @@ export enum EUnleashFlags {
"compatibilityV1EmailEnter" = "compatibilityV1EmailEnter", "compatibilityV1EmailEnter" = "compatibilityV1EmailEnter",
"compatibilityV2ScanHand" = "compatibilityV2ScanHand", "compatibilityV2ScanHand" = "compatibilityV2ScanHand",
"preloadImages" = "preloadImages", "preloadImages" = "preloadImages",
"scanHandTimeCompatibilityV2" = "scanHandTimeCompatibilityV2"
} }
interface IUseUnleashProps<T extends EUnleashFlags> { interface IUseUnleashProps<T extends EUnleashFlags> {
@ -54,6 +55,7 @@ interface IVariants {
[EUnleashFlags.compatibilityV1EmailEnter]: "show" | "hide"; [EUnleashFlags.compatibilityV1EmailEnter]: "show" | "hide";
[EUnleashFlags.compatibilityV2ScanHand]: "show" | "hide"; [EUnleashFlags.compatibilityV2ScanHand]: "show" | "hide";
[EUnleashFlags.preloadImages]: "yes" | "no"; [EUnleashFlags.preloadImages]: "yes" | "no";
[EUnleashFlags.scanHandTimeCompatibilityV2]: "v0" | "v1";
} }
/** /**

View File

@ -56,6 +56,10 @@ export enum EGoals {
DOWNLOAD_APP = "DownloadApp", DOWNLOAD_APP = "DownloadApp",
CAMERA_HAND = "CameraHand",
SCAN_ARTIFICIAL_PHOTO = "ScanArtificialPhoto",
CAMERA_ANDROID_INSTAGRAM = "CameraAndroidInstagram",
// FB // FB
LEAD = "Lead", LEAD = "Lead",
PURCHASE = "Purchase", PURCHASE = "Purchase",

View File

@ -0,0 +1,22 @@
export async function checkCameraPermissionState(): Promise<PermissionState | null> {
if (!navigator.permissions || !navigator.permissions.query) {
// Permissions API не поддерживается
console.warn("Permissions API не поддерживается в этом браузере.");
return null;
}
try {
const permissionStatus = await navigator.permissions.query({ name: 'camera' as PermissionName });
console.log(`Статус разрешения камеры: ${permissionStatus.state}`);
// Можно добавить обработку изменения статуса
// permissionStatus.onchange = () => {
// console.log(`Статус разрешения камеры изменился: ${permissionStatus.state}`);
// };
return permissionStatus.state; // 'granted', 'denied', 'prompt'
} catch (error) {
console.error("Ошибка при проверке разрешения камеры:", error);
return null;
}
}