Merge branch 'develop' into 'main'

develop

See merge request witapp/aura-webapp!625
This commit is contained in:
Daniil Chemerkin 2025-02-25 11:05:56 +00:00
commit ec3057196a
79 changed files with 3268 additions and 314 deletions

View File

@ -37,24 +37,24 @@
"description": "In palmistry, everyone has both masculine and feminine traits. <br><br> Let's determine yours for a more accurate palm reading.",
"already_have_account": "Already have an account? Sign in",
"v1": {
"title": "Тест на Совместимость<br>👩‍❤️‍👨 ",
"subtitle": "Все начинается с тебя!<br>Выбери свой пол 👇",
"title": "Compatibility Test<br>👩‍❤️‍👨",
"subtitle": "It all starts with you!<br>Select your gender 👇",
"points": {
"point1": "тест займет не более 1 мин",
"point2": "ты получишь анализ совместимости связанный с линиям на твоей руке",
"point3": "100% достоверность данных",
"point4": "более 50 стр разбора"
"point1": "The test takes less than a minute.",
"point2": "Youll receive an analysis of your compatibility based on the lines on your hand.",
"point3": "100% data accuracy.",
"point4": "Over 50 pages of analysis."
}
},
"v2": {
"title": "Тест на Совместимость",
"subtitle": "Все начинается с тебя! Выбери свой пол.",
"title": "Compatibility Test",
"subtitle": "It all starts with you! Choose your gender.",
"points": {
"point1": "Тест займет не более 1 мин.",
"point2": "Ты получишь разбор совместимости по хиромантическому анализу линий на твоей руке.",
"point3": "Решишь проблемы в отношениях за месяц.",
"point4": "Сэкономите сотни долларов на ненадёжных прогнозах.",
"point5": "Получите персональный анализ."
"point1": "The test takes less than a minute.",
"point2": "You'll receive a compatibility analysis through palmistry based on the lines on your hand.",
"point3": "Resolve relationship issues in a month.",
"point4": "Save hundreds of dollars on unreliable forecasts.",
"point5": "Get a personalized analysis."
}
}
},
@ -447,7 +447,16 @@
"bad_photo": "Bad Photo!",
"try_again": "Try Again",
"do_better": "You Can Do Better",
"next": "Next"
"no_access_camera": "No Access to Camera",
"give_access": "Give Access",
"reload_page": "Please reload the page to continue.",
"reload_page_button": "Reload Page",
"next": "Next",
"modal": {
"title": "To scan your hand, access to the Camera is required.",
"cancel": "Cancel",
"allow": "Allow"
}
},
"/depends": {
"with-partner": {
@ -506,5 +515,36 @@
"capricorn": "Capricorn",
"aquarius": "Aquarius",
"pisces": "Pisces"
},
"/try-app": {
"header": {
"title": "Your Personalized Offer Reserved",
"get-prediction-in-app": "Get prediction in<br>the App"
},
"palm_lines": {
"head": "Head line ✅",
"heart": "Love line ✅",
"fate": "Fate line ✅",
"life": "Life line ✅"
},
"reading_ready": {
"title": "Your Palm Reading is READY and available in the app for your iPhone!"
},
"your_access_code": "Your Access Code",
"copy": "COPY",
"instruction_point_1": "1. Download App",
"instruction_point_2": "2. Enter Your Access Code",
"not_share_description": "Enter your access code in the app to access Your Personalized Reading. Do not share your code with anyone.",
"get_prediction_in_app": "Get personal prediction in the App",
"enter-code-title": "Enter Your Access Code:",
"code-copied": "Code copied",
"copy-code-title": "Click to copy code",
"how_work": {
"title": "How does AURA work?"
},
"get-my-reading-in-app": "GET MY READING IN THE APP",
"why_love": "Why does everyone <color> ?",
"why_love_color": "love AURA",
"as_seen_in": "<color> As Seen in "
}
}

View File

@ -0,0 +1,5 @@
{
"firstNames": "Carlos,Luis,Maria,Ana,Juan,Pedro,Jose",
"lastNames": "Gomez,Lopez,Martinez,Rodriguez,Fernandez,Perez",
"domains": "gmail.com,yahoo.com,outlook.com,hotmail.com"
}

View File

@ -0,0 +1,5 @@
{
"firstNames": "Carlos,Luis,Maria,Ana,Juan,Pedro,Jose",
"lastNames": "Gomez,Lopez,Martinez,Rodriguez,Fernandez,Perez",
"domains": "gmail.com,yahoo.com,outlook.com,hotmail.com"
}

View File

@ -287,7 +287,8 @@
"total_due": "Total due today: <trialPrice>",
"app_number_one_color": "25 million",
"app_number_one": "The #1 Astrology app trusted by over <color> people.",
"payment_success": "Payment success"
"payment_success": "Payment success",
"payment_error": "Payment error"
},
"/camera": {
"bad_photo": "Bad photo!",

View File

@ -4,31 +4,31 @@
"eula_link": "EULA",
"privacy_notice": "Privacy Notice",
"policy_here": "here",
"thumb": "Thumb finger",
"index_finger": "Index finger",
"middle_finger": "Middle finger",
"ring_finger": "Ring finger",
"pinky": "Little finger",
"/scanned-photo": {
"title": "We are putting together a comprehensive Palmistry Reading just for you!",
"text": "Wow, looks like there is a lot we can tell about your ambitious and strong self-confident future."
"title": "Your extended palmistry analysis is almost ready!",
"text": "Judging by the lines on your palm, you have an exciting future ahead. Lets explore it in more detail!",
"palm_lines": {
"head": "Head line ✅",
"heart": "Love line ✅",
"fate": "Fate line ✅",
"life": "Life line ✅"
}
},
"aura_paywall_palmistry_main": {
"text_0": "We've helped millions of people to reveal the destiny of their love life and what the future holds for them and their families.",
"text_1": "It costs us $13.21 to compensate our AURA\nemployees for the trial, but please choose the\namount you are comfortable with."
},
"skip_trial": "Skip Trial",
"add_consultant": "Add Consultant",
"add_guides": "Add Guides",
"access_product": "Access Product",
"thank_you": "Thank you!",
"order_successful": "Your order was successful!",
"/skip-trial": {
"title": "Not planning on looking back?",
"price_per_week": "<price> per week",
@ -45,7 +45,6 @@
"skip_trial": "Accept offer and skip trial"
}
},
"/add-consultant": {
"more_for_you": "More for you",
"exclusive_offer": "Exclusive offer recommended for you to achieve your goals faster",
@ -62,59 +61,69 @@
"unlock_profound": "Unlock profound insights into your personality, relationships, career trajectory, and life's pivotal moments through astrology, empowering you to make informed decisions and achieve greater fulfillment.",
"choose_from": "Choose from 80+ experts astrologers."
},
"/find-your-happiness": {
"title": "Find your happiness with highly-personalized predictions.",
"title": "Gain clarity and confidence in life",
"point1": "rated by real users",
"point2": "93.4% Accuracy",
"point3": "20m users choice",
"point4": "4.8 satisfaction sco",
"text": "Understand your self and improve relationships with astrology"
"text": "Use astrology and palmistry to strengthen yourself and your relationships",
"advantage1": "In-depth analysis: We scan the lines on your palm",
"advantage2": "Personal approach: Analysis of personal destiny and future",
"advantage3": "Quick results: No more than 5 minutes to complete"
},
"/gender": {
"title": "Whats your gender?",
"description": "In Palmistry, everyone is a blend of masculine and feminine, so it helps to know yours.",
"title": "What is your gender?",
"description": "In palmistry, everyone has both masculine and feminine traits. <br><br> Let's clarify yours for a more accurate hand reading.",
"already_have_account": "Already have an account? Sign in"
},
"/birthdate": {
"title": "Whats your date of birth?",
"text": "Your birth date reveals your core personality traits, needs and desires."
"title": "When were you born?",
"text": "Your birth date reveals which strengths and values can help you move forward"
},
"/palms-information": {
"title": "Your palms hold a wealth of information about your fate and personality."
"title": "Your palms carry a vast amount of information about your destiny and character"
},
"/what-aspects": {
"title": "What aspects of your life do you wish to gain insight into through palmistry?",
"answer1": "Love & Relationships",
"answer2": "Health & Vitality",
"answer3": "Career & Destiny"
"title": "In which areas of life do you seek deeper understanding?",
"answer1": "Love and relationships",
"answer2": "Health and energy",
"answer3": "Career and purpose",
"answer4": "Life transitions"
},
"/relationship-status": {
"title": "So we can get to know you better, please tell us your relationship status",
"title": "To better understand your essence, please indicate your current relationship status",
"answer1": "Single",
"answer2": "In a relationship"
"answer2": "In a relationship",
"answer3": "Married",
"answer4": "Divorced",
"answer5": "It's complicated"
},
"/element-resonates": {
"title": "Which element resonates with you most?",
"title": "Which element fills you with the greatest strength?",
"answer1": "Water",
"answer2": "Fire",
"answer3": "Air",
"answer4": "Earth"
"answer4": "Earth",
"answer5": "Light",
"answer6": "Darkness"
},
"/favorite-color": {
"title": "Which color do you like the most?",
"title": "Which color best represents your character?",
"answer1": "Blue",
"answer2": "Green",
"answer3": "Orange",
"answer4": "Violet",
"answer5": "Red",
"answer6": "Yellow"
"answer6": "Yellow",
"answer7": "Turquoise"
},
"/head-or-heart": {
"title": "Do you make decisions with your head or your heart?",
"answer1": "Heart",
"answer2": "Head",
"answer3": "Both"
"title": "What guides you in life: the call of the heart or the voice of reason?",
"answer1": "I follow my heart",
"answer2": "I rely on reason",
"answer3": "I combine both approaches",
"answer4": "It depends on the situation"
},
"/relate-following": {
"title": "Do you relate to the following:",
@ -127,17 +136,17 @@
"strongly_disagree": "Strongly Disagree"
},
"/let-scan": {
"title": "Let`s scan your palms",
"text": "Follow the on-screen instructions, so we can analyze your palm lines and reveal your future, and the secrets of your destiny!"
"title": "We are scanning your palm",
"text": "Follow the on-screen instructions so we can analyze the lines of your palm, revealing the future and the secrets of your destiny!"
},
"biometric_data": "No biometric data collected. All recognition process performs on your device.",
"biometric_data": "We do not collect biometric data. The entire recognition process happens on your device.",
"/scan-instruction": {
"title": "Take your palm picture as instructed",
"button": "Take a picture now"
"title": "Photograph your palm as shown",
"button": "Take a photo now"
},
"/email": {
"title": "Enter your email to get your advanced Palmistry reading with AURA",
"not_share": "We dont share any personal information.",
"title": "Enter your email to receive an extended palmistry analysis with AURA",
"not_share": "We do not share your personal information with third parties.",
"placeholder_email": "Your email",
"placeholder_name": "Your name"
},
@ -146,6 +155,22 @@
"text": "The <color> app trusted by over 25 million people.",
"color": "#1 Astrology"
},
"/trial-choice": {
"v1": {
"paragraph1": "AURA is the only accurate app with reliable fate line analysis, verified by professionals and guaranteed to provide precise predictions.<br><br>AURA has already helped millions of people find happiness and discover the whole truth about their relationships.<br><br>Your fate analysis, which will completely change your life, is almost ready! Before we provide it to you, we would like to offer you the opportunity to choose the amount you consider reasonable to try AURA for 7 days and which you think is fair for the changes that will happen to you:",
"paragraph2": "A 7-day trial period costs us <price>, but please choose the amount that suits you best.",
"points": {
"point1": "You will discover all the most intimate secrets that the stars have prepared for you and solve relationship issues within just one month;",
"point2": "You will once and for all put the finishing touches on unresolved issues and forget about problems that have been haunting you for years (if not decades);",
"point3": "You will save hundreds of dollars on fake and unprofessional astrological predictions and fortune tellers;",
"point4": "You will receive not only a personal analysis but also personalized daily horoscopes, learn who and how is draining your energy, and get other personalized readings."
},
"emails_list": {
"title": "Сегодня купили <count>",
"description": "Сейчас покупают <count> человек:"
}
}
},
"/trial-payment": {
"palm_is_ready": {
"title": "Your Palm Reading <color>",
@ -226,9 +251,9 @@
}
},
"/payment": {
"will_be_charged_trial_info": "<trialPrice> for your <trialDuration>-day trial",
"will_be_charged": "You will be charged only <trialInfo>. Save <save> now. Then <splitPrice> per week. Well <emailReminder> before your trial ends.",
"will_be_charged_email_reminder": "email you a reminder",
"will_be_charged": "You will be charged only <trialInfo>. Then <fullPrice> <trialPrice> per week. Save <save> every week. Well <emailReminder> before your trial ends.",
"will_be_charged_trial_info": "<trialPrice> for your <trialDuration>-day trial",
"payment_information": {
"personalized_offer": "Personalized offer reserved",
"title": "Start your <trialDuration>-day trial",
@ -246,9 +271,62 @@
"app_number_one": "The #1 Astrology app trusted by over <color>"
},
"/camera": {
"bad_photo": "Bad photo!",
"try_again": "Try again",
"do_better": "You can do it better",
"next": "Next"
"bad_photo": "Bad Photo!",
"try_again": "Try Again",
"do_better": "You Can Do Better",
"no_access_camera": "No Access to Camera",
"give_access": "Give Access",
"reload_page": "Please reload the page to continue.",
"reload_page_button": "Reload Page",
"next": "Next",
"modal": {
"title": "To scan your hand, access to the Camera is required.",
"cancel": "Cancel",
"allow": "Allow"
}
},
"/with-heart": {
"title": "Your choice is natural—based on our data, 51% of <gender> with the <zodiacSign> sign follow their heart. We'll take this into account in your lines!"
},
"/with-head": {
"title": "Even among <zodiacSign>, not everything is decided by the heart—based on our data, 35% of <gender> of your sign make decisions guided by reason. We'll factor this into your analysis."
},
"/both": {
"wonderful": "Wonderful!",
"title": "The facts speak for themselves! According to our data, only 15% of <gender> born under the <zodiacSign> sign follow both heart and mind equally. That's the secret to harmonious relationships, and we'll reflect this in your lines."
},
"/depends": {
"title": "Based on our data, only 9% of <gender> born under the <zodiacSign> sign possess a clear logical clarity—a rare gift. We'll certainly take this trait into account in your lines."
},
"/try-app": {
"header": {
"title": "Your Personalized Offer Reserved",
"get-prediction-in-app": "Get prediction in<br>the App"
},
"palm_lines": {
"head": "Head line ✅",
"heart": "Love line ✅",
"fate": "Fate line ✅",
"life": "Life line ✅"
},
"reading_ready": {
"title": "Your Palm Reading is READY and available in the app for your iPhone!"
},
"your_access_code": "Your Access Code",
"copy": "COPY",
"instruction_point_1": "1. Download App",
"instruction_point_2": "2. Enter Your Access Code",
"not_share_description": "Enter your access code in the app to access Your Personalized Reading. Do not share your code with anyone.",
"get_prediction_in_app": "Get personal prediction in the App",
"enter-code-title": "Enter Your Access Code:",
"code-copied": "Code copied",
"copy-code-title": "Click to copy code",
"how_work": {
"title": "How does AURA work?"
},
"get-my-reading-in-app": "GET MY READING IN THE APP",
"why_love": "Why does everyone <color> ?",
"why_love_color": "love AURA",
"as_seen_in": "<color> As Seen in "
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.29289 20.7321C7.68342 21.1226 8.31658 21.1226 8.70711 20.7321L15.0711 14.3681C15.4616 13.9776 15.4616 13.3444 15.0711 12.9539C14.6805 12.5634 14.0474 12.5634 13.6569 12.9539L8 18.6108L2.34315 12.9539C1.95262 12.5634 1.31946 12.5634 0.928932 12.9539C0.538407 13.3444 0.538407 13.9776 0.928932 14.3681L7.29289 20.7321ZM7 -4.37114e-08L7 20.025L9 20.025L9 4.37114e-08L7 -4.37114e-08Z" fill="#224E90"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -77,6 +77,7 @@ const api = {
getPalmistryLines: createMethod<Palmistry.Payload, Palmistry.Response>(Palmistry.createRequest),
// New Authorization
authorization: createMethod<User.ICreateAuthorizePayload, User.ICreateAuthorizeResponse>(User.createAuthorizeRequest),
authorizationAnonymous: createMethod<Omit<User.ICreateAuthorizePayload, "email">, User.ICreateAuthorizeResponse>(User.createAuthorizeAnonymousRequest),
login: createMethod<Login.Payload, Login.Response>(Login.createRequest),
resetPassword: createMethod<Password.Payload, Password.Response>(Password.resetRequest),
// Paywall

View File

@ -197,6 +197,15 @@ export const createAuthorizeRequest = (data: ICreateAuthorizePayload): Request =
});
}
export const createAuthorizeAnonymousRequest = (data: Omit<ICreateAuthorizePayload, "email">): Request => {
const body = JSON.stringify(data);
return new Request(routes.server.dApiAnonymousAuth(), {
method: "POST",
headers: getBaseHeaders(),
body,
});
}
export interface IMeResponse {
user: IUser;
}

View File

@ -2,7 +2,6 @@ import Title from "@/components/Title";
import styles from "./styles.module.scss";
import { compatibilityV2Prefix } from "@/routes";
import { useMemo, useRef } from "react";
import MoneyBackGuarantee from "../MoneyBackGuarantee";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
@ -70,7 +69,7 @@ function HowWork() {
</div>
</div>
))}
<MoneyBackGuarantee />
{/* <MoneyBackGuarantee /> */}
</div>
);
}

View File

@ -2,8 +2,8 @@ import Title from "@/components/Title";
import styles from "./styles.module.scss";
import EmailInput from "@/components/pages/ABDesign/v1/pages/EmailEnterPage/EmailInput";
import { useCallback, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { actions } from "@/store";
import { useDispatch, useSelector } from "react-redux";
import { actions, selectors } from "@/store";
import Button from "../../components/Button";
import { useAuthentication } from "@/hooks/authentication/use-authentication";
import { ESourceAuthorization } from "@/api/resources/User";
@ -32,6 +32,7 @@ function Email() {
const [isValidName, setIsValidName] = useState(true);
const [isDisabled, setIsDisabled] = useState(true);
const [isAuth, setIsAuth] = useState(false);
const authCode = useSelector(selectors.selectAuthCode);
const { flags } = useMetricABFlags();
const auraVideoTrial = flags?.auraVideoTrial?.[0];
@ -91,11 +92,14 @@ function Email() {
};
const handleNext = useCallback(() => {
if (!!authCode?.length) {
return navigate(routes.client.compatibilityV2TryApp());
}
if (auraVideoTrial === "on") {
return navigate(routes.client.compatibilityV2TrialChoiceVideo());
}
return navigate(routes.client.compatibilityV2TrialChoice());
}, [auraVideoTrial, navigate]);
}, [auraVideoTrial, authCode, navigate]);
useEffect(() => {
if (user && token?.length && !isLoading && !error) {

View File

@ -24,11 +24,9 @@ function FindHappiness() {
routes.client.compatibilityV2Welcome(),
""
);
dispatch(
actions.userConfig.setFeature(
feature.includes("/v1/gender") ? "" : feature
)
);
if (!!feature?.length) {
dispatch(actions.userConfig.setFeature(feature));
}
}, [dispatch, location.pathname]);
return (

View File

@ -44,6 +44,16 @@ function GenderPage() {
title: translate(gender.id, undefined, ELocalesPlacement.V1),
}));
useEffect(() => {
const feature = location.pathname.replace(
routes.client.compatibilityV2Gender(),
""
);
if (!!feature?.length) {
dispatch(actions.userConfig.setFeature(feature));
}
}, [dispatch, location.pathname]);
const selectGender = (_gender: Gender | null) => {
dispatch(actions.privacyPolicy.updateChecked(true));
setIsSelected(true);

View File

@ -12,6 +12,8 @@ import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import ProgressBarLine from "@/components/ui/ProgressBarLine";
import Modal from "@/components/Modal";
import { useAuthentication } from "@/hooks/authentication/use-authentication";
import { ESourceAuthorization } from "@/api/resources/User";
const drawElementChangeDelay = 1500;
const startDelay = 500;
@ -43,6 +45,12 @@ function ScannedPhoto() {
const [isDecorationShown, setIsDecorationShown] = useState(true);
const [classNameScannedPhoto, setClassNameScannedPhoto] = useState("");
const feature = useSelector(selectors.selectFeature);
const isIOSPath = useMemo(() => feature?.toLowerCase()?.includes("ios"), [feature]);
const authCode = useSelector(selectors.selectAuthCode);
const { authorization } = useAuthentication();
const drawElements = useMemo(() => [...fingers, ...lines], [fingers, lines]);
const { relationshipStatus } = useSelector(selectors.selectCompatibilityV2Answers)
@ -83,6 +91,14 @@ function ScannedPhoto() {
]
}, []);
useEffect(() => {
if (isIOSPath) {
(async () => {
await authorization("", ESourceAuthorization["aura.compatibility.v2"], true);
})();
}
}, [isIOSPath, authorization])
useEffect(() => {
if (!drawElements[currentElementIndex]) return;
changeTitleTimeOut.current = setTimeout(() => {
@ -174,8 +190,11 @@ function ScannedPhoto() {
);
const onEndLoading = useCallback(() => {
if (isIOSPath && !!authCode) {
return navigate(routes.client.compatibilityV2TryApp());
}
navigate(routes.client.compatibilityV2Email());
}, []);
}, [isIOSPath, authCode, navigate]);
useEffect(() => {
if (progress === 75) {

View File

@ -23,6 +23,7 @@ import { useDynamicSize } from "@/hooks/useDynamicSize";
import { formatDateToLocale } from "@/locales/localFormats";
import { useEffect } from "react";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import MoneyBackGuarantee from "../../components/MoneyBackGuarantee";
function TrialPayment() {
const { height, elementRef } = useDynamicSize<HTMLDivElement>({});
@ -127,6 +128,7 @@ function TrialPayment() {
{translate("/trial-payment.how_work.title")}
</Title>
<HowWork />
<MoneyBackGuarantee />
<Button className={styles["begin-trial"]} onClick={handleNext}>
{translate("/trial-payment.begin_trial_now")}
</Button>

View File

@ -0,0 +1,55 @@
import { useSelector } from "react-redux";
import styles from "./styles.module.scss";
import { selectors } from "@/store";
import { images } from "@/components/CompatibilityV2/data";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/services/data";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
interface ICopyCodeProps {
variant?: "default" | "black";
}
function CopyCode({
variant = "default"
}: ICopyCodeProps) {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const code = useSelector(selectors.selectAuthCode);
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
const isCopied = await copyToClipboard(code);
setIsCopied(isCopied);
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 4000);
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<>
<div
className={`${styles.container} ${styles[variant]}`}
onClick={handleCopy}
title={translate("/try-app.copy-code-title")}
>
<span>{code}</span>
{variant === "default" && <img className={styles.copyIcon} src={images("copy-icon.png")} alt="Copy code" />}
{variant === "black" && <img className={styles.copyIcon} src={images("copy-icon-white.png")} alt="Copy code" />}
</div>
{isCopied && <Toast variant="success" classNameContainer={styles.toast}>
{translate("/try-app.code-copied")}
</Toast>}
</>
)
}
export default CopyCode;

View File

@ -0,0 +1,40 @@
.container {
width: 100%;
max-width: 188px;
background-color: #CADCFF;
padding-inline: 11px;
font-weight: 600;
font-size: 23px;
line-height: 27.84px;
text-align: center;
min-height: 45px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 24px;
cursor: pointer;
&>span {
width: 100%;
text-align: center;
}
&.black {
background-color: #000;
color: #fff;
}
}
.copyIcon {
width: 23px;
height: 23px;
}
.toast {
position: fixed;
width: calc(100% - 32px);
max-width: 528px;
bottom: calc(0dvh + 16px);
z-index: 9999;
}

View File

@ -0,0 +1,20 @@
import Title from "@/components/Title";
import styles from "./styles.module.scss";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import CopyCode from "../CopyCode";
function EnterCode() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
return (
<div className={styles.container}>
<Title className={styles.title}>
{translate("/try-app.enter-code-title")}
</Title>
<CopyCode variant="black" />
</div>
);
}
export default EnterCode;

View File

@ -0,0 +1,14 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
font-weight: 600;
font-size: 22px;
line-height: 26.63px;
text-align: center;
margin-bottom: 0;
}

View File

@ -0,0 +1,66 @@
import Title from "@/components/Title"
import styles from "./styles.module.scss"
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import { images } from "@/components/CompatibilityV2/data";
import { useSelector } from "react-redux";
import { selectors } from "@/store";
import useTimer from "@/hooks/palmistry/use-timer";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/services/data";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
interface IHeaderProps {
onButtonClick: () => void;
}
function Header({
onButtonClick
}: IHeaderProps) {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const authCode = useSelector(selectors.selectAuthCode);
const timer = useTimer();
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
const isCopied = await copyToClipboard(authCode);
setIsCopied(isCopied);
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 4000);
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<div className={styles.container}>
<Title variant="h3" className={styles.title}>
{translate("/try-app.header.title")}
</Title>
<div className={styles.buttons}>
<div className={styles.copyCode} onClick={handleCopy} title={translate("/try-app.copy-code-title")}>
<img src={images("copy-icon.png")} alt="Copy code" />
<span className={styles.code}>{authCode}</span>
</div>
<div className={styles.downloadApp} onClick={onButtonClick}>
<span className={styles.timer}>{timer}</span>
<p className={styles.downloadAppDescription}>
{translate("/try-app.header.get-prediction-in-app", {
br: <br />
})}
</p>
</div>
</div>
{isCopied && <Toast variant="success" classNameContainer={styles.toast}>
{translate("/try-app.code-copied")}
</Toast>}
</div>
)
}
export default Header

View File

@ -0,0 +1,100 @@
.container {
display: flex;
align-items: center;
flex-direction: column;
width: 100vw;
max-width: 560px;
padding: 0 6px 8px;
background-color: #EFF0FC;
position: sticky;
top: calc(0dvh);
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.11);
margin-bottom: 46px;
z-index: 9999;
&>* {
font-family: SF Pro Text, sans-serif;
}
&>.title {
margin: 0;
font-size: 14px;
line-height: 26px;
font-weight: 600;
}
}
.buttons {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 11px;
&>.copyCode {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 0px 14px;
border-radius: 22px;
background-color: #CADCFF;
min-width: 144px;
cursor: pointer;
&>img {
width: 20px;
height: 20px;
}
&>.code {
font-size: 23px;
line-height: 45px;
font-weight: 600;
text-align: center;
width: 100%;
}
}
&>.downloadApp {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 0px 10px;
border-radius: 8px;
background-color: #000000;
color: #FFFFFF;
min-height: 53px;
width: 100%;
max-width: 230px;
box-shadow: 2px 5px 2.5px -1px rgba(0, 0, 0, 0.2);
cursor: pointer;
&>.timer {
font-weight: 600;
font-size: 14px;
line-height: 18px;
text-align: center;
}
&>.downloadAppDescription {
font-family: SF Pro Text;
font-weight: 500;
font-size: 17px;
line-height: 21.25px;
text-align: center;
width: 100%;
}
}
}
.toast {
position: fixed;
width: calc(100% - 32px);
max-width: 528px;
bottom: calc(0dvh + 16px);
z-index: 9999;
}

View File

@ -0,0 +1,187 @@
import { selectors } from "@/store";
import styles from "./styles.module.scss";
import { useSelector } from "react-redux";
import { useCallback, useEffect, useRef, useState } from "react";
import { IPalmistryPoint } from "@/api/resources/Palmistry";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
function PalmPhoto() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const photo = useSelector(selectors.selectCompatibilityV2Photo);
const [isImageLoaded, setIsImageLoaded] = useState(false);
const [imageWidth, setImageWidth] = useState(0);
const [imageHeight, setImageHeight] = useState(0);
const imageRef = useRef<HTMLImageElement>(null);
const linesRef = useRef<SVGPathElement[]>([]);
const fingers = useSelector(selectors.selectCompatibilityV2Fingers);
const lines = useSelector(selectors.selectCompatibilityV2Lines);
const [textPositions, setTextPositions] = useState<Array<{ x: number, y: number }>>([]);
useEffect(() => {
if (isImageLoaded && imageRef.current) {
setImageWidth(imageRef.current.width || 0);
setImageHeight(imageRef.current.height || 0);
}
}, [isImageLoaded]);
const getCoordinatesString = useCallback(
(points: IPalmistryPoint[]) => {
const coordinatesString = `M ${points[0]?.x * imageWidth} ${points[0]?.y * imageHeight
}`;
return points.reduce(
(acc, point) =>
`${acc} L ${point?.x * imageWidth} ${point?.y * imageHeight}`,
coordinatesString
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lines, isImageLoaded, imageWidth, imageHeight]
);
const getLineLength = (line: SVGPathElement) => {
return line?.getTotalLength();
};
useEffect(() => {
if (!imageWidth || !imageHeight || !lines.length) return;
const textWidth = 90;
const textHeight = 17;
const padding = 10;
const newPositions: Array<{ x: number, y: number }> = [];
lines.forEach((line, index) => {
const points = line.points;
const positions = [];
for (let i = 0; i < points.length - 1; i++) {
const x = (points[i].x + points[i + 1].x) / 2;
const y = (points[i].y + points[i + 1].y) / 2;
positions.push({ x, y });
}
positions.unshift({ x: points[0].x, y: points[0].y });
positions.push({ x: points[points.length - 1].x, y: points[points.length - 1].y });
let positionFound = false;
for (const pos of positions) {
let hasOverlap = false;
for (const existingPos of newPositions) {
if (
pos.x * imageWidth + padding < existingPos.x + textWidth &&
pos.x * imageWidth + padding + textWidth > existingPos.x &&
pos.y * imageHeight - padding < existingPos.y + textHeight &&
pos.y * imageHeight - padding + textHeight > existingPos.y
) {
hasOverlap = true;
break;
}
}
if (!hasOverlap) {
newPositions.push({
x: pos.x * imageWidth + 10,
y: pos.y * imageHeight - 5
});
positionFound = true;
break;
}
}
if (!positionFound) {
newPositions.push({
x: points[0].x * imageWidth + textWidth + padding * (index + 1),
y: points[0].y * imageHeight - textHeight - padding * (index + 1)
});
}
});
setTextPositions(newPositions);
}, [lines, imageWidth, imageHeight]);
return (
<div className={styles.container}>
<div className={styles.photoContainer}>
<img
ref={imageRef}
className={styles.photo}
src={photo}
alt="Palm photo"
onLoad={() => setIsImageLoaded(true)}
/>
{/* <div className={styles.blur}></div> */}
{!!imageHeight && !!imageWidth && (
<svg
viewBox={`0 0 ${imageWidth} ${imageHeight}`}
className="scanned-photo__svg-objects"
>
{!!fingers.length &&
fingers?.map((finger, index) => {
return (
<svg
x={finger.point.x * imageWidth - 12}
y={finger.point.y * imageHeight - 12}
height="24px"
width="24px"
key={index}
>
<circle
cx="50%"
cy="50%"
r="11"
fill="white"
opacity="0.3"
className="scanned-photo__finger-point"
/>
<circle
cx="50%"
cy="50%"
r="5"
fill="#066FDE"
stroke="white"
strokeWidth="0.3"
className="scanned-photo__finger-point"
/>
</svg>
);
})}
{lines.map((line, index) => (
<g key={`line-${index}`}>
<path
className={`scanned-photo__line scanned-photo__line_${line?.name}`}
d={getCoordinatesString(line?.points)}
ref={(el) =>
(linesRef.current[index] = el as SVGPathElement)
}
style={{
strokeDasharray:
getLineLength(linesRef.current[index]) || 500,
strokeDashoffset:
getLineLength(linesRef.current[index]) || 500,
}}
/>
</g>
))}
{lines.map((line, index) => (
<g key={`line-label-${index}`}>
<text
x={textPositions[index]?.x || 0}
y={textPositions[index]?.y || 0}
fill="#066FDE"
className={`scanned-photo__line-text scanned-photo__line-text_${line?.name}`}
>
{translate(`/try-app.palm_lines.${line.name}`)}
</text>
</g>
))}
</svg>
)}
</div>
</div>
)
}
export default PalmPhoto

View File

@ -0,0 +1,31 @@
.container {
width: 100%;
background: linear-gradient(0.63deg, #FFFFFF 0.53%, #C8DBFF 99.45%);
border-radius: 30px;
padding-top: 24px;
display: flex;
justify-content: center;
margin-top: 16px;
}
.photoContainer {
position: relative;
&>.blur {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 65px;
// background: rgba(255, 255, 255, 1);
backdrop-filter: blur(32px);
}
}
.photo {
width: 100%;
object-fit: contain;
max-width: 280px;
border-radius: 20px 20px 0 0;
}

View File

@ -0,0 +1,54 @@
import { useTranslations } from "@/hooks/translations";
import styles from "./styles.module.scss";
import { ELocalesPlacement } from "@/locales";
import { useSelector } from "react-redux";
import { selectors } from "@/store";
import { images } from "@/components/CompatibilityV2/data";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/services/data";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
function YourAccessCode(): JSX.Element {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
const code = useSelector(selectors.selectAuthCode)
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
const isCopied = await copyToClipboard(code);
setIsCopied(isCopied);
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 4000);
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<div className={styles.container}>
<div className={styles.header}>
<span>{translate("/try-app.your_access_code")}</span>
</div>
<div className={styles.body} onClick={handleCopy} title={translate("/try-app.copy-code-title")}>
<div className={styles.codeContainer}>
<div className={styles.code}>
<span>{code}</span>
</div>
<div className={styles.copy}>
<img src={images("copy-icon.png")} alt="Copy code" />
<span>{translate("/try-app.copy")}</span>
</div>
</div>
</div>
{isCopied && <Toast variant="success" classNameContainer={styles.toast}>
{translate("/try-app.code-copied")}
</Toast>}
</div>
);
}
export default YourAccessCode;

View File

@ -0,0 +1,75 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
border: solid 3px #1171AC;
width: 100%;
max-width: 326px;
border-radius: 11px 11px 22px 22px;
}
.header {
min-height: 42px;
width: 100%;
background-color: #1171AC;
font-family: Inter;
font-weight: 600;
font-size: 20px;
line-height: 24.2px;
text-align: center;
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
}
.body {
min-height: 106px;
padding-inline: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
cursor: pointer;
}
.codeContainer {
padding-inline: 38px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.code {
width: 100%;
font-weight: 600;
font-size: 34px;
line-height: 41.15px;
text-align: center;
color: #000000;
}
.copy {
margin-right: -38px;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 2px;
cursor: pointer;
&>img {
width: 34px;
height: 34px;
}
}
.toast {
position: fixed;
width: calc(100% - 32px);
max-width: 528px;
bottom: calc(0dvh + 16px);
z-index: 9999;
}

View File

@ -0,0 +1,118 @@
import Title from "@/components/Title";
import AppNumberOne from "../../components/AppNumberOne";
import Button from "../../components/Button";
import styles from "./styles.module.scss";
import HowWork from "../../components/HowWork";
import WhatIncluded from "../../components/WhatIncluded";
import PalmsSayAbout from "../../components/PalmsSayAbout";
import Reviews from "../../components/Reviews";
import { compatibilityV2Prefix } from "@/routes";
import Footer from "../../components/Footer";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import { usePreloadImages } from "@/hooks/preload/images";
import { useEffect } from "react";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import Header from "./components/Header";
import PalmPhoto from "./components/PalmPhoto";
import YourAccessCode from "./components/YourAccessCode";
import { images } from "../../data";
import CopyCode from "./components/CopyCode";
import EnterCode from "./components/EnterCode";
function TryApp() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
usePreloadImages([
"/v1/palmistry/ticket.svg",
])
const downloadApp = () => {
// TODO
window.location.href =
"https://apps.apple.com/us/app/aura-astrology-horoscope/id1601978549";
};
useEffect(() => {
metricService.reachGoal(EGoals.TRIAL_PAYMENT_PAGE_VISIT, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
metricService.reachGoal(EGoals.AURA_TRIAL_PAYMENT_PAGE_VISIT, [EMetrics.KLAVIYO]);
}, []);
return (
<>
<Header onButtonClick={downloadApp} />
<AppNumberOne />
<PalmPhoto />
<Title className={styles["reading-ready"]}>
{translate("/try-app.reading_ready.title")}
</Title>
<YourAccessCode />
<p className={styles.instructionPoint}>{translate("/try-app.instruction_point_1")}</p>
<img className={styles.downloadApp} src={images("download-app.png")} alt="Download app" onClick={downloadApp} />
<p className={styles.instructionPoint}>{translate("/try-app.instruction_point_2")}</p>
<CopyCode />
<p className={styles.notShareDescription}>
{translate("/try-app.not_share_description")}
</p>
<Button className={styles.getPredictionInApp} onClick={downloadApp}>
<img src={images("apple-icon.png")} alt="Apple icon" />
{translate("/try-app.get_prediction_in_app")}
</Button>
<Title className={styles["how-work"]}>
{translate("/try-app.how_work.title")}
</Title>
<HowWork />
{/* <MoneyBackGuarantee /> */}
<EnterCode />
<Button className={styles["begin-trial"]} onClick={downloadApp}>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<WhatIncluded />
<PalmsSayAbout />
<EnterCode />
<Button className={styles["discover-more"]} onClick={downloadApp}>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<Title className={styles["why-love"]}>
{translate("/try-app.why_love", {
color: <span>{translate("/try-app.why_love_color")}</span>,
})}
</Title>
<Reviews />
<EnterCode />
<Button className={styles["success-story"]} onClick={downloadApp}>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<Title className={styles["as-seen-in"]}>
{translate("/try-app.as_seen_in", {
color: (
<span>
{translate("app_name", undefined, ELocalesPlacement.V1)}
</span>
),
})}
</Title>
<img className={styles.partners} src={`${compatibilityV2Prefix}/partners.png`} alt="Partners" />
<Footer />
{/* <div className={styles["paywall__get-prediction"]}>
<div>
{translate("/paywall.offer_reserved.title", undefined, ELocalesPlacement.PalmistryV0)}
<span className={styles["paywall__get-prediction-timer"]}>
<span>{time}</span>
</span>
</div>
<Button
type="button"
className={styles["paywall__get-prediction-button"]}
onClick={handleNext}
>
{translate("/paywall.offer_reserved.button", undefined, ELocalesPlacement.PalmistryV0)}
</Button>
</div> */}
</>
);
}
export default TryApp;

View File

@ -0,0 +1,224 @@
.get-prediction {
margin-top: 26px;
font-size: 24px;
font-weight: 500;
}
.how-work {
margin-top: 40px;
}
.begin-trial,
.discover-more {
margin-top: 18px;
}
.begin-trial,
.discover-more,
.success-story {
font-weight: 700;
font-size: 17px;
line-height: 20.57px;
text-align: center;
}
.why-love {
font-size: 28px;
margin: 40px 18px 20px;
font-weight: 700;
&>span {
color: #224e90;
}
}
.success-story {
margin-top: 22px;
}
.as-seen-in {
font-size: 32px;
margin-top: 50px;
&>span {
color: #224e90;
}
}
.partners {
width: 100%;
}
.paywall__get-prediction {
position: fixed;
bottom: 0dvh;
left: 0;
align-items: center;
background: #eff2fd;
box-shadow: 0 -3px 11px rgba(0, 0, 0, .15);
color: #4a567a;
display: flex;
font-size: 14px;
padding: 12px 24px;
transition: all 0.5s;
width: 100%;
z-index: 10;
}
.paywall__get-prediction-timer {
font-family: "SF Mono Bold, sans-serif";
border-radius: 4px;
background: initial;
display: inline;
margin: 5px;
min-width: 62px;
padding: 0;
}
.paywall__get-prediction-timer>span {
color: #066fde;
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.paywall__get-prediction-button {
font-weight: 700;
font-size: 18px;
line-height: 26px;
min-height: auto;
min-width: auto;
padding: 6px 8px;
white-space: normal;
width: auto;
}
.information-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 0;
}
.zodiac-images {
position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 0px;
margin-top: 8px;
&.with-partner {
&>img:first-child {
margin-right: -30%;
}
&>img:last-child {
// margin-left: -10px;
z-index: -3;
}
&>img {
width: 50%;
}
}
img {
position: relative;
width: 70%;
object-fit: cover;
z-index: -2;
}
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 60%;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, .7) 10%,
rgba(255, 255, 255, 1) 15%,
rgba(255, 255, 255, 1) 30%,
rgba(255, 255, 255, 1) 40%,
rgba(255, 255, 255, 1) 100%);
pointer-events: none;
z-index: -1;
}
}
.information-description {
text-align: left;
font-size: 16px;
line-height: 125%;
font-weight: 400;
margin-top: 8px;
margin-bottom: 8px;
&>span {
color: #146DA5;
}
}
.reading-ready {
// font-family: Inter;
font-weight: 600;
font-size: 26px;
line-height: 31.47px;
text-align: center;
margin-top: 16px;
margin-bottom: 16px;
}
.instructionPoint {
width: 100%;
font-weight: 600;
font-size: 19px;
line-height: 22.99px;
text-align: center;
margin-top: 32px;
}
.downloadApp {
width: 100%;
max-width: 236px;
margin-top: 16px;
cursor: pointer;
}
.notShareDescription {
font-weight: 500;
font-size: 15px;
line-height: 18.15px;
text-align: center;
max-width: 300px;
margin-top: 24px;
}
.getPredictionInApp {
width: 100%;
max-width: 342px;
padding: 12px;
padding-left: 19px;
background-color: #000;
border-radius: 8px;
box-shadow: 2px 5px 2.5px -1px rgba(0, 0, 0, 0.2);
font-family: SF Pro Text;
font-weight: 500;
font-size: 24px;
line-height: 30px;
text-align: left;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 22px;
margin-top: 36px;
&>img {
width: 38px;
}
}

View File

@ -0,0 +1,87 @@
import Title from "@/components/Title"
import styles from "./styles.module.scss"
import { IPaywallProduct } from "@/api/resources/Paywall"
import { useState, useEffect } from "react"
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.PalmistryV1);
const [countBuyingEmails, setCountBuyingEmails] = useState(3);
const {
displayEmails,
countBoughtEmails
} = useEmailsGeneration(products);
useEffect(() => {
const updateCount = () => {
const change = Math.floor(Math.random() * 3) + 1; // 1, 2 или 3
const increase = Math.random() < 0.5;
setCountBuyingEmails(prev => {
const newCount = increase ? prev + change : prev - change;
if (newCount < 1) return 1;
if (newCount > 5) return 5;
return newCount;
});
setTimeout(updateCount, 1000 + Math.random() * 3000);
};
updateCount();
return () => {
setCountBuyingEmails(17);
};
}, []);
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: countBuyingEmails
})}
</p>
<div className={styles.emails}>
{displayEmails.map((item) => (
<div key={item.id} className={styles.emailContainer}>
<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="url(#paint0_linear_263_585)" />
<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" />
<defs>
<linearGradient id="paint0_linear_263_585" x1="8.5" y1="17" x2="8.5" y2="0" gradientUnits="userSpaceOnUse">
<stop stopColor="#00D26A" />
<stop offset="1" stopColor="#62DFA1" />
</linearGradient>
</defs>
</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,84 @@
.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%;
&>.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

@ -23,11 +23,9 @@ function FindHappiness() {
routes.client.palmistryV1Welcome(),
""
);
dispatch(
actions.userConfig.setFeature(
feature.includes("/v1/gender") ? "" : feature
)
);
if (!!feature?.length) {
dispatch(actions.userConfig.setFeature(feature));
}
}, [dispatch, location.pathname]);
return (
@ -59,24 +57,24 @@ function FindHappiness() {
{translate("/find-your-happiness.title")}
</Title>
<div className={styles.advantages}>
<ul className={styles.list}>
<li>
{translate("/find-your-happiness.advantage1")}
</li>
<li>
{translate("/find-your-happiness.advantage2")}
</li>
<li>
{translate("/find-your-happiness.advantage3")}
</li>
</ul>
<ul className={styles.list}>
<li>
{translate("/find-your-happiness.advantage1")}
</li>
<li>
{translate("/find-your-happiness.advantage2")}
</li>
<li>
{translate("/find-your-happiness.advantage3")}
</li>
</ul>
</div>
<div className={styles["button-container"]}>
<Button onClick={() => navigate(`${palmistryV1Prefix}/gender`)}>
{translate("next")}
</Button>
</div>
<p className={styles.description}>
<div className={styles["button-container"]}>
<Button onClick={() => navigate(`${palmistryV1Prefix}/gender`)}>
{translate("next")}
</Button>
</div>
<p className={styles.description}>
{translate("/find-your-happiness.text")}
</p>
</>

View File

@ -41,6 +41,16 @@ function GenderPalmistry() {
title: translate(gender.id, undefined, ELocalesPlacement.V1),
}));
useEffect(() => {
const feature = location.pathname.replace(
routes.client.palmistryV1Gender(),
""
);
if (!!feature?.length) {
dispatch(actions.userConfig.setFeature(feature));
}
}, [dispatch, location.pathname]);
const selectGender = (_gender: Gender | null) => {
dispatch(actions.privacyPolicy.updateChecked(true));
setIsSelected(true);

View File

@ -10,6 +10,8 @@ import routes from "@/routes";
import ScannedPhotoElement from "@/components/palmistry/scanned-photo/scanned-photo";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import { useAuthentication } from "@/hooks/authentication/use-authentication";
import { ESourceAuthorization } from "@/api/resources/User";
const drawElementChangeDelay = 1500;
const startDelay = 500;
@ -28,6 +30,22 @@ function ScannedPhoto() {
const [shouldDisplayPalmLines, setShouldDisplayPalmLines] = useState(false);
const [smallPhotoState, setSmallPhotoState] = useState(false);
const feature = useSelector(selectors.selectFeature);
const isIOSPath = useMemo(() => feature?.toLowerCase()?.includes("ios"), [feature]);
const authCode = useSelector(selectors.selectAuthCode);
const { authorization } = useAuthentication();
useEffect(() => {
if (isIOSPath) {
(async () => {
await authorization("", ESourceAuthorization["aura.palmistry.new"], true);
})();
}
}, [isIOSPath, authorization])
const drawElements = useMemo(() => [...fingers, ...lines], [fingers, lines]);
useEffect(() => {
@ -62,7 +80,7 @@ function ScannedPhoto() {
}, drawElementChangeDelay * 2);
const goNextTimer = setTimeout(
() => {
navigate(routes.client.palmistryV1Email())
handleNext()
},
drawElementChangeDelay * drawElements.length + 8000
);
@ -75,7 +93,14 @@ function ScannedPhoto() {
clearTimeout(goNextTimer);
}
};
}, [currentElementIndex, drawElements.length, navigate]);
}, [currentElementIndex, drawElements.length]);
const handleNext = () => {
if (isIOSPath && !!authCode) {
return navigate(routes.client.palmistryV1TryApp());
}
return navigate(routes.client.palmistryV1Email())
}
// useEffect(() => {
// if (currentElementIndex < drawElements.length) return;
@ -110,7 +135,10 @@ function ScannedPhoto() {
drawElementChangeDelay={drawElementChangeDelay}
startDelay={startDelay}
displayLines={shouldDisplayPalmLines}
lines={lines}
lines={lines.map((line) => ({
...line,
label: translate(`/scanned-photo.palm_lines.${line.name}`)
}))}
fingers={fingers}
drawElements={drawElements}
/>

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.PalmistryV1);
@ -32,7 +33,9 @@ function TrialChoice() {
const locale = getDefaultLocaleByLanguage(language);
// const { flags } = useMetricABFlags();
const { flags, ready } = useMetricABFlags();
const trialChoicePageType = flags?.trialChoicePageType?.[0];
// const trialChoicePageType = "v1";
// const isLongText = flags?.text?.[0] === "on";
const [isDisabled, setIsDisabled] = useState(true);
@ -64,6 +67,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,130 @@
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 { useState } from "react";
import { actions, selectors } from "@/store";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import routes from "@/routes";
import Button from "@/components/PalmistryV1/components/Button";
import Loader from "@/components/Loader";
import EmailSubstrate from "@/components/PalmistryV1/components/EmailSubstrate";
import PriceList from "@/components/pages/ABDesign/v1/components/PriceList";
import { EPlacementKeys } from "@/api/resources/Paywall";
import EmailsList from "@/components/PalmistryV1/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.PalmistryV1);
const navigate = useNavigate();
const dispatch = useDispatch();
const { products, isLoading, currency, getText } = usePaywall({
placementKey: EPlacementKeys["aura.placement.palmistry.redesign"],
localesPlacement: ELocalesPlacement.PalmistryV1,
});
// 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.palmistryV1TrialPayment());
};
// useEffect(() => {
// metricService.reachGoal(EGoals.TRIAL_CHOICE_PAGE_VISIT, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
// metricService.reachGoal(EGoals.AURA_TRIAL_CHOICE_PAGE_VISIT, [EMetrics.KLAVIYO]);
// }, []);
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}
/>
</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

@ -0,0 +1,55 @@
import { useSelector } from "react-redux";
import styles from "./styles.module.scss";
import { selectors } from "@/store";
import { images } from "@/components/PalmistryV1/data";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/services/data";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
interface ICopyCodeProps {
variant?: "default" | "black";
}
function CopyCode({
variant = "default"
}: ICopyCodeProps) {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
const code = useSelector(selectors.selectAuthCode);
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
const isCopied = await copyToClipboard(code);
setIsCopied(isCopied);
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 4000);
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<>
<div
className={`${styles.container} ${styles[variant]}`}
onClick={handleCopy}
title={translate("/try-app.copy-code-title")}
>
<span>{code}</span>
{variant === "default" && <img className={styles.copyIcon} src={images("copy-icon.png")} alt="Copy code" />}
{variant === "black" && <img className={styles.copyIcon} src={images("copy-icon-white.png")} alt="Copy code" />}
</div>
{isCopied && <Toast variant="success" classNameContainer={styles.toast}>
{translate("/try-app.code-copied")}
</Toast>}
</>
)
}
export default CopyCode;

View File

@ -0,0 +1,40 @@
.container {
width: 100%;
max-width: 188px;
background-color: #CADCFF;
padding-inline: 11px;
font-weight: 600;
font-size: 23px;
line-height: 27.84px;
text-align: center;
min-height: 45px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 24px;
cursor: pointer;
&>span {
width: 100%;
text-align: center;
}
&.black {
background-color: #000;
color: #fff;
}
}
.copyIcon {
width: 23px;
height: 23px;
}
.toast {
position: fixed;
width: calc(100% - 32px);
max-width: 528px;
bottom: calc(0dvh + 16px);
z-index: 9999;
}

View File

@ -0,0 +1,20 @@
import Title from "@/components/Title";
import styles from "./styles.module.scss";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import CopyCode from "../CopyCode";
function EnterCode() {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
return (
<div className={styles.container}>
<Title className={styles.title}>
{translate("/try-app.enter-code-title")}
</Title>
<CopyCode variant="black" />
</div>
);
}
export default EnterCode;

View File

@ -0,0 +1,14 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
font-weight: 600;
font-size: 22px;
line-height: 26.63px;
text-align: center;
margin-bottom: 0;
}

View File

@ -0,0 +1,66 @@
import Title from "@/components/Title"
import styles from "./styles.module.scss"
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import { images } from "@/components/PalmistryV1/data";
import { useSelector } from "react-redux";
import { selectors } from "@/store";
import useTimer from "@/hooks/palmistry/use-timer";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/services/data";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
interface IHeaderProps {
onButtonClick: () => void;
}
function Header({
onButtonClick
}: IHeaderProps) {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
const authCode = useSelector(selectors.selectAuthCode);
const timer = useTimer();
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
const isCopied = await copyToClipboard(authCode);
setIsCopied(isCopied);
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 4000);
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<div className={styles.container}>
<Title variant="h3" className={styles.title}>
{translate("/try-app.header.title")}
</Title>
<div className={styles.buttons}>
<div className={styles.copyCode} onClick={handleCopy} title={translate("/try-app.copy-code-title")}>
<img src={images("copy-icon.png")} alt="Copy code" />
<span className={styles.code}>{authCode}</span>
</div>
<div className={styles.downloadApp} onClick={onButtonClick}>
<span className={styles.timer}>{timer}</span>
<p className={styles.downloadAppDescription}>
{translate("/try-app.header.get-prediction-in-app", {
br: <br />
})}
</p>
</div>
</div>
{isCopied && <Toast variant="success" classNameContainer={styles.toast}>
{translate("/try-app.code-copied")}
</Toast>}
</div>
)
}
export default Header

View File

@ -0,0 +1,100 @@
.container {
display: flex;
align-items: center;
flex-direction: column;
width: 100vw;
max-width: 560px;
padding: 0 6px 8px;
background-color: #EFF0FC;
position: sticky;
top: calc(0dvh);
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.11);
margin-bottom: 46px;
z-index: 9999;
&>* {
font-family: SF Pro Text, sans-serif;
}
&>.title {
margin: 0;
font-size: 14px;
line-height: 26px;
font-weight: 600;
}
}
.buttons {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 11px;
&>.copyCode {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 0px 14px;
border-radius: 22px;
background-color: #CADCFF;
min-width: 144px;
cursor: pointer;
&>img {
width: 20px;
height: 20px;
}
&>.code {
font-size: 23px;
line-height: 45px;
font-weight: 600;
text-align: center;
width: 100%;
}
}
&>.downloadApp {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 0px 10px;
border-radius: 8px;
background-color: #000000;
color: #FFFFFF;
min-height: 53px;
width: 100%;
max-width: 230px;
box-shadow: 2px 5px 2.5px -1px rgba(0, 0, 0, 0.2);
cursor: pointer;
&>.timer {
font-weight: 600;
font-size: 14px;
line-height: 18px;
text-align: center;
}
&>.downloadAppDescription {
font-family: SF Pro Text;
font-weight: 500;
font-size: 17px;
line-height: 21.25px;
text-align: center;
width: 100%;
}
}
}
.toast {
position: fixed;
width: calc(100% - 32px);
max-width: 528px;
bottom: calc(0dvh + 16px);
z-index: 9999;
}

View File

@ -0,0 +1,187 @@
import { selectors } from "@/store";
import styles from "./styles.module.scss";
import { useSelector } from "react-redux";
import { useCallback, useEffect, useRef, useState } from "react";
import { IPalmistryPoint } from "@/api/resources/Palmistry";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
function PalmPhoto() {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
const photo = useSelector(selectors.selectPalmistryPhoto);
const [isImageLoaded, setIsImageLoaded] = useState(false);
const [imageWidth, setImageWidth] = useState(0);
const [imageHeight, setImageHeight] = useState(0);
const imageRef = useRef<HTMLImageElement>(null);
const linesRef = useRef<SVGPathElement[]>([]);
const fingers = useSelector(selectors.selectPalmistryFingers);
const lines = useSelector(selectors.selectPalmistryLines);
const [textPositions, setTextPositions] = useState<Array<{ x: number, y: number }>>([]);
useEffect(() => {
if (isImageLoaded && imageRef.current) {
setImageWidth(imageRef.current.width || 0);
setImageHeight(imageRef.current.height || 0);
}
}, [isImageLoaded]);
const getCoordinatesString = useCallback(
(points: IPalmistryPoint[]) => {
const coordinatesString = `M ${points[0]?.x * imageWidth} ${points[0]?.y * imageHeight
}`;
return points.reduce(
(acc, point) =>
`${acc} L ${point?.x * imageWidth} ${point?.y * imageHeight}`,
coordinatesString
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lines, isImageLoaded, imageWidth, imageHeight]
);
const getLineLength = (line: SVGPathElement) => {
return line?.getTotalLength();
};
useEffect(() => {
if (!imageWidth || !imageHeight || !lines.length) return;
const textWidth = 90;
const textHeight = 17;
const padding = 10;
const newPositions: Array<{ x: number, y: number }> = [];
lines.forEach((line, index) => {
const points = line.points;
const positions = [];
for (let i = 0; i < points.length - 1; i++) {
const x = (points[i].x + points[i + 1].x) / 2;
const y = (points[i].y + points[i + 1].y) / 2;
positions.push({ x, y });
}
positions.unshift({ x: points[0].x, y: points[0].y });
positions.push({ x: points[points.length - 1].x, y: points[points.length - 1].y });
let positionFound = false;
for (const pos of positions) {
let hasOverlap = false;
for (const existingPos of newPositions) {
if (
pos.x * imageWidth + padding < existingPos.x + textWidth &&
pos.x * imageWidth + padding + textWidth > existingPos.x &&
pos.y * imageHeight - padding < existingPos.y + textHeight &&
pos.y * imageHeight - padding + textHeight > existingPos.y
) {
hasOverlap = true;
break;
}
}
if (!hasOverlap) {
newPositions.push({
x: pos.x * imageWidth + 10,
y: pos.y * imageHeight - 5
});
positionFound = true;
break;
}
}
if (!positionFound) {
newPositions.push({
x: points[0].x * imageWidth + textWidth + padding * (index + 1),
y: points[0].y * imageHeight - textHeight - padding * (index + 1)
});
}
});
setTextPositions(newPositions);
}, [lines, imageWidth, imageHeight]);
return (
<div className={styles.container}>
<div className={styles.photoContainer}>
<img
ref={imageRef}
className={styles.photo}
src={photo}
alt="Palm photo"
onLoad={() => setIsImageLoaded(true)}
/>
{/* <div className={styles.blur}></div> */}
{!!imageHeight && !!imageWidth && (
<svg
viewBox={`0 0 ${imageWidth} ${imageHeight}`}
className="scanned-photo__svg-objects"
>
{!!fingers.length &&
fingers?.map((finger, index) => {
return (
<svg
x={finger.point.x * imageWidth - 12}
y={finger.point.y * imageHeight - 12}
height="24px"
width="24px"
key={index}
>
<circle
cx="50%"
cy="50%"
r="11"
fill="white"
opacity="0.3"
className="scanned-photo__finger-point"
/>
<circle
cx="50%"
cy="50%"
r="5"
fill="#066FDE"
stroke="white"
strokeWidth="0.3"
className="scanned-photo__finger-point"
/>
</svg>
);
})}
{lines.map((line, index) => (
<g key={`line-${index}`}>
<path
className={`scanned-photo__line scanned-photo__line_${line?.name}`}
d={getCoordinatesString(line?.points)}
ref={(el) =>
(linesRef.current[index] = el as SVGPathElement)
}
style={{
strokeDasharray:
getLineLength(linesRef.current[index]) || 500,
strokeDashoffset:
getLineLength(linesRef.current[index]) || 500,
}}
/>
</g>
))}
{lines.map((line, index) => (
<g key={`line-label-${index}`}>
<text
x={textPositions[index]?.x || 0}
y={textPositions[index]?.y || 0}
fill="#066FDE"
className={`scanned-photo__line-text scanned-photo__line-text_${line?.name}`}
>
{translate(`/try-app.palm_lines.${line.name}`)}
</text>
</g>
))}
</svg>
)}
</div>
</div>
)
}
export default PalmPhoto

View File

@ -0,0 +1,31 @@
.container {
width: 100%;
background: linear-gradient(0.63deg, #FFFFFF 0.53%, #C8DBFF 99.45%);
border-radius: 30px;
padding-top: 24px;
display: flex;
justify-content: center;
margin-top: 16px;
}
.photoContainer {
position: relative;
&>.blur {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 65px;
// background: rgba(255, 255, 255, 1);
backdrop-filter: blur(32px);
}
}
.photo {
width: 100%;
object-fit: contain;
max-width: 280px;
border-radius: 20px 20px 0 0;
}

View File

@ -0,0 +1,54 @@
import { useTranslations } from "@/hooks/translations";
import styles from "./styles.module.scss";
import { ELocalesPlacement } from "@/locales";
import { useSelector } from "react-redux";
import { selectors } from "@/store";
import { images } from "@/components/PalmistryV1/data";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/services/data";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
function YourAccessCode(): JSX.Element {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
const code = useSelector(selectors.selectAuthCode)
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
const isCopied = await copyToClipboard(code);
setIsCopied(isCopied);
};
useEffect(() => {
if (!isCopied) return;
const timeout = setTimeout(() => {
setIsCopied(false);
}, 4000);
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<div className={styles.container}>
<div className={styles.header}>
<span>{translate("/try-app.your_access_code")}</span>
</div>
<div className={styles.body} onClick={handleCopy} title={translate("/try-app.copy-code-title")}>
<div className={styles.codeContainer}>
<div className={styles.code}>
<span>{code}</span>
</div>
<div className={styles.copy}>
<img src={images("copy-icon.png")} alt="Copy code" />
<span>{translate("/try-app.copy")}</span>
</div>
</div>
</div>
{isCopied && <Toast variant="success" classNameContainer={styles.toast}>
{translate("/try-app.code-copied")}
</Toast>}
</div>
);
}
export default YourAccessCode;

View File

@ -0,0 +1,75 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
border: solid 3px #1171AC;
width: 100%;
max-width: 326px;
border-radius: 11px 11px 22px 22px;
}
.header {
min-height: 42px;
width: 100%;
background-color: #1171AC;
font-family: Inter;
font-weight: 600;
font-size: 20px;
line-height: 24.2px;
text-align: center;
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
}
.body {
min-height: 106px;
padding-inline: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
cursor: pointer;
}
.codeContainer {
padding-inline: 38px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.code {
width: 100%;
font-weight: 600;
font-size: 34px;
line-height: 41.15px;
text-align: center;
color: #000000;
}
.copy {
margin-right: -38px;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 2px;
cursor: pointer;
&>img {
width: 34px;
height: 34px;
}
}
.toast {
position: fixed;
width: calc(100% - 32px);
max-width: 528px;
bottom: calc(0dvh + 16px);
z-index: 9999;
}

View File

@ -0,0 +1,118 @@
import Title from "@/components/Title";
import AppNumberOne from "../../components/AppNumberOne";
import Button from "../../components/Button";
import styles from "./styles.module.scss";
import HowWork from "../../components/HowWork";
import WhatIncluded from "../../components/WhatIncluded";
import PalmsSayAbout from "../../components/PalmsSayAbout";
import Reviews from "../../components/Reviews";
import { palmistryV1Prefix } from "@/routes";
import Footer from "../../components/Footer";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import { usePreloadImages } from "@/hooks/preload/images";
import { useEffect } from "react";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import Header from "./components/Header";
import PalmPhoto from "./components/PalmPhoto";
import YourAccessCode from "./components/YourAccessCode";
import { images } from "../../data";
import CopyCode from "./components/CopyCode";
import EnterCode from "./components/EnterCode";
function TryApp() {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
usePreloadImages([
"/v1/palmistry/ticket.svg",
])
const downloadApp = () => {
// TODO
window.location.href =
"https://apps.apple.com/us/app/aura-astrology-horoscope/id1601978549";
};
useEffect(() => {
metricService.reachGoal(EGoals.TRIAL_PAYMENT_PAGE_VISIT, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
metricService.reachGoal(EGoals.AURA_TRIAL_PAYMENT_PAGE_VISIT, [EMetrics.KLAVIYO]);
}, []);
return (
<>
<Header onButtonClick={downloadApp} />
<AppNumberOne />
<PalmPhoto />
<Title className={styles["reading-ready"]}>
{translate("/try-app.reading_ready.title")}
</Title>
<YourAccessCode />
<p className={styles.instructionPoint}>{translate("/try-app.instruction_point_1")}</p>
<img className={styles.downloadApp} src={images("download-app.png")} alt="Download app" onClick={downloadApp} />
<p className={styles.instructionPoint}>{translate("/try-app.instruction_point_2")}</p>
<CopyCode />
<p className={styles.notShareDescription}>
{translate("/try-app.not_share_description")}
</p>
<Button className={styles.getPredictionInApp} onClick={downloadApp}>
<img src={images("apple-icon.png")} alt="Apple icon" />
{translate("/try-app.get_prediction_in_app")}
</Button>
<Title className={styles["how-work"]}>
{translate("/try-app.how_work.title")}
</Title>
<HowWork />
{/* <MoneyBackGuarantee /> */}
<EnterCode />
<Button className={styles["begin-trial"]} onClick={downloadApp}>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<WhatIncluded />
<PalmsSayAbout />
<EnterCode />
<Button className={styles["discover-more"]} onClick={downloadApp}>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<Title className={styles["why-love"]}>
{translate("/try-app.why_love", {
color: <span>{translate("/try-app.why_love_color")}</span>,
})}
</Title>
<Reviews />
<EnterCode />
<Button className={styles["success-story"]} onClick={downloadApp}>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<Title className={styles["as-seen-in"]}>
{translate("/try-app.as_seen_in", {
color: (
<span>
{translate("app_name", undefined, ELocalesPlacement.V1)}
</span>
),
})}
</Title>
<img className={styles.partners} src={`${palmistryV1Prefix}/partners.png`} alt="Partners" />
<Footer />
{/* <div className={styles["paywall__get-prediction"]}>
<div>
{translate("/paywall.offer_reserved.title", undefined, ELocalesPlacement.PalmistryV0)}
<span className={styles["paywall__get-prediction-timer"]}>
<span>{time}</span>
</span>
</div>
<Button
type="button"
className={styles["paywall__get-prediction-button"]}
onClick={handleNext}
>
{translate("/paywall.offer_reserved.button", undefined, ELocalesPlacement.PalmistryV0)}
</Button>
</div> */}
</>
);
}
export default TryApp;

View File

@ -0,0 +1,224 @@
.get-prediction {
margin-top: 26px;
font-size: 24px;
font-weight: 500;
}
.how-work {
margin-top: 40px;
}
.begin-trial,
.discover-more {
margin-top: 18px;
}
.begin-trial,
.discover-more,
.success-story {
font-weight: 700;
font-size: 17px;
line-height: 20.57px;
text-align: center;
}
.why-love {
font-size: 28px;
margin: 40px 18px 20px;
font-weight: 700;
&>span {
color: #224e90;
}
}
.success-story {
margin-top: 22px;
}
.as-seen-in {
font-size: 32px;
margin-top: 50px;
&>span {
color: #224e90;
}
}
.partners {
width: 100%;
}
.paywall__get-prediction {
position: fixed;
bottom: 0dvh;
left: 0;
align-items: center;
background: #eff2fd;
box-shadow: 0 -3px 11px rgba(0, 0, 0, .15);
color: #4a567a;
display: flex;
font-size: 14px;
padding: 12px 24px;
transition: all 0.5s;
width: 100%;
z-index: 10;
}
.paywall__get-prediction-timer {
font-family: "SF Mono Bold, sans-serif";
border-radius: 4px;
background: initial;
display: inline;
margin: 5px;
min-width: 62px;
padding: 0;
}
.paywall__get-prediction-timer>span {
color: #066fde;
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.paywall__get-prediction-button {
font-weight: 700;
font-size: 18px;
line-height: 26px;
min-height: auto;
min-width: auto;
padding: 6px 8px;
white-space: normal;
width: auto;
}
.information-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 0;
}
.zodiac-images {
position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 0px;
margin-top: 8px;
&.with-partner {
&>img:first-child {
margin-right: -30%;
}
&>img:last-child {
// margin-left: -10px;
z-index: -3;
}
&>img {
width: 50%;
}
}
img {
position: relative;
width: 70%;
object-fit: cover;
z-index: -2;
}
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 60%;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, .7) 10%,
rgba(255, 255, 255, 1) 15%,
rgba(255, 255, 255, 1) 30%,
rgba(255, 255, 255, 1) 40%,
rgba(255, 255, 255, 1) 100%);
pointer-events: none;
z-index: -1;
}
}
.information-description {
text-align: left;
font-size: 16px;
line-height: 125%;
font-weight: 400;
margin-top: 8px;
margin-bottom: 8px;
&>span {
color: #146DA5;
}
}
.reading-ready {
// font-family: Inter;
font-weight: 600;
font-size: 26px;
line-height: 31.47px;
text-align: center;
margin-top: 16px;
margin-bottom: 16px;
}
.instructionPoint {
width: 100%;
font-weight: 600;
font-size: 19px;
line-height: 22.99px;
text-align: center;
margin-top: 32px;
}
.downloadApp {
width: 100%;
max-width: 236px;
margin-top: 16px;
cursor: pointer;
}
.notShareDescription {
font-weight: 500;
font-size: 15px;
line-height: 18.15px;
text-align: center;
max-width: 300px;
margin-top: 24px;
}
.getPredictionInApp {
width: 100%;
max-width: 342px;
padding: 12px;
padding-left: 19px;
background-color: #000;
border-radius: 8px;
box-shadow: 2px 5px 2.5px -1px rgba(0, 0, 0, 0.2);
font-family: SF Pro Text;
font-weight: 500;
font-size: 24px;
line-height: 30px;
text-align: left;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 22px;
margin-top: 36px;
&>img {
width: 38px;
}
}

View File

@ -13,10 +13,14 @@ function GetInformationPartnerPage() {
const path = searchParams.get("path");
const handleBack = () => {
if (path === "palmistry") {
if (path === "palmistry-v1") {
return navigate(routes.client.palmistryV1SkipTrial());
// return navigate(routes.client.addGuides());
}
if (path === "palmistry") {
return navigate(routes.client.skipTrial());
// return navigate(routes.client.addGuides());
}
if (path === "compatibility") {
return navigate(routes.client.compatibilityV2SkipTrial());
}
@ -30,7 +34,7 @@ function GetInformationPartnerPage() {
};
const handleNext = () => {
if (path === "palmistry") {
if (path === "palmistry" || path === "palmistry-v1") {
return navigate(
`${routes.client.palmistryOnboardingV1()}?path=palmistry`
);

View File

@ -9,7 +9,7 @@ import { useCallback, useEffect, useState } from "react";
import { useApi } from "@/api";
import { useAuth } from "@/auth";
import { useNavigate } from "react-router-dom";
import routes, { compatibilityV2Prefix, emailMarketingV1Prefix, palmistryV2Prefix } from "@/routes";
import routes, { compatibilityV2Prefix, emailMarketingV1Prefix, palmistryV1Prefix, palmistryV2Prefix } from "@/routes";
import { loadStripe, Stripe, StripeElementLocale } from "@stripe/stripe-js";
import { ResponseGet } from "@/api/resources/SinglePayment";
import { Elements } from "@stripe/react-stripe-js";
@ -59,6 +59,9 @@ function SkipTrial() {
if (window.location.pathname.includes(emailMarketingV1Prefix)) {
return navigate(`${routes.client.getInformationPartner()}?path=email-compatibility`);
}
if (window.location.pathname.includes(palmistryV1Prefix)) {
return navigate(`${routes.client.getInformationPartner()}?path=palmistry-v1`);
}
if (window.location.pathname.includes(palmistryV2Prefix)) {
return navigate(`${routes.client.getInformationPartner()}?path=email-palmistry`);
}

View File

@ -44,4 +44,4 @@
.palm-camera-modal__overlay {
height: fit-content;
min-height: 100dvh;
}
}

View File

@ -12,28 +12,6 @@ type Props = {
export default function PalmCameraModal(props: Props) {
const videoEl = React.useRef<HTMLVideoElement | null>(null);
const [isVideoReady, setIsVideoReady] = React.useState(false);
React.useEffect(() => {
const video = videoEl.current;
if (!video) return;
const handleVideoReady = () => {
console.log('Метаданные видео загружены:', {
videoWidth: videoEl.current?.videoWidth,
videoHeight: videoEl.current?.videoHeight,
readyState: videoEl.current?.readyState
});
setIsVideoReady(true);
video.play();
};
video.addEventListener("loadedmetadata", handleVideoReady);
return () => {
video.removeEventListener("loadedmetadata", handleVideoReady);
};
}, []);
const [mediaStream, setMediaStream] = React.useState<MediaStream | null>(
null
@ -53,26 +31,17 @@ export default function PalmCameraModal(props: Props) {
video: { facingMode: { ideal: "environment" } },
});
console.log('Медиа поток получен:', {
tracks: stream.getTracks().map(track => ({
kind: track.kind,
settings: track.getSettings()
}))
});
setMediaStream(stream);
videoEl.current.srcObject = stream;
// videoEl.current.addEventListener("loadedmetadata", videoEl.current.play);
videoEl.current.addEventListener("loadedmetadata", videoEl.current.play);
} catch (error) {
console.error("Camera is not available", error);
}
};
const deactivateCamera = React.useCallback(() => {
if (!mediaStream) return;
mediaStream?.getTracks().forEach((track) => track.stop());
setMediaStream(null);
}, [mediaStream]);
React.useEffect(() => {
@ -82,122 +51,29 @@ export default function PalmCameraModal(props: Props) {
React.useEffect(() => {
return () => {
deactivateCamera();
if (videoEl.current) {
videoEl.current.srcObject = null;
videoEl.current.load();
}
};
}, [deactivateCamera]);
const takePhoto = () => {
console.log('Начало захвата фото');
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
// Функция очистки ресурсов
const cleanup = () => {
// Очищаем canvas
if (context) {
context.clearRect(0, 0, canvas.width, canvas.height);
}
// Удаляем canvas
canvas.width = 0;
canvas.height = 0;
canvas.remove();
};
if (!context || !videoEl.current) return null;
try {
if (!context || !videoEl.current) {
console.error('Контекст canvas или видео не доступны');
return null;
};
const width = videoEl.current.videoWidth;
const height = videoEl.current.videoHeight;
const width = videoEl.current.videoWidth;
const height = videoEl.current.videoHeight;
canvas.width = width;
canvas.height = height;
context.drawImage(videoEl.current, 0, 0, width, height);
console.log('Размеры видео:', {
width,
height,
estimatedMemoryUsage: (width * height * 4 / (1024 * 1024)).toFixed(2) + ' MB' // 4 байта на пиксель
});
const data = canvas.toDataURL("image/png");
console.log('Параметры видео перед захватом:', {
videoWidth: width,
videoHeight: height,
videoReady: isVideoReady,
videoCurrentTime: videoEl.current.currentTime,
readyState: videoEl.current.readyState
});
if (!width || !height) {
console.log("Video is not ready");
return;
}
// Попробуем уменьшить размер, если он слишком большой
// const MAX_DIMENSION = 1920; // максимальный размер стороны
// let targetWidth = width;
// let targetHeight = height;
// if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
// if (width > height) {
// targetWidth = MAX_DIMENSION;
// targetHeight = Math.round(height * (MAX_DIMENSION / width));
// } else {
// targetHeight = MAX_DIMENSION;
// targetWidth = Math.round(width * (MAX_DIMENSION / height));
// }
// }
canvas.width = width;
canvas.height = height;
console.log('Попытка отрисовки на canvas');
context.drawImage(videoEl.current, 0, 0, width, height);
console.log('Попытка конвертации в base64');
const data = canvas.toDataURL("image/png");
cleanup();
console.log('Размер base64:', {
length: data.length,
megabytes: (data.length / (1024 * 1024)).toFixed(2) + ' MB'
});
console.log('Результат захвата:', {
dataLength: data.length,
isEmptyCapture: data === 'data:,',
canvasWidth: canvas.width,
canvasHeight: canvas.height
});
if (data === "data:,") {
console.log("Couldn't get a photo");
return;
}
return data;
} catch (error) {
console.error('Ошибка при обработке фото:', error);
cleanup();
return null;
}
return data;
};
const onClickTakePhoto = React.useCallback(() => {
console.log('Попытка сделать фото:', {
isVideoReady,
videoElement: {
width: videoEl.current?.videoWidth,
height: videoEl.current?.videoHeight,
readyState: videoEl.current?.readyState
}
});
if (!isVideoReady) {
console.warn('Видео не готово для захвата');
return;
};
const onClickTakePhoto = () => {
const photo = takePhoto();
deactivateCamera();
@ -205,7 +81,7 @@ export default function PalmCameraModal(props: Props) {
if (!photo) return;
props.onTakePhoto(photo);
}, [isVideoReady]);
};
return (
<ModalOverlay
@ -276,9 +152,6 @@ export default function PalmCameraModal(props: Props) {
<div
className="palm-camera-modal__button-shutter"
onClick={onClickTakePhoto}
style={{
opacity: isVideoReady ? 1 : 0.5,
}}
/>
</div>
</Modal>

View File

@ -5,6 +5,7 @@
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.payment-screen__header {
@ -34,7 +35,7 @@
margin-bottom: 16px;
}
.payment-screen__about-us > span {
.payment-screen__about-us>span {
padding: 0 60px;
margin-bottom: 6px;
}
@ -232,6 +233,23 @@
height: 100%;
}
.payment-screen__button {
position: sticky;
bottom: calc(0dvh + 26px);
max-width: 343px;
font-size: 24px;
margin-top: 16px;
}
.payment-screen__toast {
position: fixed;
bottom: calc(0dvh + 26px);
left: 50%;
transform: translateX(-50%);
width: calc(100% - 32px);
max-width: 492px;
z-index: 9999;
}
@media screen and (max-width: 560px) {
.payment-screen__widget {

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
@ -8,61 +8,124 @@ import useSteps, { Step } from "@/hooks/palmistry/use-steps";
import useTimer from "@/hooks/palmistry/use-timer";
import HeaderLogo from "@/components/palmistry/header-logo/header-logo";
import { selectors } from "@/store";
import { EPlacementKeys } from "@/api/resources/Paywall";
import { useNavigate, useSearchParams } from "react-router-dom";
import PaymentModalNew from "@/components/PaymentModalNew";
import { Navigate, useNavigate } from "react-router-dom";
import { addCurrency, ELocalesPlacement } from "@/locales";
import { getPriceCentsToDollars } from "@/services/price";
import routes from "@/routes";
import { useTranslations } from "@/hooks/translations";
import Button from "@/components/PalmistryV1/components/Button";
import { usePayment } from "@/hooks/payment/nmi/usePayment";
import { EPlacementKeys } from "@/api/resources/Paywall";
import Toast from "@/components/pages/ABDesign/v1/components/Toast";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
const placementKey = EPlacementKeys["aura.placement.palmistry.main"];
export default function PaymentScreen() {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV0);
const navigate = useNavigate();
const time = useTimer();
const activeProductFromStore = useSelector(selectors.selectActiveProduct);
const [searchParams] = useSearchParams();
const subscriptionStatus =
searchParams.get("redirect_status") === "succeeded" ? "subscribed" : "lead";
const [height, setHeight] = useState(
subscriptionStatus === "subscribed" ? 246 : 146
);
// const [searchParams] = useSearchParams();
// const subscriptionStatus =
// searchParams.get("redirect_status") === "succeeded" ? "subscribed" : "lead";
// // const [height, setHeight] = useState(
// // subscriptionStatus === "subscribed" ? 246 : 146
// // );
const [isPaymentError, setIsPaymentError] = useState(false);
const steps = useSteps();
React.useEffect(() => {
if (subscriptionStatus === "subscribed") {
metricService.reachGoal(EGoals.PAYMENT_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
metricService.reachGoal(EGoals.PAYMENT_SUCCESS_PALMISTRY, [
EMetrics.YANDEX,
]);
// if (activeProductFromStore) {
// metricService.reachGoal(EGoals.PURCHASE, [EMetrics.FACEBOOK], {
// currency: "USD",
// value: ((activeProductFromStore.trialPrice || 100) / 100).toFixed(2),
// });
// }
setTimeout(() => {
// steps.goNext();
navigate(routes.client.skipTrial());
}, 1500);
}
}, [activeProductFromStore, navigate, subscriptionStatus]);
if (!activeProductFromStore) {
return <Navigate to={routes.client.palmistrySubscriptionPlan()} />
}
React.useEffect(() => {
if (!activeProductFromStore) {
steps.setFirstUnpassedStep(Step.SubscriptionPlan);
const {
error,
isPaymentSuccess,
showCreditCardForm,
} = usePayment({
placementKey,
activeProduct: activeProductFromStore,
paymentFormType: "lightbox"
});
const onPaymentSuccess = () => {
metricService.reachGoal(EGoals.PAYMENT_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
metricService.reachGoal(EGoals.PURCHASE, [EMetrics.FACEBOOK], {
currency: "USD",
value: ((activeProductFromStore?.trialPrice || 100) / 100).toFixed(2),
});
return setTimeout(() => {
navigate(routes.client.skipTrial());
}, 3000)
}
// const onModalClosed = () => {
// // setIsPaymentModalOpen(false);
// }
const onPaymentError = (error?: string) => {
if (error === "Product not found") {
return steps.setFirstUnpassedStep(Step.SubscriptionPlan);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProductFromStore]);
metricService.reachGoal(EGoals.PAYMENT_ERROR, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
return setIsPaymentError(true);
}
useEffect(() => {
if (error) {
onPaymentError(error);
}
}, [error])
useEffect(() => {
if (isPaymentSuccess) {
onPaymentSuccess();
}
}, [isPaymentSuccess])
const handlePayment = () => {
// dispatch(actions.compatibilityV2.setIsShowPaymentModalV1(true));
// metricService.reachGoal(EGoals.PAYMENT_METHODS_OPENED, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
// metricService.reachGoal(EGoals.AURA_PAYMENT_METHODS_OPENED, [EMetrics.KLAVIYO]);
// setIsPaymentModalOpen(true);
showCreditCardForm();
};
// React.useEffect(() => {
// if (subscriptionStatus === "subscribed") {
// metricService.reachGoal(EGoals.PAYMENT_SUCCESS, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
// metricService.reachGoal(EGoals.PAYMENT_SUCCESS_PALMISTRY, [
// EMetrics.YANDEX,
// ]);
// // if (activeProductFromStore) {
// // metricService.reachGoal(EGoals.PURCHASE, [EMetrics.FACEBOOK], {
// // currency: "USD",
// // value: ((activeProductFromStore.trialPrice || 100) / 100).toFixed(2),
// // });
// // }
// setTimeout(() => {
// // steps.goNext();
// navigate(routes.client.skipTrial());
// }, 1500);
// }
// }, [activeProductFromStore, navigate, subscriptionStatus]);
// React.useEffect(() => {
// if (!activeProductFromStore) {
// steps.setFirstUnpassedStep(Step.SubscriptionPlan);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [activeProductFromStore]);
const trialPrice = activeProductFromStore?.trialPrice || 0;
const fullPrice = activeProductFromStore?.price || 0;
const currency = useSelector(selectors.selectCurrency);
const [minutes, seconds] = time.split(":");
const returnUrl = window.location.origin + "/palmistry/payment";
// const returnUrl = window.location.origin + "/palmistry/payment";
return (
<div className="payment-screen">
<div className="payment-screen__header">
@ -290,9 +353,26 @@ export default function PaymentScreen() {
</div>
</div>
<style>{`.palmistry-payment-modal { max-height: calc(100dvh - 40px) }`}</style>
{/* <style>{`.palmistry-payment-modal { max-height: calc(100dvh - 40px) }`}</style> */}
{activeProductFromStore && (
<Button className="payment-screen__button" onClick={handlePayment}>
{translate("/payment.get_personal_prediction")}
</Button>
{isPaymentError &&
<Toast variant="error" classNameContainer="payment-screen__toast">
{translate("/payment.payment_error")}
</Toast>
}
{isPaymentSuccess &&
<Toast variant="success" classNameContainer="payment-screen__toast">
{translate("/payment.payment_success")}
</Toast>
}
{/* {activeProductFromStore && (
<div
className="payment-screen__widget_modal_container"
style={{ minHeight: `${height}px` }}
@ -334,7 +414,7 @@ export default function PaymentScreen() {
)}
</div>
</div>
)}
)} */}
</div>
);
}

View File

@ -179,6 +179,43 @@
height: 100%;
}
.scanned-photo__line-text {
font-size: 16px;
font-weight: 600;
opacity: 0;
animation: fadeIn 0.3s forwards;
stroke: #000;
}
.scanned-photo__line-text_heart {
fill: #f8d90f;
/* animation-delay: 4.5s; */
}
.scanned-photo__line-text_life {
fill: #e51c39;
}
.scanned-photo__line-text_head {
fill: #00d114;
/* animation-delay: 1.5s; */
}
.scanned-photo__line-text_fate {
fill: #05ced8;
/* animation-delay: 3s; */
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scanned-photo-resize {
100% {
height: 207px;

View File

@ -10,24 +10,26 @@ type Props = {
photo: string;
small: boolean;
displayLines: boolean;
lines: IPalmistryLine[];
lines: Array<IPalmistryLine & { label?: string }>;
fingers: IPalmistryFinger[];
drawElementChangeDelay: number;
startDelay: number;
drawElements: Array<IPalmistryLine | IPalmistryFinger>;
className?: string;
isDecorationShown?: boolean;
lineLabelsShown?: boolean;
};
export default function StepScanPhoto(props: Props) {
const { className: classNameProp = "", isDecorationShown = true } = props;
const className = ["scanned-photo", classNameProp];
const { lines, drawElementChangeDelay, fingers, drawElements } = props;
const { lines, drawElementChangeDelay, fingers, drawElements, lineLabelsShown = false } = props;
const imageRef = useRef<HTMLImageElement>(null);
const linesRef = useRef<SVGPathElement[]>([]);
const [isImageLoaded, setIsImageLoaded] = useState(false);
const [imageWidth, setImageWidth] = useState(0);
const [imageHeight, setImageHeight] = useState(0);
const [textPositions, setTextPositions] = useState<Array<{ x: number, y: number }>>([]);
if (props.small) {
className.push("scanned-photo_small");
@ -40,6 +42,64 @@ export default function StepScanPhoto(props: Props) {
}
}, [isImageLoaded]);
useEffect(() => {
if (!imageWidth || !imageHeight || !lines.length) return;
const textWidth = 90;
const textHeight = 17;
const padding = 10;
const newPositions: Array<{ x: number, y: number }> = [];
lines.forEach((line, index) => {
const points = line.points;
const positions = [];
for (let i = 0; i < points.length - 1; i++) {
const x = (points[i].x + points[i + 1].x) / 2;
const y = (points[i].y + points[i + 1].y) / 2;
positions.push({ x, y });
}
positions.unshift({ x: points[0].x, y: points[0].y });
positions.push({ x: points[points.length - 1].x, y: points[points.length - 1].y });
let positionFound = false;
for (const pos of positions) {
let hasOverlap = false;
for (const existingPos of newPositions) {
if (
pos.x * imageWidth + padding < existingPos.x + textWidth &&
pos.x * imageWidth + padding + textWidth > existingPos.x &&
pos.y * imageHeight - padding < existingPos.y + textHeight &&
pos.y * imageHeight - padding + textHeight > existingPos.y
) {
hasOverlap = true;
break;
}
}
if (!hasOverlap) {
newPositions.push({
x: pos.x * imageWidth + 10,
y: pos.y * imageHeight - 5
});
positionFound = true;
break;
}
}
if (!positionFound) {
newPositions.push({
x: points[0].x * imageWidth + textWidth + padding * (index + 1),
y: points[0].y * imageHeight - textHeight - padding * (index + 1)
});
}
});
setTextPositions(newPositions);
}, [lines, imageWidth, imageHeight]);
const getCoordinatesString = useCallback(
(points: IPalmistryPoint[]) => {
const coordinatesString = `M ${points[0]?.x * imageWidth} ${points[0]?.y * imageHeight
@ -58,10 +118,6 @@ export default function StepScanPhoto(props: Props) {
return line?.getTotalLength();
};
// const getAnimationDelayOfLine = (index: number) => {
// return `${drawElementChangeDelay * index + startDelay}ms`;
// };
return (
<div
className={className.join(" ")}
@ -137,22 +193,38 @@ export default function StepScanPhoto(props: Props) {
{props.displayLines && (
<>
{lines.map((line, index) => (
<path
key={index}
className={`scanned-photo__line scanned-photo__line_${line?.name}`}
d={getCoordinatesString(line?.points)}
ref={(el) =>
(linesRef.current[index] = el as SVGPathElement)
}
style={{
strokeDasharray:
getLineLength(linesRef.current[index]) || 500,
strokeDashoffset:
getLineLength(linesRef.current[index]) || 500,
animationDelay: `${drawElementChangeDelay * (index + 1)
}ms`,
}}
/>
<g key={`line-${index}`}>
<path
className={`scanned-photo__line scanned-photo__line_${line?.name}`}
d={getCoordinatesString(line?.points)}
ref={(el) =>
(linesRef.current[index] = el as SVGPathElement)
}
style={{
strokeDasharray:
getLineLength(linesRef.current[index]) || 500,
strokeDashoffset:
getLineLength(linesRef.current[index]) || 500,
animationDelay: `${drawElementChangeDelay * (index + 1)
}ms`,
}}
/>
</g>
))}
{lineLabelsShown && lines.map((line, index) => (
<g key={`line-label-${index}`}>
<text
x={textPositions[index]?.x || 0}
y={textPositions[index]?.y || 0}
fill="#066FDE"
className={`scanned-photo__line-text scanned-photo__line-text_${line?.name}`}
style={{
animationDelay: `${drawElementChangeDelay * (index + 1) + 300}ms`,
}}
>
{line.label || line.name}
</text>
</g>
))}
</>
)}

View File

@ -163,13 +163,13 @@ export const useAuthentication = () => {
}
}, [api, dispatch, locale, signUp])
const authorization = useCallback(async (email: string, source: ESourceAuthorization) => {
const authorization = useCallback(async (email: string, source: ESourceAuthorization, isAnonymous = false) => {
try {
setIsLoading(true);
setError(null)
const payload = getAuthorizationPayload(email, source);
const { token, userId: userIdFromApi, generatingVideo, videoId, authCode } = await api.authorization(payload);
if (!!token) {
const { token, userId: userIdFromApi, generatingVideo, videoId, authCode } = isAnonymous ? await api.authorizationAnonymous(payload) : await api.authorization(payload);
if (!!token && !isAnonymous) {
metricService.reachGoal(EGoals.ENTERED_EMAIL, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
metricService.reachGoal(EGoals.LEAD, [EMetrics.FACEBOOK]);
}
@ -187,9 +187,7 @@ export const useAuthentication = () => {
}
signUp(token, user, userMe);
setToken(token);
if (authCode?.length) {
dispatch(actions.userConfig.setAuthCode(authCode));
}
dispatch(actions.userConfig.setAuthCode(authCode || ""));
dispatch(actions.personalVideo.updateStatus({ generatingVideo: generatingVideo || false, videoId: videoId || "" }));
if (generatingVideo) {
metricService.reachGoal(EGoals.ROSE_VIDEO_CREATION_START, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
@ -226,9 +224,7 @@ export const useAuthentication = () => {
}
signUp(token, user, userMe);
setToken(token);
if (authCode?.length) {
dispatch(actions.userConfig.setAuthCode(authCode));
}
dispatch(actions.userConfig.setAuthCode(authCode || ""));
dispatch(actions.personalVideo.updateStatus({ generatingVideo: generatingVideo || false, videoId: videoId || "" }));
if (generatingVideo) {
metricService.reachGoal(EGoals.ROSE_VIDEO_CREATION_START, [EMetrics.YANDEX, EMetrics.KLAVIYO]);

View File

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { IPaywallProduct } from "@/api/resources/Paywall";
import { generateRealisticEmail } from "@/services/random-value/emailGenerator";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
interface DisplayEmail {
email: string;
price: number;
id: number;
willBeRemoved: boolean;
}
export const useEmailsGeneration = (products: Array<IPaywallProduct & { weight: number }>) => {
const { translate } = useTranslations(ELocalesPlacement.EmailGenerator);
const [displayEmails, setDisplayEmails] = useState<DisplayEmail[]>([]);
const [countBoughtEmails, setCountBoughtEmails] = useState(758);
const maxEmails = 4;
const minEmails = 3;
const getRandomProduct = () => {
const totalWeight = products.reduce((sum, product) => sum + (product.weight || 1), 0);
let random = Math.random() * totalWeight;
for (const product of products) {
random -= (product.weight || 1);
if (random <= 0) return product;
}
return products[0];
};
const createEmail = () => {
const product = getRandomProduct();
return {
email: generateRealisticEmail(
translate("firstNames").split(",") || [],
translate("lastNames").split(",") || [],
translate("domains").split(",") || []
),
price: (product.trialPrice || 0) / 100,
id: Date.now() + Math.random(),
willBeRemoved: false
};
};
useEffect(() => {
const addEmails = () => {
const count = Math.random() < 0.7 ? 1 : 2;
const newEmails = Array(count).fill(null).map(createEmail);
setDisplayEmails(prev => {
const updatedEmails = [...prev, ...newEmails].slice(-maxEmails);
if (updatedEmails.length < minEmails) {
const additionalCount = minEmails - updatedEmails.length;
const additionalEmails = Array(additionalCount).fill(null).map(createEmail);
return [...updatedEmails, ...additionalEmails];
}
return updatedEmails;
});
const deleteTime = 3000 + Math.random() * 6000;
setTimeout(() => {
setDisplayEmails(prev =>
prev.map(email =>
newEmails.includes(email)
? { ...email, willBeRemoved: true }
: email
)
);
}, deleteTime - 1000);
setTimeout(() => {
setDisplayEmails(prev => {
const filteredEmails = prev.filter(email => !newEmails.includes(email));
setCountBoughtEmails(prev => prev + +(filteredEmails.length >= minEmails));
return filteredEmails.length >= minEmails ? (filteredEmails) : prev;
});
}, deleteTime);
setTimeout(addEmails, 1000 + Math.random() * 3000);
};
setDisplayEmails(Array(minEmails).fill(null).map(createEmail));
addEmails();
return () => setDisplayEmails([]);
}, [products]);
return { displayEmails, countBoughtEmails };
};

View File

@ -129,7 +129,7 @@ export const usePayment = ({
placeholder: 'CVC',
selector: '#card-cvv',
title: "Card CVC",
display: "hide"
// display: "hide"
}
},
validationCallback: (field: string, status: boolean, message: string) => {

View File

@ -88,8 +88,6 @@ const init = async () => {
// };
// googleManager();
console.log(window.location.pathname);
return (
<React.Fragment>
{isProduction && window.location.hostname === "aura.witapps.us" && <HeadData />}

View File

@ -87,6 +87,7 @@ export enum ELocalesPlacement {
EmailMarketingPalmistryV2 = "email-marketing-palmistry-v2",
EmailMarketingCompatibilityV2 = "email-marketing-comp-v2",
CompatibilityV2 = "compatibility-v2",
EmailGenerator = "email-generator",
}
interface ITranslationJSON {

View File

@ -23,6 +23,7 @@ const isBackButtonVisibleRoutes = [
routes.client.compatibilityV2Email(),
routes.client.compatibilityV2TrialChoice(),
routes.client.compatibilityV2TrialPayment(),
routes.client.compatibilityV2TryApp(),
routes.client.compatibilityV2Payment(),
];

View File

@ -44,6 +44,7 @@ import AddConsultant from "@/components/palmistry/AdditionalPurchases/pages/AddC
import AddGuides from "@/components/palmistry/AdditionalPurchases/pages/AddGuides";
import PaymentPage from "@/components/Payment/nmi/PaymentPage";
import { EPlacementKeys } from "@/api/resources/Paywall";
import TryApp from "@/components/CompatibilityV2/pages/TryApp";
const removePrefix = (path: string) => path.replace(compatibilityV2Prefix, "");
@ -278,6 +279,10 @@ function CompatibilityV2Routes() {
path={removePrefix(routes.client.compatibilityV2TrialPayment())}
element={<TrialPayment />}
/>
<Route
path={removePrefix(routes.client.compatibilityV2TryApp())}
element={<TryApp />}
/>
<Route
path={removePrefix(routes.client.compatibilityV2Payment())}
element={<Payment />}

View File

@ -38,6 +38,7 @@ import AddConsultant from "@/components/palmistry/AdditionalPurchases/pages/AddC
import AddGuides from "@/components/palmistry/AdditionalPurchases/pages/AddGuides";
import PaymentPage from "@/components/Payment/nmi/PaymentPage";
import { EPlacementKeys } from "@/api/resources/Paywall";
import TryApp from "@/components/PalmistryV1/pages/TryApp";
const removePrefix = (path: string) => path.replace(palmistryV1Prefix, "");
@ -244,6 +245,10 @@ function PalmistryV1Routes() {
path={removePrefix(routes.client.palmistryV1TrialPayment())}
element={<TrialPayment />}
/>
<Route
path={removePrefix(routes.client.palmistryV1TryApp())}
element={<TryApp />}
/>
<Route
path={removePrefix(routes.client.palmistryV1Payment())}
element={<Payment />}

View File

@ -199,6 +199,7 @@ const routes = {
palmistryV1TrialChoice: () => [palmistryV1Prefix, "trial-choice"].join("/"),
palmistryV1TrialChoiceVideo: () => [palmistryV1Prefix, "trial-choice-video"].join("/"),
palmistryV1TrialPayment: () => [palmistryV1Prefix, "trial-payment"].join("/"),
palmistryV1TryApp: () => [palmistryV1Prefix, "try-app"].join("/"),
palmistryV1Payment: () => [palmistryV1Prefix, "payment"].join("/"),
palmistryV1PaymentModal: () => [palmistryV1Prefix, "payment-modal"].join("/"),
palmistryV1SecretDiscountPaymentModal: () => [palmistryV1Prefix, "secret-discount-payment-modal"].join("/"),
@ -231,6 +232,7 @@ const routes = {
compatibilityV2TrialChoice: () => [compatibilityV2Prefix, "trial-choice"].join("/"),
compatibilityV2TrialChoiceVideo: () => [compatibilityV2Prefix, "trial-choice-video"].join("/"),
compatibilityV2TrialPayment: () => [compatibilityV2Prefix, "trial-payment"].join("/"),
compatibilityV2TryApp: () => [compatibilityV2Prefix, "try-app"].join("/"),
compatibilityV2Payment: () => [compatibilityV2Prefix, "payment"].join("/"),
compatibilityV2PaymentModal: () => [compatibilityV2Prefix, "payment-modal"].join("/"),
compatibilityV2SecretDiscountPaymentModal: () => [compatibilityV2Prefix, "secret-discount-payment-modal"].join("/"),
@ -427,6 +429,7 @@ const routes = {
"/"
),
dApiAuth: () => [dApiHost, "users", "auth"].join("/"),
dApiAnonymousAuth: () => [dApiHost, "users", "anonymous", "auth"].join("/"),
dApiGetRealToken: () => [dApiHost, "users", "auth", "token"].join("/"),

View File

@ -11,3 +11,13 @@ export const DataURIToBlob = (dataURI: string) => {
return new Blob([ia], { type: mimeString });
};
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
};

View File

@ -208,6 +208,7 @@ type TABFlags = {
esFlag: "hiCopy" | "standard";
palmOnPayment: "graphical" | "real";
genderPageType: "v1" | "v2";
trialChoicePageType: "v1" | "v2";
}
export const useMetricABFlags = () => {

View File

@ -0,0 +1,100 @@
export function generateRealisticEmail(
firstNames: string[],
lastNames: string[],
domains: string[]
): string {
// Функция для выборки случайного элемента массива
const getRandomElement = <T>(arr: T[]): T =>
arr[Math.floor(Math.random() * arr.length)];
// Берём случайные имя и фамилию (сразу приводим к нижнему регистру)
// и подрезаем до 3-4 символов, чтобы результат был короче
const rawFirstName = getRandomElement(firstNames).toLowerCase().slice(0, 4);
const rawLastName = getRandomElement(lastNames).toLowerCase().slice(0, 4);
// Случайно решаем, включать ли «осмысленные» цифры (примерно в 40% случаев)
const includeDigits = Math.random() < 0.4;
// Генерируем «реалистичное» число (часто год рождения или возраст)
function getRealisticNumber(): string {
// 50% год рождения (19702010), 50% «возраст» (1080).
const isYear = Math.random() < 0.5;
if (isYear) {
const year = Math.floor(Math.random() * (2010 - 1970 + 1)) + 1970; // 19702010
return String(year);
} else {
const age = Math.floor(Math.random() * (80 - 10 + 1)) + 10; // 1080
return String(age);
}
}
// Цифровая часть (если решено, что будет)
const numberPart = includeDigits ? getRealisticNumber() : '';
// Несколько укороченных паттернов (чтобы адреса были не слишком длинными).
// Используем уже подрезанные кусочки имён.
// {fn} = имя (уже укороченное), {ln} = фамилия (уже укороченная),
// {f} = первая буква имени, {num} = сгенерированное «реалистичное» число.
const patterns = [
'{f}{ln}', // Пример: cgom
'{ln}{f}', // Пример: gomc
'{fn}.{ln}', // Пример: carl.gome
'{fn}{ln}', // Пример: carlgome
'{fn}{num}', // Пример: carl1979
'{ln}{num}', // Пример: gome1979
'{fn}{ln}{num}' // Пример: carlgome1979
];
// Выбираем шаблон
const randomPattern = getRandomElement(patterns);
// Подготовим первую букву имени
const f = rawFirstName.slice(0, 1);
// Формируем локальную часть по выбранному шаблону
let localPart = randomPattern
.replace('{fn}', rawFirstName)
.replace('{ln}', rawLastName)
.replace('{f}', f)
.replace('{num}', numberPart);
// Чтобы адреса были «чуть короче», ограничим длину локальной части до 6 символов.
// (Не считая маски, которую сейчас добавим)
if (localPart.length > 6) {
localPart = localPart.slice(0, 6);
}
// Функция для добавления маски *** или **** в середину
function addMaskInTheMiddle(str: string): string {
// Случайная маска
const mask = Math.random() < 0.5 ? '***' : '****';
// Если строка совсем короткая (например, 1-2 символа),
// то проще добавить маску в конец или начало.
if (str.length < 3) {
return str + mask;
}
// Находим «середину» строки
const middleIndex = Math.floor(str.length / 2);
// Вставляем маску примерно в середину
return str.slice(0, middleIndex) + mask + str.slice(middleIndex);
}
// Добавляем маску
localPart = addMaskInTheMiddle(localPart);
// Выбираем домен
const domain = getRandomElement(domains);
// Итоговый email
return `${localPart}@${domain}`;
}
// // Пример использования
// const firstNames = ["Carlos", "Luis", "Maria", "Ana", "Juan", "Pedro", "Jose"];
// const lastNames = ["Gomez", "Lopez", "Martinez", "Rodriguez", "Fernandez", "Perez"];
// const domains = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com"];
// // Генерируем несколько адресов
// for (let i = 0; i < 10; i++) {
// console.log(generateRealisticEmail(firstNames, lastNames, domains));
// }

View File

@ -3,7 +3,7 @@ import { chatsPrefix, compatibilityV2Prefix, palmistryV1Prefix } from "@/routes"
import { Helmet } from "react-helmet";
const routesPalmistry = [
// "/palmistry",
"/palmistry",
// routes.client.skipTrial(),
// routes.client.addConsultant(),
// routes.client.addGuides(),