Merge branch 'develop' into 'main'
hint-palm See merge request witapp/aura-webapp!675
@ -268,7 +268,10 @@
|
||||
},
|
||||
"/let-scan": {
|
||||
"title": "We Are Scanning Your Palm",
|
||||
"text": "Follow the on-screen instructions so we can analyze the lines of your palm, revealing the future and the secrets of your destiny!"
|
||||
"text": "Follow the on-screen instructions so we can analyze the lines of your palm, revealing the future and the secrets of your destiny!",
|
||||
"hands": {
|
||||
"title": "Сейчас сканируют ладонь <count> человек:"
|
||||
}
|
||||
},
|
||||
"/scan-instruction": {
|
||||
"title": "Photograph Your Palm as Shown",
|
||||
|
||||
@ -137,7 +137,10 @@
|
||||
},
|
||||
"/let-scan": {
|
||||
"title": "We are scanning your palm",
|
||||
"text": "Follow the on-screen instructions so we can analyze the lines of your palm, revealing the future and the secrets of your destiny!"
|
||||
"text": "Follow the on-screen instructions so we can analyze the lines of your palm, revealing the future and the secrets of your destiny!",
|
||||
"hands": {
|
||||
"title": "Сейчас сканируют ладонь <count> человек:"
|
||||
}
|
||||
},
|
||||
"biometric_data": "We do not collect biometric data. The entire recognition process happens on your device.",
|
||||
"/scan-instruction": {
|
||||
|
||||
BIN
public/users-hands/1.webp
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
public/users-hands/10.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
public/users-hands/11.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/users-hands/12.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/users-hands/13.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/users-hands/2.webp
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
public/users-hands/3.webp
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
public/users-hands/4.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/users-hands/5.webp
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
public/users-hands/6.webp
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
public/users-hands/7.webp
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
public/users-hands/8.webp
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
public/users-hands/9.png
Normal file
|
After Width: | Height: | Size: 299 KiB |
@ -8,6 +8,10 @@ import { useTranslations } from "@/hooks/translations";
|
||||
import { ELocalesPlacement } from "@/locales";
|
||||
import { ELottieKeys, useLottie } from "@/hooks/lottie/useLottie";
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
import UsersHands from "@/components/UsersHands";
|
||||
import { useHandsGeneration } from "@/hooks/handsGeneration/useHandsGeneration";
|
||||
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
|
||||
import Loader, { LoaderColor } from "@/components/Loader";
|
||||
|
||||
function LetScan() {
|
||||
const { translate } = useTranslations(ELocalesPlacement.CompatibilityV2);
|
||||
@ -16,10 +20,20 @@ function LetScan() {
|
||||
loadKey: ELottieKeys.letScan,
|
||||
});
|
||||
|
||||
const {
|
||||
displayHands
|
||||
} = useHandsGeneration();
|
||||
|
||||
const { isReady, variant: dynamicHandsCompV2 } = useUnleash({
|
||||
flag: EUnleashFlags.dynamicHandsCompV2
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
navigate(routes.client.compatibilityV2ScanInstruction());
|
||||
};
|
||||
|
||||
if (!isReady) return <Loader color={LoaderColor.Black} />;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles["lottie-animation-container"]}>
|
||||
@ -28,7 +42,7 @@ function LetScan() {
|
||||
className={`${styles["lottie-animation"]} ym-hide-content`}
|
||||
data={animationData}
|
||||
autoplay
|
||||
loop={false}
|
||||
loop={true}
|
||||
width={323}
|
||||
/>}
|
||||
</div>
|
||||
@ -36,6 +50,14 @@ function LetScan() {
|
||||
{translate("/let-scan.title")}
|
||||
</Title>
|
||||
<p className={styles.text}>{translate("/let-scan.text")}</p>
|
||||
{dynamicHandsCompV2 === "show" && (
|
||||
<UsersHands
|
||||
title={translate("/let-scan.hands.title", {
|
||||
count: String(displayHands.length),
|
||||
})}
|
||||
hands={displayHands}
|
||||
/>
|
||||
)}
|
||||
<Button className={styles.button} onClick={handleNext}>
|
||||
{translate("next")}
|
||||
</Button>
|
||||
|
||||
@ -63,16 +63,16 @@ function PalmsInformation() {
|
||||
<img
|
||||
className={styles.image}
|
||||
// src={images(`zodiacs/${gender}/${zodiacSign.toUpperCase()}.webp`)}
|
||||
src={`/zodiac-signs/${gender?.toLowerCase()}/${zodiacSign.toLowerCase()}.svg`}
|
||||
src={`/zodiac-signs/${gender?.toLowerCase()}/${zodiacSign?.toLowerCase()}.svg`}
|
||||
alt="Zodiac sign"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Title variant="h2" className={styles.title}>
|
||||
{translate(`/palms-information.${zodiacSign.toLowerCase()}.title`)}
|
||||
{translate(`/palms-information.${zodiacSign?.toLowerCase()}.title`)}
|
||||
</Title>
|
||||
<p className={styles.description}>
|
||||
{translate(`/palms-information.${zodiacSign.toLowerCase()}.description`)}
|
||||
{translate(`/palms-information.${zodiacSign?.toLowerCase()}.description`)}
|
||||
</p>
|
||||
<Button onClick={handleNext}>
|
||||
{translate("next")}
|
||||
|
||||
@ -63,7 +63,7 @@ function PalmsInformationPartner() {
|
||||
<img
|
||||
className={styles.image}
|
||||
// src={images(`zodiacs/${gender}/${zodiacSign.toUpperCase()}.webp`)}
|
||||
src={`/zodiac-signs/${partnerGender?.toLowerCase()}/${zodiacSign.toLowerCase()}.svg`}
|
||||
src={`/zodiac-signs/${partnerGender?.toLowerCase()}/${zodiacSign?.toLowerCase()}.svg`}
|
||||
alt="Zodiac sign"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,10 @@ import { useTranslations } from "@/hooks/translations";
|
||||
import { ELocalesPlacement } from "@/locales";
|
||||
import { ELottieKeys, useLottie } from "@/hooks/lottie/useLottie";
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
import UsersHands from "@/components/UsersHands";
|
||||
import { useHandsGeneration } from "@/hooks/handsGeneration/useHandsGeneration";
|
||||
import { EUnleashFlags, useUnleash } from "@/hooks/ab/unleash/useUnleash";
|
||||
import Loader, { LoaderColor } from "@/components/Loader";
|
||||
|
||||
function LetScan() {
|
||||
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
|
||||
@ -16,10 +20,20 @@ function LetScan() {
|
||||
loadKey: ELottieKeys.letScan,
|
||||
});
|
||||
|
||||
const {
|
||||
displayHands
|
||||
} = useHandsGeneration();
|
||||
|
||||
const { isReady, variant: dynamicHandsPalmistryV1 } = useUnleash({
|
||||
flag: EUnleashFlags.dynamicHandsPalmistryV1
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
navigate(routes.client.palmistryV1ScanInstruction());
|
||||
};
|
||||
|
||||
if (!isReady) return <Loader color={LoaderColor.Black} />;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles["lottie-animation-container"]}>
|
||||
@ -28,7 +42,7 @@ function LetScan() {
|
||||
className={`${styles["lottie-animation"]} ym-hide-content`}
|
||||
data={animationData}
|
||||
autoplay
|
||||
loop={false}
|
||||
loop={true}
|
||||
width={323}
|
||||
/>}
|
||||
</div>
|
||||
@ -36,6 +50,14 @@ function LetScan() {
|
||||
{translate("/let-scan.title")}
|
||||
</Title>
|
||||
<p className={styles.text}>{translate("/let-scan.text")}</p>
|
||||
{dynamicHandsPalmistryV1 === "show" && (
|
||||
<UsersHands
|
||||
title={translate("/let-scan.hands.title", {
|
||||
count: String(displayHands.length),
|
||||
})}
|
||||
hands={displayHands}
|
||||
/>
|
||||
)}
|
||||
<Button className={styles.button} onClick={handleNext}>
|
||||
{translate("next")}
|
||||
</Button>
|
||||
|
||||
104
src/components/UsersHands/index.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { IPalmistryPoint } from "@/api/resources/Palmistry";
|
||||
import Title from "../Title";
|
||||
import styles from "./styles.module.scss";
|
||||
import { DisplayHand } from "@/hooks/handsGeneration/useHandsGeneration";
|
||||
import { useRef } from "react";
|
||||
|
||||
interface IUsersHandsProps {
|
||||
title?: string;
|
||||
hands: DisplayHand[];
|
||||
}
|
||||
|
||||
function UsersHands({
|
||||
title,
|
||||
hands
|
||||
}: IUsersHandsProps) {
|
||||
const linesRef = useRef<SVGPathElement[]>([]);
|
||||
|
||||
const getCoordinatesString = (points: IPalmistryPoint[]) => {
|
||||
const coordinatesString = `M ${points[0]?.x * 56} ${points[0]?.y * 75
|
||||
}`;
|
||||
return points.reduce(
|
||||
(acc, point) =>
|
||||
`${acc} L ${point?.x * 56} ${point?.y * 75}`,
|
||||
coordinatesString
|
||||
);
|
||||
}
|
||||
|
||||
const getLineLength = (line: SVGPathElement) => {
|
||||
return line?.getTotalLength();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{!!title?.length && <Title variant="h3" className={styles.title}>
|
||||
{title}
|
||||
</Title>}
|
||||
<div className={styles.hands}>
|
||||
{hands.map(({ image, lines, willBeRemoved }, index) => (
|
||||
<div className={`${styles.hand} ${willBeRemoved ? styles.willBeRemoved : ""}`} key={index}>
|
||||
<img
|
||||
src={image}
|
||||
alt={`hand-${index}`}
|
||||
/>
|
||||
<svg
|
||||
viewBox={`0 0 ${56} ${75}`}
|
||||
className={`scanned-photo__svg-objects ${styles.svgObjects}`}
|
||||
>
|
||||
{/* {!!fingers.length &&
|
||||
fingers?.map((finger, index) => {
|
||||
return (
|
||||
<svg
|
||||
x={finger.point.x * 56 - 12}
|
||||
y={finger.point.y * 75 - 12}
|
||||
height="24px"
|
||||
width="24px"
|
||||
key={index}
|
||||
>
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="11"
|
||||
fill="white"
|
||||
opacity="0.3"
|
||||
className="scanned-photo__finger-point"
|
||||
/>
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="5"
|
||||
fill="#066FDE"
|
||||
stroke="white"
|
||||
strokeWidth="0.3"
|
||||
className="scanned-photo__finger-point"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
})} */}
|
||||
|
||||
{lines.map((line, index) => (
|
||||
<g key={`line-${index}`}>
|
||||
<path
|
||||
className={`scanned-photo__line scanned-photo__line_${line?.name} ${styles.line}`}
|
||||
d={getCoordinatesString(line?.points)}
|
||||
ref={(el) =>
|
||||
(linesRef.current[index] = el as SVGPathElement)
|
||||
}
|
||||
style={{
|
||||
strokeDasharray:
|
||||
getLineLength(linesRef.current[index]) || 500,
|
||||
strokeDashoffset:
|
||||
getLineLength(linesRef.current[index]) || 500,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersHands
|
||||
86
src/components/UsersHands/styles.module.scss
Normal file
@ -0,0 +1,86 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
background: linear-gradient(0.63deg, #E1EBFF 0.53%, rgba(75, 136, 255, 0.31) 0.54%);
|
||||
border-radius: 18px;
|
||||
padding: 32px 14px 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: SF Pro Text;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 125%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 21px;
|
||||
}
|
||||
|
||||
.hands {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
padding-bottom: 10px;
|
||||
|
||||
&>.hand {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 75px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
opacity: 1;
|
||||
margin-right: 10px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&.willBeRemoved {
|
||||
animation: fadeOut 1s ease-in-out forwards;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.svgObjects {
|
||||
animation: fadeIn 1.5s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.line {
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
99% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
margin-right: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
@ -5,23 +5,19 @@ import { useSearchParams } from "react-router-dom";
|
||||
export enum EUnleashFlags {
|
||||
"genderPageType" = "genderPageType",
|
||||
"zodiacImages" = "zodiacImages",
|
||||
"dynamicHandsCompV2" = "dynamicHandsCompV2",
|
||||
"dynamicHandsPalmistryV1" = "dynamicHandsPalmistryV1",
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для входных параметров хука useUnleash
|
||||
* Использует дженерик T для типизации флага
|
||||
*/
|
||||
interface IUseUnleashProps<T extends EUnleashFlags> {
|
||||
flag: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для возможных вариантов значений флагов
|
||||
* Каждый ключ соответствует флагу из EUnleashFlags
|
||||
*/
|
||||
interface IVariants {
|
||||
[EUnleashFlags.genderPageType]: "v0" | "v1" | "v2";
|
||||
[EUnleashFlags.zodiacImages]: "new" | "old";
|
||||
[EUnleashFlags.dynamicHandsCompV2]: "show" | "hide";
|
||||
[EUnleashFlags.dynamicHandsPalmistryV1]: "show" | "hide";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4910
src/hooks/handsGeneration/hands.ts
Normal file
138
src/hooks/handsGeneration/useHandsGeneration.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { IPalmistryFinger, IPalmistryLine } from '@/api/resources/Palmistry';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getShuffledHands } from './hands';
|
||||
|
||||
const hands = getShuffledHands();
|
||||
|
||||
export interface DisplayHand {
|
||||
image: string;
|
||||
willBeRemoved: boolean;
|
||||
id: number;
|
||||
lines: IPalmistryLine[];
|
||||
fingers: IPalmistryFinger[];
|
||||
}
|
||||
|
||||
interface HandsToDelete {
|
||||
hands: DisplayHand[];
|
||||
deleteAt: number;
|
||||
markAt: number;
|
||||
marked: boolean;
|
||||
}
|
||||
|
||||
export const useHandsGeneration = () => {
|
||||
const [displayHands, setDisplayHands] = useState<DisplayHand[]>([]);
|
||||
const [handsToDeleteQueue, setHandsToDeleteQueue] = useState<HandsToDelete[]>([]);
|
||||
const currentHandIndexRef = useRef(1);
|
||||
|
||||
const minHands = 1;
|
||||
const maxHands = 5;
|
||||
|
||||
const getHand = useCallback(() => {
|
||||
const currentIndex = currentHandIndexRef.current;
|
||||
|
||||
currentHandIndexRef.current = currentIndex >= hands.length ? 1 : currentIndex + 1;
|
||||
|
||||
const currentHand = hands[currentIndex - 1];
|
||||
|
||||
return {
|
||||
id: Date.now() + Math.random(),
|
||||
image: currentHand.image,
|
||||
willBeRemoved: false,
|
||||
lines: currentHand.lines,
|
||||
fingers: currentHand.fingers
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createHand = useCallback(() => {
|
||||
return getHand();
|
||||
}, [getHand]);
|
||||
|
||||
useEffect(() => {
|
||||
addHands(3);
|
||||
return () => setDisplayHands([]);
|
||||
}, []);
|
||||
|
||||
const addHands = useCallback((countHands?: number) => {
|
||||
const addHandsTimeout = 6000 + Math.random() * 4000;
|
||||
|
||||
const count = !!countHands ? countHands : Math.random() < 0.6 ? 1 : 2;
|
||||
const _newHands = Array(count).fill(null).map(createHand);
|
||||
|
||||
setDisplayHands(prev => {
|
||||
const updatedHands = [...prev, ..._newHands].slice(-maxHands);
|
||||
|
||||
if (updatedHands.length < minHands) {
|
||||
const additionalCount = minHands - updatedHands.length;
|
||||
const additionalHands = Array(additionalCount).fill(null).map(createHand);
|
||||
return [...updatedHands, ...additionalHands];
|
||||
}
|
||||
|
||||
return updatedHands;
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
setHandsToDeleteQueue(prev => [...prev, ..._newHands.map((value) => {
|
||||
const deleteTime = 6000 + Math.random() * 20000;
|
||||
return {
|
||||
hands: [value],
|
||||
deleteAt: now + deleteTime,
|
||||
markAt: now + deleteTime - 3000,
|
||||
marked: false
|
||||
}
|
||||
})]);
|
||||
|
||||
const timeoutAddHands = setTimeout(() => {
|
||||
addHands();
|
||||
clearTimeout(timeoutAddHands);
|
||||
}, addHandsTimeout);
|
||||
}, [createHand]);
|
||||
|
||||
useEffect(() => {
|
||||
if (handsToDeleteQueue.length === 0) return;
|
||||
|
||||
const checkQueue = () => {
|
||||
const now = Date.now();
|
||||
|
||||
setHandsToDeleteQueue(prev => {
|
||||
const updatedQueue = [...prev];
|
||||
let hasChanges = false;
|
||||
|
||||
updatedQueue.forEach(item => {
|
||||
if (displayHands.length <= minHands) return;
|
||||
if (!item.marked && now >= item.markAt) {
|
||||
markHands(item.hands);
|
||||
item.marked = true;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (now >= item.deleteAt) {
|
||||
deleteHands(item.hands);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? updatedQueue.filter(item => now < item.deleteAt) : updatedQueue;
|
||||
});
|
||||
};
|
||||
|
||||
const interval = setInterval(checkQueue, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [handsToDeleteQueue]);
|
||||
|
||||
const deleteHands = (handsToDelete: DisplayHand[]) => {
|
||||
setDisplayHands(prev =>
|
||||
prev.filter(hand => !handsToDelete.some(h => h.id === hand.id))
|
||||
);
|
||||
}
|
||||
|
||||
const markHands = (handsToMark: DisplayHand[]) => {
|
||||
setDisplayHands(prev =>
|
||||
prev.map(hand =>
|
||||
handsToMark.some(h => h.id === hand.id)
|
||||
? { ...hand, willBeRemoved: true }
|
||||
: hand
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { displayHands };
|
||||
};
|
||||