Enhance lead form and localization with new phone input components. Added international phone input handling and validation in LeadForm. Updated localization files for phone-related messages in both English and Russian. Introduced new utility functions for phone number normalization and formatting.

This commit is contained in:
2026-04-29 17:14:37 +05:00
parent c2bee615fa
commit de5d64eae4
8 changed files with 185 additions and 28 deletions
+21
View File
@@ -6,13 +6,16 @@
"name": "ps2-react-client",
"dependencies": {
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.3": "^1.0.8",
"@tanstack/react-query": "^5.62.7",
"@uidotdev/usehooks": "^2.4.1",
"ahooks": "^3.7.10",
"baseline-browser-mapping": "^2.9.14",
"caniuse-lite": "^1.0.30001764",
"date-fns": "^2.30.0",
"framer-motion": "^11.17.0",
"i18next": "^23.8.2",
"ky": "^1.1.3",
"libphonenumber-js": "^1.11.7",
"peerjs": "^1.5.4",
"react": "^18.2.0",
"react-calendar": "^4.3.0",
@@ -21,10 +24,12 @@
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-full-screen": "^1.1.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.0.3",
"react-input-mask": "^2.0.4",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.11.2",
"react-swipeable": "^7.0.2",
"react-timeit": "^1.2.12",
"react-timer-hook": "^3.0.7",
"react-toastify": "^10.0.5",
@@ -182,6 +187,10 @@
"@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.100.6", "", {}, "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.100.6", "", { "dependencies": { "@tanstack/query-core": "5.100.6" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@20.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw=="],
@@ -376,6 +385,8 @@
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fscreen": ["fscreen@1.2.0", "", {}, "sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg=="],
@@ -490,6 +501,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libphonenumber-js": ["libphonenumber-js@1.12.42", "", {}, "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -520,6 +533,10 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
"motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
@@ -620,6 +637,8 @@
"react-full-screen": ["react-full-screen@1.1.1", "", { "dependencies": { "fscreen": "^1.0.2" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-xoEgkoTiN0dw9cjYYGViiMCBYbkS97BYb4bHPhQVWXj1UnOs8PZ1rPzpX+2HMhuvQV1jA5AF9GaRbO3fA5aZtg=="],
"react-hook-form": ["react-hook-form@7.74.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g=="],
"react-i18next": ["react-i18next@14.1.3", "", { "dependencies": { "@babel/runtime": "^7.23.9", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw=="],
"react-input-mask": ["react-input-mask@2.0.4", "", { "dependencies": { "invariant": "^2.2.4", "warning": "^4.0.2" }, "peerDependencies": { "react": ">=0.14.0", "react-dom": ">=0.14.0" } }, "sha512-1hwzMr/aO9tXfiroiVCx5EtKohKwLk/NT8QlJXHQ4N+yJJFyUuMT+zfTpLBwX/lK3PkuMlievIffncpMZ3HGRQ=="],
@@ -634,6 +653,8 @@
"react-router-dom": ["react-router-dom@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw=="],
"react-swipeable": ["react-swipeable@7.0.2", "", { "peerDependencies": { "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w=="],
"react-timeit": ["react-timeit@1.2.12", "", { "dependencies": { "react-jss": "^10.9.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0" } }, "sha512-/c+NzN32ju98+RJ30TzFvp8eiJCSMtT5jheu9kUHIbDC7grZX5l2iK6i+AUGf/WAF8R++sjBNr9VVpY4VXQVSw=="],
"react-timer-hook": ["react-timer-hook@3.0.8", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-bi2e7DhPBU1MRPU4ZHaVqBmgM9e2HK0ae8O2AIqwqjcPo4/qR7lVGQonOQLAKOZPQCJSYfV8F5aBWzOLXElzqQ=="],
+12 -4
View File
@@ -124,9 +124,12 @@ const resources = {
consentLinkData: "согласие на обработку персональных данных",
consentMiddle: "и принимаете",
consentLinkPolicy: "условия политики",
phonePlaceholder: "+7 (XXX) XXX-XX-XX",
phoneRequired: "Укажите номер телефона",
phoneInvalid: "Введите корректный номер телефона",
},
questionModal: {
phonePlaceholder: "+X (XXX) XXX - XX - XX",
phonePlaceholder: "+7 (XXX) XXX-XX-XX",
},
feedbackModal: {
successRich:
@@ -247,7 +250,8 @@ const resources = {
// Error codes from server
INTERNAL_ERROR: "Внутренняя ошибка сервера",
INVALID_OBJECT_ID: "Некорректный идентификатор объекта",
SESSION_NOT_FOUND: "Сессия не найдена. Возвращаемся на главную страницу...",
SESSION_NOT_FOUND:
"Сессия не найдена. Возвращаемся на главную страницу...",
SESSION_FETCH_ERROR: "Ошибка при получении данных сессии",
IP_ADDRESS_ERROR: "Не удалось определить IP-адрес",
COUNTRY_CODE_FETCH_ERROR: "Ошибка при определении страны",
@@ -261,7 +265,8 @@ const resources = {
},
speedtest: {
pleaseWait: "Пожалуйста, подождите",
checkingConnection: "Проверяем качество вашего<br />интернет-соединения",
checkingConnection:
"Проверяем качество вашего<br />интернет-соединения",
checking: "Проверка",
secondsLeft: "Осталось {{count}} секунд",
},
@@ -437,9 +442,12 @@ const resources = {
consentLinkData: "consent to personal data processing",
consentMiddle: "and accept the",
consentLinkPolicy: "policy terms",
phonePlaceholder: "+XXXXXXXXXXXXXXX",
phoneRequired: "Enter your phone number",
phoneInvalid: "Enter a valid phone number",
},
questionModal: {
phonePlaceholder: "+X (XXX) XXX - XX - XX",
phonePlaceholder: "+XXXXXXXXXXXXXXX",
},
feedbackModal: {
successRich:
+66 -15
View File
@@ -6,6 +6,9 @@ import { productOptionsFromT } from "@/landing/lib/productLabels";
import type { Product } from "@/landing/types";
import { Button } from "@/landing/ui/Button";
import { CheckboxesGroup } from "@/landing/ui/CheckboxesGroup";
import { ruPhoneDigits } from "@/landing/lib/phoneRu";
import { INTL_PHONE_MAX_DIGITS } from "@/landing/lib/phoneIntl";
import { PhoneInputIntl } from "@/landing/ui/PhoneInputIntl";
import { PhoneInputRu } from "@/landing/ui/PhoneInputRu";
import { useRefererStore } from "@/landing/stores/useRefererStore";
import {
@@ -32,7 +35,8 @@ export function LeadForm({
phonePlaceholder?: string;
formClassName?: string;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const isRuLocale = i18n.language.startsWith("ru");
const { referer } = useRefererStore();
const [submitError, setSubmitError] = useState<string | null>(null);
const projectOptions = useMemo(() => productOptionsFromT(t), [t]);
@@ -46,9 +50,15 @@ export function LeadForm({
},
});
const { register, handleSubmit, formState, control } = form;
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
control,
} = form;
const nameId = idPrefix ? `${idPrefix}name` : "name";
const emailId = idPrefix ? `${idPrefix}email` : "email";
const phoneId = idPrefix ? `${idPrefix}tel` : "tel";
const onSubmit: SubmitHandler<LeadFormValues> = async (data) => {
setSubmitError(null);
@@ -105,29 +115,68 @@ export function LeadForm({
<Controller
name="phone"
control={control}
rules={{ required: true }}
render={({ field }) => (
<PhoneInputRu
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder={phonePlaceholder}
/>
)}
rules={{
validate: (value: string) => {
if (!value?.trim()) return t("leadForm.phoneRequired");
if (isRuLocale) {
return (
ruPhoneDigits(value).length === 11 ||
t("leadForm.phoneInvalid")
);
}
const digits = value.replace(/\D/g, "");
return (
(digits.length >= 8 &&
digits.length <= INTL_PHONE_MAX_DIGITS) ||
t("leadForm.phoneInvalid")
);
},
}}
render={({ field }) =>
isRuLocale ? (
<PhoneInputRu
id={phoneId}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder={
phonePlaceholder ?? t("leadForm.phonePlaceholder")
}
/>
) : (
<PhoneInputIntl
id={phoneId}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder={
phonePlaceholder ?? t("leadForm.phonePlaceholder")
}
/>
)
}
/>
<div className="bottom-0 absolute w-full border-b border-[#37393B] peer-focus:border-white -mb-2" />
</div>
{errors.phone?.message ? (
<p className="text-sm text-red-400 -mt-1" role="alert">
{String(errors.phone.message)}
</p>
) : null}
<div className="md:flex items-stretch lg:gap-[0.833vw] gap-3 max-md:translate-y-2">
<Button
type="submit"
disabled={formState.isSubmitting}
disabled={isSubmitting}
className="btnl max-md:mb-3 max-md:w-full lg:px-[2.222vw] lg:py-[1.389vw] px-8 py-5 cursor-pointer lg:rounded-[1.111vw] rounded-2xl disabled:opacity-60"
>
{t("leadForm.submit")}
</Button>
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
<span className="text-[#7A7A7A]">{t("leadForm.consentBefore")}</span>{" "}
<span className="text-[#7A7A7A]">
{t("leadForm.consentBefore")}
</span>{" "}
<a
target="_blank"
rel="noopener noreferrer"
@@ -136,7 +185,9 @@ export function LeadForm({
>
{t("leadForm.consentLinkData")}
</a>{" "}
<span className="text-[#7A7A7A]">{t("leadForm.consentMiddle")} </span>
<span className="text-[#7A7A7A]">
{t("leadForm.consentMiddle")}{" "}
</span>
<a
target="_blank"
rel="noopener noreferrer"
@@ -28,8 +28,10 @@ export function StreamingProject({
const streamHref = build ? streamDemoUrlFromBuild(build) : href;
return (
<a
href={streamHref}
<div
onClick={() => {
window.location.href = streamHref;
}}
className={`lg:aspect-[344/396] max-md:aspect-none flex-1 md:max-lg:min-w-[300px] transition-transform will-change-transform lg:rounded-[1.111vw] rounded-2xl lg:p-[1.111vw] p-4 flex duration-500 relative overflow-hidden group max-md:absolute max-md:inset-x-5 max-md:w-auto select-none h-full ${
index === current
? "max-md:[transform:translateZ(40px)]"
@@ -40,13 +42,13 @@ export function StreamingProject({
: "max-md:[transform:scale(0.85)]"
} ${className ?? ""}`}
>
<div className="z-0 rounded-2xl absolute inset-0 overflow-hidden transition-transform duration-500 group-hover:scale-110">
<div className="group-hover:scale-110 overflow-hidden absolute inset-0 z-0 rounded-2xl transition-transform duration-500">
<img
src={imgSrc}
alt=""
loading="lazy"
decoding="async"
className="absolute inset-0 z-0 size-full object-cover object-bottom"
className="size-full object-cover object-bottom absolute inset-0 z-0"
draggable={false}
/>
<div
@@ -93,6 +95,6 @@ export function StreamingProject({
</div>
</div>
</a>
</a>
</div>
);
}
+5 -2
View File
@@ -63,10 +63,13 @@
"consentBefore": "*By submitting, you give",
"consentLinkData": "consent to personal data processing",
"consentMiddle": "and accept the",
"consentLinkPolicy": "policy terms"
"consentLinkPolicy": "policy terms",
"phonePlaceholder": "+XXXXXXXXXXXXXXX",
"phoneRequired": "Enter your phone number",
"phoneInvalid": "Enter a valid phone number"
},
"questionModal": {
"phonePlaceholder": "+X (XXX) XXX - XX - XX"
"phonePlaceholder": "+XXXXXXXXXXXXXXX"
},
"feedbackModal": {
"successRich": "Weve received your request <brMobile /> and well contact you <brDesktop /> soon!",
+5 -2
View File
@@ -63,10 +63,13 @@
"consentBefore": "*Нажимая кнопку отправить, вы даете",
"consentLinkData": "согласие на обработку персональных данных",
"consentMiddle": "и принимаете",
"consentLinkPolicy": "условия политики"
"consentLinkPolicy": "условия политики",
"phonePlaceholder": "+7 (XXX) XXX-XX-XX",
"phoneRequired": "Укажите номер телефона",
"phoneInvalid": "Введите корректный номер телефона"
},
"questionModal": {
"phonePlaceholder": "+X (XXX) XXX - XX - XX"
"phonePlaceholder": "+7 (XXX) XXX-XX-XX"
},
"feedbackModal": {
"successRich": "Мы получили заявку <brMobile /> и скоро свяжемся <brDesktop /> с вами!",
+24
View File
@@ -0,0 +1,24 @@
/** Максимум значащих цифр в международном номере (E.164). */
export const INTL_PHONE_MAX_DIGITS = 15;
/** Нормализация: «+» и только цифры после него (до 15), без пробелов и прочего. */
export function normalizeIntlPhoneFromInput(rawNoSpaces: string): string {
if (!rawNoSpaces) return "";
const v = rawNoSpaces.trim();
const plusIdx = v.indexOf("+");
if (plusIdx === -1) {
const digitsOnly = v.replace(/\D/g, "").slice(0, INTL_PHONE_MAX_DIGITS);
return digitsOnly ? `+${digitsOnly}` : "";
}
const afterPlus = v
.slice(plusIdx + 1)
.replace(/\D/g, "")
.slice(0, INTL_PHONE_MAX_DIGITS);
if (!afterPlus) return "+";
return `+${afterPlus}`;
}
/** Отображение: «+» и цифры подряд, без пробелов. */
export function formatIntlPhoneDisplay(stored: string): string {
return normalizeIntlPhoneFromInput(stored.replace(/\s/g, ""));
}
+45
View File
@@ -0,0 +1,45 @@
import { type Ref } from "react";
import {
formatIntlPhoneDisplay,
normalizeIntlPhoneFromInput,
} from "@/landing/lib/phoneIntl";
const inputClassName =
"placeholder:btnl placeholder:font-medium placeholder:select-none peer btnl w-full h-full bg-transparent rounded-none transition-all outline-none";
interface PhoneInputIntlProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
inputRef?: Ref<HTMLInputElement>;
id?: string;
placeholder?: string;
}
export function PhoneInputIntl({
value,
onChange,
onBlur,
inputRef,
id = "tel",
placeholder = "+123456789012345",
}: PhoneInputIntlProps) {
return (
<input
ref={inputRef}
type="tel"
autoComplete="tel"
inputMode="tel"
id={id}
placeholder={placeholder}
className={inputClassName}
value={formatIntlPhoneDisplay(value)}
onBlur={onBlur}
onChange={(e) => {
if (!e.nativeEvent.type.startsWith("input")) return;
const cleanValue = e.target.value.replace(/\s/g, "");
onChange(normalizeIntlPhoneFromInput(cleanValue));
}}
/>
);
}