diff --git a/bun.lock b/bun.lock index 2985a67..1096365 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/src/i18n.ts b/src/i18n.ts index fb427b5..521c547 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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: "Проверяем качество вашего
интернет-соединения", + checkingConnection: + "Проверяем качество вашего
интернет-соединения", 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: diff --git a/src/landing/features/lead-form/LeadForm.tsx b/src/landing/features/lead-form/LeadForm.tsx index be4eee8..d2be1fb 100644 --- a/src/landing/features/lead-form/LeadForm.tsx +++ b/src/landing/features/lead-form/LeadForm.tsx @@ -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(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 = async (data) => { setSubmitError(null); @@ -105,29 +115,68 @@ export function LeadForm({ ( - - )} + 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 ? ( + + ) : ( + + ) + } />
+ {errors.phone?.message ? ( +

+ {String(errors.phone.message)} +

+ ) : null}
- {t("leadForm.consentBefore")}{" "} + + {t("leadForm.consentBefore")} + {" "} {t("leadForm.consentLinkData")} {" "} - {t("leadForm.consentMiddle")} + + {t("leadForm.consentMiddle")}{" "} + { + 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 ?? ""}`} > -
+
- +
); } diff --git a/src/landing/i18n/locales/en.json b/src/landing/i18n/locales/en.json index 1e3e85f..823a2a0 100644 --- a/src/landing/i18n/locales/en.json +++ b/src/landing/i18n/locales/en.json @@ -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": "We’ve received your request and we’ll contact you soon!", diff --git a/src/landing/i18n/locales/ru.json b/src/landing/i18n/locales/ru.json index 7aa62b3..e78c4e0 100644 --- a/src/landing/i18n/locales/ru.json +++ b/src/landing/i18n/locales/ru.json @@ -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": "Мы получили заявку и скоро свяжемся с вами!", diff --git a/src/landing/lib/phoneIntl.ts b/src/landing/lib/phoneIntl.ts new file mode 100644 index 0000000..9d5cbbd --- /dev/null +++ b/src/landing/lib/phoneIntl.ts @@ -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, "")); +} diff --git a/src/landing/ui/PhoneInputIntl.tsx b/src/landing/ui/PhoneInputIntl.tsx new file mode 100644 index 0000000..6b55b3d --- /dev/null +++ b/src/landing/ui/PhoneInputIntl.tsx @@ -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; + id?: string; + placeholder?: string; +} + +export function PhoneInputIntl({ + value, + onChange, + onBlur, + inputRef, + id = "tel", + placeholder = "+123456789012345", +}: PhoneInputIntlProps) { + return ( + { + if (!e.nativeEvent.type.startsWith("input")) return; + const cleanValue = e.target.value.replace(/\s/g, ""); + onChange(normalizeIntlPhoneFromInput(cleanValue)); + }} + /> + ); +}