This commit is contained in:
Daniil Chemerkin 2025-03-01 20:20:19 +00:00
parent a29d8e6fec
commit 8083d7ac9c
19 changed files with 961 additions and 39 deletions

View File

@ -5,6 +5,18 @@ import Modal from "@/components/palmistry/modal/modal";
import { useRef, useState } from "react";
// import { useDynamicSize } from "@/hooks/useDynamicSize";
interface ExtendedMediaTrackCapabilities extends MediaTrackCapabilities {
torch?: boolean;
}
interface TorchConstraints extends MediaTrackConstraintSet {
torch?: boolean;
}
interface ExtendedMediaTrackConstraints extends MediaTrackConstraints {
advanced?: TorchConstraints[];
}
interface CameraModalProps {
onClose: () => void;
onTakePhoto: (photo: string) => void;
@ -21,6 +33,8 @@ function CameraModal({
isCameraVisible = true
}: CameraModalProps) {
const [isVideoReady, setIsVideoReady] = useState(false);
const [isTorchOn, setIsTorchOn] = useState(false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
// const {
// width: _width, height: _height,
// elementRef: containerRef } = useDynamicSize<HTMLDivElement>({});
@ -55,6 +69,47 @@ function CameraModal({
}
};
const checkTorchAvailability = async (track: MediaStreamTrack) => {
try {
const capabilities = track.getCapabilities() as ExtendedMediaTrackCapabilities;
setIsTorchAvailable(!!capabilities.torch);
} catch (error) {
setIsTorchAvailable(false);
console.error('Ошибка при проверке поддержки фонарика:', error);
}
};
const onUserMedia = (stream: MediaStream) => {
setIsVideoReady(true);
const track = stream.getVideoTracks()[0];
if (track) {
checkTorchAvailability(track);
}
};
const toggleTorch = async () => {
try {
const track = cameraRef.current?.video?.srcObject instanceof MediaStream
? (cameraRef.current.video.srcObject as MediaStream).getVideoTracks()[0]
: null;
if (track) {
const capabilities = track.getCapabilities() as ExtendedMediaTrackCapabilities;
if (capabilities.torch) {
const constraints: ExtendedMediaTrackConstraints = {
advanced: [{ torch: !isTorchOn }]
};
await track.applyConstraints(constraints);
setIsTorchOn(!isTorchOn);
} else {
onError("Вспышка не поддерживается на этом устройстве");
}
}
} catch (error) {
onError(error instanceof Error ? error.message : "Ошибка при управлении вспышкой");
}
};
return <ModalOverlay
type={ModalOverlayType.Dark}
className={styles.overlay}
@ -89,9 +144,10 @@ function CameraModal({
// height,
// aspectRatio: ratio,
}}
onUserMedia={() => setIsVideoReady(true)}
onUserMedia={onUserMedia}
onUserMediaError={(error) => {
setIsVideoReady(false);
setIsTorchAvailable(false);
console.error(error);
onError(error);
}}
@ -103,6 +159,29 @@ function CameraModal({
opacity: isVideoReady ? 1 : 0.5,
}}
/>
{isTorchAvailable && (
<button
className={`${styles.torchButton} ${isTorchOn ? styles.torchButtonActive : ""}`}
onClick={toggleTorch}
style={{
opacity: isVideoReady ? 1 : 0.5,
}}
>
<div className={styles.torchIconContainer}>
<svg
className={styles.torchIcon}
width="38"
height="38"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="#fff" d="M634 64H390c-35.2 0-48 28.8-48 64h340c0-35.2-12.8-64-48-64zM392.2 295c15.2 17.6 23.8 40 23.8 63.4v531.8c0 43.8 35.8 69.8 79.8 69.8h32.6c43.8 0 79.8-25.8 79.8-69.8V358.4c0-23.4 8.6-45.6 23.8-63.4 30.8-35.8 50-69 50-135H342c0 70 19.2 99.2 50.2 135z m63.8 181.6c0-31.2 25.2-56.6 56-56.6s56 25.4 56 56.6v70.8c0 31.2-25.2 56.6-56 56.6s-56-25.4-56-56.6v-70.8z" />
<path fill="#fff" d="M512 546m-40 0a40 40 0 1 0 80 0 40 40 0 1 0-80 0Z" />
</svg>
</div>
</button>
)}
</div>
</Modal>
</ModalOverlay>;

View File

@ -63,4 +63,43 @@
transform: translate(-50%);
width: auto;
z-index: 9;
}
.torchButton {
position: fixed;
bottom: calc(0dvh + 22px);
right: 20px;
width: 50px;
height: 50px;
border-radius: 25px;
background: rgba(0, 0, 0, 0.5);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
}
&>.torchIconContainer {
display: block;
z-index: 10;
width: 38px;
height: 38px;
}
&.torchButtonActive {
background-color: #fff;
&.torchIcon>path {
fill: #000;
&:last-child {
transform: translate(0, -64px);
}
}
}
}

View File

@ -0,0 +1,63 @@
import Title from "@/components/Title"
import styles from "./styles.module.scss"
import { IPaywallProduct } from "@/api/resources/Paywall"
import { useTranslations } from "@/hooks/translations"
import { ELocalesPlacement } from "@/locales"
import { useEmailsGeneration } from "@/hooks/emailsGeneration/useEmailsGeneration"
interface IEmailsListProps {
products: Array<IPaywallProduct & { weight: number }>
}
function EmailsList({ products }: IEmailsListProps) {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const {
displayEmails,
countBoughtEmails
} = useEmailsGeneration(products);
return (
<div className={styles.container}>
<div className={styles.header}>
<Title className={styles.title} variant="h2">
{translate("/trial-choice.v1.emails_list.title", {
count: countBoughtEmails
})}
</Title>
</div>
<p className={styles.description}>
{translate("/trial-choice.v1.emails_list.description", {
count: displayEmails?.length
})}
</p>
<div className={styles.emails}>
{displayEmails.map((item) => (
<div key={item.id} className={`${styles.emailContainer} ${item.willBeRemoved ? styles.removed : ""}`}>
<div className={styles.priceContainer}>
{item.willBeRemoved && (
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
width="17"
height="17"
rx="8.5"
fill="#62DFA1"
/>
<path d="M7.2586 12C7.08625 12 6.9139 11.9285 6.78205 11.785L4.19724 8.96395C4.13481 8.89628 4.0852 8.8154 4.05132 8.7261C4.01745 8.63679 4 8.54086 4 8.44395C4 8.34704 4.01745 8.2511 4.05132 8.1618C4.0852 8.07249 4.13481 7.99162 4.19724 7.92394C4.32473 7.78583 4.49574 7.70853 4.67379 7.70853C4.85184 7.70853 5.02285 7.78583 5.15034 7.92394L7.2586 10.2245L11.8491 5.21541C11.9766 5.0773 12.1476 5 12.3256 5C12.5037 5 12.6747 5.0773 12.8022 5.21541C12.8648 5.28298 12.9145 5.36382 12.9485 5.45314C12.9825 5.54246 13 5.63844 13 5.73541C13 5.83239 12.9825 5.92837 12.9485 6.01769C12.9145 6.10701 12.8648 6.18785 12.8022 6.25542L7.73515 11.7845C7.60814 11.9234 7.43683 12.0009 7.2586 12Z" fill="#F4F4F4" />
</svg>
)}
<div className={styles.price}>
${item.price.toFixed(2)}
</div>
</div>
<p className={styles.email}>
{item.email}
</p>
</div>
))}
</div>
</div>
)
}
export default EmailsList

View File

@ -0,0 +1,91 @@
.container {
width: 100%;
max-width: 328px;
background-color: #4B88FF4F;
border-radius: 18px;
margin-top: 16px;
& * {
font-family: SF Pro Text, sans-serif;
}
&>.description {
font-size: 16px;
line-height: 20px;
font-weight: 500;
color: #191919;
margin-top: 4px;
width: 100%;
padding-inline: 16px;
text-align: center;
}
}
.header {
width: 100%;
border-bottom: 1px solid #E2ECFF;
padding-inline: 16px;
&>.title {
font-size: 18px;
margin-bottom: 0;
line-height: 216%;
font-weight: 600;
}
}
.emails {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 13px;
height: 62px;
overflow-y: hidden;
&>.emailContainer {
// display: flex;
// align-items: center;
// justify-content: center;
display: grid;
grid-template-columns: 100px 1fr;
padding-inline: 16px;
gap: 4px;
width: 100%;
opacity: 1;
transition: opacity 1.5s ease-in-out;
will-change: opacity;
&.removed {
opacity: 0;
}
&>.priceContainer {
display: flex;
align-items: center;
justify-content: right;
gap: 4px;
&>.price {
padding: 1px 12px;
background-color: #FFFFFF;
border-radius: 24px;
font-size: 13px;
font-weight: 600;
line-height: 16px;
text-align: center;
color: #27296E;
width: fit-content;
}
}
&>.email {
font-size: 13px;
line-height: 16px;
font-weight: 400;
color: #000;
padding-left: 2px;
text-align: left;
}
}
}

View File

@ -38,7 +38,7 @@ function GenderPage() {
});
const { flags, ready } = useMetricABFlags();
const pageType = flags?.genderPageType?.[0];
const pageType = flags?.genderPageType?.[0] || "v2";
const localGenders = genders.map((gender) => ({
id: gender.id,
title: translate(gender.id, undefined, ELocalesPlacement.V1),
@ -97,6 +97,35 @@ function GenderPage() {
if (!ready) return <Loader color={LoaderColor.Black} />;
switch (pageType) {
case "v0":
return (
<>
<Title variant="h2" className={styles.title}>
{translate("/gender.title")}
</Title>
<p className={styles.description}>{translate("/gender.description", {
br: <br />,
})}</p>
{/* <ChooseGender onSelectGender={selectGender} /> */}
<PrivacyPolicy containerClassName={styles["privacy-policy"]} haveCheckbox={false} />
<div className={styles["genders-container"]}>
{localGenders.map((_gender, index) => (
<Answer
key={index}
answer={_gender}
isSelected={gender === _gender.id}
onClick={() => selectGender(genders.find((g) => g.id === _gender.id) ?? null)}
/>
))}
</div>
<AlreadyHaveAccount text={translate("/gender.already_have_account")} />
{/* {gender && !privacyPolicyChecked && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
{translate("/gender.toast", undefined, ELocalesPlacement.V1)}
</Toast>
)} */}
</>
)
case "v1":
return (
<>

View File

@ -19,7 +19,8 @@ import Loader from "@/components/Loader";
import { useTranslations } from "@/hooks/translations";
// import { useMetricABFlags } from "@/services/metric/metricService";
import { getLongText } from "./abText";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import metricService, { EGoals, EMetrics, useMetricABFlags } from "@/services/metric/metricService";
import TrialChoiceV1 from "./v1";
function TrialChoice() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
@ -32,6 +33,10 @@ function TrialChoice() {
const locale = getDefaultLocaleByLanguage(language);
const { flags, ready } = useMetricABFlags();
const trialChoicePageType = flags?.trialChoicePageType?.[0];
// const trialChoicePageType = "v1";
// const { flags } = useMetricABFlags();
// const isLongText = flags?.text?.[0] === "on";
@ -64,6 +69,18 @@ function TrialChoice() {
metricService.reachGoal(EGoals.AURA_TRIAL_CHOICE_PAGE_VISIT, [EMetrics.KLAVIYO]);
}, []);
if (!ready) {
return (
<div className={styles.container}>
<Loader className={styles.loader} />
</div>
);
}
if (trialChoicePageType === "v1") {
return <TrialChoiceV1 />;
}
return (
<div className={styles.container}>
{!isLoading && (

View File

@ -0,0 +1,141 @@
import { useTranslations } from "@/hooks/translations";
import styles from "./styles.module.scss";
import { addCurrency, ELocalesPlacement } from "@/locales";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { usePaywall } from "@/hooks/paywall/usePaywall";
import { useEffect, useState } from "react";
import { actions, selectors } from "@/store";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import routes from "@/routes";
import Loader from "@/components/Loader";
import PriceList from "@/components/pages/ABDesign/v1/components/PriceList";
import { EPlacementKeys } from "@/api/resources/Paywall";
import Button from "@/components/CompatibilityV2/components/Button";
import EmailSubstrate from "@/components/CompatibilityV2/components/EmailSubstrate";
import EmailsList from "@/components/CompatibilityV2/components/EmailsList";
const productWeights: Record<number, number> = {
100: 1, // 10%
500: 1, // 10%
900: 3, // 30%
1376: 5 // 50%
}
function TrialChoiceV1() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const navigate = useNavigate();
const dispatch = useDispatch();
const { products, isLoading, currency, getText } = usePaywall({
placementKey: EPlacementKeys["aura.placement.compatibility.v2"],
localesPlacement: ELocalesPlacement.CompatibilityV2,
});
const popularProduct = products[products.length - 1];
// const { flags } = useMetricABFlags();
// const isLongText = flags?.text?.[0] === "on";
const [isDisabled, setIsDisabled] = useState(true);
const selectedPrice = useSelector(selectors.selectSelectedPrice);
const email = useSelector(selectors.selectEmail);
const homeConfig = useSelector(selectors.selectHome);
const handlePriceItem = () => {
metricService.reachGoal(EGoals.SELECT_TRIAL, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
metricService.reachGoal(EGoals.AURA_SELECT_TRIAL, [EMetrics.KLAVIYO]);
setIsDisabled(false);
};
const handleNext = () => {
if (isDisabled) {
return;
}
dispatch(
actions.siteConfig.update({
home: { pathFromHome: homeConfig.pathFromHome, isShowNavbar: false },
})
);
navigate(routes.client.compatibilityV2TrialPayment());
};
// useEffect(() => {
// metricService.reachGoal(EGoals.TRIAL_CHOICE_PAGE_VISIT, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
// metricService.reachGoal(EGoals.AURA_TRIAL_CHOICE_PAGE_VISIT, [EMetrics.KLAVIYO]);
// }, []);
useEffect(() => {
if (popularProduct) {
dispatch(actions.payment.update({
activeProduct: popularProduct
}))
setIsDisabled(false);
}
}, [popularProduct])
return (
<div className={styles.container}>
{!isLoading && (
<>
<EmailSubstrate className={styles["email-substrate"]} email={email} />
{/* {!isLongText && (
<Title className={styles.title} variant="h2">
{getText("text.0")}
</Title>
)} */}
{/* <p className={styles.text}>{getLongText(locale)}</p> */}
<p className={styles.text}>
{translate("/trial-choice.v1.paragraph1", {
br: <br />
})}
</p>
<ul className={styles.list}>
{Array.from({ length: 4 }).map((_, index) => (
<li key={index} className={styles.listItem}>
{translate(`/trial-choice.v1.points.point${index + 1}`)}
</li>
))}
</ul>
<p
className={styles.text}
style={{
marginTop: "16px"
}}
>
{translate("/trial-choice.v1.paragraph2", {
price: addCurrency((Math.max(...products.map(product => product.trialPrice || 0)) / 100).toFixed(2), currency)
})}
</p>
<div className={styles["price-container"]}>
<p className={styles["auxiliary-text"]}>{getText("text.1")}</p>
<PriceList
products={products}
activeItem={selectedPrice}
classNameItem={styles["price-item"]}
classNameItemActive={`${styles["price-item-active"]}`}
classNamePricesContainer={styles["prices-container"]}
currency={currency}
click={handlePriceItem}
preActiveItems={[popularProduct?._id]}
/>
</div>
{!!products.length && <EmailsList
products={products.map(product => ({ ...product, weight: productWeights[product.trialPrice || 100] }))}
/>}
<Button
className={styles.button}
disabled={isDisabled}
onClick={handleNext}
>
{translate("next")}
</Button>
</>
)}
{isLoading && <Loader className={styles.loader} />}
</div>
);
}
export default TrialChoiceV1

View File

@ -0,0 +1,160 @@
.container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.email-substrate {
display: grid;
grid-template-columns: 1fr 41px !important;
align-content: center;
position: absolute;
max-width: 100%;
top: -18px;
right: -12px;
padding: 0px !important;
padding-left: 16px !important;
border-radius: 20px !important;
height: 41px !important;
background: transparent !important;
outline: 2px solid #81A9F5 !important;
&>p {
color: #000000 !important;
font-size: 14px !important;
}
&>div {
height: 45px !important;
width: 45px !important;
background-color: #81A9F5 !important;
}
}
.title {
font-size: 28px;
line-height: 125%;
margin-top: 44px;
color: #2c2c2c;
font-weight: 500;
}
.prices-container {
justify-content: center;
gap: 15px;
width: fit-content;
margin: 0 auto;
}
.price-item {
border: solid #2c2c2c 1px;
border-radius: 8px;
width: 70px;
height: 65px;
font-size: 20px;
font-weight: 500;
&:last-child::after {
content: "";
position: absolute;
width: 16px;
height: 20px;
// background-color: #224e90;
background-image: url("/v1/palmistry/trial-choice/arrow.svg");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
top: -30px;
left: 50%;
transform: translateX(-50%);
}
&.price-item-active {
border: solid #224e90 3px !important;
background-color: transparent;
color: #224e90;
}
}
.price-container {
position: relative;
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 32px;
}
.auxiliary-text {
font-size: 15px;
line-height: 125%;
font-weight: 600;
color: #0244a5;
width: 100%;
text-align: center;
}
.cursor {
position: absolute;
width: 2px;
height: 20px;
background-color: #224e90;
top: 71px;
right: 34px;
}
.loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.button {
margin-top: 20px;
transition: background 0.2s ease, color 0.2s ease;
// position: sticky;
// bottom: calc(0dvh + 16px);
&:disabled {
border: solid #224e90 1px;
background: none;
color: #120d0d;
opacity: 1;
}
}
.text {
width: calc(100% + 24px);
white-space: pre-wrap;
margin-top: 40px;
margin-bottom: 0px;
font-size: 15px;
font-weight: 500;
line-height: 125%;
color: #2C2C2C;
text-align: center;
}
.list {
width: calc(100% + 24px);
margin-top: 16px;
margin-bottom: 0px;
display: flex;
flex-direction: column;
gap: 16px;
li {
font-size: 15px;
font-weight: 500;
line-height: 125%;
color: #2C2C2C;
list-style-type: disc;
margin-left: 24px;
&::marker {
font-size: 10px;
}
}
}

View File

@ -13,6 +13,18 @@ interface CameraModalProps {
isCameraVisible?: boolean;
}
interface ExtendedMediaTrackCapabilities extends MediaTrackCapabilities {
torch?: boolean;
}
interface TorchConstraints extends MediaTrackConstraintSet {
torch?: boolean;
}
interface ExtendedMediaTrackConstraints extends MediaTrackConstraints {
advanced?: TorchConstraints[];
}
function CameraModal({
onClose,
onTakePhoto,
@ -21,6 +33,8 @@ function CameraModal({
isCameraVisible = true
}: CameraModalProps) {
const [isVideoReady, setIsVideoReady] = useState(false);
const [isTorchOn, setIsTorchOn] = useState(false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
// const {
// width: _width, height: _height,
// elementRef: containerRef } = useDynamicSize<HTMLDivElement>({});
@ -55,6 +69,47 @@ function CameraModal({
}
};
const checkTorchAvailability = async (track: MediaStreamTrack) => {
try {
const capabilities = track.getCapabilities() as ExtendedMediaTrackCapabilities;
setIsTorchAvailable(!!capabilities.torch);
} catch (error) {
setIsTorchAvailable(false);
console.error('Ошибка при проверке поддержки фонарика:', error);
}
};
const onUserMedia = (stream: MediaStream) => {
setIsVideoReady(true);
const track = stream.getVideoTracks()[0];
if (track) {
checkTorchAvailability(track);
}
};
const toggleTorch = async () => {
try {
const track = cameraRef.current?.video?.srcObject instanceof MediaStream
? (cameraRef.current.video.srcObject as MediaStream).getVideoTracks()[0]
: null;
if (track) {
const capabilities = track.getCapabilities() as ExtendedMediaTrackCapabilities;
if (capabilities.torch) {
const constraints: ExtendedMediaTrackConstraints = {
advanced: [{ torch: !isTorchOn }]
};
await track.applyConstraints(constraints);
setIsTorchOn(!isTorchOn);
} else {
onError("Вспышка не поддерживается на этом устройстве");
}
}
} catch (error) {
onError(error instanceof Error ? error.message : "Ошибка при управлении вспышкой");
}
};
return <ModalOverlay
type={ModalOverlayType.Dark}
className={styles.overlay}
@ -89,9 +144,10 @@ function CameraModal({
// height,
// aspectRatio: ratio,
}}
onUserMedia={() => setIsVideoReady(true)}
onUserMedia={onUserMedia}
onUserMediaError={(error) => {
setIsVideoReady(false);
setIsTorchAvailable(false);
console.error(error);
onError(error);
}}
@ -103,6 +159,29 @@ function CameraModal({
opacity: isVideoReady ? 1 : 0.5,
}}
/>
{isTorchAvailable && (
<button
className={`${styles.torchButton} ${isTorchOn ? styles.torchButtonActive : ""}`}
onClick={toggleTorch}
style={{
opacity: isVideoReady ? 1 : 0.5,
}}
>
<div className={styles.torchIconContainer}>
<svg
className={styles.torchIcon}
width="38"
height="38"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="#fff" d="M634 64H390c-35.2 0-48 28.8-48 64h340c0-35.2-12.8-64-48-64zM392.2 295c15.2 17.6 23.8 40 23.8 63.4v531.8c0 43.8 35.8 69.8 79.8 69.8h32.6c43.8 0 79.8-25.8 79.8-69.8V358.4c0-23.4 8.6-45.6 23.8-63.4 30.8-35.8 50-69 50-135H342c0 70 19.2 99.2 50.2 135z m63.8 181.6c0-31.2 25.2-56.6 56-56.6s56 25.4 56 56.6v70.8c0 31.2-25.2 56.6-56 56.6s-56-25.4-56-56.6v-70.8z" />
<path fill="#fff" d="M512 546m-40 0a40 40 0 1 0 80 0 40 40 0 1 0-80 0Z" />
</svg>
</div>
</button>
)}
</div>
</Modal>
</ModalOverlay>;

View File

@ -63,4 +63,43 @@
transform: translate(-50%);
width: auto;
z-index: 9;
}
.torchButton {
position: fixed;
bottom: calc(0dvh + 22px);
right: 20px;
width: 50px;
height: 50px;
border-radius: 25px;
background: rgba(0, 0, 0, 0.5);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
}
&>.torchIconContainer {
display: block;
z-index: 10;
width: 38px;
height: 38px;
}
&.torchButtonActive {
background-color: #fff;
&.torchIcon>path {
fill: #000;
&:last-child {
transform: translate(0, -64px);
}
}
}
}

View File

@ -38,7 +38,7 @@ function GenderPage() {
});
const { flags, ready } = useMetricABFlags();
const pageType = flags?.genderPageType?.[0];
const pageType = flags?.genderPageType?.[0] || "v2";
const localGenders = genders.map((gender) => ({
id: gender.id,
title: translate(gender.id, undefined, ELocalesPlacement.V1),
@ -97,6 +97,35 @@ function GenderPage() {
if (!ready) return <Loader color={LoaderColor.Black} />;
switch (pageType) {
case "v0":
return (
<>
<Title variant="h2" className={styles.title}>
{translate("/gender.title")}
</Title>
<p className={styles.description}>{translate("/gender.description", {
br: <br />,
})}</p>
{/* <ChooseGender onSelectGender={selectGender} /> */}
<PrivacyPolicy containerClassName={styles["privacy-policy"]} haveCheckbox={false} />
<div className={styles["genders-container"]}>
{localGenders.map((_gender, index) => (
<Answer
key={index}
answer={_gender}
isSelected={gender === _gender.id}
onClick={() => selectGender(genders.find((g) => g.id === _gender.id) ?? null)}
/>
))}
</div>
<AlreadyHaveAccount text={translate("/gender.already_have_account")} />
{/* {gender && !privacyPolicyChecked && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
{translate("/gender.toast", undefined, ELocalesPlacement.V1)}
</Toast>
)} */}
</>
)
case "v1":
return (
<>

View File

@ -37,6 +37,9 @@ function HeadOrHeart() {
useLottie({
preloadKey: ELottieKeys.letScan,
});
useLottie({
preloadKey: ELottieKeys.scannedPhoto,
});
const answers: { id: IAnswersSessionCompatibilityV3["head_or_heart"]; title: string }[] =
useMemo(

View File

@ -14,6 +14,8 @@ import ProgressBarLine from "@/components/ui/ProgressBarLine";
import Modal from "@/components/Modal";
import { useAuthentication } from "@/hooks/authentication/use-authentication";
import { ESourceAuthorization } from "@/api/resources/User";
import { ELottieKeys, useLottie } from "@/hooks/lottie/useLottie";
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
// const drawElementChangeDelay = 1500;
// const startDelay = 500;
@ -163,6 +165,10 @@ function ScannedPhoto() {
// }, [currentElementIndex, drawElements.length, navigate]);
const { animationData } = useLottie({
loadKey: ELottieKeys.scannedPhoto,
});
const [progress, setProgress] = useState(0);
const [isPause, setIsPause] = useState(false);
const interval = useRef<NodeJS.Timeout>();
@ -287,6 +293,16 @@ function ScannedPhoto() {
</div>
</Modal>
)}
<div className={styles["lottie-animation-container"]}>
{animationData &&
<DotLottieReact
className={`${styles["lottie-animation"]} ym-hide-content`}
data={animationData}
autoplay
loop={true}
width={323}
/>}
</div>
{!isDecorationShown && <div className={styles["points-container"]}>
{loadingProfilePoints.map(({ title1, title2 }, index) => (
<div
@ -299,19 +315,19 @@ function ScannedPhoto() {
<Title variant="h2" className={styles["point__title"]}>
{translate(getProgressValue(index) > 50 ? title2 : title1)}
</Title>
<ProgressBarLine
containerClassName={styles["progress-bar__container"]}
lineClassName={styles["progress-bar__line"]}
lineColor={"#275DA7"}
value={getProgressValue(index)}
delay={50}
/>
<p
className={styles["point__percentage"]}
>
{getProgressValue(index)}%
</p>
</div>
<p
className={styles["point__percentage"]}
>
{getProgressValue(index)}%
</p>
<ProgressBarLine
containerClassName={styles["progress-bar__container"]}
lineClassName={styles["progress-bar__line"]}
lineColor={"#275DA7"}
value={getProgressValue(index)}
delay={50}
/>
</div>
))}
</div>}

View File

@ -1,6 +1,6 @@
.page {
width: 100%;
padding: 0 16px 16px;
padding: 0 0 16px;
margin: 0 auto;
max-width: 560px;
height: fit-content;
@ -228,31 +228,29 @@
width: 100%;
display: flex;
flex-direction: column;
margin-top: 24px;
}
.points-container {
padding: 0 17px;
gap: 50px;
margin-top: 50px;
padding: 0;
gap: 40px;
}
.point {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-direction: column;
gap: 8px;
}
.point__text-container {
width: calc(100% - 60px);
width: calc(100%);
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
flex-direction: row;
gap: 4px;
}
.point__title {
font-size: 15px;
font-size: 20px;
font-weight: 600;
line-height: 125%;
font-weight: normal;
margin: 0;
@ -261,7 +259,7 @@
}
.point__percentage {
font-size: 16px;
font-size: 22px;
font-weight: 600;
line-height: 125%;
color: #275DA6;
@ -269,11 +267,13 @@
.progress-bar__container {
background-color: #e6e6e6;
height: 8px;
height: 38px;
border-radius: 10px;
}
.progress-bar__line {
background-color: #908cf2;
border-radius: 10px;
}
.modal-container {
@ -312,4 +312,14 @@
&:first-child {
border-right: 1px solid #D9D9D9;
}
}
.lottie-animation-container {
width: 160px;
min-height: 160px;
}
.lottie-animation {
aspect-ratio: 1;
width: 160px;
}

View File

@ -13,6 +13,19 @@ interface CameraModalProps {
isCameraVisible?: boolean;
}
interface ExtendedMediaTrackCapabilities extends MediaTrackCapabilities {
torch?: boolean;
}
interface TorchConstraints extends MediaTrackConstraintSet {
torch?: boolean;
}
interface ExtendedMediaTrackConstraints extends MediaTrackConstraints {
advanced?: TorchConstraints[];
}
function CameraModal({
onClose,
onTakePhoto,
@ -21,6 +34,8 @@ function CameraModal({
isCameraVisible = true
}: CameraModalProps) {
const [isVideoReady, setIsVideoReady] = useState(false);
const [isTorchOn, setIsTorchOn] = useState(false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
// const {
// width: _width, height: _height,
// elementRef: containerRef } = useDynamicSize<HTMLDivElement>({});
@ -55,6 +70,47 @@ function CameraModal({
}
};
const checkTorchAvailability = async (track: MediaStreamTrack) => {
try {
const capabilities = track.getCapabilities() as ExtendedMediaTrackCapabilities;
setIsTorchAvailable(!!capabilities.torch);
} catch (error) {
setIsTorchAvailable(false);
console.error('Ошибка при проверке поддержки фонарика:', error);
}
};
const onUserMedia = (stream: MediaStream) => {
setIsVideoReady(true);
const track = stream.getVideoTracks()[0];
if (track) {
checkTorchAvailability(track);
}
};
const toggleTorch = async () => {
try {
const track = cameraRef.current?.video?.srcObject instanceof MediaStream
? (cameraRef.current.video.srcObject as MediaStream).getVideoTracks()[0]
: null;
if (track) {
const capabilities = track.getCapabilities() as ExtendedMediaTrackCapabilities;
if (capabilities.torch) {
const constraints: ExtendedMediaTrackConstraints = {
advanced: [{ torch: !isTorchOn }]
};
await track.applyConstraints(constraints);
setIsTorchOn(!isTorchOn);
} else {
onError("Вспышка не поддерживается на этом устройстве");
}
}
} catch (error) {
onError(error instanceof Error ? error.message : "Ошибка при управлении вспышкой");
}
};
return <ModalOverlay
type={ModalOverlayType.Dark}
className={styles.overlay}
@ -89,9 +145,10 @@ function CameraModal({
// height,
// aspectRatio: ratio,
}}
onUserMedia={() => setIsVideoReady(true)}
onUserMedia={onUserMedia}
onUserMediaError={(error) => {
setIsVideoReady(false);
setIsTorchAvailable(false);
console.error(error);
onError(error);
}}
@ -103,6 +160,29 @@ function CameraModal({
opacity: isVideoReady ? 1 : 0.5,
}}
/>
{isTorchAvailable && (
<button
className={`${styles.torchButton} ${isTorchOn ? styles.torchButtonActive : ""}`}
onClick={toggleTorch}
style={{
opacity: isVideoReady ? 1 : 0.5,
}}
>
<div className={styles.torchIconContainer}>
<svg
className={styles.torchIcon}
width="38"
height="38"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="#fff" d="M634 64H390c-35.2 0-48 28.8-48 64h340c0-35.2-12.8-64-48-64zM392.2 295c15.2 17.6 23.8 40 23.8 63.4v531.8c0 43.8 35.8 69.8 79.8 69.8h32.6c43.8 0 79.8-25.8 79.8-69.8V358.4c0-23.4 8.6-45.6 23.8-63.4 30.8-35.8 50-69 50-135H342c0 70 19.2 99.2 50.2 135z m63.8 181.6c0-31.2 25.2-56.6 56-56.6s56 25.4 56 56.6v70.8c0 31.2-25.2 56.6-56 56.6s-56-25.4-56-56.6v-70.8z" />
<path fill="#fff" d="M512 546m-40 0a40 40 0 1 0 80 0 40 40 0 1 0-80 0Z" />
</svg>
</div>
</button>
)}
</div>
</Modal>
</ModalOverlay>;

View File

@ -63,4 +63,43 @@
transform: translate(-50%);
width: auto;
z-index: 9;
}
.torchButton {
position: fixed;
bottom: calc(0dvh + 22px);
right: 20px;
width: 50px;
height: 50px;
border-radius: 25px;
background: rgba(0, 0, 0, 0.5);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
}
&>.torchIconContainer {
display: block;
z-index: 10;
width: 38px;
height: 38px;
}
&.torchButtonActive {
background-color: #fff;
&.torchIcon>path {
fill: #000;
&:last-child {
transform: translate(0, -64px);
}
}
}
}

View File

@ -14,7 +14,7 @@ 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, useMetricABFlags } from "@/services/metric/metricService";
import Modal from "@/components/Modal";
import Title from "@/components/Title";
@ -47,10 +47,13 @@ function Camera() {
const [isLoading, setIsLoading] = useState(false);
const [uploadMenuModalIsOpen, setUploadMenuModalIsOpen] = useState(false);
const [toastVisible, setToastVisible] = useState<EToastVisible | null>(null);
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState(isIphoneSafari ? false : true);
const [cameraKey, setCameraKey] = useState(0);
const [isCameraModalOpen, setIsCameraModalOpen] = useState(false);
const { flags, ready } = useMetricABFlags();
const isCameraRequestModal = flags?.cameraRequestModal?.[0] !== "without";
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState((isIphoneSafari || !isCameraRequestModal) ? false : true);
const handleNext = () => {
metricService.reachGoal(EGoals.CAMERA_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
navigate(routes.client.palmistryV1ScannedPhoto());
@ -185,7 +188,7 @@ function Camera() {
const cameraError = (error: string | DOMException) => {
console.error("Camera error", error)
if (!isIphoneSafari) return;
if (!isIphoneSafari || !isCameraRequestModal) return;
if (error === "Video is not ready") {
return setToastVisible(EToastVisible.no_access_camera)
}
@ -210,6 +213,8 @@ function Camera() {
}
};
if (!ready) return null;
return (
<>
<Modal
@ -269,7 +274,7 @@ function Camera() {
onClose={() => console.log("close")}
onTakePhoto={onTakePhoto}
onError={cameraError}
isCameraVisible={isIphoneSafari ? true : isCameraModalOpen}
isCameraVisible={(isIphoneSafari || isCameraRequestModal) ? true : isCameraModalOpen}
reinitializeKey={cameraKey}
/>
)}

View File

@ -22,6 +22,7 @@ export enum ELottieKeys {
scalesHeadPalmistry = "scalesHeadPalmistry",
scalesHeartPalmistry = "scalesHeartPalmistry",
letScan = "letScan",
scannedPhoto = "scannedPhoto",
}
export const lottieUrls = {
@ -44,6 +45,7 @@ export const lottieUrls = {
[ELottieKeys.scalesHeadPalmistry]: "https://lottie.host/d16336c4-2622-48f8-b361-8d9d50b3c8a6/wWSM7JMCHu.lottie",
[ELottieKeys.scalesHeartPalmistry]: "https://lottie.host/fa931c2d-07f5-4c57-a4bb-8302b411ecca/zy9ag3MyMe.lottie",
[ELottieKeys.letScan]: "https://lottie.host/f87184ec-aa5e-4cf4-82a5-9ab5e60c22d5/qpgweCSCtn.lottie",
[ELottieKeys.scannedPhoto]: "https://lottie.host/a63a6fa4-420d-42b9-a202-cf6939f554a7/A4ZU7LYWa3.lottie",
}
interface IUseLottieProps {

View File

@ -209,9 +209,10 @@ type TABFlags = {
auraPalmistry: "on";
esFlag: "hiCopy" | "standard";
palmOnPayment: "graphical" | "real";
genderPageType: "v1" | "v2";
genderPageType: "v0" | "v1" | "v2";
trialChoicePageType: "v1" | "v2";
welcomePageImage: "v1" | "v2";
cameraRequestModal: "with" | "without";
}
export const useMetricABFlags = () => {