385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import styles from "./styles.module.scss";
|
|
import { DataURIToBlob } from "@/services/data";
|
|
import { useApi } from "@/api";
|
|
import { IPalmistryFinger, IPalmistryLine } from "@/api/resources/Palmistry";
|
|
import { IPalmistryFingerLocal } from "@/store/palmistry";
|
|
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 Modal from "@/components/Modal";
|
|
import Title from "@/components/Title";
|
|
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() {
|
|
const isIphoneSafari = useMemo((): boolean => {
|
|
const userAgent = navigator.userAgent;
|
|
const isIOS = /iPhone/i.test(userAgent);
|
|
const isSafari = /Safari/i.test(userAgent) &&
|
|
!/CriOS/i.test(userAgent) && // не Chrome
|
|
!/FxiOS/i.test(userAgent) && // не Firefox
|
|
!/EdgiOS/i.test(userAgent) && // не Edge
|
|
!/OPiOS/i.test(userAgent); // не Opera
|
|
|
|
return isIOS && isSafari;
|
|
}, []);
|
|
|
|
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
|
|
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 { flags, ready } = useMetricABFlags();
|
|
|
|
const { isReady, variant: cameraRequestModalPalmistryV1 } = useUnleash({
|
|
flag: EUnleashFlags.cameraRequestModalPalmistryV1
|
|
});
|
|
|
|
// const isCameraRequestModal = flags?.cameraRequestModal?.[0] !== "without";
|
|
|
|
const isCameraRequestModal = cameraRequestModalPalmistryV1 !== "hide";
|
|
|
|
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState((isIphoneSafari || !isCameraRequestModal) ? false : true);
|
|
|
|
const handleNext = () => {
|
|
metricService.reachGoal(EGoals.CAMERA_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
|
|
navigate(routes.client.palmistryV1ScannedPhoto());
|
|
};
|
|
|
|
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[]
|
|
): IPalmistryFingerLocal[] => {
|
|
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.palmistry.update({
|
|
lines: result?.lines,
|
|
fingers,
|
|
})
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
dispatch(
|
|
actions.palmistry.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.palmistry.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.palmistry.update({
|
|
photo: reader.result as string,
|
|
})
|
|
);
|
|
if (!checkPalmistryLines(result?.lines || [])) return;
|
|
handleNext();
|
|
};
|
|
|
|
reader.readAsDataURL(event.target.files[0]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
metricService.reachGoal(EGoals.CAMERA_OPEN, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (toastVisible === EToastVisible.try_again) {
|
|
metricService.reachGoal(EGoals.CAMERA_ERROR, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
|
|
}
|
|
}, [toastVisible]);
|
|
|
|
const cameraError = (error: string | DOMException) => {
|
|
console.error("Camera error", error)
|
|
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 (
|
|
<>
|
|
<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={() => {
|
|
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)
|
|
}
|
|
}}>
|
|
<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}>
|
|
{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)
|
|
}
|
|
}}>
|
|
{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.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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default Camera;
|