This commit is contained in:
Daniil Chemerkin 2025-05-06 22:13:47 +00:00
parent 37b12a69af
commit 6dd37bb284
69 changed files with 2465 additions and 408 deletions

View File

@ -48,7 +48,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="preload" as="image" href="/leo.webp" fetchpriority="high" />
<!-- <link rel="preload" as="image" href="/leo.webp" fetchpriority="high" /> -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />

41
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@lottiefiles/dotlottie-react": "^0.6.4",
"@mediapipe/tasks-vision": "^0.10.22-rc.20250304",
"@microsoft/clarity": "^1.0.0",
"@mui/material": "^5.15.21",
"@reduxjs/toolkit": "^1.9.5",
@ -20,6 +21,7 @@
"core-js": "^3.37.1",
"framer-motion": "^11.0.8",
"html-react-parser": "^3.0.16",
"i18n-iso-countries": "^7.14.0",
"i18next": "^22.5.0",
"i18next-react-postprocessor": "^3.1.0",
"idb": "^8.0.0",
@ -1183,6 +1185,11 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.22-rc.20250304",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.22-rc.20250304.tgz",
"integrity": "sha512-dElxVXMFGthshfIj+qAVm8KE2jmNo2p8oXFib8WzEjb7GNaX/ClWBc8UJfoSZwjEMVrdHJ4YUfa7P3ifl6MIWw=="
},
"node_modules/@microsoft/clarity": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/clarity/-/clarity-1.0.0.tgz",
@ -2614,6 +2621,11 @@
"node": ">=8"
}
},
"node_modules/diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -3540,6 +3552,17 @@
"node": ">= 6"
}
},
"node_modules/i18n-iso-countries": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz",
"integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==",
"dependencies": {
"diacritics": "1.3.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/i18next": {
"version": "22.5.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.5.0.tgz",
@ -6290,6 +6313,11 @@
"tar": "^6.1.11"
}
},
"@mediapipe/tasks-vision": {
"version": "0.10.22-rc.20250304",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.22-rc.20250304.tgz",
"integrity": "sha512-dElxVXMFGthshfIj+qAVm8KE2jmNo2p8oXFib8WzEjb7GNaX/ClWBc8UJfoSZwjEMVrdHJ4YUfa7P3ifl6MIWw=="
},
"@microsoft/clarity": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/clarity/-/clarity-1.0.0.tgz",
@ -7169,6 +7197,11 @@
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"optional": true
},
"diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="
},
"dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -7876,6 +7909,14 @@
"debug": "4"
}
},
"i18n-iso-countries": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz",
"integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==",
"requires": {
"diacritics": "1.3.0"
}
},
"i18next": {
"version": "22.5.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.5.0.tgz",

View File

@ -18,6 +18,7 @@
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@lottiefiles/dotlottie-react": "^0.6.4",
"@mediapipe/tasks-vision": "^0.10.22-rc.20250304",
"@microsoft/clarity": "^1.0.0",
"@mui/material": "^5.15.21",
"@reduxjs/toolkit": "^1.9.5",
@ -27,6 +28,7 @@
"core-js": "^3.37.1",
"framer-motion": "^11.0.8",
"html-react-parser": "^3.0.16",
"i18n-iso-countries": "^7.14.0",
"i18next": "^22.5.0",
"i18next-react-postprocessor": "^3.1.0",
"idb": "^8.0.0",

View File

@ -43,7 +43,7 @@
"life": "Life line ✅"
},
"reading_ready": {
"title": "Your Compatibility Reading is READY and available in the app for your iPhone!"
"title": "Your Compatibility Reading is READY"
},
"your_access_code": "Your Access Code",
"copy": "COPY",
@ -88,10 +88,30 @@
"point6": "Индивидуальный прогноз на развитие отношений: что ждет вас впереди.",
"point7": "Вопросы и персональные рекомендации от эксперта.",
"point8": "Уникальная информация, которую нельзя найти в стандартных гороскопах."
},
"v3": {
"point1": "Индивидуальный прогноз на развитие отношений: что ждет вас впереди.",
"point2": "Уникальная информация, которую нельзя найти в стандартных гороскопах.",
"point3": "Подробный астрологический разбор: что сближает и что вызывает напряжение.",
"point4": "Полная картина вашей совместимости: уровни в процентах — без иллюзий и догадок.",
"point5": "Вопросы и персональные рекомендации от эксперта.",
"point6": "Подробный астрологический разбор: что сближает и что вызывает напряжение.",
"point7": "Глубокое понимание партнера: как он любит и что для него важно."
}
},
"description": "To read the full reading, you need to get access through the app for your iPhone"
}
},
"offer_reserved": {
"title": "Offer reserved",
"button": "Get my Reading"
},
"information-title": "Мы готовы дать тебе все ответы, не трать годы на сомнения!",
"information-description-single": "Ты когда-нибудь задумывался, почему одни отношения развиваются легко, а другие будто натянуты, как струна? Совпадение или знак судьбы? Руки рассказывают больше, чем вы думаете. Линии на вашей ладони — это карта ваших отношений. <color> в вашей жизни есть скрытые знаки, которые вы ещё не заметили. <eventDescription><br>Получите детальный анализ совместимости по хиромантии и откройте ответы, которые уже написаны в вашей судьбе.",
"information-description-single-color": "<zodiacSign> (<birthdate>)",
"information-description-single-event-description": "Ваша дата <dateEvent> могла стать поворотной точкой или скрытым сигналом.",
"information-description-with-partner": "Ты когда-нибудь задумывался, почему одни отношения развиваются легко, а другие будто натянуты, как струна? Совпадение или знак судьбы? <color> — два знака, созданные для глубины, но какие тайны скрывает ваш союз? <eventDescription> Получите детальный анализ совместимости и откройте ответы, которые уже написаны в вашей судьбе.",
"information-description-with-partner-color": "<zodiacSign> (<birthdate>) + <partnerZodiacSign> (<partnerBirthdate>)",
"information-description-with-partner-event-description": "Ваша дата <dateEvent> могла стать поворотной точкой или скрытым сигналом."
},
"/find-your-happiness": {
"title": "Gain Clarity and Confidence in Life",

View File

@ -0,0 +1,52 @@
{
"/profile": {
"profile_information": {
"title": "Profile Information",
"description": "To update your email address please contact support.",
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",
"subscription_type": "Subscription Type:",
"billing_button": "Billing",
"credits": {
"title": "You have <credits> credits left",
"description": "You can use them to chat with any Expert on the platform."
},
"any_questions": "Any questions? <link>",
"any_questions_link": "Contact us",
"subscription_update": "<bold><br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"subscription_update_bold": "Subscription information is updated every few hours."
},
"log_out": {
"title": "Log out",
"log_out_button": "Log out",
"modal": {
"title": "Are you sure you want to log out?",
"description": "Are you sure you want to log out?",
"stay_button": "Stay",
"log_out_button": "Log out"
}
}
},
"/subscriptions": {
"title": "Manage my subscriptions",
"modal": {
"title": "Are you sure you want to cancel your subscription?",
"description": "Are you sure you want to cancel your subscription?",
"cancel_button": "Cancel subscription",
"stay_button": "Stay"
},
"table": {
"subscription_type": "Subscription Type",
"subscription_status": "Subscription Status",
"billing_period": "Billing Period",
"last_payment_on": "Last Payment On",
"renewal_date": "Renewal Date",
"renewal_amount": "Renewal Amount",
"cancel_subscription": "Cancel Subscription"
}
}
}

View File

@ -126,7 +126,25 @@
"description": "You will be charged only <priceForDays>. \n<emailReminder> before your trial period ends. \nCancel anytime. The charge will appear on your bill as witapps.",
"address": "2108 N ST STE 5446 SACRAMENTO, CA 95816",
"form_error": "Проверьте правильность заполнения формы",
"price_information": "A <price> charge"
"price_information": "A <price> charge",
"address_form": {
"labels": {
"country": "Country",
"address": "Address",
"zip": "Zip"
},
"placeholders": {
"country": "Select country",
"address": "City, street, house",
"zip": "Postal code"
},
"errors": {
"country": "Please select a country",
"address": "Please enter an address",
"zip": "Please enter a zip code",
"zip_invalid": "Invalid zip code for selected country"
}
}
},
"add_report": "Add Report",
"unlimited_readings": "Unlimited Readings",

View File

@ -1,6 +1,7 @@
import routes from "@/routes";
import { getAuthHeaders, getBaseHeaders } from "../utils";
import { ICreateAuthorizeResponse } from "./User";
import { AddressFields } from "@/hooks/payment/nmi/useAddressFields";
export interface Payload {
token: string;
@ -11,6 +12,7 @@ export interface PayloadPost extends Payload {
placementId: string;
paywallId: string;
paymentToken?: string;
address?: AddressFields;
}
export interface PayloadPostAnonymous {
@ -19,6 +21,7 @@ export interface PayloadPostAnonymous {
paywallId: string;
paymentToken: string;
sessionId: string;
address?: AddressFields;
}
interface ResponsePostSuccess {
@ -62,25 +65,27 @@ export interface ResponsePostAnonymousSuccess {
invoiceId: string;
}
export const createRequestPost = ({ token, productId, placementId, paywallId, paymentToken }: PayloadPost): Request => {
export const createRequestPost = ({ token, productId, placementId, paywallId, paymentToken, address }: PayloadPost): Request => {
const url = new URL(routes.server.makePayment());
const body = JSON.stringify({
productId,
placementId,
paywallId,
paymentToken
paymentToken,
address
});
return new Request(url, { method: "POST", headers: getAuthHeaders(token), body });
};
export const createRequestPostAnonymous = ({ productId, placementId, paywallId, paymentToken, sessionId }: PayloadPostAnonymous): Request => {
export const createRequestPostAnonymous = ({ productId, placementId, paywallId, paymentToken, sessionId, address }: PayloadPostAnonymous): Request => {
const url = new URL(routes.server.makeAnonymousPayment());
const body = JSON.stringify({
productId,
placementId,
paywallId,
paymentToken,
sessionId
sessionId,
address
});
return new Request(url, { method: "POST", headers: getBaseHeaders(), body });
};

View File

@ -1,7 +1,9 @@
import {
// useCallback,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
// useMemo,
// useRef,
useState,
@ -14,6 +16,7 @@ import {
useLocation,
// useNavigate,
useSearchParams,
useNavigate,
} from "react-router-dom";
import { useAuth } from "@/auth";
import { useDispatch, useSelector } from "react-redux";
@ -23,7 +26,7 @@ import routes, {
getRouteBy,
// hasNoFooter,
// hasNoHeader,
// hasNavbarFooter,
hasNavbarFooter,
// hasFullDataModal,
palmistryV1Prefix,
// chatsPrefix,
@ -33,6 +36,11 @@ import routes, {
anonymousPrefix,
compatibilityV3Prefix,
compatibilityV4Prefix,
hasFullDataModal,
hasNoHeader,
hasNoFooter,
hasNavigation,
profilePrefix,
} from "@/routes";
import BirthdayPage from "../BirthdayPage";
import BirthtimePage from "../BirthtimePage";
@ -41,9 +49,9 @@ import EmailEnterPage from "../EmailEnterPage";
import PaymentPage from "../PaymentPage";
import WallpaperPage from "../WallpaperPage";
// import NotFoundPage from "../NotFoundPage";
// import Header from "../Header";
// import Navbar from "../Navbar";
// import Footer from "../Footer";
import Header from "../Header";
import Navbar from "../Navbar";
import Footer from "../Footer";
import "./styles.css";
import DidYouKnowPage from "../DidYouKnowPage";
import FreePeriodInfoPage from "../FreePeriodInfoPage";
@ -54,11 +62,11 @@ import PriceListPage from "../PriceListPage";
import CompatResultPage from "../CompatResultPage";
import HomePage from "../HomePage";
import UserCallbacksPage from "../UserCallbacksPage";
// import NavbarFooter, { INavbarHomeItems } from "../NavbarFooter";
// import { EPathsFromHome } from "@/store/siteConfig";
import { APNG } from "apng-js";
// import { useApi, useApiCall } from "@/api";
// import { Asset } from "@/api/resources/Assets";
import NavbarFooter, { INavbarHomeItems } from "../NavbarFooter";
import { EPathsFromHome } from "@/store/siteConfig";
import parseAPNG, { APNG } from "apng-js";
import { useApi, useApiCall } from "@/api";
import { Asset } from "@/api/resources/Assets";
import PaymentResultPage from "../PaymentPage/results";
import PaymentSuccessPage from "../PaymentPage/results/SuccessPage";
import PaymentFailPage from "../PaymentPage/results/ErrorPage";
@ -80,8 +88,8 @@ import NameHoroscopeResult from "../pages/NameHoroscopeResult";
// import LoadingInRelationshipPage from "../pages/LoadingInRelationship";
// import QuestionnaireIntermediatePage from "../pages/QuestionnaireIntermediate";
// import RelationshipAlmostTherePage from "../pages/RelationshipAlmostThere";
// import Modal from "../Modal";
// import FullDataModal from "../FullDataModal";
import Modal from "../Modal";
import FullDataModal from "../FullDataModal";
// import SingleZodiacInfoPage from "../pages/SingleZodiacInfo";
// import ProblemsPage from "../pages/Problems";
// import WorksRouterPage from "../pages/WorksRouter";
@ -118,7 +126,7 @@ import Advisors from "../pages/Advisors";
import AdvisorChatPage from "../pages/AdvisorChat";
// import SuccessPaymentPage from "../pages/SinglePaymentPage/ResultPayment/SuccessPaymentPage";
// import FailPaymentPage from "../pages/SinglePaymentPage/ResultPayment/FailPaymentPage";
// import { useSchemeColorByElement } from "@/hooks/useSchemeColorByElement";
import { useSchemeColorByElement } from "@/hooks/useSchemeColorByElement";
import GetInformationPartnerPage from "../pages/GetInformationPartner";
// import BirthPlacePage from "../pages/BirthPlacePage";
// import LoadingPage from "../pages/LoadingPage";
@ -148,6 +156,7 @@ import { useSession } from "@/hooks/session/useSession";
import { getSourceByPathname } from "@/utils/source.utils";
import "../palmistry/palmistry-container/palmistry-container.css"
import ProfileRoutes from "@/routerComponents/Profile";
const isProduction = import.meta.env.MODE === "production";
const gaMeasurementId = import.meta.env.AURA_GA_MEASUREMENT_ID;
@ -161,13 +170,12 @@ ReactGA.initialize(gaMeasurementId);
function App(): JSX.Element {
const location = useLocation();
const [leoApng, setLeoApng] = useState<Error | APNG>(Error);
setLeoApng
useScrollToTop({ scrollBehavior: "auto" });
// const [
// padLockApng,
// setPadLockApng,
// ] = useState<Error | APNG>(Error);
// const api = useApi();
const api = useApi();
const dispatch = useDispatch();
const { user } = useAuth();
const { session } = useSession();
@ -193,7 +201,7 @@ function App(): JSX.Element {
unleashClient.updateContext({
sessionId: session?.[source] || undefined,
properties: {
source
source
}
});
}
@ -267,27 +275,27 @@ function App(): JSX.Element {
});
}, []);
// const assetsData = useCallback(async () => {
// const { assets } = await api.getAssets({
// category: String("au"),
// });
// return assets;
// }, [api]);
const assetsData = useCallback(async () => {
const { assets } = await api.getAssets({
category: String("au"),
});
return assets;
}, [api]);
// const { data } = useApiCall<Asset[]>(assetsData);
const { data } = useApiCall<Asset[]>(assetsData);
// data
// useEffect(() => {
// async function getApng() {
// if (!data) return;
// const response = await fetch(
// data.find((item) => item.key === "au.apng.leo")?.url || ""
// );
// const arrayBuffer = await response.arrayBuffer();
// setLeoApng(parseAPNG(arrayBuffer));
// }
// getApng();
// }, [data]);
useEffect(() => {
async function getApng() {
if (!data) return;
const response = await fetch(
data.find((item) => item.key === "au.apng.leo")?.url || ""
);
const arrayBuffer = await response.arrayBuffer();
setLeoApng(parseAPNG(arrayBuffer));
}
getApng();
}, [data]);
// useEffect(() => {
// (async () => {
@ -319,6 +327,10 @@ function App(): JSX.Element {
<CookieYesController isDelete={subscriptionStatus === "subscribed"} />
}
>
<Route
path={`${profilePrefix}/*`}
element={<ProfileRoutes />}
/>
<Route
path={`${anonymousPrefix}/*`}
element={<AnonymousRoutes />}
@ -437,66 +449,70 @@ function App(): JSX.Element {
/>
</Route>
<Route element={<PrivateSubscriptionOutlet />}>
<Route path={routes.client.home()} element={<HomePage />} />
<Route
path={routes.client.compatibility()}
element={<CompatibilityPage />}
/>
<Route
path={routes.client.compatibilityResult()}
element={<CompatResultPage />}
/>
<Route
path={routes.client.breath()}
element={<BreathPage leoApng={leoApng} />}
/>
<Route
path={routes.client.breathResult()}
element={<UserCallbacksPage />}
/>
<Route
path={routes.client.wallpaper()}
element={<WallpaperPage />}
/>
<Route path={routes.client.advisors()} element={<Advisors />} />
<Route path={routes.client.advisors()}>
<Route path=":id" element={<AdvisorChatPage />} />
element={<Layout />}
>
<Route path={routes.client.home()} element={<HomePage />} />
<Route
path={routes.client.compatibility()}
element={<CompatibilityPage />}
/>
<Route
path={routes.client.compatibilityResult()}
element={<CompatResultPage />}
/>
<Route
path={routes.client.breath()}
element={<BreathPage leoApng={leoApng} />}
/>
<Route
path={routes.client.breathResult()}
element={<UserCallbacksPage />}
/>
<Route
path={routes.client.wallpaper()}
element={<WallpaperPage />}
/>
<Route path={routes.client.advisors()} element={<Advisors />} />
<Route path={routes.client.advisors()}>
<Route path=":id" element={<AdvisorChatPage />} />
</Route>
<Route
path={routes.client.magicBall()}
element={<MagicBallPage />}
/>
<Route
path={routes.client.horoscopeBestiesResult()}
element={<BestiesHoroscopeResult />}
/>
<Route
path={routes.client.predictionMoonResult()}
element={<PredictionMoonResult />}
/>
<Route
path={routes.client.myHoroscopeResult()}
element={<MyHoroscopeResult />}
/>
<Route
path={routes.client.thermalResult()}
element={<ThermalResult />}
/>
<Route
path={routes.client.moonPhaseTracker()}
element={<MoonPhaseTrackerResult />}
/>
<Route
path={routes.client.energyVampirismResult()}
element={<EnergyVampirismResult />}
/>
<Route
path={routes.client.nameHoroscopeResult()}
element={<NameHoroscopeResult />}
/>
</Route>
<Route
path={routes.client.magicBall()}
element={<MagicBallPage />}
/>
<Route
path={routes.client.horoscopeBestiesResult()}
element={<BestiesHoroscopeResult />}
/>
<Route
path={routes.client.predictionMoonResult()}
element={<PredictionMoonResult />}
/>
<Route
path={routes.client.myHoroscopeResult()}
element={<MyHoroscopeResult />}
/>
<Route
path={routes.client.thermalResult()}
element={<ThermalResult />}
/>
<Route
path={routes.client.moonPhaseTracker()}
element={<MoonPhaseTrackerResult />}
/>
<Route
path={routes.client.energyVampirismResult()}
element={<EnergyVampirismResult />}
/>
<Route
path={routes.client.nameHoroscopeResult()}
element={<NameHoroscopeResult />}
/>
</Route>
</Route>
{/* <Route path="*" element={<ABDesignV1Routes />} /> */}
<Route path="*" element={<Navigate to={getRouteBy(subscriptionStatus)} />} />
@ -975,126 +991,127 @@ function App(): JSX.Element {
);
}
// function Layout(): JSX.Element {
// const location = useLocation();
// const navigate = useNavigate();
// const dispatch = useDispatch();
// const showNavbar = hasNavigation(location.pathname);
// const showFooter = hasNoFooter(location.pathname);
// const showHeader = hasNoHeader(location.pathname);
// const isRouteFullDataModal = hasFullDataModal(location.pathname);
// const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
// const homeConfig = useSelector(selectors.selectHome);
// const showNavbarFooter = homeConfig.isShowNavbar;
// const mainRef = useRef<HTMLDivElement>(null);
// useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
// location,
// ]);
function Layout(): JSX.Element {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const showNavbar = hasNavigation(location.pathname);
const showFooter = hasNoFooter(location.pathname);
const showHeader = hasNoHeader(location.pathname);
const isRouteFullDataModal = hasFullDataModal(location.pathname);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
// const homeConfig = useSelector(selectors.selectHome);
// const showNavbarFooter = homeConfig.isShowNavbar;
const showNavbarFooter = true;
const mainRef = useRef<HTMLDivElement>(null);
useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
location,
]);
// const birthdate = useSelector(selectors.selectBirthdate);
// const dataItems = useMemo(() => [birthdate], [birthdate]);
// const [isShowFullDataModal, setIsShowFullDataModal] =
// useState<boolean>(false);
const birthdate = useSelector(selectors.selectBirthdate);
const dataItems = useMemo(() => [birthdate], [birthdate]);
const [isShowFullDataModal, setIsShowFullDataModal] =
useState<boolean>(false);
// useEffect(() => {
// setIsShowFullDataModal(getIsShowFullDataModal(dataItems));
// }, [dataItems]);
useEffect(() => {
setIsShowFullDataModal(getIsShowFullDataModal(dataItems));
}, [dataItems]);
// const onCloseFullDataModal = (_birthDate: string) => {
// dispatch(actions.form.addDate(_birthDate));
// setIsShowFullDataModal(getIsShowFullDataModal(dataItems));
// };
const onCloseFullDataModal = (_birthDate: string) => {
dispatch(actions.form.addDate(_birthDate));
setIsShowFullDataModal(getIsShowFullDataModal(dataItems));
};
// const handleCompatibility = () => {
// dispatch(
// actions.siteConfig.update({
// home: {
// pathFromHome: EPathsFromHome.navbar,
// isShowNavbar: showNavbarFooter,
// },
// })
// );
// navigate(routes.client.compatibility());
// };
// const handleBreath = () => {
// dispatch(
// actions.siteConfig.update({
// home: {
// pathFromHome: EPathsFromHome.navbar,
// isShowNavbar: showNavbarFooter,
// },
// })
// );
// navigate(routes.client.breath());
// };
const handleCompatibility = () => {
dispatch(
actions.siteConfig.update({
home: {
pathFromHome: EPathsFromHome.navbar,
isShowNavbar: showNavbarFooter,
},
})
);
navigate(routes.client.compatibility());
};
const handleBreath = () => {
dispatch(
actions.siteConfig.update({
home: {
pathFromHome: EPathsFromHome.navbar,
isShowNavbar: showNavbarFooter,
},
})
);
navigate(routes.client.breath());
};
// const navbarItems: INavbarHomeItems[] = [
// {
// title: "Breathing",
// path: routes.client.breath(),
// paths: [routes.client.breath(), routes.client.breathResult()],
// image: "Breath.svg",
// onClick: handleBreath,
// },
// {
// title: "Aura",
// path: routes.client.home(),
// paths: [routes.client.home()],
// image: "Aura.svg",
// active: true,
// onClick: () => null,
// },
// {
// title: "Compatibility",
// path: routes.client.compatibility(),
// paths: [
// routes.client.compatibility(),
// routes.client.compatibilityResult(),
// ],
// image: "Compatibility.svg",
// onClick: handleCompatibility,
// },
// {
// title: "Advisors",
// path: routes.client.advisors(),
// paths: [routes.client.advisors()],
// image: "moon.svg",
// onClick: () => null,
// },
// {
// title: "My Moon",
// path: routes.client.wallpaper(),
// paths: [routes.client.wallpaper()],
// image: "moon.svg",
// onClick: () => null,
// },
// ];
const navbarItems: INavbarHomeItems[] = [
{
title: "Breathing",
path: routes.client.breath(),
paths: [routes.client.breath(), routes.client.breathResult()],
image: "Breath.svg",
onClick: handleBreath,
},
{
title: "Aura",
path: routes.client.home(),
paths: [routes.client.home()],
image: "Aura.svg",
active: true,
onClick: () => null,
},
{
title: "Compatibility",
path: routes.client.compatibility(),
paths: [
routes.client.compatibility(),
routes.client.compatibilityResult(),
],
image: "Compatibility.svg",
onClick: handleCompatibility,
},
{
title: "Advisors",
path: routes.client.advisors(),
paths: [routes.client.advisors()],
image: "moon.svg",
onClick: () => null,
},
{
title: "My Moon",
path: routes.client.wallpaper(),
paths: [routes.client.wallpaper()],
image: "moon.svg",
onClick: () => null,
},
];
// return (
// <div className="container">
// {showHeader ? (
// <Header
// openMenu={() => setIsMenuOpen(true)}
// />
// ) : null}
// {isRouteFullDataModal && (
// <Modal open={isShowFullDataModal} isCloseButtonVisible={false} onClose={() => { }}>
// <FullDataModal onClose={onCloseFullDataModal} />
// </Modal>
// )}
// <main className="content" ref={mainRef}>
// <Outlet />
// </main>
// {showFooter ? <Footer color={showNavbar ? "black" : "white"} /> : null}
// {showNavbar ? (
// <Navbar isOpen={isMenuOpen} closeMenu={() => setIsMenuOpen(false)} />
// ) : null}
// {showNavbarFooter && hasNavbarFooter(location.pathname) ? (
// <NavbarFooter items={navbarItems} />
// ) : null}
// </div>
// );
// }
return (
<div className="container">
{showHeader ? (
<Header
openMenu={() => setIsMenuOpen(true)}
/>
) : null}
{isRouteFullDataModal && (
<Modal open={isShowFullDataModal} isCloseButtonVisible={false} onClose={() => { }}>
<FullDataModal onClose={onCloseFullDataModal} />
</Modal>
)}
<main className="content" ref={mainRef}>
<Outlet />
</main>
{showFooter ? <Footer color={showNavbar ? "black" : "white"} /> : null}
{showNavbar ? (
<Navbar isOpen={isMenuOpen} closeMenu={() => setIsMenuOpen(false)} />
) : null}
{showNavbarFooter && hasNavbarFooter(location.pathname) ? (
<NavbarFooter items={navbarItems} />
) : null}
</div>
);
}
// enum EIsAuthPageType {
// private,
@ -1275,18 +1292,18 @@ function PrivateSubscriptionOutlet(): JSX.Element {
);
}
// function getIsShowFullDataModal(dataItems: Array<unknown> = []): boolean {
// let hasNoDataItem = false;
function getIsShowFullDataModal(dataItems: Array<unknown> = []): boolean {
let hasNoDataItem = false;
// for (const item of dataItems) {
// if (!item) {
// hasNoDataItem = true;
// break;
// }
// }
for (const item of dataItems) {
if (!item) {
hasNoDataItem = true;
break;
}
}
// return hasNoDataItem;
// }
return hasNoDataItem;
}
function SkipStep(): JSX.Element {
const { user } = useAuth();

View File

@ -2,8 +2,9 @@ import Webcam from "react-webcam";
import styles from "./styles.module.scss";
import ModalOverlay, { ModalOverlayType } from "@/components/palmistry/modal-overlay/modal-overlay";
import Modal from "@/components/palmistry/modal/modal";
import { useRef, useState } from "react";
// import { useDynamicSize } from "@/hooks/useDynamicSize";
import { useEffect, useRef, useState } from "react";
import { HandLandmarker, DrawingUtils, HandLandmarkerResult } from "@mediapipe/tasks-vision";
import { handLandmarkerSingleton } from "@/utils/handLandmarkerSingleton";
interface ExtendedMediaTrackCapabilities extends MediaTrackCapabilities {
torch?: boolean;
@ -22,23 +23,60 @@ interface CameraModalProps {
onTakePhoto: (photo: string) => void;
onError: (error: string | DOMException) => void;
onVideoReady?: () => void;
onMediaPipeInitChange?: (status: boolean) => void;
onIsFacingCameraChange?: (isFacingCamera: boolean) => void;
reinitializeKey?: number; // for reinitializing the camera (change the key to reinitialize the camera)
isCameraVisible?: boolean;
className?: string;
isShowHand?: boolean;
isMediaPipe?: boolean;
mediaPipeRenderingTemplate?: "v2" | "v3" | "v4";
}
function getIsPalmFacingCamera(landmarks: { x: number, y: number, z: number }[], handedness: "Left" | "Right"): boolean {
const v1 = {
x: landmarks[5].x - landmarks[0].x,
y: landmarks[5].y - landmarks[0].y,
z: landmarks[5].z - landmarks[0].z,
};
const v2 = {
x: landmarks[17].x - landmarks[0].x,
y: landmarks[17].y - landmarks[0].y,
z: landmarks[17].z - landmarks[0].z,
};
const normal = {
x: v1.y * v2.z - v1.z * v2.y,
y: v1.z * v2.x - v1.x * v2.z,
z: v1.x * v2.y - v1.y * v2.x,
};
if (handedness === "Right") {
return normal.z < 0;
} else {
return normal.z > 0;
}
}
function CameraModal({
onClose,
onTakePhoto,
onError,
onVideoReady,
onMediaPipeInitChange,
onIsFacingCameraChange,
reinitializeKey = 0,
isCameraVisible = true,
className = ""
className = "",
isShowHand = true,
isMediaPipe = false,
mediaPipeRenderingTemplate = "v2"
}: CameraModalProps) {
const [isVideoReady, setIsVideoReady] = useState(false);
const [isTorchOn, setIsTorchOn] = useState(false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
const [isHandDetected, setIsHandDetected] = useState(false);
const [isPalmFacingCamera, setIsPalmFacingCamera] = useState(false);
const [handName, setHandName] = useState<"Left" | "Right">("Left");
// const {
// width: _width, height: _height,
// elementRef: containerRef } = useDynamicSize<HTMLDivElement>({});
@ -49,7 +87,9 @@ function CameraModal({
// const isLandscape = height <= width;
// const ratio = isLandscape ? width / height : height / width;
const cameraRef = useRef<Webcam>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handLandmarkerRef = useRef<HandLandmarker | null>(null);
// useEffect(() => {
// setIsVideoReady(false);
// }, [reinitializeKey]);
@ -115,6 +155,166 @@ function CameraModal({
}
};
useEffect(() => {
const unsubscribe = handLandmarkerSingleton.subscribe((isLoaded) => {
onMediaPipeInitChange?.(isLoaded);
if (isLoaded) {
handLandmarkerRef.current = handLandmarkerSingleton.getHandLandmarker();
}
});
handLandmarkerSingleton.preload();
return () => unsubscribe();
}, []);
useEffect(() => {
if (!isCameraVisible || !isVideoReady || !isMediaPipe || !handLandmarkerRef.current) return;
let running = true;
let animationFrameId: number;
let lastVideoTime = -1;
let results: HandLandmarkerResult | undefined = undefined;
const predictWebcam = () => {
const handLandmarker = handLandmarkerRef.current;
if (!handLandmarker || !cameraRef.current?.video || !canvasRef.current) {
animationFrameId = requestAnimationFrame(predictWebcam);
return;
}
const video = cameraRef.current.video;
const canvas = canvasRef.current;
// if (
// canvas.width !== video.videoWidth ||
// canvas.height !== video.videoHeight
// ) {
// canvas.width = video.videoWidth;
// canvas.height = video.videoHeight;
// }
if (
video.videoWidth === 0 ||
video.videoHeight === 0
) {
animationFrameId = requestAnimationFrame(predictWebcam);
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
const now = performance.now();
if (lastVideoTime !== video.currentTime) {
lastVideoTime = video.currentTime;
results = handLandmarker.detectForVideo(video, now);
}
const _isHandDetected = !!(results?.landmarks && results?.landmarks.length > 0);
setIsHandDetected(_isHandDetected);
ctx.save();
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (results?.landmarks) {
const drawingUtils = new DrawingUtils(ctx);
results.landmarks.forEach((landmarks, i) => {
const handedness = results?.handedness[i][0].categoryName as "Left" | "Right";
const _isPalmFacingCamera = getIsPalmFacingCamera(landmarks, handedness);
setIsPalmFacingCamera(_isPalmFacingCamera);
if (_isHandDetected) {
onIsFacingCameraChange?.(_isPalmFacingCamera);
}
if (_isHandDetected && _isPalmFacingCamera) {
setHandName(handedness);
let fingertipIndexes = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
if (mediaPipeRenderingTemplate === "v2") {
fingertipIndexes = [4, 8, 12, 16, 20];
}
const fingertipPoints = fingertipIndexes.map(idx => landmarks[idx]);
const fingertipConnections = [
{ start: 0, end: 1 },
{ start: 1, end: 2 },
{ start: 3, end: 4 },
{ start: 4, end: 5 },
{ start: 5, end: 6 },
{ start: 7, end: 8 },
{ start: 8, end: 9 },
{ start: 9, end: 10 },
{ start: 11, end: 12 },
{ start: 12, end: 13 },
{ start: 13, end: 14 },
{ start: 15, end: 16 },
{ start: 16, end: 17 },
{ start: 17, end: 18 },
];
fingertipPoints.forEach(point => {
if (mediaPipeRenderingTemplate === "v2") {
drawingUtils.drawLandmarks([point], { color: "#ffffff4d", lineWidth: 4, fillColor: "#0b60f1" });
}
if (mediaPipeRenderingTemplate === "v3") {
drawingUtils.drawLandmarks([point], { color: "#0b60f1", lineWidth: 1 });
}
if (mediaPipeRenderingTemplate === "v4") {
drawingUtils.drawLandmarks([point], { color: "#0b60f1", lineWidth: 1 });
drawingUtils.drawConnectors(fingertipPoints, fingertipConnections, { color: "#a3a3a303", lineWidth: 2 });
}
});
}
})
}
ctx.restore();
if (running) {
animationFrameId = requestAnimationFrame(predictWebcam);
}
};
predictWebcam();
return () => {
running = false;
if (animationFrameId) cancelAnimationFrame(animationFrameId);
};
}, [isCameraVisible, reinitializeKey, isVideoReady, isMediaPipe, handLandmarkerRef.current]);
const getStyleCanvas = () => {
const containerWidth = containerRef?.current?.clientWidth || 0;
const containerHeight = containerRef?.current?.clientHeight || 0;
const videoWidth = cameraRef?.current?.video?.videoWidth || 0;
const videoHeight = cameraRef?.current?.video?.videoHeight || 0;
const videoAspect = videoWidth / videoHeight;
const containerAspect = containerWidth / containerHeight;
let style = {};
if (containerAspect > videoAspect) {
const displayWidth = containerWidth;
style = {
position: "absolute",
width: `${videoWidth}px`,
height: `${videoHeight}px`,
left: "50%",
top: "50%",
transform: `translate(-50%, -50%) scale(${displayWidth / videoWidth})`,
pointerEvents: "none",
};
} else {
const displayHeight = containerHeight;
style = {
position: "absolute",
width: `${videoWidth}px`,
height: `${videoHeight}px`,
left: "50%",
top: "50%",
transform: `translate(-50%, -50%) scale(${displayHeight / videoHeight})`,
pointerEvents: "none",
};
}
return style;
}
return <ModalOverlay
type={ModalOverlayType.Dark}
className={`${styles.overlay} ${className}`}
@ -126,37 +326,57 @@ function CameraModal({
modalClassName={styles.modal}
>
<div
// ref={containerRef}
ref={containerRef}
className={styles.cameraContainer}>
{/* {width} {height} */}
<svg
className={styles.handIcon} width="226" height="435" viewBox="0 0 226 435" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44.9888 101.991C31.3554 80.777 16.4145 88.555 8.56162 99.382C3.99661 105.676 3.45507 113.764 3.41227 121.539C3.06124 185.302 2.66386 298.933 3.47936 322.748C4.34753 348.102 22.1089 380.082 30.881 392.903C47.521 412.857 95.0172 446.752 151.882 422.699C208.748 398.646 222.91 337.033 223 307.148V181.957V181.957C222.663 170.327 216.966 158.307 205.412 156.937C201.087 156.425 197.042 156.699 193.716 157.409C191.581 157.864 189.641 158.915 187.848 160.159V160.159C181.442 164.605 177.583 171.876 177.493 179.673L176.571 259.523L177.251 200.682L176.705 74.0516C176.618 53.7907 170.942 24.5591 151.038 28.3435C144.116 29.6596 138.353 35.2118 135.333 42.942M44.9888 101.991L46.074 202.212M44.9888 101.991L45.9236 62.9801C46.2026 51.3361 47.4303 38.0466 57.4751 32.1503C67.0778 26.5135 80.2774 27.7506 90.8392 42.942M90.8392 42.942V202.212M90.8392 42.942V38.4912C90.8392 26.0387 91.7293 11.455 102.608 5.39458C108.902 1.88803 116.617 1.86364 124.232 7.80872C130.541 12.7337 133.624 20.5445 134.497 28.5001C134.994 33.0286 135.333 38.1796 135.333 42.942M135.333 42.942V202.212" stroke="white" strokeWidth="6" strokeLinecap="round" />
</svg>
{isCameraVisible && <Webcam
key={reinitializeKey}
ref={cameraRef}
className={styles.camera}
muted
// width={width}
// height={height}
// disablePictureInPicture={true}
screenshotQuality={2}
videoConstraints={{
facingMode: { ideal: "environment" },
// width,
// height,
// aspectRatio: ratio,
}}
onUserMedia={onUserMedia}
onUserMediaError={(error) => {
setIsVideoReady(false);
setIsTorchAvailable(false);
console.error(error);
onError(error);
}}
/>}
{isShowHand && (!isHandDetected || !isPalmFacingCamera || !isMediaPipe) && (
<svg
className={styles.handIcon}
width="226"
height="435"
viewBox="0 0 226 435"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{
transform: `translate(-50%) scale(${handName === "Right" ? 1 : -1}, 1)`,
}}
>
<path d="M44.9888 101.991C31.3554 80.777 16.4145 88.555 8.56162 99.382C3.99661 105.676 3.45507 113.764 3.41227 121.539C3.06124 185.302 2.66386 298.933 3.47936 322.748C4.34753 348.102 22.1089 380.082 30.881 392.903C47.521 412.857 95.0172 446.752 151.882 422.699C208.748 398.646 222.91 337.033 223 307.148V181.957V181.957C222.663 170.327 216.966 158.307 205.412 156.937C201.087 156.425 197.042 156.699 193.716 157.409C191.581 157.864 189.641 158.915 187.848 160.159V160.159C181.442 164.605 177.583 171.876 177.493 179.673L176.571 259.523L177.251 200.682L176.705 74.0516C176.618 53.7907 170.942 24.5591 151.038 28.3435C144.116 29.6596 138.353 35.2118 135.333 42.942M44.9888 101.991L46.074 202.212M44.9888 101.991L45.9236 62.9801C46.2026 51.3361 47.4303 38.0466 57.4751 32.1503C67.0778 26.5135 80.2774 27.7506 90.8392 42.942M90.8392 42.942V202.212M90.8392 42.942V38.4912C90.8392 26.0387 91.7293 11.455 102.608 5.39458C108.902 1.88803 116.617 1.86364 124.232 7.80872C130.541 12.7337 133.624 20.5445 134.497 28.5001C134.994 33.0286 135.333 38.1796 135.333 42.942M135.333 42.942V202.212" stroke="white" strokeWidth="6" strokeLinecap="round" />
</svg>
)}
{isCameraVisible && (
<>
<Webcam
key={reinitializeKey}
ref={cameraRef}
className={styles.camera}
muted
// width={width}
// height={height}
// disablePictureInPicture={true}
screenshotQuality={2}
videoConstraints={{
facingMode: { ideal: "environment" },
// width,
// height,
// aspectRatio: ratio,
}}
onUserMedia={onUserMedia}
onUserMediaError={(error) => {
setIsVideoReady(false);
setIsTorchAvailable(false);
console.error(error);
onError(error);
}}
/>
{isMediaPipe && <canvas
ref={canvasRef}
className={styles.overlayCanvas}
width={cameraRef?.current?.video?.videoWidth || 0}
height={cameraRef?.current?.video?.videoHeight || 0}
style={getStyleCanvas()}
/>}
</>
)}
<div
className={styles.shutterButton}
onClick={onClickTakePhoto}

View File

@ -29,6 +29,7 @@
width: 100%;
height: 100dvh;
object-fit: cover;
object-position: center;
}
.shutterButton {
@ -102,4 +103,9 @@
}
}
}
}
.overlayCanvas {
position: absolute;
pointer-events: none;
}

File diff suppressed because one or more lines are too long

View File

@ -27,6 +27,7 @@ enum EToastVisible {
"no_access_camera" = "no_access_camera",
"reload_page" = "reload_page",
"upload_photo" = "upload_photo",
"turn_hand" = "turn_hand",
}
const isWebViewAndroid = isWebView() && isAndroid;
@ -53,6 +54,10 @@ function AndroidCamera() {
flag: EUnleashFlags.compatibilityV2TimeForCameraInit
});
const { variant: v2CompatibilityCameraTemplate } = useUnleash({
flag: EUnleashFlags.v2CompatibilityCameraTemplate
});
const timeForCameraInit = Number(compatibilityV2TimeForCameraInit) || 6000;
const [isCameraModalOpen, setIsCameraModalOpen] = useState(false);
@ -60,6 +65,7 @@ function AndroidCamera() {
const [isRequestCameraModalOpen, setIsRequestCameraModalOpen] = useState(isShowCameraRequestModal);
const [toastVisible, setToastVisible] = useState<EToastVisible | null>(null);
const [isVideoReady, setIsVideoReady] = useState(false);
const [isMediaPipeInit, setIsMediaPipeInit] = useState(true);
const handleToScanHand = () => {
metricService.reachGoal(EGoals.SCAN_ARTIFICIAL_PHOTO, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
@ -294,18 +300,30 @@ function AndroidCamera() {
)} */}
{/* Модальное окно камеры */}
{!isLoading && <CameraModal
<CameraModal
onClose={() => console.log("close")}
onTakePhoto={handleCameraSuccess}
onError={handleCameraError}
isShowHand={v2CompatibilityCameraTemplate !== "v1"}
isMediaPipe={["v2", "v3", "v4"].includes(v2CompatibilityCameraTemplate)}
mediaPipeRenderingTemplate={v2CompatibilityCameraTemplate as "v2" | "v3" | "v4"}
onMediaPipeInitChange={(status) => setIsMediaPipeInit(status)}
isCameraVisible={isCameraModalOpen}
onVideoReady={() => setIsVideoReady(true)}
className={(isLoading || !isMediaPipeInit) ? styles.hideCameraModal : ""}
onIsFacingCameraChange={(isFacingCamera) => {
if (!isFacingCamera) {
setToastVisible(EToastVisible.turn_hand)
} else {
setToastVisible(null)
}
}}
// reinitializeKey={reinitializeCameraCount}
/>}
/>
{/* Лоадер */}
{isLoading && (
{(isLoading || !isMediaPipeInit) && (
<Loader className={styles.loader} color={LoaderColor.Black} />
)}
@ -409,6 +427,18 @@ function AndroidCamera() {
</div>
</Toast>
)}
{/* Тост если нужно повернуть руку */}
{toastVisible === EToastVisible.turn_hand && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div
className={styles["toast-content"]}
style={{ flexDirection: "column" }}
>
<span>{translate("/camera.turn_hand")}</span>
</div>
</Toast>
)}
</>
)
}

View File

@ -24,6 +24,7 @@ enum EToastVisible {
"try_again_or_next" = "try_again_or_next",
"no_access_camera" = "no_access_camera",
"reload_page" = "reload_page",
"turn_hand" = "turn_hand",
}
function IphoneCamera() {
@ -42,6 +43,11 @@ function IphoneCamera() {
flag: EUnleashFlags.compatibilityV2ScanHand
});
const { variant: v2CompatibilityCameraTemplate = "v0" } = useUnleash({
flag: EUnleashFlags.v2CompatibilityCameraTemplate
});
console.log("v2CompatibilityCameraTemplate: ", v2CompatibilityCameraTemplate)
const isShowScanHand = compatibilityV2ScanHand !== "hide";
const [isLoading, setIsLoading] = useState(false);
@ -49,6 +55,7 @@ function IphoneCamera() {
const [isCameraModalOpen, setIsCameraModalOpen] = useState(false);
const [reinitializeCameraCount, setReinitializeCameraCount] = useState(0);
const [toastVisible, setToastVisible] = useState<EToastVisible | null>(null);
const [isMediaPipeInit, setIsMediaPipeInit] = useState(true);
const handleToScanHand = () => {
metricService.reachGoal(EGoals.SCAN_ARTIFICIAL_PHOTO, [EMetrics.YANDEX, EMetrics.KLAVIYO]);
@ -250,14 +257,25 @@ function IphoneCamera() {
onClose={() => console.log("close")}
onTakePhoto={handleCameraSuccess}
onError={handleCameraError}
isShowHand={v2CompatibilityCameraTemplate !== "v1"}
isMediaPipe={["v2", "v3", "v4"].includes(v2CompatibilityCameraTemplate)}
mediaPipeRenderingTemplate={v2CompatibilityCameraTemplate as "v2" | "v3" | "v4"}
onMediaPipeInitChange={(status) => setIsMediaPipeInit(status)}
isCameraVisible={isCameraModalOpen}
reinitializeKey={reinitializeCameraCount}
className={isLoading ? styles.hideCameraModal : ""}
className={(isLoading || !isMediaPipeInit) ? styles.hideCameraModal : ""}
onIsFacingCameraChange={(isFacingCamera) => {
if (!isFacingCamera) {
setToastVisible(EToastVisible.turn_hand)
} else {
setToastVisible(null)
}
}}
/>
{/* )} */}
{/* Лоадер */}
{isLoading && (
{(isLoading || !isMediaPipeInit) && (
<Loader className={styles.loader} color={LoaderColor.Black} />
)}
@ -347,6 +365,18 @@ function IphoneCamera() {
</div>
</Toast>
)}
{/* Тост если нужно повернуть руку */}
{toastVisible === EToastVisible.turn_hand && (
<Toast classNameContainer={styles["toast-container"]} variant="error">
<div
className={styles["toast-content"]}
style={{ flexDirection: "column" }}
>
<span>{translate("/camera.turn_hand")}</span>
</div>
</Toast>
)}
</>
)
}

View File

@ -9,7 +9,7 @@ import { useCallback, useEffect, useState } from "react";
import { sleep } from "@/services/date";
import metricService, { useMetricABFlags } from "@/services/metric/metricService";
import { genders } from "@/components/pages/ABDesign/v1/data/genders";
import { useNavigate } from "react-router-dom";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import routes, { compatibilityV2Prefix } from "@/routes";
import { usePreloadImages } from "@/hooks/preload/images";
import { useSession } from "@/hooks/session/useSession";
@ -30,6 +30,9 @@ function GenderPage() {
selectors.selectPrivacyPolicy
);
const [searchParams] = useSearchParams();
const noRedirectAB = searchParams.get("noRedirectAB") === "true";
const { gender } = useSelector(selectors.selectQuestionnaire);
const [isSelected, setIsSelected] = useState(false);
@ -41,6 +44,10 @@ function GenderPage() {
flag: EUnleashFlags.genderPageType
});
const { variant: relationshipStatusPagePlacement = "v0" } = useUnleash({
flag: EUnleashFlags.v2CompatibilityRelationshipStatusPagePlacement
});
const pageType = flags?.genderPageType?.[0] || genderPageType || "v2";
const genderButtonIcon = flags?.genderButtonIcon?.[0] || "hide";
@ -97,6 +104,9 @@ function GenderPage() {
sessionId: session.sessionId,
});
}
if (relationshipStatusPagePlacement === "v2") {
return navigate(routes.client.compatibilityV2RelationshipStatus());
}
return navigate(routes.client.compatibilityV2Birthdate());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gender, navigate]);
@ -110,6 +120,10 @@ function GenderPage() {
if (!ready || !isReady) return <Loader color={LoaderColor.Black} />;
if (relationshipStatusPagePlacement === "v1" && !noRedirectAB) {
return <Navigate to={routes.client.compatibilityV2RelationshipStatus()} />
}
switch (pageType) {
case "v0":
return (

View File

@ -29,7 +29,14 @@ function PalmsInformation() {
flag: EUnleashFlags.zodiacImages
});
const { variant: relationshipStatusPagePlacement = "v0" } = useUnleash({
flag: EUnleashFlags.v2CompatibilityRelationshipStatusPagePlacement
});
const handleNext = () => {
if (relationshipStatusPagePlacement === "v1" || relationshipStatusPagePlacement === "v2") {
return navigate(`${routes.client.compatibilityV2RelateFollowing()}/1`);
}
navigate(routes.client.compatibilityV2RelationshipStatus());
};

View File

@ -13,6 +13,7 @@ import { useMemo } from "react";
import { useSession } from "@/hooks/session/useSession";
import { IAnswersSessionCompatibilityV2 } from "@/api/resources/Session";
import { ESourceAuthorization } from "@/api/resources/User";
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
function RelationshipStatus() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
@ -23,6 +24,10 @@ function RelationshipStatus() {
selectors.selectCompatibilityV2Answers
);
const { variant: relationshipStatusPagePlacement = "v0" } = useUnleash({
flag: EUnleashFlags.v2CompatibilityRelationshipStatusPagePlacement
});
const answers: {
id: IAnswersSessionCompatibilityV2["relationship_status"];
title: string;
@ -60,6 +65,15 @@ function RelationshipStatus() {
},
}, ESourceAuthorization["aura.compatibility.v2"]);
if (id !== relationshipStatus) await sleep(answerTimeOut);
if (relationshipStatusPagePlacement === "v1") {
return navigate(`${routes.client.compatibilityV2Gender()}?noRedirectAB=true`);
}
if (relationshipStatusPagePlacement === "v2") {
return navigate(routes.client.compatibilityV2Birthdate());
}
navigate(`${routes.client.compatibilityV2RelateFollowing()}/1`);
};

View File

@ -35,6 +35,7 @@ function TryApp() {
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(code);
// TODO
window.location.href =

View File

@ -14,6 +14,7 @@ function CodeInstruction() {
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(code);
// TODO
window.location.href =

View File

@ -44,6 +44,7 @@ function TryApp() {
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(code);
// TODO
window.location.href =

View File

@ -1,10 +1,21 @@
import styles from "./styles.module.scss";
function Address() {
interface IAddressProps {
version?: "v1" | "v2"
}
function Address({
version = "v1"
}: IAddressProps) {
return (
<p className={styles.address}>
2025, Wit Apps LLC, <br />
2108 N ST STE 5446 SACRAMENTO, CA 95816, US
{version === "v1" && <>
2025, Wit Apps LLC, <br />
2108 N ST STE 5446 SACRAMENTO, CA 95816, US
</>}
{version === "v2" && <>
2025, Wit Apps LLC, US
</>}
</p>
);
}

View File

@ -4,7 +4,15 @@ import Address from "../Address";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
function Footer() {
interface IFooterProps {
version?: "v1" | "v2"
addressVersion?: "v1" | "v2"
}
function Footer({
version = "v1",
addressVersion = "v1"
}: IFooterProps) {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV4);
return (
@ -13,15 +21,15 @@ function Footer() {
<p className={styles.text}>{translate("/trial-payment.footer.text1")}</p>
<div className={styles.links}>
<a href="https://witapps.us" target="_blank" className={styles.link}>
<img src={`${compatibilityV4Prefix}/headphones.svg`} alt="Headphones" />
{version === "v1" && <img src={`${compatibilityV4Prefix}/headphones.svg`} alt="Headphones" />}
{translate("/trial-payment.footer.text2")}
</a>
<a href="https://witapps.us" target="_blank" className={styles.link}>
<img src={`${compatibilityV4Prefix}/question.svg`} alt="Headphones" />
{version === "v1" && <img src={`${compatibilityV4Prefix}/question.svg`} alt="Headphones" />}
{translate("/trial-payment.footer.text3")}
</a>
</div>
<Address />
<Address version={addressVersion} />
</footer>
);
}

View File

@ -14,6 +14,7 @@ function CodeInstruction() {
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(code);
// TODO
window.location.href =

View File

@ -83,43 +83,43 @@ function TrialPayment() {
</Title>
<ZodiacImagesWithBook />
{(relationshipStatus === "single" || !partnerBirthdate) &&
<p className={styles["information-description"]}>
{translate("/trial-payment.information-description-single", {
color: <span>
{(relationshipStatus === "single" || !partnerBirthdate) &&
<p className={styles["information-description"]}>
{translate("/trial-payment.information-description-single", {
color: <span>
{translate("/trial-payment.information-description-single-color", {
zodiacSign: zodiacSign?.toLowerCase().charAt(0).toUpperCase() + zodiacSign?.toLowerCase().slice(1),
birthdate: formatDateToLocale(birthdate, language),
zodiacSign: zodiacSign?.toLowerCase().charAt(0).toUpperCase() + zodiacSign?.toLowerCase().slice(1),
birthdate: formatDateToLocale(birthdate, language),
})}
</span>,
eventDescription: dateEvent ? <b>
{translate("/trial-payment.information-description-single-event-description", {
dateEvent: formatDateToLocale(dateEvent, language),
})}
</b> : "",
br: <br />
})}
</p>
}
{relationshipStatus !== "single" && partnerBirthdate &&
<p className={styles["information-description"]}>
{translate("/trial-payment.information-description-with-partner", {
color: <span>
eventDescription: dateEvent ? <b>
{translate("/trial-payment.information-description-single-event-description", {
dateEvent: formatDateToLocale(dateEvent, language),
})}
</b> : "",
br: <br />
})}
</p>
}
{relationshipStatus !== "single" && partnerBirthdate &&
<p className={styles["information-description"]}>
{translate("/trial-payment.information-description-with-partner", {
color: <span>
{translate("/trial-payment.information-description-with-partner-color", {
zodiacSign: zodiacSign?.toLowerCase().charAt(0).toUpperCase() + zodiacSign?.toLowerCase().slice(1),
partnerZodiacSign: partnerZodiacSign?.toLowerCase().charAt(0).toUpperCase() + partnerZodiacSign?.toLowerCase().slice(1),
birthdate: formatDateToLocale(birthdate, language),
partnerBirthdate: formatDateToLocale(partnerBirthdate, language),
zodiacSign: zodiacSign?.toLowerCase().charAt(0).toUpperCase() + zodiacSign?.toLowerCase().slice(1),
partnerZodiacSign: partnerZodiacSign?.toLowerCase().charAt(0).toUpperCase() + partnerZodiacSign?.toLowerCase().slice(1),
birthdate: formatDateToLocale(birthdate, language),
partnerBirthdate: formatDateToLocale(partnerBirthdate, language),
})}
</span>,
eventDescription: dateEvent ? <b>
{translate("/trial-payment.information-description-with-partner-event-description", {
dateEvent: formatDateToLocale(dateEvent, language),
})}
</b> : "",
})}
</p>
}
eventDescription: dateEvent ? <b>
{translate("/trial-payment.information-description-with-partner-event-description", {
dateEvent: formatDateToLocale(dateEvent, language),
})}
</b> : "",
})}
</p>
}
{/*{relationshipStatus !== "single" && partnerBirthdate &&*/}

View File

@ -11,6 +11,13 @@ interface YourReadingProps {
relationshipStatus: string;
partnerGender?: string;
partnerZodiacSign?: string;
isDescriptionVisible?: boolean;
pointsClassName?: string;
pointsLength?: number;
titleClassName?: string;
subtitleClassName?: string;
blurPointsIndex?: number;
pointsTranslateVersion?: string;
}
function YourReading({
@ -18,16 +25,27 @@ function YourReading({
zodiacSign,
relationshipStatus,
partnerGender,
partnerZodiacSign
partnerZodiacSign,
isDescriptionVisible = true,
pointsClassName = "",
pointsLength = 8,
titleClassName = "",
subtitleClassName = "",
blurPointsIndex = 3,
pointsTranslateVersion = "v1"
}: YourReadingProps) {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV4);
const { flags, ready } = useMetricABFlags();
const version = flags?.yourReading?.[0] ?? "v1";
let version = flags?.yourReading?.[0] ?? "v1";
if (pointsTranslateVersion) {
version = pointsTranslateVersion;
}
return (
<div className={styles.container}>
<Title
className={styles.title}
className={`${styles.title} ${titleClassName}`}
variant="h3"
>
{translate("/try-app.your-reading.title")}
@ -41,21 +59,21 @@ function YourReading({
classNameContainer={styles.zodiacImages}
/>
<Title
className={styles.subtitle}
className={`${styles.subtitle} ${subtitleClassName}`}
variant="h4"
>
{translate("/try-app.your-reading.subtitle")}
</Title>
<ul className={styles.points}>
{ready && Array.from({ length: 8 }).map((_, index) => (
<li key={index} className={`${index > 3 ? styles.point_blur : ""}`}>
<ul className={`${styles.points} ${pointsClassName}`}>
{ready && Array.from({ length: pointsLength }).map((_, index) => (
<li key={index} className={`${index > blurPointsIndex ? styles.point_blur : ""}`}>
{translate(`/try-app.your-reading.points.${version}.point${index + 1}`)}
</li>
))}
</ul>
<p className={styles.description}>
{isDescriptionVisible && <p className={styles.description}>
{translate("/try-app.your-reading.description")}
</p>
</p>}
</div>
)
}

View File

@ -15,6 +15,7 @@ import { getFormattedPrice } from "@/utils/price.utils";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { actions } from "@/store";
import TryAppV1 from "./v1";
function TryApp() {
const dispatch = useDispatch();
@ -52,6 +53,8 @@ function TryApp() {
return <Loader color={LoaderColor.Black} />
}
return <TryAppV1 />
return (
<>
<Title className={styles.title}>

View File

@ -31,4 +31,212 @@
background: #F1F1F1;
color: #121620;
border-radius: 21px;
}
.paywall__get-prediction {
position: sticky;
top: 0dvh;
left: 0;
background: #eff2fd;
box-shadow: 0 -3px 11px rgba(0, 0, 0, .15);
padding: 12px 24px;
transition: all 0.5s;
width: 100dvw;
max-width: 560px;
z-index: 10;
color: #000;
font-size: 15px;
font-weight: 600;
line-height: 23px;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
text-align: center;
}
.paywall__get-prediction-timer {
border-radius: 4px;
background: initial;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 5px;
min-width: 62px;
padding: 0;
}
.paywall__get-prediction-timer>span {
color: #000;
font-size: 23px;
font-weight: 600;
line-height: 23px;
}
.paywall__get-prediction-button {
font-weight: 700;
font-size: 17px;
line-height: 125%;
min-height: 52px;
min-width: auto;
padding: 6px 8px;
white-space: normal;
width: 100%;
max-width: 220px;
}
.information-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
margin-top: 20px;
line-height: 130%;
}
.information-description {
text-align: center;
font-size: 16px;
line-height: 125%;
font-weight: 400;
margin-top: 8px;
margin-bottom: 8px;
&>span {
font-weight: 600;
// color: #146DA5;
}
}
.reading-ready {
margin-top: 32px;
}
.palm-reading-ready {
margin-top: 64px;
}
.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;
}
.how-work {
margin-top: 40px;
}
.instructionPoint2 {
font-weight: 600;
font-size: 22px;
line-height: 26.63px;
text-align: center;
}
.pulse-button {
animation: pulseButton 1.2s infinite ease-in-out;
will-change: transform;
}
.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: 20px;
line-height: 30px;
text-align: left;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 22px;
margin-top: 36px;
margin-bottom: 62px;
&>img {
width: 38px;
}
}
.your-reading-subtitle {
font-size: 27px !important;
line-height: 125% !important;
margin-top: 32px !important;
}
.your-reading-points {
margin-top: 16px !important;
&>li {
margin-top: 16px;
}
}
.get-my-reading-in-app {
font-weight: 700;
font-size: 17px;
line-height: 20.57px;
text-align: center;
margin-top: 16px;
}
.why-love {
font-size: 28px;
margin: 40px 18px 20px;
font-weight: 700;
&>span {
color: #224e90;
}
}
.as-seen-in {
font-size: 32px;
margin-top: 50px;
&>span {
color: #224e90;
}
}
.partners {
width: 100%;
}
@keyframes pulseButton {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}

View File

@ -0,0 +1,187 @@
import { useTranslations } from "@/hooks/translations";
import styles from "../styles.module.scss";
import Button from "@/components/CompatibilityV4/components/Button";
import useTimer from "@/hooks/palmistry/use-timer";
import metricService, { EGoals, EMetrics } from "@/services/metric/metricService";
import { copyToClipboard } from "@/services/data";
import { useSelector } from "react-redux";
import { selectors } from "@/store";
import { ELocalesPlacement, language } from "@/locales";
import AppNumberOne from "@/components/CompatibilityV4/components/AppNumberOne";
import Title from "@/components/Title";
import ZodiacImagesWithBook from "../../TrialPayment/components/ZodiacImagesWithBook";
import { getZodiacSignByDate } from "@/services/zodiac-sign";
import { formatDateToLocale } from "@/locales/localFormats";
import YourAccessCode from "../components/YourAccessCode";
import { images } from "@/components/CompatibilityV4/data";
import HowWork from "@/components/CompatibilityV4/components/HowWork";
import MoneyBackGuarantee from "@/components/CompatibilityV4/components/MoneyBackGuarantee";
import CopyCode from "../components/CopyCode";
import YourReading from "../components/YourReading";
import WhatIncluded from "@/components/CompatibilityV4/components/WhatIncluded";
import Reviews from "@/components/CompatibilityV4/components/Reviews";
import EnterCode from "../components/EnterCode";
import { compatibilityV4Prefix } from "@/routes";
import Footer from "@/components/CompatibilityV4/components/Footer";
function TryAppV1() {
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV4);
const time = useTimer();
const code = useSelector(selectors.selectAuthCode);
const { gender, birthdate, partnerGender, partnerBirthdate } = useSelector(selectors.selectQuestionnaire);
const { dateEvent } = useSelector(selectors.selectCompatibilityV4Answers);
const { relationshipStatus } = useSelector(selectors.selectCompatibilityV4Answers);
const zodiacSign = getZodiacSignByDate(birthdate);
const partnerZodiacSign = getZodiacSignByDate(partnerBirthdate);
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(code);
// TODO
window.location.href =
"https://apps.apple.com/us/app/aura-astrology-horoscope/id1601978549";
};
return (
<>
<div className={styles["paywall__get-prediction"]}>
<div>
{translate("/try-app.offer_reserved.title")}
<span className={styles["paywall__get-prediction-timer"]}>
<span>{time}</span>
</span>
</div>
<Button
type="button"
className={`${styles["paywall__get-prediction-button"]} ${styles["pulse-button"]}`}
onClick={downloadApp}
>
{translate("/try-app.offer_reserved.button")}
</Button>
</div>
<AppNumberOne />
<Title className={styles["information-title"]}>
{translate("/try-app.information-title")}
</Title>
<ZodiacImagesWithBook />
{(relationshipStatus === "single" || !partnerBirthdate) &&
<p className={styles["information-description"]}>
{translate("/try-app.information-description-single", {
color: <span>
{translate("/try-app.information-description-single-color", {
zodiacSign: zodiacSign?.toLowerCase().charAt(0).toUpperCase() + zodiacSign?.toLowerCase().slice(1),
birthdate: formatDateToLocale(birthdate, language),
})}
</span>,
eventDescription: dateEvent ? <b>
{translate("/try-app.information-description-single-event-description", {
dateEvent: formatDateToLocale(dateEvent, language),
})}
</b> : "",
br: <br />
})}
</p>
}
{relationshipStatus !== "single" && partnerBirthdate &&
<p className={styles["information-description"]}>
{translate("/try-app.information-description-with-partner", {
color: <span>
{translate("/try-app.information-description-with-partner-color", {
zodiacSign: zodiacSign?.toLowerCase().charAt(0).toUpperCase() + zodiacSign?.toLowerCase().slice(1),
partnerZodiacSign: partnerZodiacSign?.toLowerCase().charAt(0).toUpperCase() + partnerZodiacSign?.toLowerCase().slice(1),
birthdate: formatDateToLocale(birthdate, language),
partnerBirthdate: formatDateToLocale(partnerBirthdate, language),
})}
</span>,
eventDescription: dateEvent ? <b>
{translate("/try-app.information-description-with-partner-event-description", {
dateEvent: formatDateToLocale(dateEvent, language),
})}
</b> : "",
})}
</p>
}
<Title className={styles["reading-ready"]}>
{translate("/try-app.reading_ready.title")}
</Title>
<p className={styles.instructionPoint} style={{
marginBottom: "16px",
marginTop: "16px"
}}>{translate("/try-app.instruction_point_1")}</p>
<YourAccessCode />
<p className={styles.instructionPoint}>{translate("/try-app.instruction_point_2")}</p>
<img className={styles.downloadApp} src={images("download-app.png")} alt="Download app" onClick={downloadApp} />
<p className={styles.instructionPoint}>{translate("/try-app.instruction_point_3")}</p>
<p className={styles.notShareDescription}>
{translate("/try-app.not_share_description")}
</p>
<Title className={styles["how-work"]}>
{translate("/try-app.how_work.title")}
</Title>
<HowWork />
<MoneyBackGuarantee />
<Title className={styles["palm-reading-ready"]}>
{translate("/try-app.your_palm_reading_is_ready")}
</Title>
<p className={styles.instructionPoint2}>{translate("/try-app.instruction_point_4")}</p>
<CopyCode variant="black" />
<p className={styles.instructionPoint2} style={{ marginTop: "24px" }}>{translate("/try-app.instruction_point_5")}</p>
<Button className={`${styles.getPredictionInApp} ${styles["pulse-button"]}`} onClick={downloadApp}>
<img src={images("apple-icon.png")} alt="Apple icon" />
{translate("/try-app.get_prediction_in_app")}
</Button>
<YourReading
gender={gender}
zodiacSign={zodiacSign}
relationshipStatus={relationshipStatus}
partnerGender={partnerGender}
partnerZodiacSign={partnerZodiacSign}
isDescriptionVisible={false}
subtitleClassName={styles["your-reading-subtitle"]}
pointsClassName={styles["your-reading-points"]}
blurPointsIndex={2}
pointsLength={7}
pointsTranslateVersion="v3"
/>
<WhatIncluded />
<Title className={styles["palm-reading-ready"]}>
{translate("/try-app.your_palm_reading_is_ready")}
</Title>
<p className={styles.instructionPoint2}>{translate("/try-app.instruction_point_4")}</p>
<CopyCode variant="black" />
<p className={styles.instructionPoint2} style={{ marginTop: "24px" }}>{translate("/try-app.instruction_point_5")}</p>
<Button className={`${styles["get-my-reading-in-app"]} ${styles["pulse-button"]}`} 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 style={{ marginTop: "32px" }} />
<Button
className={`${styles["get-my-reading-in-app"]} ${styles["pulse-button"]}`}
style={{ marginTop: "28px" }}
onClick={downloadApp}
>
{translate("/try-app.get-my-reading-in-app")}
</Button>
<Title className={styles["as-seen-in"]}>
{translate("/trial-payment.as_seen_in", {
color: (
<span>
{translate("app_name", undefined, ELocalesPlacement.V1)}
</span>
),
})}
</Title>
<img className={styles.partners} src={`${compatibilityV4Prefix}/partners.png`} alt="Partners" />
<Footer version="v2" addressVersion="v2" />
</>
)
}
export default TryAppV1;

View File

@ -166,6 +166,9 @@ function WhatAddToAnalysis() {
if (whatAddToAnalysis.includes("potential_partner")) {
return navigate(routes.client.compatibilityV4PotentialPartnerName());
}
if (whatAddToAnalysis.includes("former_partner")) {
return navigate(routes.client.compatibilityV4FormerPartnerName());
}
navigate(routes.client.compatibilityV4Loading());
};

View File

@ -10,10 +10,11 @@ const isValidEmail = (email: string) => {
type EmailInputProps = FormField<string> & {
className?: string;
readonly?: boolean;
};
function EmailInput(props: EmailInputProps): JSX.Element {
const { name, value, placeholder, onValid, onInvalid, className } = props;
const { name, value, placeholder, onValid, onInvalid, className, readonly } = props;
const [email, setEmail] = useState(value);
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -44,6 +45,7 @@ function EmailInput(props: EmailInputProps): JSX.Element {
value={email}
onChange={handleChangeEmail}
placeholder=" "
readOnly={readonly}
/>
<span className={styles["input__placeholder"]}>{placeholder}</span>
</div>

View File

@ -35,6 +35,7 @@ function TryApp() {
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(code);
// TODO
window.location.href =

View File

@ -0,0 +1,60 @@
import React from "react";
import styles from "./styles.module.scss";
import { AddressFields as AddressFieldsType } from "@/hooks/payment/nmi/useAddressFields";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
interface Props {
fields: AddressFieldsType;
errors: Partial<Record<keyof AddressFieldsType, string>>;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
countryList: { code: string; name: string }[];
}
export default function AddressFields({ fields, errors, onChange, countryList }: Props) {
const { translate } = useTranslations(ELocalesPlacement.V1);
return (
<div className={styles.formGroup}>
<div className={styles.formRow}>
<label htmlFor="country">{translate("payment_modal.address_form.labels.country")}</label>
<select
id="country"
name="country"
className={`${styles.input} ${errors.country ? styles.invalid : ""}`}
value={fields.country}
onChange={onChange}
>
<option value="">{translate("payment_modal.address_form.placeholders.country")}</option>
{countryList.map(c => (
<option key={c.code} value={c.code}>{c.name}</option>
))}
</select>
{errors.country && <div className={styles.errorMessage}>{errors.country}</div>}
</div>
<div className={styles.formRow}>
<label htmlFor="address">{translate("payment_modal.address_form.labels.address")}</label>
<input
id="address"
name="address"
className={`${styles.input} ${errors.address ? styles.invalid : ""}`}
value={fields.address}
onChange={onChange}
placeholder={translate("payment_modal.address_form.placeholders.address")}
/>
{errors.address && <div className={styles.errorMessage}>{errors.address}</div>}
</div>
<div className={styles.formRow}>
<label htmlFor="zip">{translate("payment_modal.address_form.labels.zip")}</label>
<input
id="zip"
name="zip"
className={`${styles.input} ${errors.zip ? styles.invalid : ""}`}
value={fields.zip}
onChange={onChange}
placeholder={translate("payment_modal.address_form.placeholders.zip")}
/>
{errors.zip && <div className={styles.errorMessage}>{errors.zip}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
.formGroup {
display: grid;
grid-template-columns: 1fr;
// gap: 1rem;
max-width: 302px;
margin-inline: auto;
}
.formRow {
display: flex;
flex-direction: column;
margin-bottom: 16px;
label {
display: block;
margin-bottom: 5px;
font-weight: 400;
text-align: left;
padding-left: 7px;
font-size: 16px;
max-width: 302px;
}
}
.input {
background-color: #F0F5FF;
border: 0.5px solid #CDCDCD;
border-radius: 11px;
overflow: hidden;
transition: border-color 0.2s;
padding: 16px;
height: 52px;
width: 100%;
max-width: 302px;
&:focus-within {
border-color: #3b82f6;
}
&.invalid {
border-color: #dc3545;
}
}
.fieldContainer {
background-color: #F0F5FF;
border: 0.5px solid #CDCDCD;
border-radius: 11px;
overflow: hidden;
transition: border-color 0.2s;
padding: 2px;
height: 52px;
width: 100%;
&:focus-within {
border-color: #3b82f6;
}
&.invalid {
border-color: #dc3545;
}
}
.errorMessage {
color: #dc3545;
font-size: 12px;
margin-top: 4px;
}
@media (max-width: 280px) {
.formGroup {
grid-template-columns: 1fr;
gap: 0;
}
.formRow {
&:nth-child(2) {
margin-left: 0 !important;
}
}
}

View File

@ -42,6 +42,8 @@ export default function CheckoutForm({
});
const isNewPaymentButton = variant === "new";
// const address = useAddressFields(language);
const {
isLoading,
error,
@ -82,6 +84,7 @@ export default function CheckoutForm({
e.preventDefault();
setPayButtonClicked(true);
// const isAddressValid = address.validateAll();
if (!isFormValid) {
return;
}
@ -145,6 +148,12 @@ export default function CheckoutForm({
)}
</div>
</div>
{/* <AddressFields
fields={address.fields}
errors={address.errors}
onChange={address.handleChange}
countryList={address.countryList}
/> */}
</div>
{isNewPaymentButton && payButtonClicked && !isFormValid &&
<p className={styles.errorMessage} style={{ marginBottom: "16px" }}>

View File

@ -0,0 +1,46 @@
import styles from "./styles.module.scss"
import Button from "@/components/CompatibilityV2/components/Button";
import { useTranslations } from "@/hooks/translations"
import { ELocalesPlacement } from "@/locales"
interface IBillingProps {
onBilling: () => void;
}
function Billing({ onBilling }: IBillingProps) {
const { translate } = useTranslations(ELocalesPlacement.Profile);
return (
<div className={styles.container}>
<Button
className={styles.button}
onClick={onBilling}
>
{translate("/profile.billing.billing_button")}
</Button>
<div className={styles.credits}>
<p className={styles.creditsTitle}>
{translate("/profile.billing.credits.title", {
credits: String(0)
})}
</p>
<p className={styles.creditsDescription}>{translate("/profile.billing.credits.description")}</p>
</div>
<p className={styles.anyQuestions}>
{translate("/profile.billing.any_questions", {
link: <a href="https://witapps.us/en#contact-us" target="_blank" rel="noopener noreferrer">
{translate("/profile.billing.any_questions_link")}
</a>
})}
</p>
<p className={styles.subscriptionUpdate}>
{translate("/profile.billing.subscription_update", {
bold: <span>{translate("/profile.billing.subscription_update_bold")}</span>,
br: <br />
})}
</p>
</div>
)
}
export default Billing

View File

@ -0,0 +1,56 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.button {
max-width: 100%;
min-height: 60px;
font-size: 24px;
}
.credits {
padding: 16px;
background-color: #275ca7;
border-radius: 12px;
color: #fff;
width: 100%;
.creditsTitle {
font-size: 16px;
font-weight: 700;
}
.creditsDescription {
font-size: 14px;
font-weight: 400;
margin-top: 8px;
}
}
.anyQuestions {
width: 100%;
text-align: left;
font-size: 16px;
font-weight: 700;
&>a {
color: #275ca7;
text-decoration: underline;
}
}
.subscriptionUpdate {
width: 100%;
text-align: left;
font-size: 16px;
font-weight: 400;
color: #778499;
line-height: 1.25;
&>span {
font-weight: 700;
}
}

View File

@ -0,0 +1,22 @@
import styles from "./styles.module.scss"
import Button from "@/components/CompatibilityV2/components/Button";
interface ILogOutProps {
onLogout: () => void;
}
function LogOut({ onLogout }: ILogOutProps) {
return (
<div className={styles.container}>
<Button
className={styles.button}
onClick={onLogout}
>
Log out
</Button>
</div>
)
}
export default LogOut

View File

@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.button {
max-width: 100%;
min-height: 60px;
font-size: 24px;
}

View File

@ -0,0 +1,24 @@
import Title from "@/components/Title"
import styles from "./styles.module.scss"
interface ProfileBlockProps {
title: string
description?: string
children?: React.ReactNode
}
function ProfileBlock({ title, description, children }: ProfileBlockProps) {
return (
<section className={styles.container}>
<header className={styles.header}>
<Title variant="h2" className={styles.title}>{title}</Title>
{description && <p className={styles.description}>{description}</p>}
</header>
{!!children && <div className={styles.content}>
{children}
</div>}
</section>
)
}
export default ProfileBlock

View File

@ -0,0 +1,31 @@
.container {
width: 100%;
padding: 16px;
}
.header {
display: flex;
flex-direction: column;
gap: 4px;
&>.title {
font-size: 24px;
line-height: 32px;
text-align: left;
margin: 0;
}
&>.description {
margin: 0;
display: block;
font-size: 14px;
line-height: 20px;
text-align: left;
font-weight: 100;
}
}
.content {
width: 100%;
margin-top: 16px;
}

View File

@ -0,0 +1,39 @@
import styles from "./styles.module.scss"
import { useSelector } from "react-redux"
import { selectors } from "@/store"
import EmailInput from "@/components/pages/ABDesign/v1/pages/EmailEnterPage/EmailInput"
import NameInput from "@/components/pages/ABDesign/v1/pages/EmailEnterPage/NameInput"
import { useTranslations } from "@/hooks/translations"
import { ELocalesPlacement } from "@/locales"
function ProfileInformation() {
const { translate } = useTranslations(ELocalesPlacement.Profile);
const email = useSelector(selectors.selectEmail) || ""
const name = useSelector(selectors.selectUser)?.username || ""
return (
<div className={styles.container}>
<EmailInput
name="email"
value={email}
placeholder={translate("/profile.profile_information.email_placeholder")}
inputContainerClassName={styles.inputContainer}
inputClassName={styles.input}
onValid={() => { }}
onInvalid={() => { }}
readonly
/>
<NameInput
value={name}
placeholder={translate("/profile.profile_information.name_placeholder")}
inputContainerClassName={styles.inputContainer}
inputClassName={styles.input}
onValid={() => { }}
onInvalid={() => { }}
readonly
/>
</div>
)
}
export default ProfileInformation

View File

@ -0,0 +1,16 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
}
.input {
background-color: #f3f3f3;
min-height: 60px;
}
.inputContainer {
margin: 0;
width: 100%;
max-width: 100%;
}

View File

@ -0,0 +1,23 @@
import styles from "./styles.module.scss"
interface ITableProps {
data: (string | React.ReactNode)[][];
}
function Table({ data }: ITableProps) {
return (
<div className={styles.container}>
{data.map((row, index) => (
<div key={index} className={styles.row}>
{row.map((cell, cellIndex) => (
<div key={cellIndex} className={styles.cell}>
{cell}
</div>
))}
</div>
))}
</div>
)
}
export default Table

View File

@ -0,0 +1,33 @@
.container {
width: 100%;
padding: 16px;
border-radius: 12px;
background-color: #f0f0f4;
display: flex;
flex-direction: column;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 8px;
border-bottom: 1px solid #e5e7eb;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.cell {
width: 100%;
line-height: 1.5;
font-size: 16px;
color: #7d8785;
&:nth-child(2) {
color: #090909;
font-weight: 600;
}
}

View File

@ -0,0 +1,100 @@
import { useMemo, useState } from "react"
import styles from "./styles.module.scss"
import ProfileBlock from "../../components/ProfileBlock"
import ProfileInformation from "../../components/ProfileInformation"
import Billing from "../../components/Billing"
import LogOut from "../../components/LogOut"
import routes from "@/routes"
import { useAuth } from "@/auth"
import { useNavigate } from "react-router-dom"
import Modal from "@/components/Modal"
import Title from "@/components/Title"
import { useTranslations } from "@/hooks/translations"
import { ELocalesPlacement } from "@/locales"
function ProfilePage() {
const { translate } = useTranslations(ELocalesPlacement.Profile);
const { logout } = useAuth();
const navigate = useNavigate();
const [logoutModal, setLogoutModal] = useState(false);
const handleLogout = () => {
localStorage.removeItem("palmistry.firstUnpassedStep");
navigate(routes.client.compatibilityV2Gender());
logout();
};
const handleLogoutModal = () => {
setLogoutModal(true);
};
const handleBilling = () => {
navigate(routes.client.profileSubscriptions());
};
const profileBlocks = useMemo(() => [
{
title: translate("/profile.profile_information.title"),
description: translate("/profile.profile_information.description"),
children: <ProfileInformation />
},
// {
// title: "Access code",
// description: "Your account is protected by securely generated access code."
// },
{
title: translate("/profile.billing.title"),
description: translate("/profile.billing.description"),
children: <Billing onBilling={handleBilling} />
},
// {
// title: "Delete Account",
// description: "Once your account is deleted, its resources and data will be permanently deleted. Your subscription will be active until cancelation in the Billing block above. Before deleting your account, please download any data or information you wish to retain."
// },
{
title: translate("/profile.log_out.title"),
children: <LogOut onLogout={handleLogoutModal} />
}
], [translate])
return (
<div className={styles.container}>
{profileBlocks.map((block, index) => (
<div key={block.title} className={styles.block}>
<ProfileBlock {...block}>
{block.children}
</ProfileBlock>
{index !== profileBlocks.length - 1 && <hr />}
</div>
))}
{logoutModal && <Modal
isCloseButtonVisible={false}
open={!!logoutModal}
onClose={() => setLogoutModal(false)}
className={styles.modal}
containerClassName={styles["modal-container"]}
>
<Title variant="h4" className={styles["modal-title"]}>
{translate("/profile.log_out.modal.title")}
</Title>
<p className={styles["modal-description"]}>
{translate("/profile.log_out.modal.description")}
</p>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={handleLogout}>
<p className={styles["modal-answer-text"]}>
{translate("/profile.log_out.modal.log_out_button")}
</p>
</div>
<div className={styles["modal-answer"]} onClick={() => setLogoutModal(false)}>
<p className={styles["modal-answer-text"]}>
{translate("/profile.log_out.modal.stay_button")}
</p>
</div>
</div>
</Modal>}
</div>
)
}
export default ProfilePage

View File

@ -0,0 +1,76 @@
.container {
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 12px;
box-shadow: 0 0 #0000, 0 0 #0000, 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);
}
.block {
width: 100%;
&>hr {
width: 100%;
height: 1px;
background-color: #f0f0f0;
margin: 0;
}
}
.modal-container {
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
}
.modal-title {
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
}
.modal-description {
padding-inline: 24px;
text-align: center;
}
.modal-answers {
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #D9D9D9;
}
.modal-answer {
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275DA7;
font-weight: 600;
cursor: pointer;
&:first-child {
border-right: 1px solid #D9D9D9;
}
}
:global(.dark-theme) .modal-container {
background-color: #343639;
&>.modal-answers>.modal-answer {
color: #1e7dff;
}
}
:global(.dark-theme) .modal-title {
color: #F7F7F7;
}
:global(.dark-theme) .modal-description {
color: #F7F7F7;
}

View File

@ -0,0 +1,71 @@
import Title from "@/components/Title"
import styles from "./styles.module.scss"
import Table from "../../components/Table"
import { useMemo, useState } from "react"
import Modal from "@/components/Modal"
import Button from "@/components/CompatibilityV2/components/Button"
import { useTranslations } from "@/hooks/translations"
import { ELocalesPlacement } from "@/locales"
function SubscriptionsPage() {
const { translate } = useTranslations(ELocalesPlacement.Profile);
const [cancelSubscriptionModal, setCancelSubscriptionModal] = useState(false);
const handleCancelSubscriptionModal = () => {
setCancelSubscriptionModal(true);
};
const handleCancelSubscription = () => {
console.log("cancel subscription");
setCancelSubscriptionModal(false);
};
const data = useMemo(() => [
[translate("/subscriptions.table.subscription_type"), "Weekly Subscription"],
[translate("/subscriptions.table.subscription_status"), "Cancels on May 06, 2025"],
[translate("/subscriptions.table.billing_period"), "Week"],
[translate("/subscriptions.table.last_payment_on"), "Apr 29, 2025"],
[translate("/subscriptions.table.renewal_date"), "May 06, 2025"],
[translate("/subscriptions.table.renewal_amount"), "$14.50"],
[
<Button className={styles.button} onClick={handleCancelSubscriptionModal}>
{translate("/subscriptions.table.cancel_subscription")}
</Button>
]
], [translate])
return (
<div className={styles.container}>
<Title variant="h1" className={styles.title}>{translate("/subscriptions.title")}</Title>
<Table data={data} />
{cancelSubscriptionModal && <Modal
isCloseButtonVisible={false}
open={!!cancelSubscriptionModal}
onClose={() => setCancelSubscriptionModal(false)}
className={styles.modal}
containerClassName={styles["modal-container"]}
>
<Title variant="h4" className={styles["modal-title"]}>
{translate("/subscriptions.modal.title")}
</Title>
<p className={styles["modal-description"]}>
{translate("/subscriptions.modal.description")}
</p>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={handleCancelSubscription}>
<p className={styles["modal-answer-text"]}>
{translate("/subscriptions.modal.cancel_button")}
</p>
</div>
<div className={styles["modal-answer"]} onClick={() => setCancelSubscriptionModal(false)}>
<p className={styles["modal-answer-text"]}>
{translate("/subscriptions.modal.stay_button")}
</p>
</div>
</div>
</Modal>}
</div>
)
}
export default SubscriptionsPage

View File

@ -0,0 +1,74 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
padding-inline: 8px;
}
.title {
margin: 0;
font-size: 20px;
}
.button {
max-width: 100%;
min-height: 60px;
font-size: 24px;
}
.modal-container {
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
}
.modal-title {
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
}
.modal-description {
padding-inline: 24px;
text-align: center;
}
.modal-answers {
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #D9D9D9;
}
.modal-answer {
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275DA7;
font-weight: 600;
cursor: pointer;
text-align: center;
&:first-child {
border-right: 1px solid #D9D9D9;
}
}
:global(.dark-theme) .modal-container {
background-color: #343639;
&>.modal-answers>.modal-answer {
color: #1e7dff;
}
}
:global(.dark-theme) .modal-title {
color: #F7F7F7;
}
:global(.dark-theme) .modal-description {
color: #F7F7F7;
}

View File

@ -16,7 +16,7 @@ const isValidBirthplace = (birthplace: string) => {
function BirthplaceInput({
inputClassName,
value,
value = "",
placeholder,
placeholderClassName,
onValid,

View File

@ -11,6 +11,7 @@ const isValidEmail = (email: string) => {
interface IEmailInputProps {
inputContainerClassName?: string;
placeholderClassName?: string;
readonly?: boolean;
}
function EmailInput(props: FormField<string> & IEmailInputProps): JSX.Element {
@ -23,6 +24,7 @@ function EmailInput(props: FormField<string> & IEmailInputProps): JSX.Element {
placeholderClassName = "",
onValid,
onInvalid,
readonly = false,
} = props;
const [email, setEmail] = useState(value);
@ -55,6 +57,7 @@ function EmailInput(props: FormField<string> & IEmailInputProps): JSX.Element {
onChange={handleChangeEmail}
placeholder=" "
autoComplete="email"
readOnly={readonly}
/>
<span
className={`${styles["input__placeholder"]} ${placeholderClassName}`}

View File

@ -6,6 +6,8 @@ interface INameInputProps {
value: string;
placeholder: string;
placeholderClassName?: string;
inputContainerClassName?: string;
readonly?: boolean;
onValid: (value: string) => void;
onInvalid: () => void;
}
@ -16,11 +18,13 @@ const isValidName = (name: string) => {
function NameInput({
inputClassName,
inputContainerClassName = "",
value,
placeholder,
placeholderClassName,
onValid,
onInvalid,
readonly = false,
}: INameInputProps & Partial<FormField<string>>) {
const [name, setName] = useState(value);
@ -35,7 +39,7 @@ function NameInput({
};
return (
<div className={styles["input-container"]}>
<div className={`${styles["input-container"]} ${inputContainerClassName}`}>
<input
className={inputClassName}
type="text"
@ -45,6 +49,7 @@ function NameInput({
onChange={handleChangeName}
placeholder=" "
autoComplete="name"
readOnly={readonly}
/>
<span className={`${styles["input__placeholder"]} ${placeholderClassName}`}>{placeholder}</span>
</div>

View File

@ -51,6 +51,7 @@ function TryAppPage() {
const downloadApp = async () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
await copyToClipboard(authCode);
// TODO
window.location.href =

View File

@ -47,6 +47,7 @@ function TryAppPage() {
const downloadApp = () => {
metricService.reachGoal(EGoals.DOWNLOAD_APP, [EMetrics.YANDEX]);
metricService.reachGoal(EGoals.BLACK_BUTTON, [EMetrics.FACEBOOK]);
// TODO
window.location.href =
"https://apps.apple.com/us/app/aura-astrology-horoscope/id1601978549";

View File

@ -32,7 +32,9 @@ export enum EUnleashFlags {
"compatibilityV4TrialTextPrice" = "v4-compatibility-trial-text-price",
"palmistryV1TrialTextPrice" = "v1-palmistry-trial-text-price",
"v2CompatibilityScanResultNumbers" = "v2-compatibility-scan-result-numbers",
"v2CompatibilityScanInstructionImage" = "v2-compatibility-scan-instruction-image"
"v2CompatibilityScanInstructionImage" = "v2-compatibility-scan-instruction-image",
"v2CompatibilityCameraTemplate" = "v2-compatibility-camera-template",
"v2CompatibilityRelationshipStatusPagePlacement" = "v2-compatibility-relationship-status-page-placement",
}
interface IUseUnleashProps<T extends EUnleashFlags> {
@ -70,7 +72,8 @@ interface IVariants {
[EUnleashFlags.palmistryV1TrialTextPrice]: "v0" | "v1" | "v2" | "v3";
[EUnleashFlags.v2CompatibilityScanResultNumbers]: 'off' | 'on';
[EUnleashFlags.v2CompatibilityScanInstructionImage]: 'v0' | 'v1';
[EUnleashFlags.v2CompatibilityCameraTemplate]: 'v0' | 'v1' | 'v2' | 'v3' | 'v4';
[EUnleashFlags.v2CompatibilityRelationshipStatusPagePlacement]: 'v0' | 'v1' | 'v2';
}
/**

View File

@ -0,0 +1,155 @@
import { useState, useMemo } from "react";
import countries from "i18n-iso-countries";
import en from "i18n-iso-countries/langs/en.json";
import fr from "i18n-iso-countries/langs/fr.json";
import es from "i18n-iso-countries/langs/es.json";
import de from "i18n-iso-countries/langs/de.json";
import pt from "i18n-iso-countries/langs/pt.json";
import hi from "i18n-iso-countries/langs/hi.json";
import ru from "i18n-iso-countries/langs/ru.json";
import { ELocalesPlacement } from "@/locales";
import { useTranslations } from "@/hooks/translations";
const locales = [en, fr, es, de, pt, hi, ru];
locales.forEach(locale => {
countries.registerLocale(locale);
});
export interface AddressFields {
country: string;
address: string;
zip: string;
}
export function useAddressFields(locale: string = "en", initial?: Partial<AddressFields>) {
const { translate } = useTranslations(ELocalesPlacement.V1);
const [fields, setFields] = useState<AddressFields>({
country: "",
address: "",
zip: "",
...initial,
});
const [errors, setErrors] = useState<Partial<Record<keyof AddressFields, string>>>({
country: "",
address: "",
zip: "",
});
const [touched, setTouched] = useState<Partial<Record<keyof AddressFields, boolean>>>({
country: false,
address: false,
zip: false,
});
const isValid = useMemo(() => {
if (Object.values(touched).some(value => !value)) {
return false;
}
return Object.values(errors).every(error => error === "");
}, [errors, touched]);
const countryList = useMemo(() => {
let _locale = locale;
if (locale === "es-419") {
_locale = "es";
}
if (locale === "pt-BR" || locale === "pt-PT") {
_locale = "pt";
}
if (!Object.keys(countries.getNames(_locale)).length) {
_locale = "en";
}
const names = countries.getNames(_locale, { select: "official" });
return Object.entries(names).map(([code, name]) => ({ code, name }));
}, [locale]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setTouched({ ...touched, [e.target.name]: true });
validate(e.target.name as keyof AddressFields, e.target.value);
setFields({ ...fields, [e.target.name]: e.target.value });
};
const validate = (name: keyof AddressFields, value: string) => {
switch (name) {
case "country":
if (!value) {
setErrors({ ...errors, country: translate("payment_modal.address_form.errors.country") });
} else {
setErrors({ ...errors, country: "" });
}
break;
case "address":
if (!value) {
setErrors({ ...errors, address: translate("payment_modal.address_form.errors.address") });
} else {
setErrors({ ...errors, address: "" });
}
break;
case "zip":
const country = fields.country || initial?.country || "";
let zipValid = false;
let zipError = "";
const zipRegexes: Record<string, RegExp> = {
US: /^\d{5}(-\d{4})?$/, // 12345 или 12345-6789
GB: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i, // SW1A 1AA
AU: /^\d{4}$/, // 4000
FR: /^\d{5}$/, // 75008
ES: /^\d{5}$/, // 28013
DE: /^\d{5}$/, // 10115
PT: /^\d{4}-\d{3}$/, // 1000-001
BR: /^\d{5}-?\d{3}$/, // 12345-678 или 12345678
IN: /^\d{6}$/, // 110001
RU: /^\d{6}$/, // 123456
};
if (!value) {
zipError = translate("payment_modal.address_form.errors.zip");
} else if (country && zipRegexes[country]) {
zipValid = zipRegexes[country].test(value);
if (!zipValid) {
zipError = translate("payment_modal.address_form.errors.zip_invalid");
}
} else {
zipValid = !!value;
if (!zipValid) {
zipError = translate("payment_modal.address_form.errors.zip");
}
}
setErrors({ ...errors, zip: zipError });
break;
}
};
const validateAll = () => {
for (const key in fields) {
validate(key as keyof AddressFields, fields[key as keyof AddressFields]);
}
return Object.values(errors).every(error => error === "");
};
return useMemo(() => ({
fields,
errors,
handleChange,
validate,
validateAll,
countryList,
setFields,
setErrors,
isValid
}), [
fields,
errors,
handleChange,
validate,
validateAll,
countryList,
setFields,
setErrors,
isValid
]);
}

View File

@ -5,8 +5,9 @@ import { useAuthentication } from "@/hooks/authentication/use-authentication";
import useElementRemovalObserver from "@/hooks/DOM/useElementRemovalObserver";
import { usePaywall } from "@/hooks/paywall/usePaywall";
import { selectors } from "@/store";
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { useSelector } from "react-redux";
import { AddressFields } from "./useAddressFields";
interface IUsePaymentProps {
placementKey: EPlacementKeys;
@ -52,6 +53,11 @@ export const usePayment = ({
cvv: { isValid: false, message: '' }
});
const [isCollectJSLoaded, setIsCollectJSLoaded] = useState(false);
const addressRef = useRef<AddressFields>({
country: "",
address: "",
zip: "",
});
const { anonymousAuthorization } = useAuthentication();
@ -184,7 +190,8 @@ export const usePayment = ({
productId: activeProduct?._id || "",
placementId,
paywallId,
paymentToken: response.token
paymentToken: response.token,
address: addressRef.current || undefined
})
:
await api.makeAnonymousPayment({
@ -192,7 +199,8 @@ export const usePayment = ({
placementId,
paywallId,
paymentToken: response.token,
sessionId: sessionId
sessionId: sessionId,
address: addressRef.current || undefined
})
if (isAnonymous && "user" in res && res?.user) {
@ -224,7 +232,10 @@ export const usePayment = ({
setIsOpenModal(true)
};
const submitInlineForm = () => {
const submitInlineForm = (address?: AddressFields) => {
if (address) {
addressRef.current = address;
}
try {
setIsSubmitting(true);
window.CollectJS.startPaymentRequest();

View File

@ -53,7 +53,7 @@ export const defaultPaywalls: { [key in EPlacementKeys]: IPaywall } = {
},
{
"key": "text.4",
"value": "*Cost of trial as of February 2024",
"value": "*Cost of trial as of February 2025",
"_id": "664542bbfe0a8eb4ee0b4f2c"
},
{
@ -174,7 +174,7 @@ export const defaultPaywalls: { [key in EPlacementKeys]: IPaywall } = {
},
{
"key": "text.4",
"value": "*Cost of trial as of February 2024",
"value": "*Cost of trial as of February 2025",
"_id": "664542bbfe0a8eb4ee0b4f2c"
},
{
@ -295,7 +295,7 @@ export const defaultPaywalls: { [key in EPlacementKeys]: IPaywall } = {
},
{
"key": "text.4",
"value": "*Cost of trial as of February 2024",
"value": "*Cost of trial as of February 2025",
"_id": "664542bbfe0a8eb4ee0b4f2c"
},
{

View File

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { useLocation, useSearchParams } from "react-router-dom";
import { FlagProvider } from '@unleash/proxy-client-react';
import { handLandmarkerSingleton } from "@/utils/handLandmarkerSingleton";
interface InitializationProviderProps {
children: React.ReactNode;
@ -37,6 +38,11 @@ export function InitializationProvider({ children }: InitializationProviderProps
} = useSession()
const source = getSourceByPathname();
useEffect(() => {
handLandmarkerSingleton.preload();
}, []);
useEffect(() => {
const createSessionBySource = async () => {
if (!availableSourcesForSession.includes(source)) {

View File

@ -92,6 +92,7 @@ export enum ELocalesPlacement {
CompatibilityV3 = "compatibility-v3",
CompatibilityV4 = "compatibility-v4",
EmailGenerator = "email-generator",
Profile = "profile",
}
interface ITranslationJSON {

View File

@ -4,6 +4,7 @@ import { useRef } from "react";
import { Outlet, useLocation } from "react-router-dom";
import routes from "@/routes";
import Header from "@/components/CompatibilityV2/components/Header";
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
const isBackButtonVisibleRoutes = [
routes.client.compatibilityV2Birthdate(),
@ -32,7 +33,22 @@ function Layout() {
location,
]);
const { variant: relationshipStatusPagePlacement = "v0" } = useUnleash({
flag: EUnleashFlags.v2CompatibilityRelationshipStatusPagePlacement
});
const getIsBackButtonVisible = () => {
if (
relationshipStatusPagePlacement === "v1" && location.pathname.includes(routes.client.compatibilityV2RelationshipStatus())
) {
return false;
}
if (
relationshipStatusPagePlacement === "v1" && location.pathname.includes(routes.client.compatibilityV2Gender())
) {
return true;
}
for (const route of isBackButtonVisibleRoutes) {
if (location.pathname.includes(route)) return true;
}

View File

@ -1,11 +1,8 @@
import routes from "@/routes";
import styles from "./styles.module.scss";
import PersonalVideo from "@/components/pages/ABDesign/v1/pages/TrialPayment/components/PersonalVideo";
import { actions, selectors } from "@/store";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@/store";
import { useDispatch } from "react-redux";
import { Outlet, useLocation } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { getDefaultLocaleByLanguage, language } from "@/locales";
import { useEffect, useRef, useState } from "react";
export interface IOutletContext {
containerVideoRef: React.RefObject<HTMLDivElement>;
@ -17,23 +14,23 @@ export interface IOutletContext {
function LayoutPersonalVideo() {
const dispatch = useDispatch();
const location = useLocation();
const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, setIsVisibleElements] = useState(false);
const showElementsTimer = useRef<NodeJS.Timeout>();
// const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, _setIsVisibleElements] = useState(false);
// const showElementsTimer = useRef<NodeJS.Timeout>();
const containerVideoRef = useRef<HTMLDivElement>(null);
const locale = getDefaultLocaleByLanguage(language);
// const locale = getDefaultLocaleByLanguage(language);
const videoUrl =
locale === "es"
? "/trial-choice-palmistry-es.mp4"
: "/trial-choice-palmistry.mp4";
// const videoUrl =
// locale === "es"
// ? "/trial-choice-palmistry-es.mp4"
// : "/trial-choice-palmistry.mp4";
const showElements = useCallback(() => {
showElementsTimer.current = setTimeout(() => {
setIsVisibleElements(true);
}, 29_000);
}, [setIsVisibleElements]);
// const showElements = useCallback(() => {
// showElementsTimer.current = setTimeout(() => {
// setIsVisibleElements(true);
// }, 29_000);
// }, [setIsVisibleElements]);
useEffect(() => {
if (location.pathname !== routes.client.compatibilityV2TrialChoiceVideo()) {
@ -43,7 +40,7 @@ function LayoutPersonalVideo() {
return (
<>
<PersonalVideo
{/* <PersonalVideo
gender={gender}
url={videoUrl}
classNameContainer={
@ -57,7 +54,7 @@ function LayoutPersonalVideo() {
isAutoPlay={
location.pathname === routes.client.compatibilityV2TrialChoiceVideo()
}
/>
/> */}
<Outlet
context={{
containerVideoRef,

View File

@ -1,11 +1,8 @@
import routes from "@/routes";
import styles from "./styles.module.scss";
import PersonalVideo from "@/components/pages/ABDesign/v1/pages/TrialPayment/components/PersonalVideo";
import { actions, selectors } from "@/store";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@/store";
import { useDispatch } from "react-redux";
import { Outlet, useLocation } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { getDefaultLocaleByLanguage, language } from "@/locales";
import { useEffect, useRef, useState } from "react";
export interface IOutletContext {
containerVideoRef: React.RefObject<HTMLDivElement>;
@ -17,23 +14,23 @@ export interface IOutletContext {
function LayoutPersonalVideo() {
const dispatch = useDispatch();
const location = useLocation();
const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, setIsVisibleElements] = useState(false);
const showElementsTimer = useRef<NodeJS.Timeout>();
// const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, _setIsVisibleElements] = useState(false);
// const showElementsTimer = useRef<NodeJS.Timeout>();
const containerVideoRef = useRef<HTMLDivElement>(null);
const locale = getDefaultLocaleByLanguage(language);
// const locale = getDefaultLocaleByLanguage(language);
const videoUrl =
locale === "es"
? "/trial-choice-palmistry-es.mp4"
: "/trial-choice-palmistry.mp4";
// const videoUrl =
// locale === "es"
// ? "/trial-choice-palmistry-es.mp4"
// : "/trial-choice-palmistry.mp4";
const showElements = useCallback(() => {
showElementsTimer.current = setTimeout(() => {
setIsVisibleElements(true);
}, 29_000);
}, [setIsVisibleElements]);
// const showElements = useCallback(() => {
// showElementsTimer.current = setTimeout(() => {
// setIsVisibleElements(true);
// }, 29_000);
// }, [setIsVisibleElements]);
useEffect(() => {
if (location.pathname !== routes.client.compatibilityV3TrialChoiceVideo()) {
@ -43,7 +40,7 @@ function LayoutPersonalVideo() {
return (
<>
<PersonalVideo
{/* <PersonalVideo
gender={gender}
url={videoUrl}
classNameContainer={
@ -57,7 +54,7 @@ function LayoutPersonalVideo() {
isAutoPlay={
location.pathname === routes.client.compatibilityV3TrialChoiceVideo()
}
/>
/> */}
<Outlet
context={{
containerVideoRef,

View File

@ -1,11 +1,8 @@
import routes from "@/routes";
import styles from "./styles.module.scss";
import PersonalVideo from "@/components/pages/ABDesign/v1/pages/TrialPayment/components/PersonalVideo";
import { actions, selectors } from "@/store";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@/store";
import { useDispatch } from "react-redux";
import { Outlet, useLocation } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { getDefaultLocaleByLanguage, language } from "@/locales";
import { useEffect, useRef, useState } from "react";
export interface IOutletContext {
containerVideoRef: React.RefObject<HTMLDivElement>;
@ -17,23 +14,23 @@ export interface IOutletContext {
function LayoutPersonalVideo() {
const dispatch = useDispatch();
const location = useLocation();
const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, setIsVisibleElements] = useState(false);
const showElementsTimer = useRef<NodeJS.Timeout>();
// const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, _setIsVisibleElements] = useState(false);
// const showElementsTimer = useRef<NodeJS.Timeout>();
const containerVideoRef = useRef<HTMLDivElement>(null);
const locale = getDefaultLocaleByLanguage(language);
// const locale = getDefaultLocaleByLanguage(language);
const videoUrl =
locale === "es"
? "/trial-choice-palmistry-es.mp4"
: "/trial-choice-palmistry.mp4";
// const videoUrl =
// locale === "es"
// ? "/trial-choice-palmistry-es.mp4"
// : "/trial-choice-palmistry.mp4";
const showElements = useCallback(() => {
showElementsTimer.current = setTimeout(() => {
setIsVisibleElements(true);
}, 29_000);
}, [setIsVisibleElements]);
// const showElements = useCallback(() => {
// showElementsTimer.current = setTimeout(() => {
// setIsVisibleElements(true);
// }, 29_000);
// }, [setIsVisibleElements]);
useEffect(() => {
if (location.pathname !== routes.client.compatibilityV4TrialChoiceVideo()) {
@ -43,7 +40,7 @@ function LayoutPersonalVideo() {
return (
<>
<PersonalVideo
{/* <PersonalVideo
gender={gender}
url={videoUrl}
classNameContainer={
@ -57,7 +54,7 @@ function LayoutPersonalVideo() {
isAutoPlay={
location.pathname === routes.client.compatibilityV4TrialChoiceVideo()
}
/>
/> */}
<Outlet
context={{
containerVideoRef,

View File

@ -1,11 +1,8 @@
import routes from "@/routes";
import styles from "./styles.module.scss";
import PersonalVideo from "@/components/pages/ABDesign/v1/pages/TrialPayment/components/PersonalVideo";
import { actions, selectors } from "@/store";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@/store";
import { useDispatch } from "react-redux";
import { Outlet, useLocation } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { getDefaultLocaleByLanguage, language } from "@/locales";
import { useEffect, useRef, useState } from "react";
export interface IOutletContext {
containerVideoRef: React.RefObject<HTMLDivElement>;
@ -17,23 +14,23 @@ export interface IOutletContext {
function LayoutPersonalVideo() {
const dispatch = useDispatch();
const location = useLocation();
const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, setIsVisibleElements] = useState(false);
const showElementsTimer = useRef<NodeJS.Timeout>();
// const { gender } = useSelector(selectors.selectQuestionnaire);
const [isVisibleElements, _setIsVisibleElements] = useState(false);
// const showElementsTimer = useRef<NodeJS.Timeout>();
const containerVideoRef = useRef<HTMLDivElement>(null);
const locale = getDefaultLocaleByLanguage(language);
// const locale = getDefaultLocaleByLanguage(language);
const videoUrl =
locale === "es"
? "/trial-choice-palmistry-es.mp4"
: "/trial-choice-palmistry.mp4";
// const videoUrl =
// locale === "es"
// ? "/trial-choice-palmistry-es.mp4"
// : "/trial-choice-palmistry.mp4";
const showElements = useCallback(() => {
showElementsTimer.current = setTimeout(() => {
setIsVisibleElements(true);
}, 29_000);
}, [setIsVisibleElements]);
// const showElements = useCallback(() => {
// showElementsTimer.current = setTimeout(() => {
// setIsVisibleElements(true);
// }, 29_000);
// }, [setIsVisibleElements]);
useEffect(() => {
if (location.pathname !== routes.client.palmistryV1TrialChoiceVideo()) {
@ -43,7 +40,7 @@ function LayoutPersonalVideo() {
return (
<>
<PersonalVideo
{/* <PersonalVideo
gender={gender}
url={videoUrl}
classNameContainer={
@ -57,7 +54,7 @@ function LayoutPersonalVideo() {
isAutoPlay={
location.pathname === routes.client.palmistryV1TrialChoiceVideo()
}
/>
/> */}
<Outlet
context={{
containerVideoRef,

View File

@ -0,0 +1,43 @@
import { useRef } from "react";
import styles from "./styles.module.scss"
import { useSchemeColorByElement } from "@/hooks/useSchemeColorByElement";
import { Outlet } from "react-router-dom";
import Header from "@/components/CompatibilityV2/components/Header";
import routes from "@/routes";
const isBackButtonVisibleRoutes = [
routes.client.profileSubscriptions(),
];
function Layout() {
const mainRef = useRef<HTMLDivElement>(null);
useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
location,
]);
const getIsBackButtonVisible = () => {
for (const route of isBackButtonVisibleRoutes) {
if (location.pathname.includes(route)) return true;
}
return false;
};
return (
<>
<main className={`${styles.main} content`} ref={mainRef}>
<Header
className={styles.header}
classNameTitle={styles["header-title"]}
isBackButtonVisible={getIsBackButtonVisible()}
/>
{/* <Suspense fallback={<LoadingPage />}> */}
<section className={styles.page}>
<Outlet />
</section>
{/* </Suspense> */}
</main>
</>
);
}
export default Layout

View File

@ -0,0 +1,33 @@
.main {
align-items: center;
padding: 20px 8px;
height: fit-content;
min-height: 100dvh;
max-width: 560px;
margin: 0 auto;
}
.header {
padding: 8px 0 30px;
}
:global(.dark-theme) .header>button>svg>path {
fill: #BAC2EE;
}
.header-title {
color: #275CA7;
}
:global(.dark-theme) .header>svg>path {
fill: #4F8DE5;
}
.page {
width: 100%;
height: fit-content;
padding-bottom: 32px;
display: flex;
flex-direction: column;
align-items: center;
}

View File

@ -0,0 +1,31 @@
import ProfilePage from '@/components/Profile/pages/Profile';
import routes, { profilePrefix } from '@/routes'
import { Route, Routes } from 'react-router-dom'
import Layout from './Layout';
import SubscriptionsPage from '@/components/Profile/pages/Subscriptions';
const removePrefix = (path: string) => path.replace(profilePrefix, "");
function ProfileRoutes() {
return (
<Routes>
<Route element={<Layout />}>
<Route
path={removePrefix(routes.client.profile())}
element={
<ProfilePage />
}
/>
<Route
path={removePrefix(routes.client.profileSubscriptions())}
element={
<SubscriptionsPage />
}
/>
</Route>
</Routes>
)
}
export default ProfileRoutes

View File

@ -20,6 +20,7 @@ export const palmistryV2Prefix = [host, "v2", "palmistry"].join("/")
export const palmistryEmailMarketingV2Prefix = [palmistryV2Prefix, "email-marketing"].join("/")
export const emailMarketingV1Prefix = [host, "v1", "email-marketing"].join("/")
export const anonymousPrefix = [host, "anonymous"].join("/")
export const profilePrefix = [host, "profile"].join("/")
export const chatsPrefix = [host, "chats"].join("/")
@ -477,6 +478,10 @@ const routes = {
chatsThankYou: () => [chatsPrefix, "thankYou"].join("/"),
chatsConnecting: () => [chatsPrefix, "connecting"].join("/"),
chatsExpert: () => [chatsPrefix, "expert"].join("/"),
// Profile
profile: () => [profilePrefix].join("/"),
profileSubscriptions: () => [profilePrefix, "subscriptions"].join("/"),
},
server: {
userLocale: () => ["https://ipapi.co", "json"].join("/"),

View File

@ -55,6 +55,7 @@ export enum EGoals {
ROSE_VIDEO_PLAY_USER_PLAY = "RoseVideoPlayUserPlay",
DOWNLOAD_APP = "DownloadApp",
BLACK_BUTTON = "BlackButton",
CAMERA_HAND = "CameraHand",
SCAN_ARTIFICIAL_PHOTO = "ScanArtificialPhoto",

View File

@ -0,0 +1,59 @@
import { HandLandmarker, FilesetResolver } from "@mediapipe/tasks-vision";
type Callback = (isLoaded: boolean) => void;
class HandLandmarkerSingleton {
private handLandmarker: HandLandmarker | null = null;
private isLoading = false;
private isLoaded = false;
private callbacks: Callback[] = [];
private loadPromise: Promise<void> | null = null;
preload = () => {
if (this.isLoaded || this.isLoading) return this.loadPromise!;
this.isLoading = true;
this.loadPromise = (async () => {
const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm");
this.handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
delegate: "GPU"
},
runningMode: "VIDEO",
numHands: 1
});
this.warmUpHandLandmarker(this.handLandmarker);
this.isLoaded = true;
this.isLoading = false;
this.callbacks.forEach(cb => cb(true));
})();
this.callbacks.forEach(cb => cb(false));
return this.loadPromise;
};
getHandLandmarker = () => this.handLandmarker;
subscribe = (cb: Callback) => {
this.callbacks.push(cb);
cb(this.isLoaded);
return () => {
this.callbacks = this.callbacks.filter(f => f !== cb);
};
};
private warmUpHandLandmarker(handLandmarker: HandLandmarker) {
try {
const video = document.createElement('video');
Object.defineProperty(video, 'videoWidth', { value: 1280 });
Object.defineProperty(video, 'videoHeight', { value: 720 });
handLandmarker.detectForVideo(video, performance.now());
} catch (e) {
console.error(e);
}
}
}
export const handLandmarkerSingleton = new HandLandmarkerSingleton();