w-aura/src/components/PalmistryV1/pages/Camera/index.tsx
2025-04-01 13:07:55 +00:00

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;