Compare commits

...

6 Commits

Author SHA1 Message Date
mikhail_lanskikh de5d64eae4 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. 2026-04-29 17:14:37 +05:00
inmake c2bee615fa Refactor feedback forms to include MAIL_REQUEST_FROM in API requests. Updated API utility to use mailApi for mail-related endpoints. Enhanced FeedbackFormModal and LeadForm components for improved data handling and user experience. 2026-04-21 18:08:41 +05:00
inmake 68b4ce1a8d Update package dependencies, enhance localization, and refactor App component. Added new dependencies for improved state management and UI interactions. Streamlined i18n configuration and integrated path aliases for better module resolution. Adjusted Tailwind CSS configuration to include new screen sizes and optimized component structure for clarity and performance. 2026-04-21 17:21:39 +05:00
inmake 3c0f556503 Refactor language detection and localization handling. Removed i18next-browser-languagedetector dependency, integrated language detection logic directly into the application, and improved user experience with loading indicators. Updated App and routing structure to support language detection more effectively. Cleaned up package dependencies and adjusted image assets for better performance. 2026-03-17 17:55:32 +05:00
inmake c2fc1624a4 Enhance Video and StreamPage components with improved media stream handling. Added audio and video track management, refined microphone and camera status updates, and optimized rendering based on available media streams. Improved user experience with conditional rendering and state management for audio and video devices. 2026-02-27 18:34:51 +05:00
inmake cb156bd99d Update footer copyright year to 2026 and enhance media device management in SetNameModal and StreamPage components. Added microphone and camera status handling, device selection, and improved user experience with updated state management in the stream store. 2026-02-27 15:21:28 +05:00
105 changed files with 8948 additions and 1612 deletions
+21 -3
View File
@@ -6,14 +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",
"i18next-browser-languagedetector": "^8.2.0",
"ky": "^1.1.3",
"libphonenumber-js": "^1.11.7",
"peerjs": "^1.5.4",
"react": "^18.2.0",
"react-calendar": "^4.3.0",
@@ -22,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",
@@ -183,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=="],
@@ -377,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=="],
@@ -409,8 +419,6 @@
"i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -493,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=="],
@@ -523,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=="],
@@ -623,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=="],
@@ -637,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=="],
+4439
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -11,14 +11,16 @@
},
"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",
"i18next-browser-languagedetector": "^8.2.0",
"ky": "^1.1.3",
"libphonenumber-js": "^1.11.7",
"peerjs": "^1.5.4",
"react": "^18.2.0",
"react-calendar": "^4.3.0",
@@ -27,10 +29,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",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, noarchive">
<meta name="format-detection" content="telephone=no">
<title>Transfonter demo</title>
<link href="stylesheet.css" rel="stylesheet">
<style>
/*
http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* demo styles */
body {
background: #f0f0f0;
color: #000;
}
.page {
background: #fff;
width: 920px;
margin: 0 auto;
padding: 20px 20px 0 20px;
overflow: hidden;
}
.font-container {
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 40px;
line-height: 1.3;
white-space: nowrap;
padding-bottom: 5px;
}
h1 {
position: relative;
background: #444;
font-size: 32px;
color: #fff;
padding: 10px 20px;
margin: 0 -20px 12px -20px;
}
.letters {
font-size: 25px;
margin-bottom: 20px;
}
.s10:before {
content: '10px';
}
.s11:before {
content: '11px';
}
.s12:before {
content: '12px';
}
.s14:before {
content: '14px';
}
.s18:before {
content: '18px';
}
.s24:before {
content: '24px';
}
.s30:before {
content: '30px';
}
.s36:before {
content: '36px';
}
.s48:before {
content: '48px';
}
.s60:before {
content: '60px';
}
.s72:before {
content: '72px';
}
.s10:before, .s11:before, .s12:before, .s14:before,
.s18:before, .s24:before, .s30:before, .s36:before,
.s48:before, .s60:before, .s72:before {
font-family: Arial, sans-serif;
font-size: 10px;
font-weight: normal;
font-style: normal;
color: #999;
padding-right: 6px;
}
pre {
display: block;
padding: 9px;
margin: 0 0 12px;
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.428571429;
color: #333;
font-weight: normal;
font-style: normal;
background-color: #f5f5f5;
border: 1px solid #ccc;
overflow-x: auto;
border-radius: 4px;
}
/* responsive */
@media (max-width: 959px) {
.page {
width: auto;
margin: 0;
}
}
</style>
</head>
<body>
<div class="page">
<div class="demo">
<h1 style="font-family: 'TTHovesPro-DmBd'; font-weight: 600; font-style: normal;">☝︎TT Hoves Pro DemiBold</h1>
<pre title="Usage">.your-style {
font-family: 'TTHovesPro-DmBd';
font-weight: 600;
font-style: normal;
}</pre>
<pre title="Preload (optional)">
&lt;link rel=&quot;preload&quot; href=&quot;TTHovesPro-DmBd.woff2&quot; as=&quot;font&quot; type=&quot;font/woff2&quot; crossorigin&gt;</pre>
<div class="font-container" style="font-family: 'TTHovesPro-DmBd'; font-weight: 600; font-style: normal;">
<p class="letters">
abcdefghijklmnopqrstuvwxyz<br>
ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
0123456789.:,;()*!?'@#&lt;&gt;$%&^+-=~
</p>
<p class="s10" style="font-size: 10px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s11" style="font-size: 11px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s12" style="font-size: 12px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s14" style="font-size: 14px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s18" style="font-size: 18px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s24" style="font-size: 24px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s30" style="font-size: 30px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s36" style="font-size: 36px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s48" style="font-size: 48px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s60" style="font-size: 60px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s72" style="font-size: 72px;">The quick brown fox jumps over the lazy dog.</p>
</div>
</div>
<div class="demo">
<h1 style="font-family: 'TTHovesPro-Md'; font-weight: 500; font-style: normal;">☝︎TT Hoves Pro Medium</h1>
<pre title="Usage">.your-style {
font-family: 'TTHovesPro-Md';
font-weight: 500;
font-style: normal;
}</pre>
<pre title="Preload (optional)">
&lt;link rel=&quot;preload&quot; href=&quot;TTHovesPro-Md.woff2&quot; as=&quot;font&quot; type=&quot;font/woff2&quot; crossorigin&gt;</pre>
<div class="font-container" style="font-family: 'TTHovesPro-Md'; font-weight: 500; font-style: normal;">
<p class="letters">
abcdefghijklmnopqrstuvwxyz<br>
ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
0123456789.:,;()*!?'@#&lt;&gt;$%&^+-=~
</p>
<p class="s10" style="font-size: 10px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s11" style="font-size: 11px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s12" style="font-size: 12px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s14" style="font-size: 14px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s18" style="font-size: 18px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s24" style="font-size: 24px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s30" style="font-size: 30px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s36" style="font-size: 36px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s48" style="font-size: 48px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s60" style="font-size: 60px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s72" style="font-size: 72px;">The quick brown fox jumps over the lazy dog.</p>
</div>
</div>
<div class="demo">
<h1 style="font-family: 'TTHovesPro-Rg'; font-weight: normal; font-style: normal;">☝︎TT Hoves Pro Regular</h1>
<pre title="Usage">.your-style {
font-family: 'TTHovesPro-Rg';
font-weight: normal;
font-style: normal;
}</pre>
<pre title="Preload (optional)">
&lt;link rel=&quot;preload&quot; href=&quot;TTHovesPro-Rg.woff2&quot; as=&quot;font&quot; type=&quot;font/woff2&quot; crossorigin&gt;</pre>
<div class="font-container" style="font-family: 'TTHovesPro-Rg'; font-weight: normal; font-style: normal;">
<p class="letters">
abcdefghijklmnopqrstuvwxyz<br>
ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
0123456789.:,;()*!?'@#&lt;&gt;$%&^+-=~
</p>
<p class="s10" style="font-size: 10px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s11" style="font-size: 11px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s12" style="font-size: 12px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s14" style="font-size: 14px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s18" style="font-size: 18px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s24" style="font-size: 24px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s30" style="font-size: 30px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s36" style="font-size: 36px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s48" style="font-size: 48px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s60" style="font-size: 60px;">The quick brown fox jumps over the lazy dog.</p>
<p class="s72" style="font-size: 72px;">The quick brown fox jumps over the lazy dog.</p>
</div>
</div>
</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
@font-face {
font-family: 'TTHovesPro';
src: url('TTHovesPro-DmBd.eot');
src:
url('TTHovesPro-DmBd.eot?#iefix') format('embedded-opentype'),
url('TTHovesPro-DmBd.woff2') format('woff2'),
url('TTHovesPro-DmBd.woff') format('woff'),
url('TTHovesPro-DmBd.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'TTHovesPro';
src: url('TTHovesPro-Md.eot');
src:
url('TTHovesPro-Md.eot?#iefix') format('embedded-opentype'),
url('TTHovesPro-Md.woff2') format('woff2'),
url('TTHovesPro-Md.woff') format('woff'),
url('TTHovesPro-Md.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'TTHovesPro';
src: url('TTHovesPro-Rg.eot');
src:
url('TTHovesPro-Rg.eot?#iefix') format('embedded-opentype'),
url('TTHovesPro-Rg.woff2') format('woff2'),
url('TTHovesPro-Rg.woff') format('woff'),
url('TTHovesPro-Rg.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 83 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12C0 5.37258 5.37258 0 12 0H48V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#B5F54E"/>
<path d="M14.556 20.0507C14.2126 18.6521 15.7064 17.4123 17.6391 17.4123C19.3806 17.4123 21.1257 17.776 21.7876 20.044H24.0212V23.5056C21.239 21.3698 15.2073 22.7088 14.556 20.0473M37.7127 19.0885H32.6863L27.9042 23.9817V14.2086H24.0247V16.5791C23.8973 16.4271 23.7663 16.275 23.6141 16.1262C22.2017 14.731 20.17 14.0234 17.5718 14.0234C14.5666 14.0234 12.8569 15.2798 11.9508 16.3345C10.8287 17.6437 10.3261 19.4291 10.6729 20.8871C11.572 24.6562 15.3135 25.2579 18.0638 25.5919C20.2337 25.8564 22.3115 26.2233 22.2549 28.0318C22.1982 29.9065 19.9328 30.3693 18.4497 30.3693C14.5808 30.3693 14.4498 27.6979 14.4498 27.6979H10.2871C10.3473 28.6997 10.6729 30.3131 11.9932 31.6951C13.441 33.2094 15.6108 33.9764 18.4461 33.9764C20.6195 33.9764 22.6017 33.3152 24.0212 32.1381V33.7549H27.9007V29.1394L29.2741 27.7343L33.1925 33.7549H37.7091L32.0067 24.9372L37.7127 19.0952V19.0885Z" fill="#4C5658"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.
Binary file not shown.
+33 -534
View File
@@ -1,552 +1,51 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable no-irregular-whitespace */
import "./App.css";
import { Trans, useTranslation } from "react-i18next";
import FeedbackForm from "./components/FeedbackForm";
import ArrowRightIcon from "./components/icons/ArrowRightIcon";
import LogoIcon from "./components/icons/LogoIcon";
import MailIcon from "./components/icons/MailIcon";
import PhoneIcon from "./components/icons/PhoneIcon";
import TelegramIcon from "./components/icons/TelegramIcon";
import VKIcon from "./components/icons/VKIcon";
import YouTubeIcon from "./components/icons/YouTubeIcon";
import Sidebar from "./components/Sidebar";
import Header from "./components/Header";
import { Transition } from "react-transition-group";
import useSidebarStore from "./stores/useSidebarStore";
import { useEffect, useState } from "react";
import ky from "ky";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Bounce, ToastContainer, toast } from "react-toastify";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import InfoIcon from "./components/icons/InfoIcon";
import { detectUserRegion, getRegionHeaders } from "./utils/api";
import { handleApiError, isErrorResponse } from "./utils/errorHandler";
import { Footer } from "@/landing/components/Layout/Footer";
import Header from "@/landing/components/Layout/Header";
import { ModalContainer } from "@/landing/components/Layout/ModalContainer";
import StreamDemo from "@/landing/features/stream-demo/StreamDemo";
import { useAutoStartFromQuery } from "@/landing/hooks/useAutoStartFromQuery";
function App() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useSidebarStore((state) => [
state.isOpen,
state.setIsOpen,
]);
// const [loading, setLoading] = useState<boolean>(false);
// const [countdownTimer, setCountdownTimer] = useState(15);
const { t, i18n } = useTranslation();
const build = searchParams.get("build") || null;
const type = searchParams.get("type") || "demo";
const endAt = searchParams.get("endAt");
const [streamUrl, setStreamUrl] = useState<string>();
const [regionDetected, setRegionDetected] = useState<boolean>(false);
function toastError(text: string) {
toast.error(text, {
icon: <InfoIcon className="text-red-500" />,
position: "top-center",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
transition: Bounce,
});
}
async function startStream(build: string) {
let location = "a1";
if (searchParams.has("location")) {
location = searchParams.get("location") as string;
}
try {
const response: any = await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/start?location=${location}&build=${build}&type=${type}&endAt=${endAt}`,
{ headers: getRegionHeaders() }
)
.json();
// Проверяем, является ли ответ ошибкой
if (isErrorResponse(response)) {
handleApiError(response, t, navigate);
return;
}
if (response.stream) {
setStreamUrl(`/stream/${response.stream}`);
} else if (response.error) {
toastError(response.error);
} else {
toastError(t("errors.unknownError"));
}
} catch (error) {
if (error instanceof Error) {
toastError(t("errors.networkError") + `: ${error.message}`);
}
}
}
// useEffect(() => {
// if (countdownTimer > 0 || !streamUrl) return;
// navigate(streamUrl);
// }, [countdownTimer]);
useEffect(() => {
if (!streamUrl) return;
navigate(streamUrl);
}, [streamUrl]);
// Определяем регион пользователя при первой загрузке
useEffect(() => {
async function initializeRegion() {
try {
await detectUserRegion();
setRegionDetected(true);
} catch (error) {
console.error("Failed to detect user region:", error);
// Даже при ошибке продолжаем работу с дефолтным регионом
setRegionDetected(true);
}
}
void initializeRegion();
}, []);
useEffect(() => {
if (build && regionDetected) {
void startStream(build);
}
}, [regionDetected]);
useAutoStartFromQuery(searchParams, navigate, t);
useEffect(() => {
document.title = t("title");
}, [i18n.language, t]);
/*
* Locale selection (URL `?lang=` → country-code fallback) is handled by the
* top-level `LanguageDetector` wrapper in `src/main.tsx`, which calls
* `detectUserRegion()` once for the whole app. We intentionally don't mount
* the landing's `LocaleSync` here — it would fire a duplicate
* `getCountryCode` request. We only mirror the resulting `i18n.language`
* onto `<html lang>` for accessibility/SEO.
*/
useEffect(() => {
document.documentElement.lang = i18n.language.startsWith("ru")
? "ru"
: "en";
}, [i18n.language]);
return (
<>
<div className="min-h-screen bg-[#14161F] text-white overflow-hidden">
<div className="container mx-auto 2xl:px-10 lg:px-8 sm:px-6 px-4 max-w-[1600px]">
<Header />
<div className="2xl:mt-[72px] lg:mt-16 sm:mt-[88px] mt-14 relative">
<div className="flex absolute -top-8 justify-center items-center w-full blur-sm">
<img src="/images/shapes/shape-1.svg" alt="" className="" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:gap-4 sm:gap-3">
<p className="2xl:text-[64px] xl:text-[52px] text-[40px] text-gradient w-fit font-gilroy leading-none font-medium">
<Trans i18nKey={"main.title"}>
Доступные
<br />
демонстрации
</Trans>
</p>
<p className="lg:w-[368px] lg:text-base text-sm">
<Trans i18nKey={"main.desc"}>
Клиент из любой точки мира может посмотреть жилой комплекс,
даже на нулевом этапе строительства. Он выберет лучшую
планировку и оценит вид из окон своей будущей квартиры.
</Trans>
</p>
</div>
<div className="grid gap-2 mt-8 sm:mt-16 sm:grid-cols-1 lg:gap-4 sm:gap-3">
{/* <div
className="group relative sm:h-full h-[264px] bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/upside.jpg")`,
}}
>
<div className="h-full transition-opacity duration-300 bg-gradient-card group-hover:opacity-60"></div>
<div className="absolute bottom-0 p-6 space-y-6">
<div>
<p className="text-xl font-semibold xl:text-2xl font-gilroy">
<Trans i18nKey={"main.cards.title4"}>
ЖК «Upside Towers»
</Trans>
</p>
<p className="text-xs lg:text-sm">
<Trans i18nKey={"main.cards.city3"}>
Россия, Москва
</Trans>
</p>
</div>
<button
onClick={() => void startStream("upsideTowersDev")}
className="flex gap-0 p-2 rounded-full transition-all duration-300 bg-gradient group-hover:gap-1 group-hover:pr-4 group-hover:pl-6"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>Запустить</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div> */}
<div className="grid gap-2 lg:grid-cols-2 lg:gap-4 sm:gap-3">
{i18n.language === "ru" ? (
<>
<div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-1 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/nks.jpg")`,
}}
>
<div className="h-full transition-opacity duration-300 bg-gradient-card group-hover:opacity-50"></div>
<div className="absolute bottom-0 p-6 space-y-6">
<div>
<p className="text-xl font-semibold xl:text-2xl font-gilroy">
<Trans i18nKey={"main.cards.title"}>
МФК «Revolution towers»
</Trans>
</p>
<p className="text-xs lg:text-sm">
<Trans i18nKey={"main.cards.city1"}>
Россия, Екатеринбург
</Trans>
</p>
</div>
<button
onClick={() => void startStream("nksJukovaDev")}
className="flex gap-0 p-2 rounded-full transition-all duration-300 bg-gradient group-hover:gap-1 group-hover:pr-4 group-hover:pl-6"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>
Запустить
</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
<div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-1 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/liferes.jpg")`,
}}
>
<div className="h-full transition-opacity duration-300 bg-gradient-card group-hover:opacity-50"></div>
<div className="absolute bottom-0 p-6 space-y-6">
<div>
<p className="text-xl font-semibold xl:text-2xl font-gilroy">
<Trans i18nKey={"main.cards.title2"}>
ЖК «Life Резиденция»
</Trans>
</p>
<p className="text-xs lg:text-sm">
<Trans i18nKey={"main.cards.city2"}>
Россия, Тюмень
</Trans>
</p>
</div>
<button
onClick={() => void startStream("lifeResidence")}
className="flex gap-0 p-2 rounded-full transition-all duration-300 bg-gradient group-hover:gap-1 group-hover:pr-4 group-hover:pl-6"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>
Запустить
</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
{/* <div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-1 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/aivaz.jpg")`,
}}
>
<div className="h-full transition-opacity duration-300 bg-gradient-card group-hover:opacity-90"></div>
<div className="absolute bottom-0 p-6 space-y-6">
<div>
<p className="text-xl font-semibold xl:text-2xl font-gilroy">
<Trans i18nKey={"main.cards.title3"}>
ЖК «Айвазовский City»
</Trans>
</p>
<p className="text-xs lg:text-sm">
<Trans i18nKey={"main.cards.city2"}>
Россия, Тюмень
</Trans>
</p>
</div>
<button
onClick={() => void startStream("IvazowskyDev")}
className="flex gap-0 p-2 rounded-full transition-all duration-300 bg-gradient group-hover:gap-1 group-hover:pr-4 group-hover:pl-6"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>
Запустить
</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div> */}
</>
) : (
<div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-2 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/shipyard.jpg")`,
}}
>
<div className="h-full transition-opacity duration-300 bg-gradient-card group-hover:opacity-90"></div>
<div className="absolute bottom-0 p-6 space-y-6">
<div>
<p className="text-xl font-semibold xl:text-2xl font-gilroy">
IMI Saudi Shipyard
</p>
<p className="text-xs lg:text-sm">Saudi Arabia</p>
</div>
<button
onClick={() => void startStream("ShipyardSaudiDev")}
className="flex gap-0 p-2 rounded-full transition-all duration-300 bg-gradient group-hover:gap-1 group-hover:pr-4 group-hover:pl-6"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>Запустить</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
)}
</div>
</div>
</div>
<div className="lg:mt-40 sm:mt-[120px] mt-[88px]">
<div className="grid gap-8 xl:grid-cols-3 lg:grid-cols-2 lg:gap-4">
<div className="flex flex-col justify-between gap-8 pb-4 border-b border-[#3D425C]">
<div className="flex flex-col gap-6 xl:gap-8">
<p className="2xl:text-[64px] xl:text-5xl text-[40px] text-gradient font-gilroy w-fit leading-none font-medium">
<Trans i18nKey={"signUp.title"}>
Запись на
<br />
удаленную
<br />
демонстрацию
</Trans>
</p>
<p className="text-sm 2xl:text-base">
<Trans i18nKey={"signUp.desc"}>
Запись на демонстрацию может быть
<br />
оформлена в виде блока на сайте
<br />
застройщика или жилого комплекса.
</Trans>
</p>
<button
onClick={() => setIsOpen(true)}
className="relative px-6 py-2 text-sm font-medium leading-normal rounded-full group bg-gradient lg:text-base w-fit"
>
<div className="absolute top-0 left-0 w-full h-full bg-black rounded-full opacity-0 transition-all group-hover:opacity-10"></div>
<span className="relative">
<Trans i18nKey={"signUp.button"}>Записаться</Trans>
</span>
</button>
</div>
<p className="2xl:text-sm text-xs text-[#52587A]">
<Trans i18nKey={"signUp.notice"}>
Запись доступна в демонстрационном режиме.
<br />
Указанные при записи данные не будут сохранены.
</Trans>
</p>
</div>
<div className="xl:col-span-2">
<video
src="/videos/video.mp4"
playsInline
autoPlay
muted
loop
></video>
</div>
</div>
</div>
<div className="lg:mt-40 sm:mt-[120px] mt-[88px]">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 lg:gap-4">
<div className="col-span-1">
<div className="grid gap-4 lg:grid-cols-1 sm:grid-cols-2 lg:gap-6">
<p className="2xl:text-[64px] xl:text-5xl text-[40px] font-gilroy text-gradient font-medium w-fit leading-none">
<Trans i18nKey={"feedback.title"}>
Свяжитесь
<br />с нами
</Trans>
</p>
<p className="font-semibold leading-tight 2xl:text-xl lg:text-lg font-gilroy">
<Trans i18nKey={"feedback.desc"}>
Хотите увеличить конверсию?
<br />
Давайте обсудим детали!
</Trans>
</p>
</div>
</div>
<div className="lg:col-span-3">
<FeedbackForm />
</div>
</div>
</div>
<div className="mt-[104px] relative">
<div className="flex absolute -top-8 left-32 justify-center items-center w-full blur-md">
<img src="/images/shapes/shape-2.svg" alt="" className="" />
</div>
<div className="grid relative gap-4 lg:grid-cols-4">
<div className="flex gap-4">
<p className="font-semibold 2xl:text-xl font-gilroy">
<Trans i18nKey={"contacts.title"}>Горячая линия</Trans>
</p>
<div className="w-full h-px bg-[#3D425C]"></div>
</div>
<div className="space-y-2 2xl:pr-4 xl:pr-2">
<a
href="mailto:info@graff.tech"
className="2xl:h-16 h-14 px-6 py-4 2xl:text-base text-sm border rounded-full font-medium flex justify-between items-center w-full border-[#52587A] opacity-80 hover:opacity-100 transition-all"
>
<span>
<Trans i18nKey={"contacts.button1"}>Написать</Trans>
</span>
<MailIcon className="w-6 h-6 lg:w-8 lg:h-8" />
</a>
<a
href="tel:88007700067"
className="2xl:h-16 h-14 px-6 py-4 2xl:text-base text-sm border rounded-full font-medium flex justify-between items-center w-full border-[#52587A] opacity-80 hover:opacity-100 transition-all"
>
<span>
<Trans i18nKey={"contacts.button2"}>Позвонить</Trans>
</span>
<PhoneIcon className="w-6 h-6 lg:w-8 lg:h-8" />
</a>
</div>
<div className="flex mt-10 sm:col-span-2 sm:justify-end lg:mt-0">
<div className="flex gap-4 justify-between w-full lg:w-auto sm:w-1/2 2xl:gap-8 lg:gap-6">
<p className="2xl:text-xl font-gilroy font-semibold 2xl:-mt-1.5 -mt-1">
<Trans i18nKey={"contacts.social.title"}>
Социальные
<br />
сети
</Trans>
</p>
<div className="flex gap-2 h-fit">
<a
href="https://www.youtube.com/@GRAFFtech"
target="_blank"
className="group border border-[#3D425C] xl:p-4 p-3 rounded-full hover:border-[#52587A] transition-all"
>
<YouTubeIcon className="w-6 h-6 2xl:w-8 2xl:h-8" />
</a>
<a
href="https://vk.com/graff.interactive"
target="_blank"
className="group border border-[#3D425C] xl:p-4 p-3 rounded-full hover:border-[#52587A] transition-all"
>
<VKIcon className="w-6 h-6 2xl:w-8 2xl:h-8" />
</a>
<a
href="https://t.me/GRAFFinteractive"
target="_blank"
className="border rounded-full border-[#52587A] xl:p-4 p-3 opacity-80 hover:opacity-100 transition-all"
>
<TelegramIcon className="w-6 h-6 2xl:w-8 2xl:h-8" />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-[50px] relative border-t border-[#3D425C] text-sm">
<div className="container mx-auto xl:px-8 max-w-[1600px]">
<div className="grid lg:grid-cols-4">
<div className="sm:col-span-2 lg:order-none order-last py-6 xl:px-0 px-6 flex sm:flex-row flex-col sm:gap-6 gap-4 lg:border-t-0 border-t border-[#3D425C]">
<div>
<LogoIcon />
</div>
<div className="flex flex-col gap-4 sm:gap-1">
<p className="flex flex-col gap-1 sm:flex-row sm:gap-4">
<a href="https://graff.tech/privacypolicy" target="_blank">
<Trans i18nKey={"footer.link"}>
Политика конфиденциальности
</Trans>
</a>
<a href="https://graff.estate" target="_blank">
graff.estate
</a>
</p>
<p className="text-xs text-[#C5C7CE]">
© 2023 GRAFF interactive.{" "}
<Trans i18nKey={"footer.text"}>Все права защищены.</Trans>
</p>
</div>
</div>
<div className="col-span-1 lg:border-l sm:border-b-0 border-b border-[#3D425C] xl:px-8 sm:px-6 px-4 py-6 flex flex-col justify-center">
<div className="flex justify-between items-center">
<div className="text-[#EBEBEB] flex flex-col gap-1">
<a href="mailto:info@graff.tech">info@graff.tech</a>
<a href="tel:88007700067">8 800 770 00 67</a>
</div>
<div className="w-12 h-12 border border-[#3D425C] rounded-full flex justify-center items-center">
RU
</div>
</div>
</div>
<div className="col-span-1 sm:border-l border-[#3D425C] xl:pl-8 xl:pr-0 sm:px-6 px-4 py-6 flex flex-col justify-center">
<div className="flex justify-between items-center">
<div className="text-[#EBEBEB] flex flex-col gap-1">
<a href="mailto:waseem@graff.tech">waseem@graff.tech</a>
<a href="tel:+971509388902">+971 50 938 8902</a>
</div>
<div className="w-12 h-12 border border-[#3D425C] rounded-full flex justify-center items-center">
UAE
</div>
</div>
</div>
</div>
</div>
</div>
<div className="landing-shell flex min-h-dvh flex-col">
<Header />
{/* Без overflow-clip: иначе flex-1 + clip часто даёт пустой/обрезанный экран */}
<div className="min-h-0 flex-1 px-[10px] pb-8 pt-14 md:max-lg:pt-6 md:px-4 md:pt-4 lg:px-[1.389vw] lg:pt-8">
<StreamDemo />
</div>
<Transition in={isOpen} timeout={200} mountOnEnter unmountOnExit>
{(state) => <Sidebar className={state} />}
</Transition>
<Footer />
<ModalContainer />
<ToastContainer />
</>
</div>
);
}
+2
View File
@@ -1,4 +1,5 @@
import ky from "ky";
import { MAIL_REQUEST_FROM } from "@/mailFrom";
import { ChangeEvent, FormEvent, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import InputMask from "react-input-mask";
@@ -29,6 +30,7 @@ function FeedbackForm() {
phone,
email,
request: description,
from: MAIL_REQUEST_FROM,
},
})
.json();
+11 -1
View File
@@ -1,12 +1,22 @@
import { ReactNode } from "react";
import { useLanguageDetection } from "../hooks/useLanguageDetection";
import LoaderIcon from "./icons/LoaderIcon";
interface LanguageDetectorProps {
children: ReactNode;
}
function LanguageDetector({ children }: LanguageDetectorProps) {
useLanguageDetection();
const { isReady } = useLanguageDetection();
if (!isReady) {
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center min-h-screen bg-[#14161F]">
<LoaderIcon className="w-12 h-12 animate-spin opacity-90" />
</div>
);
}
return <>{children}</>;
}
+68 -25
View File
@@ -13,51 +13,94 @@ interface Props {
user?: IUser;
}
const SPEAKING_HIDE_DELAY_MS = 400;
function Video({ mediaStream, muted, user }: Props) {
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const remoteAudioRef = useRef<HTMLAudioElement>(null);
const isSpeaking = useIsAudioActive({ source: mediaStream });
const [showSpeakingBorder, setShowSpeakingBorder] = useState(false);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [_muted, setMuted] = useState(muted);
const [isLoading, setIsLoading] = useState(true);
const [minimized, setMinimized] = useState(user?.isAdmin ? false : true);
const hasVideo = (mediaStream?.getVideoTracks().length ?? 0) > 0;
useEffect(() => {
if (user && user.micEnabled === false) {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowSpeakingBorder(false);
return;
}
if (isSpeaking) {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowSpeakingBorder(true);
} else {
hideTimeoutRef.current = setTimeout(() => {
setShowSpeakingBorder(false);
hideTimeoutRef.current = null;
}, SPEAKING_HIDE_DELAY_MS);
}
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, [isSpeaking, user?.micEnabled]);
function toggleSound() {
if (!remoteVideoRef.current) return;
// remoteVideoRef.current.muted = !remoteVideoRef.current.muted;
setMuted((prev) => !prev);
}
useEffect(() => {
if (!remoteVideoRef.current) return;
if (!mediaStream) return;
remoteVideoRef.current.srcObject = mediaStream;
remoteVideoRef.current.onloadedmetadata = () => {
remoteVideoRef.current?.play();
};
remoteVideoRef.current.onplay = () => {
if (hasVideo && remoteVideoRef.current) {
remoteVideoRef.current.srcObject = mediaStream;
remoteVideoRef.current.onloadedmetadata = () => {
remoteVideoRef.current?.play();
};
remoteVideoRef.current.onplay = () => setIsLoading(false);
} else if (!hasVideo && remoteAudioRef.current) {
remoteAudioRef.current.srcObject = mediaStream;
remoteAudioRef.current.onloadedmetadata = () => {
remoteAudioRef.current?.play();
};
remoteAudioRef.current.onplay = () => setIsLoading(false);
setIsLoading(false);
};
console.log("mediaStream", mediaStream?.getTracks());
}, [mediaStream]);
useEffect(() => {
console.log("remoteVideoRef.current", remoteVideoRef);
}, [remoteVideoRef.current]);
}
}, [mediaStream, hasVideo]);
return (
<div
className={`relative border-2 rounded-lg ${
minimized ? "h-8 rounded-lg overflow-hidden" : ""
} ${!_muted && user?.micEnabled && isSpeaking ? "border-green-500" : "border-transparent"}`}
} ${!_muted && user?.micEnabled !== false && showSpeakingBorder ? "border-green-500" : "border-transparent"}`}
>
<video
ref={remoteVideoRef}
className={`aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg object-cover bg-gray-500`}
playsInline
autoPlay
muted={_muted}
></video>
{hasVideo ? (
<video
ref={remoteVideoRef}
className={`aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg object-cover bg-gray-500`}
playsInline
autoPlay
muted={_muted}
></video>
) : (
<>
<audio
ref={remoteAudioRef}
autoPlay
muted={_muted}
className="hidden"
/>
<div className="aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg bg-gray-500" />
</>
)}
<div
className={`absolute -bottom-1.5 flex items-center justify-between w-full gap-2 p-2 ${
minimized ? "bg-black" : ""
+332 -9
View File
@@ -1,37 +1,237 @@
import { ChangeEvent, FormEvent } from "react";
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
import Input from "../../ui/Input";
import useStreamStore from "../../../stores/useStreamStore";
import Button from "../../ui/Button";
import useModalStore from "../../../stores/useModalStore";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import MicroOnIcon from "../../icons/MicroOnIcon";
import MicroOffIcon from "../../icons/MicroOffIcon";
import CameraOnIcon from "../../icons/CameraOnIcon";
import CameraOffIcon from "../../icons/CameraOffIcon";
interface Props {
onAction: () => void;
onAction: (
audioStream: MediaStream | null,
selectedAudioDeviceId: string,
videoStream: MediaStream | null,
selectedVideoDeviceId: string
) => void;
}
function SetNameModal({ onAction }: Props) {
const { t } = useTranslation();
const { name, setName } = useStreamStore();
const { setModal } = useModalStore();
const [micStatus, setMicStatus] = useState<
"checking" | "success" | "error"
>("checking");
const [cameraStatus, setCameraStatus] = useState<
"checking" | "success" | "error"
>("checking");
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>([]);
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedAudioDeviceId, setSelectedAudioDeviceId] =
useState<string>("");
const [selectedVideoDeviceId, setSelectedVideoDeviceId] =
useState<string>("");
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
const [videoStream, setVideoStream] = useState<MediaStream | null>(null);
const audioStreamRef = useRef<MediaStream | null>(null);
const videoStreamRef = useRef<MediaStream | null>(null);
async function checkDevices(audioDeviceId?: string, videoDeviceId?: string) {
setMicStatus("checking");
setCameraStatus("checking");
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
videoStreamRef.current?.getTracks().forEach((track) => track.stop());
audioStreamRef.current = null;
videoStreamRef.current = null;
try {
const audioConstraints: MediaStreamConstraints = {
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
};
const audioStreamResult = await navigator.mediaDevices.getUserMedia(
audioConstraints
);
const audioTracks = audioStreamResult.getAudioTracks();
if (audioTracks.length) {
const audioMediaStream = new MediaStream(audioTracks);
audioStreamRef.current = audioMediaStream;
setAudioStream(audioMediaStream);
setMicStatus("success");
} else {
setMicStatus("error");
}
} catch {
setMicStatus("error");
}
try {
const videoConstraints: MediaStreamConstraints = {
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
};
const videoStreamResult = await navigator.mediaDevices.getUserMedia(
videoConstraints
);
const videoTracks = videoStreamResult.getVideoTracks();
if (videoTracks.length) {
const videoMediaStream = new MediaStream(videoTracks);
videoStreamRef.current = videoMediaStream;
setVideoStream(videoMediaStream);
setCameraStatus("success");
} else {
setCameraStatus("error");
}
} catch {
setCameraStatus("error");
}
const devices = await navigator.mediaDevices.enumerateDevices();
const microphones = devices.filter((d) => d.kind === "audioinput");
const cameras = devices.filter((d) => d.kind === "videoinput");
setAudioDevices(microphones);
setVideoDevices(cameras);
setSelectedAudioDeviceId(
audioDeviceId ?? microphones[0]?.deviceId ?? ""
);
setSelectedVideoDeviceId(
videoDeviceId ?? cameras[0]?.deviceId ?? ""
);
}
async function checkMicrophone(deviceId?: string) {
setMicStatus("checking");
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
audioStreamRef.current = null;
try {
const constraints: MediaStreamConstraints = {
audio: deviceId ? { deviceId: { exact: deviceId } } : true,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const devices = await navigator.mediaDevices.enumerateDevices();
const microphones = devices.filter((d) => d.kind === "audioinput");
audioStreamRef.current = new MediaStream(stream.getAudioTracks());
setAudioStream(audioStreamRef.current);
setAudioDevices(microphones);
setSelectedAudioDeviceId(deviceId ?? microphones[0]?.deviceId ?? "");
setMicStatus("success");
} catch {
setMicStatus("error");
}
}
async function checkCamera(deviceId?: string) {
setCameraStatus("checking");
videoStreamRef.current?.getTracks().forEach((track) => track.stop());
videoStreamRef.current = null;
try {
const constraints: MediaStreamConstraints = {
video: deviceId ? { deviceId: { exact: deviceId } } : true,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === "videoinput");
videoStreamRef.current = new MediaStream(stream.getVideoTracks());
setVideoStream(videoStreamRef.current);
setVideoDevices(cameras);
setSelectedVideoDeviceId(deviceId ?? cameras[0]?.deviceId ?? "");
setCameraStatus("success");
} catch {
setCameraStatus("error");
}
}
useEffect(() => {
checkDevices();
return () => {
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
videoStreamRef.current?.getTracks().forEach((track) => track.stop());
};
}, []);
function handleAudioDeviceChange(e: ChangeEvent<HTMLSelectElement>) {
const id = e.target.value;
setSelectedAudioDeviceId(id);
if (videoStreamRef.current) {
checkMicrophone(id || undefined);
} else {
checkDevices(id || undefined, selectedVideoDeviceId || undefined);
}
}
function handleVideoDeviceChange(e: ChangeEvent<HTMLSelectElement>) {
const id = e.target.value;
setSelectedVideoDeviceId(id);
if (audioStreamRef.current) {
checkCamera(id || undefined);
} else {
checkDevices(selectedAudioDeviceId || undefined, id || undefined);
}
}
function handleChangeName(e: ChangeEvent<HTMLInputElement>) {
setName(e.target.value);
}
function handleAction(
audio: MediaStream | null,
audioId: string,
video: MediaStream | null,
videoId: string
) {
audioStreamRef.current = null;
videoStreamRef.current = null;
setModal(null);
onAction(audio, audioId, video, videoId);
}
function handleClickNoName() {
setName("Guest");
setModal(null);
onAction();
handleAction(
audioStream,
selectedAudioDeviceId,
videoStream,
selectedVideoDeviceId
);
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setModal(null);
onAction();
handleAction(
audioStream,
selectedAudioDeviceId,
videoStream,
selectedVideoDeviceId
);
}
function getDeviceLabel(
device: MediaDeviceInfo,
fallbackKey: string,
prefixKey: string
) {
let label =
device.label ||
(device.deviceId
? `${t(prefixKey)} ${device.deviceId.slice(0, 8)}`
: t(fallbackKey));
label = label.replace(/\s*\([0-9a-fA-F]+:[0-9a-fA-F]+\)\s*$/, "").trim();
return label;
}
return (
<div className="flex items-center justify-center w-full h-full bg-opacity-50 backdrop-blur-2xl">
<div className="bg-white sm:p-12 p-6 sm:rounded-lg space-y-6 sm:h-auto h-full sm:w-auto w-full flex flex-col max-sm:justify-center">
<div className="bg-white sm:p-12 p-6 sm:rounded-lg space-y-6 sm:h-auto h-full sm:w-[494px] w-full flex flex-col max-sm:justify-center max-h-dvh overflow-y-auto shadow">
<p className="text-2xl font-semibold">
<Trans i18nKey={"setName.hello"}>Здравствуйте!</Trans>
</p>
@@ -57,9 +257,132 @@ function SetNameModal({ onAction }: Props) {
onChange={handleChangeName}
autoFocus={!name}
required
className="max-sm:w-full"
className="!w-full"
/>
</div>
<div className="space-y-2">
<p className="text-[#77828C]">
<Trans i18nKey={"setName.micDevice"}>Микрофон</Trans>
</p>
<div className="flex gap-2 items-center flex-wrap">
{micStatus === "checking" && (
<p className="text-sm text-[#77828C]">
<Trans i18nKey={"setName.micChecking"}>
Проверка микрофона...
</Trans>
</p>
)}
{micStatus === "success" && (
<div className="flex gap-2 items-center text-green-600">
<MicroOnIcon />
<span className="text-sm">
<Trans i18nKey={"setName.micSuccess"}>
Микрофон подключен
</Trans>
</span>
</div>
)}
{micStatus === "error" && (
<div className="flex gap-2 items-center">
<MicroOffIcon />
<span className="text-sm text-[#EB5757]">
{t("setName.micError")}
</span>
<Button
variant="secondary"
type="button"
onClick={() =>
videoStreamRef.current
? checkMicrophone()
: checkDevices()
}
>
{t("setName.retry")}
</Button>
</div>
)}
</div>
{audioDevices.length >= 1 && micStatus === "success" && (
<select
value={selectedAudioDeviceId}
onChange={handleAudioDeviceChange}
className="bg-white border border-[#DAE0E5] w-full h-10 px-2 py-2.5 rounded-lg text-sm outline-none max-sm:w-full"
>
{audioDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{getDeviceLabel(
device,
"setName.defaultMic",
"setName.micDevice"
)}
</option>
))}
</select>
)}
</div>
<div className="space-y-2">
<p className="text-[#77828C]">
<Trans i18nKey={"setName.cameraDevice"}>Камера</Trans>
</p>
<div className="flex gap-2 items-center flex-wrap">
{cameraStatus === "checking" && (
<p className="text-sm text-[#77828C]">
<Trans i18nKey={"setName.cameraChecking"}>
Проверка камеры...
</Trans>
</p>
)}
{cameraStatus === "success" && (
<div className="flex gap-2 items-center text-green-600">
<CameraOnIcon />
<span className="text-sm">
<Trans i18nKey={"setName.cameraSuccess"}>
Камера подключена
</Trans>
</span>
</div>
)}
{cameraStatus === "error" && (
<div className="flex gap-2 items-center">
<CameraOffIcon />
<span className="text-sm text-[#EB5757]">
{t("setName.cameraError")}
</span>
<Button
variant="secondary"
type="button"
onClick={() =>
audioStreamRef.current
? checkCamera()
: checkDevices()
}
>
{t("setName.retry")}
</Button>
</div>
)}
</div>
{videoDevices.length >= 1 && cameraStatus === "success" && (
<select
value={selectedVideoDeviceId}
onChange={handleVideoDeviceChange}
className="bg-white border border-[#DAE0E5] w-full h-10 px-2 py-2.5 rounded-lg text-sm outline-none max-sm:w-full"
>
{videoDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{getDeviceLabel(
device,
"setName.defaultCamera",
"setName.cameraDevice"
)}
</option>
))}
</select>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
+32 -4
View File
@@ -1,12 +1,36 @@
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { detectUserRegion } from "../utils/api";
import { useSearchParams } from "react-router-dom";
import { detectUserRegion, setUserRegion } from "../utils/api";
const SUPPORTED_LANGS = ["ru", "en"] as const;
export function useLanguageDetection() {
const { i18n } = useTranslation();
const [searchParams] = useSearchParams();
const userChoseLangFromUrl = useRef(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function detectLanguage() {
const langParam = searchParams.get("lang")?.toLowerCase();
// Приоритет 1: query-параметр ?lang=ru или ?lang=en — без запроса в API
if (langParam && SUPPORTED_LANGS.includes(langParam as (typeof SUPPORTED_LANGS)[number])) {
userChoseLangFromUrl.current = true;
setUserRegion(langParam === "ru" ? "RU" : "EN");
await i18n.changeLanguage(langParam);
setIsReady(true);
return;
}
// Если пользователь ранее выбрал язык через URL — не переопределять при навигации
if (userChoseLangFromUrl.current) {
setIsReady(true);
return;
}
// Приоритет 2: определение по региону
try {
const countryCode = await detectUserRegion();
@@ -17,11 +41,15 @@ export function useLanguageDetection() {
}
} catch (error) {
console.error("Failed to get country code:", error);
// Fallback to browser language detection (handled by i18next-browser-languagedetector)
// Оставляем fallbackLng из i18n (ru)
} finally {
setIsReady(true);
}
}
void detectLanguage();
}, [i18n]);
}, [i18n, searchParams]);
return { isReady };
}
+224 -54
View File
@@ -1,6 +1,5 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
const resources = {
ru: {
@@ -50,22 +49,8 @@ const resources = {
"Запись доступна в демонстрационном режиме.<br />Указанные при записи данные не будут сохранены.",
},
feedback: {
title: "Свяжитесь<br />с нами",
desc: "Хотите увеличить конверсию?<br />Давайте обсудим детали!",
form: {
field1: "Имя",
field2: "Телефон",
field3: "Опишите вашу задачу",
button: "Отправить",
desc1: {
text1: "Нажимая кнопку «Отправить», вы принимаете",
text1_1: "Нажимая кнопку «Записаться», вы принимаете",
link1: "условия использования",
text2: "и",
link2: "политику конфиденциальности",
},
desc2: "Звездочкой отмечены обязательные<br />для заполнения поля",
},
titleLead: "Хотите увеличить конверсию?",
titleRest: "Давайте обсудим детали.",
},
contacts: {
title: "Горячая линия",
@@ -76,9 +61,100 @@ const resources = {
},
},
footer: {
link: "Политика конфиденциальности",
text: "Все права защищены.",
call: "Позвонить",
write: "Написать",
phoneDisplay: "8 800 770 00 67",
phoneTel: "+78007700067",
emailAddress: "info@graff.tech",
legalAddress: "Юридический адрес:",
addressLine1: "620063, г. Екатеринбург,",
addressLine2: "ул. Большакова, д. 66, кв. 6",
mainStack: "Наш основной стек:",
stackLine: "Unreal Engine 5, C++",
requisites: "Реквизиты:",
inn: "ИНН: 6679174128",
kpp: "КПП: 667101001",
company: 'ООО "ГРАФФ.ЭСТЕЙТ"',
ogrn: "ОГРН 1246600010140",
skolkovoAlt: "Сколково",
privacy: "Политика конфиденциальности и обработки персональных данных",
copyright: "© 2026 GRAFF interactive. Все права защищены",
site: "graff.tech",
},
languageSwitcher: {
ru: "RU",
en: "EN",
ariaLabel: "Язык",
},
legalLinks: {
privacyConsent: "https://graff.estate/privacy-policy",
policy: "https://graff.estate/policy",
},
demos: {
titleLine1: "Доступные",
titleLine2: "демонстрации",
ctaTitle: "Расскажем и покажем как это работает на\u00a0созвоне",
ctaButton: "Оставить заявку",
description:
"Клиент из любой точки мира может посмотреть жилой комплекс, даже на нулевом этапе строительства. Он выберет лучшую планировку и оценит вид из окон своей будущей квартиры.",
},
streamingProject: {
watch: "Смотреть",
startDemo: "Начать демонстрацию",
},
requestDemo: {
titleLine1: "Запись",
titleLine2: "на удаленную",
titleLine3: "демонстрацию",
description:
"Запись на демонстрацию может быть оформлена в виде блока на сайте застройщика или жилого комплекса.",
cta: "Оставить заявку",
},
streamPlayer: {
caption:
"Модуль удаленных продаж GRAFF.estate доступен на\u00a0любых устройствах, для\u00a0демонстрации нужен только\u00a0интернет.",
},
leadForm: {
submitError: "Не удалось отправить заявку. Попробуйте позже.",
needTitle: "Нам нужно",
namePlaceholder: "Имя*",
emailPlaceholder: "Email*",
submit: "Оставить заявку",
consentBefore: "*Нажимая кнопку отправить, вы даете",
consentLinkData: "согласие на обработку персональных данных",
consentMiddle: "и принимаете",
consentLinkPolicy: "условия политики",
phonePlaceholder: "+7 (XXX) XXX-XX-XX",
phoneRequired: "Укажите номер телефона",
phoneInvalid: "Введите корректный номер телефона",
},
questionModal: {
phonePlaceholder: "+7 (XXX) XXX-XX-XX",
},
feedbackModal: {
successRich:
"Мы получили заявку <brMobile /> и скоро свяжемся <brDesktop /> с вами!",
sourcesTitle1: "Расскажите, пожалуйста,",
sourcesTitle2: "откуда вы узнали о нас?",
send: "Отправить",
skip: "Пропустить",
},
products: {
interactivePresentation: "Интерактивная презентация",
remoteDemo: "Удаленная демонстрация",
archViz: "Архитектурная визуализация",
webDev: "Создание сайтов",
webTour360: "Веб-тур по 360 сферам",
},
modalFeedbackSources: [
"Увидели на выставке или форуме",
"Видели у других застройщиков",
"Из рейтингов и статей",
"Нашли в интернете",
"Перешли по рекламе",
"Из рассылки",
"Другое",
],
sidebar: {
title1: "Дата и время",
title2: "Контакты",
@@ -174,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: "Ошибка при определении страны",
@@ -188,7 +265,8 @@ const resources = {
},
speedtest: {
pleaseWait: "Пожалуйста, подождите",
checkingConnection: "Проверяем качество вашего<br />интернет-соединения",
checkingConnection:
"Проверяем качество вашего<br />интернет-соединения",
checking: "Проверка",
secondsLeft: "Осталось {{count}} секунд",
},
@@ -199,6 +277,17 @@ const resources = {
name: "Имя",
skip: "Не указывать",
continue: "Продолжить",
micChecking: "Проверка микрофона...",
micSuccess: "Микрофон подключен",
micError: "Микрофон недоступен",
retry: "Повторить",
micDevice: "Микрофон",
defaultMic: "Микрофон по умолчанию",
cameraChecking: "Проверка камеры...",
cameraSuccess: "Камера подключена",
cameraError: "Камера недоступна",
cameraDevice: "Камера",
defaultCamera: "Камера по умолчанию",
},
chat: {
placeholder: "Написать сообщение...",
@@ -278,22 +367,8 @@ const resources = {
"The recording is available in demo mode.<br />The data specified during recording will not be saved.",
},
feedback: {
title: "Contact us",
desc: "Want to increase conversion?<br />Let's discuss the details!",
form: {
field1: "Name",
field2: "Phone",
field3: "Describe your task",
button: "Send",
desc1: {
text1: 'By clicking the "Submit" button, you accept the',
text1_1: 'By clicking the "Sign up" button, you accept the',
link1: "terms of use",
text2: "and",
link2: "privacy policy",
},
desc2: "Required fields are marked<br />with an asterisk",
},
titleLead: "Want to improve conversion?",
titleRest: "Let's discuss the details.",
},
contacts: {
title: "Hot line",
@@ -304,9 +379,100 @@ const resources = {
},
},
footer: {
link: "Privacy policy",
text: "All rights reserved.",
call: "Call",
write: "Write",
phoneDisplay: "+971 58 506 0097",
phoneTel: "+971585060097",
emailAddress: "sam@graff.tech",
legalAddress: "Legal address:",
addressLine1: "620063, Yekaterinburg,",
addressLine2: "Bolshakova St., 66, apt. 6",
mainStack: "Our core stack:",
stackLine: "Unreal Engine 5, C++",
requisites: "Company details:",
inn: "TIN: 6679174128",
kpp: "IEC: 667101001",
company: "GRAFF.ESTATE LLC",
ogrn: "PSRN 1246600010140",
skolkovoAlt: "Skolkovo",
privacy: "Privacy and personal data processing policy",
copyright: "© 2026 GRAFF interactive FZCO. All rights reserved",
site: "graff.tech",
},
languageSwitcher: {
ru: "RU",
en: "EN",
ariaLabel: "Language",
},
legalLinks: {
privacyConsent: "https://graffestate.ae/privacypolicy",
policy: "https://graffestate.ae/terms-conditions",
},
demos: {
titleLine1: "Available",
titleLine2: "demos",
ctaTitle: "We'll walk you through how it works on\u00a0a call",
ctaButton: "Request a call",
description:
"Clients anywhere in the world can explore a residential complex, even at the zero stage of construction. They can pick the best layout and assess the view from the windows of their future apartment.",
},
streamingProject: {
watch: "Watch",
startDemo: "Start demo",
},
requestDemo: {
titleLine1: "Book",
titleLine2: "a remote",
titleLine3: "demo",
description:
"A demo booking can be embedded as a block on the developer's or residential complex's website.",
cta: "Request a call",
},
streamPlayer: {
caption:
"GRAFF.estate Stream is available on\u00a0any device — all you need for a demo is an internet connection.",
},
leadForm: {
submitError: "Could not submit the request. Please try again later.",
needTitle: "We need",
namePlaceholder: "Name*",
emailPlaceholder: "Email*",
submit: "Submit request",
consentBefore: "*By submitting, you give",
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: "+XXXXXXXXXXXXXXX",
},
feedbackModal: {
successRich:
"We've received your request <brMobile /> and we'll contact you <brDesktop /> soon!",
sourcesTitle1: "Please tell us",
sourcesTitle2: "how you heard about us",
send: "Send",
skip: "Skip",
},
products: {
interactivePresentation: "Interactive presentation",
remoteDemo: "Remote demonstration",
archViz: "Architectural visualization",
webDev: "Website development",
webTour360: "360° web tour",
},
modalFeedbackSources: [
"Saw us at an exhibition or forum",
"Saw it with other developers",
"From rankings and articles",
"Found online",
"Came from an ad",
"From a newsletter",
"Other",
],
sidebar: {
title1: "Date and time",
title2: "Contacts",
@@ -427,6 +593,17 @@ const resources = {
name: "Name",
skip: "Skip",
continue: "Continue",
micChecking: "Checking microphone...",
micSuccess: "Microphone connected",
micError: "Microphone unavailable",
retry: "Retry",
micDevice: "Microphone",
defaultMic: "Default microphone",
cameraChecking: "Checking camera...",
cameraSuccess: "Camera connected",
cameraError: "Camera unavailable",
cameraDevice: "Camera",
defaultCamera: "Default camera",
},
chat: {
placeholder: "Write a message...",
@@ -446,20 +623,13 @@ const resources = {
},
};
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: "ru",
supportedLngs: ["ru", "en"],
detection: {
order: ["navigator", "htmlTag"],
caches: [], // Отключаем кеширование в localStorage
},
interpolation: {
escapeValue: false,
},
});
void i18n.use(initReactI18next).init({
resources,
fallbackLng: "ru",
supportedLngs: ["ru", "en"],
interpolation: {
escapeValue: false,
},
});
export default i18n;
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Product } from "@/landing/types";
import useAddReferer from "@/landing/hooks/useAddReferer";
import { useModalStore } from "@/landing/stores/useModalStore";
import FeedbackModal from "@/landing/components/modals/FeedbackFormModal";
import { LeadForm } from "@/landing/features/lead-form/LeadForm";
export function Feedback() {
useAddReferer();
const { t } = useTranslation();
return (
<div
id="contacts"
className="lg:mb-20 md:mb-12 lg:flex lg:gap-[0.833vw] max-lg:space-y-12 justify-between lg:mt-[14.07vh] mt-[100px] mb-10"
>
<h2 className="line2 font-medium max-lg:mb-6 lg:max-w-[45%]">
<span className="text-[#7A7A7A]">{t("feedback.titleLead")}</span>
<br />
{t("feedback.titleRest")}
</h2>
<FeedbackForm />
</div>
);
}
export function FeedbackForm() {
const { t, i18n } = useTranslation();
const { setModal } = useModalStore();
const defaultProducts = useMemo(
(): Product[] => [t("products.remoteDemo")],
[t]
);
return (
<div className="flex-1 space-y-4 lg:max-w-[47.431vw]">
<div className="space-y-10">
<LeadForm
key={i18n.language}
defaultProducts={defaultProducts}
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
/>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { PropsWithChildren } from "react";
import { useTranslation } from "react-i18next";
import ArrowMoreIcon from "@/landing/components/icons/ArrowMoreIcon";
import RutubeIcon from "@/landing/components/icons/RutubeIcon";
import TelegramIcon from "@/landing/components/icons/TgIcon";
import VkIcon from "@/landing/components/icons/VKIcon";
import YoutubeIcon from "@/landing/components/icons/YoutubeIcon";
export function Footer() {
const { t, i18n } = useTranslation();
const showRuLegal = i18n.language.startsWith("ru");
const isEn = i18n.language.startsWith("en");
return (
<footer className="lg:px-5 lg:pb-5 md:max-lg:px-4 md:max-lg:pb-4 px-[10px] pb-[10px] space-y-6 mb-0">
<div className="max-md:flex-col lg:gap-[0.833vw] md:max-lg:gap-[1.042vw] flex gap-[1.111vw]">
<a
href={`tel:${t("footer.phoneTel")}`}
className="lg:p-[1.667vw] p-6 flex flex-col justify-between max-md:gap-y-10 bg-[linear-gradient(to_top_left,#7A7A7A50,transparent)] lg:rounded-[1.111vw] rounded-2xl lg:aspect-[696/248] lg:w-[48.333vw] md:max-lg:w-[47.656vw] hover:bg-[#37393B99] bg-transparent transition-colors"
>
<div className="text-[#7A7A7A] text1 font-medium">{t("footer.call")}</div>
<div className="flex items-center font-medium lg:line2 md:max-lg:heading1 line2">
{t("footer.phoneDisplay")}
<div className="text-white lg:size-[5.556vw] size-[10vw]">
<ArrowMoreIcon />
</div>
</div>
</a>
<a
href={`mailto:${t("footer.emailAddress")}`}
className="lg:p-[1.667vw] p-6 flex flex-col justify-between max-md:gap-y-10 bg-[linear-gradient(to_top_left,#7A7A7A50,transparent)] lg:rounded-[1.111vw] rounded-2xl lg:aspect-[624/248] lg:w-[43.333vw] md:max-lg:w-[47.656vw] hover:bg-[#37393B99] bg-transparent transition-colors"
>
<div className="text-[#7A7A7A] text1 font-medium">{t("footer.write")}</div>
<div className="flex items-center font-medium lg:line2 md:max-lg:heading1 line2">
{t("footer.emailAddress")}
<div className="text-white lg:size-[5.556vw] size-[10vw]">
<ArrowMoreIcon />
</div>
</div>
</a>
<div className="gap-y-2 justify-between md:gap-x-[1.042vw] gap-x-[1.111vw] md:flex-col flex">
{!isEn ? (
<>
<ContactLink href="https://t.me/graffestate">
<div className="text-white lg:size-[1.389vw] size-[5.556vw] group-hover:text-black">
<TelegramIcon />
</div>
</ContactLink>
<ContactLink href="https://rutube.ru/channel/25505040">
<div className="text-white lg:size-[1.389vw] size-[5.556vw] group-hover:text-black">
<RutubeIcon />
</div>
</ContactLink>
<ContactLink href="https://vk.com/graff.estate">
<div className="text-white lg:size-[1.389vw] size-[5.556vw] group-hover:text-black">
<VkIcon />
</div>
</ContactLink>
</>
) : null}
<ContactLink href="https://www.youtube.com/@GRAFFtech">
<div className="text-white lg:size-[1.389vw] size-[5.556vw] group-hover:text-black">
<YoutubeIcon />
</div>
</ContactLink>
</div>
</div>
{showRuLegal ? (
<div className="lg:w-full flex flex-col md:flex-row md:max-lg:gap-[1.042vw] lg:gap-x-[0.833vw] gap-6 lg:pb-6 max-lg:py-6 pb-10 md:max-lg:pt-4 !max-md:mt-[11.111vw] border-b border-[#232425] relative">
<div className="flex flex-col gap-y-[1.111vw] lg:min-w-[48.193vw] flex-1">
<span className=" text1 text-[#7A7A7A]">{t("footer.legalAddress")}</span>
<span className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
{t("footer.addressLine1")}
<br />
{t("footer.addressLine2")}
</span>
<div className="flex flex-col gap-y-[1.111vw] lg:mt-[0px] md:mt-[2.083vw] mt-6">
<span className=" text1 text-[#7A7A7A]">{t("footer.mainStack")}</span>
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
<p>{t("footer.stackLine")}</p>
</div>
</div>
</div>
<div className="flex flex-col gap-y-[1.111vw] flex-1">
<span className="text1 text-[#7A7A7A]">{t("footer.requisites")}</span>
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
<p>{t("footer.inn")}</p>
<p>{t("footer.kpp")}</p>
<p>{t("footer.company")}</p>
<p>{t("footer.ogrn")}</p>
</div>
</div>
<img
src="/img/components/header/Sk.svg"
alt={t("footer.skolkovoAlt")}
className=" lg:hidden md:size-[6.25vw] size-[13.333vw] max-md:absolute max-md:right-0 max-md:bottom-6"
/>
<img
src="/img/components/header/Sk.svg"
alt={t("footer.skolkovoAlt")}
className="hidden lg:block lg:size-[3.333vw] lg:mt-[2.292vw] lg:self-start"
/>
</div>
) : null}
<div className="lg:gap-x-[0.833vw] gap-y-2 flex max-lg:flex-col">
<a
target="_blank"
rel="noopener noreferrer"
href={t("legalLinks.policy")}
className="text-[#37393B] text1 font-medium leading-[18.9px] lg:w-[48.193vw] w-fit"
>
{t("footer.privacy")}
</a>
<p className="text-[#37393B] text1 font-medium leading-[18.9px] col-start-1">
{t("footer.copyright")}
</p>
<a
target="_blank"
rel="noopener noreferrer"
href={"https://graff.tech"}
className="text-[#37393B] text1 font-medium leading-[18.9px] lg:ml-auto w-fit md:col-start-2 md:text-right"
>
{t("footer.site")}
</a>
</div>
</footer>
);
}
export function ContactLink({
children,
href,
className = "",
}: PropsWithChildren<{ href: string; className?: string }>) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`lg:rounded-[1.111vw] rounded-2xl bg-[#37393B99] lg:p-[1.25vw] p-[18px] hover:bg-white transition-all hover:text-black flex justify-center w-full group ${className}`}
>
{children}
</a>
);
}
+11
View File
@@ -0,0 +1,11 @@
import LanguageSwitchButton from "./LanguageSwitchButton";
export default function Header() {
return (
<header
className={`lg:mt-[1.389vw] md:mt-[2.083vw] mt-[2.778vw] relative flex justify-end items-center lg:px-[1.389vw] md:px-[2.083vw] px-[2.778vw]`}
>
<LanguageSwitchButton />
</header>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import type { AppLocale } from "@/landing/i18n";
import { setLangInUrl } from "@/landing/lib/urlLang";
export default function LanguageSwitchButton({
className,
}: {
className?: string;
}) {
const { i18n, t } = useTranslation();
const current = (i18n.language.startsWith("ru") ? "ru" : "en") as AppLocale;
function handleClick() {
const next: AppLocale = current === "ru" ? "en" : "ru";
void i18n.changeLanguage(next);
setLangInUrl(next);
}
return (
<button
type="button"
className={`btnm bg-[#37393B99] active:bg-[#37393B80]
lg:px-[1.667vw] lg:py-[1.181vw] lg:rounded-[0.833vw]
md:px-[2.604vw] md:py-[1.302vw] md:rounded-[1.563vw]
px-[5.556vw] py-[2.778vw] rounded-[3.333vw]
${className ?? ""}
`}
onClick={handleClick}
aria-label={t("languageSwitcher.ariaLabel")}
>
{current === "ru" ? "RU" : "EN"}
</button>
);
}
@@ -0,0 +1,18 @@
interface BRProps {
lg?: boolean;
md?: boolean;
sm?: boolean;
className?: string;
}
export default function BR({ lg, md, sm, className }: BRProps) {
const modifier =
!lg && !md && !sm
? ""
: `lg:${lg ? `block` : "hidden"} md:${md ? "block" : "hidden"} ${
sm ? "block" : "hidden"
} `;
const combinedClassName = `${modifier} ${className ?? ""}`;
return <br className={combinedClassName} />;
}
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { AppLocale } from "@/landing/i18n";
import { useLocationSearch } from "@/landing/hooks/useLocationSearch";
import { parseLangParam } from "@/landing/lib/urlLang";
import { useCountryCodeQuery } from "@/landing/queries/getCountryCode";
/** Applies resolved locale from `?lang=` or getCountryCode; syncs `<html lang>`. */
export function LocaleSync() {
const { i18n } = useTranslation();
const search = useLocationSearch();
const langFromUrl = parseLangParam(new URLSearchParams(search).get("lang"));
const { data, isSuccess, isError } = useCountryCodeQuery();
useEffect(() => {
if (langFromUrl) {
void i18n.changeLanguage(langFromUrl);
document.documentElement.lang = langFromUrl;
return;
}
if (!isSuccess && !isError) return;
const locale: AppLocale =
isSuccess && data?.countryCode === "RU" ? "ru" : "en";
void i18n.changeLanguage(locale);
document.documentElement.lang = locale;
}, [langFromUrl, isSuccess, isError, data, i18n]);
return null;
}
@@ -0,0 +1,27 @@
import { useModalStore } from "@/landing/stores/useModalStore";
import { useEffect } from "react";
import { createPortal } from "react-dom";
export function ModalContainer() {
const { modal, setModal } = useModalStore();
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === "Escape") setModal(null);
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, [setModal]);
const jsx = modal ? (
<div className="fixed inset-0 z-[20] flex justify-center items-start transition-opacity">
<div className="absolute [backdrop-filter:blur(16px)] bg-[#0F101199] w-full h-full z-[1]" />
{modal}
</div>
) : null;
return createPortal(jsx, document.body);
}
@@ -0,0 +1,14 @@
function ArrowMoreIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="m15.588 9.59-8.52 8.52v-2.94l7.049-7.05H6.332V6.04h11.336v11.336h-2.08z"
fill="currentColor"
/>
</svg>
);
}
export default ArrowMoreIcon;
@@ -0,0 +1,14 @@
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="m9.707 18 9.707-9.707L18 6.879l-8.293 8.293-4.293-4.293L4 12.293z"
fill="currentColor"
/>
</svg>
);
}
export default CheckIcon;
@@ -0,0 +1,14 @@
function CloseIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.343 4.929 12 10.586l5.657-5.657 1.414 1.414L13.414 12l5.657 5.657-1.414 1.414L12 13.414l-5.657 5.657-1.414-1.414L10.586 12 4.929 6.343z"
fill="currentColor"
/>
</svg>
);
}
export default CloseIcon;
@@ -0,0 +1,221 @@
function LogoHorIcon() {
return (
<svg viewBox="0 0 192 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#a)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24.214 48c13.38 0 24.215-10.738 24.215-24 0-1.618-.166-3.21-.47-4.745H33.332v9.518h4.718c-1.992 5.65-7.416 9.71-13.795 9.71-8.067 0-14.612-6.488-14.612-14.483S16.19 9.518 24.256 9.518V0h-.042C10.848 0 0 10.752 0 24c0 13.262 10.848 24 24.214 24"
fill="#798FFF"
/>
<path
d="M22.996.027C12.591 1.618 4.635 10.52 4.635 21.257c0 11.863 9.727 21.49 21.724 21.49s21.723-9.613 21.723-21.49c0-.686-.027-1.358-.097-2.03H33.318v9.504h4.719c-1.993 5.65-7.417 9.696-13.81 9.696-8.08 0-14.625-6.473-14.625-14.468 0-7.557 5.853-13.756 13.325-14.4.429-.041.858-.055 1.3-.055V0H24.2c-.401 0-.802.014-1.204.027"
fill="#D375FF"
/>
<path
opacity={0.3}
d="M21.406 9.806a16 16 0 0 1 2.823-.26V0H24.2c-1.95 0-3.846.22-5.659.645z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="M8.635 5.43 7.126 26.977l2.159-2.016c-.014-.247-.014-.494-.014-.755 0-7.981 6.517-14.468 14.598-14.564l-9.686-7.543a24 24 0 0 0-5.548 3.333"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="m0 24.274 11.028 5.856a14.2 14.2 0 0 1-1.356-6.075c0-2.167.484-4.238 1.37-6.076L7.666 6.651C2.947 11.012 0 17.198 0 24.055z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="m0 23.315 14.64 21.6 15.98.287-6.24-6.76h-.193c-8.053 0-14.584-6.433-14.584-14.36 0-.329.014-.644.027-.96H0z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="m10.627 43.79 9.312-6.02c-6.032-1.838-10.391-7.242-10.391-13.633 0-.823.07-1.632.208-2.413l-6.2 15.099a24.3 24.3 0 0 0 7.071 6.967"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="M32.048 46.738A25.6 25.6 0 0 1 24.133 48c-5.12 0-9.866-1.509-13.81-4.087l9.327-6.061c1.425.438 2.933.672 4.51.672h.18z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="M29.556 47.41c-1.744.384-3.556.59-5.424.59-.803 0-1.605-.041-2.394-.11L19.58 37.837c.29.096.58.179.885.247z"
fill="#000"
fillOpacity={0.4}
/>
<path
opacity={0.3}
d="m33.47 23.122 14.598-3.4-.083-.453H33.47z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="m36.517 28.731 11.567-9.449v-.013L33.846 28.73z"
fill="#000"
fillOpacity={0.4}
/>
<path
opacity={0.3}
d="m43.295 19.269-10.53 26.976a24.23 24.23 0 0 0 10.447-7.612l1.66-19.378h-1.577z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="M47.958 19.269 32.765 46.245c9.146-3.415 15.663-12.11 15.663-22.314 0-1.59-.166-3.154-.47-4.662"
fill="#000"
fillOpacity={0.4}
/>
<path
d="M38.467 0h9.603v9.463h-9.603zm9.601 9.463h-9.603l-4.995 4.91h9.243z"
fill="#798FFF"
/>
<path d="M38.465 9.463V0L33.47 5.253v9.106z" fill="#798FFF" />
<path
opacity={0.3}
d="M48.068 8.146v1.317l-5.286 4.896h-1.12V8.05zM33.47 14.359V5.116l6.711-.563 2.2.576z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="M37.746 1.056 33.47 5.389v8.983h5.7z"
fill="#000"
fillOpacity={0.6}
/>
<path
opacity={0.3}
d="m33.72 4.896-.25.275v9.202l5.369-5.074 2.504-.096 5.66.932-.54-.795-6.78-7.228z"
fill="#D375FF"
/>
<path
opacity={0.3}
d="M38.812 0h-.346L37.04 1.495l1.688.96zm7.487 11.273-3.334 3.086h-.582l.512-6.171 2.588-.48z"
fill="#000"
fillOpacity={0.6}
/>
<path d="M38.451 9.463h9.617V0H38.45z" fill="url(#b)" />
<path
d="M122.76 9.463h-7.223v4.882h6.642v3.62h-6.642v7.708h-4.621V5.815h11.859v3.648z"
fill="url(#c)"
/>
<path
d="M76.24 24.343a15 15 0 0 1-7.459 1.659 11.3 11.3 0 0 1-4.178-.59 10.5 10.5 0 0 1-3.584-2.07 10 10 0 0 1-2.255-3.374 9.3 9.3 0 0 1-.637-3.923 9.8 9.8 0 0 1 .72-4.114 10.5 10.5 0 0 1 2.393-3.524 11 11 0 0 1 3.806-2.277 11.8 11.8 0 0 1 4.483-.672c1.965-.041 3.916.26 5.77.878v4.183a11.94 11.94 0 0 0-5.77-1.33 6.4 6.4 0 0 0-2.546.424 6.1 6.1 0 0 0-2.117 1.372c-1.26 1.33-1.91 3.058-1.813 4.827-.11 1.715.457 3.388 1.619 4.732a5.7 5.7 0 0 0 1.965 1.275c.747.288 1.55.425 2.366.398.9.027 1.813-.124 2.643-.453v-3.84H67.55v-3.58h8.648z"
fill="url(#d)"
/>
<path
d="m104.275 17.829-2.2-6.652a9 9 0 0 1-.346-1.783h-.11a7.4 7.4 0 0 1-.346 1.728l-2.242 6.734zm7.542 7.844h-5.009l-1.439-4.416h-7.293l-1.438 4.416h-5.023l7.472-19.858h5.479z"
fill="url(#e)"
/>
<path
d="M82.094 9.161v5.527h1.992c.443.014.885-.041 1.287-.192.415-.137.788-.37 1.107-.645.29-.274.525-.603.691-.96.153-.356.236-.74.222-1.124 0-1.742-1.066-2.66-3.21-2.66zm12.591 16.512h-5.3l-3.196-5.101c-.249-.384-.484-.727-.692-1.043a4.5 4.5 0 0 0-.664-.782 2.9 2.9 0 0 0-.706-.507 1.9 1.9 0 0 0-.774-.164h-1.26v7.597h-4.607V5.815h7.306c4.995 0 7.471 1.81 7.471 5.403a5.1 5.1 0 0 1-.332 1.92 5.3 5.3 0 0 1-.927 1.591c-.401.466-.9.878-1.439 1.207a7 7 0 0 1-1.895.796c.318.096.608.246.885.438.305.22.581.467.83.727.277.288.526.576.761.892.25.315.457.644.665.946z"
fill="url(#f)"
/>
<path
d="M135.6 9.463h-7.223v4.882h6.642v3.62h-6.642v7.708h-4.635V5.815H135.6z"
fill="url(#g)"
/>
<path
d="M62.942 40.784q-1.494 0-2.59-.662-1.086-.662-1.691-1.862t-.605-2.816q0-1.672.596-2.892.594-1.218 1.672-1.88 1.087-.662 2.561-.662 1.512 0 2.58.7 1.067.69 1.616 1.984.548 1.295.5 3.09h-1.417v-.49q-.038-1.986-.86-2.997-.812-1.01-2.381-1.01-1.644 0-2.523 1.067-.87 1.068-.87 3.043 0 1.928.87 2.996.879 1.058 2.485 1.058 1.096 0 1.909-.5.822-.512 1.295-1.465l1.294.5q-.604 1.334-1.786 2.07-1.17.727-2.655.727m-3.903-5v-1.162h7.796v1.163zm13.881 4.99q-1.796 0-2.958-.775-1.152-.775-1.417-2.155l1.417-.236q.228.87 1.03 1.39.813.51 2.004.51 1.162 0 1.833-.482.671-.492.671-1.333 0-.472-.217-.765-.208-.302-.86-.558-.653-.255-1.947-.604-1.389-.379-2.173-.756-.785-.378-1.115-.87-.331-.5-.331-1.219 0-.87.491-1.521.492-.661 1.361-1.02.87-.37 2.022-.37 1.154 0 2.06.378.917.37 1.475 1.04.557.67.661 1.56l-1.417.254a2.04 2.04 0 0 0-.898-1.417q-.747-.53-1.9-.548-1.086-.029-1.767.416-.68.435-.68 1.162 0 .406.245.7.246.282.889.538.651.255 1.852.557 1.409.36 2.211.757.804.396 1.144.935.34.538.34 1.333 0 1.444-1.077 2.277-1.068.822-2.949.822m11.111-.274q-.86.18-1.7.142a3.8 3.8 0 0 1-1.494-.36 2.15 2.15 0 0 1-.992-1.001 2.95 2.95 0 0 1-.302-1.144q-.02-.585-.02-1.332V27.46h1.38v9.29q0 .642.01 1.077.02.424.198.756.34.633 1.078.756.746.122 1.842-.057zm-6.69-9.015v-1.191h6.69v1.19zm11.054 9.298q-1.152 0-1.937-.415-.775-.416-1.162-1.106a3 3 0 0 1-.388-1.503q0-.83.33-1.417.341-.595.918-.973.585-.379 1.35-.577a20 20 0 0 1 1.711-.33q.945-.151 1.843-.256a59 59 0 0 0 1.588-.217l-.492.302q.03-1.512-.586-2.24-.614-.727-2.135-.727-1.05 0-1.777.473-.719.471-1.011 1.493l-1.351-.397q.35-1.371 1.408-2.127t2.75-.756q1.398 0 2.372.53.983.519 1.389 1.512.19.444.245.992.057.548.057 1.115V40.5H92.28v-2.56l.36.15q-.52 1.314-1.617 2.004-1.095.69-2.627.69m.16-1.2q.975 0 1.702-.35a3.1 3.1 0 0 0 1.172-.954q.444-.615.576-1.38a5 5 0 0 0 .123-1.077q.01-.596.01-.888l.529.274q-.71.094-1.54.189-.823.095-1.626.217-.794.123-1.437.293-.435.123-.84.35-.407.217-.672.586-.255.368-.255.916 0 .445.218.86.226.417.718.69.501.274 1.323.274m12.412.917q-.86.18-1.701.142a3.8 3.8 0 0 1-1.493-.36 2.15 2.15 0 0 1-.992-1.001 2.9 2.9 0 0 1-.303-1.144q-.018-.585-.019-1.332V27.46h1.38v9.29q0 .642.01 1.077.018.424.198.756.34.633 1.077.756.747.122 1.843-.057zm-6.69-9.015v-1.191h6.69v1.19zm12.083 9.298q-1.493 0-2.589-.661-1.087-.662-1.692-1.862t-.605-2.816q0-1.672.596-2.892.595-1.218 1.672-1.88 1.088-.662 2.561-.662 1.512 0 2.58.7 1.068.69 1.616 1.984.549 1.295.501 3.09h-1.417v-.49q-.038-1.986-.86-2.997-.813-1.01-2.382-1.01-1.644 0-2.523 1.067-.87 1.068-.869 3.043 0 1.928.869 2.996.879 1.058 2.485 1.058 1.097 0 1.909-.5.822-.512 1.295-1.465l1.295.5q-.606 1.334-1.786 2.07-1.173.727-2.656.727m-3.903-4.998v-1.163h7.797v1.163z"
fill="url(#h)"
/>
</g>
<defs>
<linearGradient
id="b"
x1={43.264}
y1={0}
x2={43.264}
y2={9.46}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<linearGradient
id="c"
x1={116.828}
y1={5.445}
x2={116.828}
y2={26.017}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<linearGradient
id="d"
x1={67.176}
y1={5.445}
x2={67.176}
y2={26.016}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<linearGradient
id="e"
x1={101.711}
y1={5.445}
x2={101.711}
y2={26.017}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<linearGradient
id="f"
x1={86.08}
y1={5.445}
x2={86.08}
y2={26.017}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<linearGradient
id="g"
x1={129.675}
y1={5.445}
x2={129.675}
y2={26.017}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<linearGradient
id="h"
x1={88.71}
y1={20.1}
x2={88.71}
y2={46.5}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset={1} stopColor="#798FFF" />
</linearGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h192v48H0z" />
</clipPath>
</defs>
</svg>
);
}
export default LogoHorIcon;
+12
View File
@@ -0,0 +1,12 @@
function MuteIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.74 3.993a1 1 0 1 0-1.48 1.345l2.116 2.328h-2.71A1.667 1.667 0 0 0 2 9.333v5.333a1.667 1.667 0 0 0 1.667 1.667h3.656l5.73 4.456A1 1 0 0 0 14.667 20v-3.214l2.926 3.22a1 1 0 0 0 1.48-1.345zM4 9.666h2.667v4.667H4zm8.667 8.289-4-3.111v-4.658l4 4.397zM10.083 6.788a1 1 0 0 1 .176-1.404l2.793-2.173A1 1 0 0 1 14.667 4v5.245a1 1 0 0 1-2 0v-3.2l-1.18.917a1 1 0 0 1-1.404-.175zM16.25 10.9a1 1 0 0 1 1.5-1.32 3.67 3.67 0 0 1 .462 4.184 1 1 0 0 1-1.75-.963 1.67 1.67 0 0 0-.212-1.903zM22 12a7 7 0 0 1-1.593 4.446 1.002 1.002 0 0 1-1.663-.16 1 1 0 0 1 .12-1.111 5 5 0 0 0-.137-6.509 1.001 1.001 0 1 1 1.49-1.333A7 7 0 0 1 22 11.999"
fill="currentColor"
/>
</svg>
);
}
export default MuteIcon;
@@ -0,0 +1,12 @@
function PauseIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10 3H7L5 5v16h3l2-2zm9 0h-3l-2 2v16h3l2-2z"
fill="currentColor"
/>
</svg>
);
}
export default PauseIcon;
@@ -0,0 +1,9 @@
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12 6 21V3z" fill="currentColor" />
</svg>
);
}
export default PlayIcon;
@@ -0,0 +1,12 @@
function RutubeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.19 10.815H6.928V7.308h9.262c.54 0 .917.09 1.106.248.189.157.306.45.306.877v1.259c0 .45-.117.742-.306.9-.189.157-.565.224-1.106.224m.635-6.814H3V19h3.928v-4.88h7.239L17.602 19H22l-3.787-4.903c1.396-.198 2.023-.607 2.54-1.282s.776-1.753.776-3.193V8.497c0-.854-.094-1.529-.259-2.046a3.4 3.4 0 0 0-.846-1.37 3.9 3.9 0 0 0-1.459-.833C18.4 4.09 17.695 4 16.825 4z"
fill="currentColor"
/>
</svg>
);
}
export default RutubeIcon;
+14
View File
@@ -0,0 +1,14 @@
function TgIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.27 11.886a640 640 0 0 1 9.65-4.47C17.511 5.36 18.467 5 19.086 5c.13 0 .439.028.645.194.155.14.207.334.232.472.026.14.052.445.026.695-.258 2.804-1.316 9.662-1.883 12.8-.233 1.333-.697 1.777-1.136 1.833-.954.083-1.702-.694-2.631-1.333-1.445-1.027-2.271-1.666-3.69-2.666-1.626-1.166-.568-1.805.361-2.832.232-.277 4.49-4.415 4.567-4.804 0-.055.026-.222-.077-.305-.104-.083-.232-.056-.336-.028-.155.028-2.477 1.694-6.992 4.97-.671.5-1.265.723-1.806.723-.594 0-1.73-.361-2.58-.667-1.033-.36-1.858-.555-1.781-1.166.077-.333.49-.667 1.264-1"
fill="currentColor"
/>
</svg>
);
}
export default TgIcon;
@@ -0,0 +1,12 @@
function UnmutedIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.106 3.102a1 1 0 0 0-1.053.109l-5.73 4.456H3.667A1.667 1.667 0 0 0 2 9.333v5.334a1.667 1.667 0 0 0 1.667 1.666h3.656l5.73 4.456A1 1 0 0 0 14.667 20V4a1 1 0 0 0-.561-.898M4 9.667h2.667v4.666H4zm8.667 8.288-4-3.11v-5.69l4-3.11zm6-5.955c0 .893-.326 1.756-.917 2.426a1 1 0 0 1-1.5-1.324 1.67 1.67 0 0 0 0-2.202 1 1 0 0 1 1.5-1.322c.59.669.916 1.53.917 2.422M22 12a7 7 0 0 1-1.782 4.667 1 1 0 0 1-1.491-1.334 5 5 0 0 0 0-6.666 1 1 0 1 1 1.49-1.334A7 7 0 0 1 22 12"
fill="currentColor"
/>
</svg>
);
}
export default UnmutedIcon;
+14
View File
@@ -0,0 +1,14 @@
function VKIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.085 6H1.788C1.13 6 1 6.297 1 6.625c0 .586.779 3.49 3.627 7.33C6.525 16.578 9.2 18 11.634 18c1.46 0 1.64-.316 1.64-.86v-1.982c0-.632.139-.758.602-.758.34 0 .925.164 2.288 1.429C17.72 17.327 17.978 18 18.854 18h2.298c.656 0 .984-.316.795-.94-.207-.62-.95-1.521-1.938-2.59-.536-.608-1.34-1.264-1.582-1.592-.341-.421-.243-.609 0-.983 0 0 2.799-3.794 3.092-5.082.145-.469 0-.813-.696-.813h-2.297c-.584 0-.853.297-.999.625 0 0-1.168 2.74-2.823 4.52-.536.515-.78.68-1.071.68-.146 0-.357-.165-.357-.633v-4.38c0-.561-.17-.812-.657-.812H9.01c-.365 0-.584.26-.584.508 0 .533.827.656.912 2.154v3.256c0 .714-.134.843-.427.843-.778 0-2.673-2.752-3.796-5.901-.22-.614-.442-.86-1.029-.86"
fill="currentColor"
/>
</svg>
);
}
export default VKIcon;
@@ -0,0 +1,14 @@
function YoutubeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.814 5.423a2.52 2.52 0 0 1 1.768 1.79C22 8.79 22 12.082 22 12.082s0 3.293-.418 4.871a2.52 2.52 0 0 1-1.768 1.79c-1.56.423-7.814.423-7.814.423s-6.254 0-7.814-.423a2.52 2.52 0 0 1-1.768-1.79C2 15.377 2 12.084 2 12.084s0-3.294.418-4.872a2.52 2.52 0 0 1 1.768-1.79C5.746 5 12 5 12 5s6.254 0 7.814.423m-9.48 3.744V15l5-2.917z"
fill="currentColor"
/>
</svg>
);
}
export default YoutubeIcon;
@@ -0,0 +1,93 @@
import { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/landing/ui/Button";
import { MAIL_REQUEST_FROM } from "@/mailFrom";
import { mailApi } from "@/landing/lib/api";
import { useModalStore } from "@/landing/stores/useModalStore";
import CustomCheckbox from "@/landing/ui/CustomCheckbox";
import CheckIcon from "@/landing/components/icons/CheckIcon";
function FeedbackModal({ id }: { id: string }) {
const { t } = useTranslation();
const { setModal } = useModalStore();
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const modalOptions = useMemo(
() => t("modalFeedbackSources", { returnObjects: true }) as string[],
[t]
);
async function sendSources() {
await mailApi.put(`mail/${id}`, {
json: { source: selectedOptions, from: MAIL_REQUEST_FROM },
});
setModal(null);
}
function onCheckboxChange(value: string, checked: boolean) {
if (checked) setSelectedOptions([...selectedOptions, value]);
else {
const updated = [...selectedOptions].filter((item) => item !== value);
setSelectedOptions(updated);
}
}
return (
<div className="fixed top-[50%] translate-y-[-50%] flex md:flex-row flex-col md:max-w-[695px] max-w-[422px] max-[360px]:max-w-[340px] z-[15] md:p-[48px] p-[32px] max-[360px]:p-[24px] bg-[#37393B99] backdrop-blur-xl backdrop-opacity-60 rounded-2xl text-white">
<div className="flex md:flex-col flex-row md:justify-center items-center md:max-w-[200px] md:gap-y-[16px] gap-x-[24px]">
<div className="p-3 rounded-full bg-gradient translate-x-[4px]">
<div className="z-10 absolute top-[-4px] left-[-4.3px] w-[56px] h-[56px] rounded-full bg-gradient-to-r from-[#6078F299] to-[#C868F599]" />
<div className="text-white lg:size-[1.389vw] size-4 relative z-20">
<CheckIcon />
</div>
</div>
<span className="text-xl leading-6 md:text-center text-start max-[360px]:text-base">
<Trans
i18nKey="feedbackModal.successRich"
components={{
brMobile: <br className="md:hidden block" />,
brDesktop: <br className="md:block hidden" />,
}}
/>
</span>
</div>
<div className="md:w-[5px] md:mx-[48px] md:my-[0px] md:h-auto h-[2px] my-[24px] bg-[#37393B] rounded-sm "></div>
<div>
<div className="text-xl leading-6 mb-[20px] max-w-[250px] max-[360px]:text-base">
{t("feedbackModal.sourcesTitle1")}
<br />
{t("feedbackModal.sourcesTitle2")}
</div>
<ul className="md:mb-[49px] mb-[58px] flex flex-col gap-y-[12px]">
{modalOptions.map((item) => (
<li key={item} className="font-normal">
<CustomCheckbox value={item} onChange={onCheckboxChange} />
</li>
))}
</ul>
<div className="flex flex-row gap-x-[12px] ">
<Button
onClick={sendSources}
className="md:px-[31px] max-[360px]:px-[32px] px-[43px] py-[15px] rounded-2xl max-[360px]:text-sm"
>
{t("feedbackModal.send")}
</Button>
<Button
onClick={() => setModal(null)}
className="md:px-[31px] px-[43px] max-[360px]:px-[32px] py-[15px] rounded-2xl bg-[#37393B] max-[360px]:text-sm"
color="secondary"
>
{t("feedbackModal.skip")}
</Button>
</div>
</div>
</div>
);
}
export default FeedbackModal;
@@ -0,0 +1,53 @@
import { useMemo, useRef, type RefObject } from "react";
import { useOnClickOutside } from "usehooks-ts";
import { useTranslation } from "react-i18next";
import { useModalStore } from "@/landing/stores/useModalStore";
import type { Product } from "@/landing/types";
import FeedbackModal from "./FeedbackFormModal";
import CloseIcon from "@/landing/components/icons/CloseIcon";
import { LeadForm } from "@/landing/features/lead-form/LeadForm";
interface QuestionFormModalProps {
products?: Product[];
}
export default function QuestionFormModal({
products,
}: QuestionFormModalProps) {
const { t, i18n } = useTranslation();
const { setModal } = useModalStore();
const defaultModalProducts = useMemo((): Product[] => {
return [t("products.webDev"), t("products.webTour360")];
}, [t]);
const formRef = useRef<HTMLDivElement>(null);
useOnClickOutside(formRef as RefObject<HTMLElement>, () => {
setModal(null);
});
return (
<div
ref={formRef}
className="p-[3.333vw] w-[64.514vw] backdrop-blur-[20px] rounded-[1.111vw] z-10 bg-[radial-gradient(circle_at_bottom_right,rgba(24,25,26,0.84)_0%,rgba(45,46,47,0.86)_100%)] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-md:w-[94.444vw] max-md:px-[6.667vw] max-md:py-[5.556vw] max-md:rounded-[4.444vw]"
>
<button
type="button"
onClick={() => setModal(null)}
className="absolute lg:p-4 p-0 lg:top-[1.667vw] md:top-[3.125vw] top-[3.333vw] right-[3.333vw] md:right-[3.125vw] cursor-pointer"
>
<div className="text-white lg:size-[1.667vw] md:size-[3.125vw] size-[6.667vw]">
<CloseIcon />
</div>
</button>
<LeadForm
key={i18n.language}
defaultProducts={products ?? defaultModalProducts}
idPrefix="modal-"
phonePlaceholder={t("questionModal.phonePlaceholder")}
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
/>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { IProject } from "@/landing/types";
const BASE: Omit<
IProject,
| "id"
| "title"
| "englishTitle"
| "city"
| "englishCity"
| "buildFilename"
| "image"
> = {
description: "",
stage: 0,
releaseDate: "2026-01-01T00:00:00.000Z",
tags: [],
};
const RU_PROJECTS: IProject[] = [
{
...BASE,
id: "revolution-towers",
title: "МФК «Revolution towers»",
englishTitle: "Revolution Towers",
city: "Россия, Екатеринбург",
englishCity: "Russia, Yekaterinburg",
buildFilename: "nksJukovaDev",
image: "/img/projects/nks.jpg",
},
{
...BASE,
id: "life-residence",
title: "ЖК «Life Резиденция»",
englishTitle: "Life Residence",
city: "Россия, Тюмень",
englishCity: "Russia, Tyumen",
buildFilename: "lifeResidence",
image: "/img/projects/liferes.jpg",
},
];
const EN_PROJECTS: IProject[] = [
{
...BASE,
id: "upside-towers",
title: "Upside Towers",
englishTitle: "Upside Towers",
city: "Russia, Moscow",
englishCity: "Russia, Moscow",
buildFilename: "upsideTowersDevEn",
image: "/img/projects/upside.jpg",
},
];
/** Demo projects for the streaming section, selected by UI language. */
export function getStreamingProjects(i18nLanguage: string): IProject[] {
return i18nLanguage.startsWith("ru") ? RU_PROJECTS : EN_PROJECTS;
}
+204
View File
@@ -0,0 +1,204 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MAIL_REQUEST_FROM } from "@/mailFrom";
import { mailApi } from "@/landing/lib/api";
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 {
Controller,
FormProvider,
useForm,
type SubmitHandler,
} from "react-hook-form";
import type { LeadFormValues } from "./types";
export type { LeadFormValues } from "./types";
export function LeadForm({
defaultProducts,
idPrefix = "",
onSuccess,
phonePlaceholder,
formClassName = "lg:space-y-[1.944vw] md:max-lg:space-y-7 space-y-3",
}: {
defaultProducts: Product[];
/** Префикс для id полей (например `modal-`), чтобы избежать дублей в DOM */
idPrefix?: string;
onSuccess: (id: string) => void;
phonePlaceholder?: string;
formClassName?: string;
}) {
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]);
const form = useForm<LeadFormValues>({
defaultValues: {
fullname: "",
email: "",
phone: "",
products: defaultProducts,
},
});
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);
try {
const { id } = await mailApi
.post("mail", {
json: { ...data, referer, from: MAIL_REQUEST_FROM },
})
.json<{ id: string }>();
onSuccess(id);
} catch {
setSubmitError(t("leadForm.submitError"));
}
};
return (
<FormProvider {...form}>
<form
className={formClassName}
onSubmit={handleSubmit(onSubmit)}
noValidate
>
{submitError ? (
<p className="text-sm text-red-400" role="alert">
{submitError}
</p>
) : null}
<div className="lg:space-y-[1.111vw] space-y-4">
<p className="font-medium heading2">{t("leadForm.needTitle")}</p>
<CheckboxesGroup<LeadFormValues>
name="products"
options={projectOptions}
/>
</div>
<input
id={nameId}
autoComplete="none"
type="text"
required
placeholder={t("leadForm.namePlaceholder")}
{...register("fullname")}
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:btnl btnl placeholder:font-medium placeholder:select-none"
/>
<input
autoComplete="none"
required
id={emailId}
type="email"
placeholder={t("leadForm.emailPlaceholder")}
{...register("email")}
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none btnl outline-none transition-all w-full placeholder:btnl placeholder:font-medium placeholder:select-none"
/>
<div className="flex gap-x-3 py-2 border-[#3D425C] relative">
<Controller
name="phone"
control={control}
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={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>{" "}
<a
target="_blank"
rel="noopener noreferrer"
href={t("legalLinks.privacyConsent")}
className="underline"
>
{t("leadForm.consentLinkData")}
</a>{" "}
<span className="text-[#7A7A7A]">
{t("leadForm.consentMiddle")}{" "}
</span>
<a
target="_blank"
rel="noopener noreferrer"
href={t("legalLinks.policy")}
className="underline"
>
{t("leadForm.consentLinkPolicy")}
</a>
</div>
</div>
</form>
</FormProvider>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Product } from "@/landing/types";
export interface LeadFormValues {
fullname: string;
phone: string;
email: string;
products: Product[];
}
@@ -0,0 +1,134 @@
import {
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useTranslation } from "react-i18next";
import BR from "@/landing/components/Layout/LineBreak";
import { getStreamingProjects } from "@/landing/data/streamingProjects";
import { StreamingProject } from "./StreamingProject";
import { useSwipeable } from "react-swipeable";
import { useMediaQueries } from "@/landing/hooks/useMediaQueries";
export default function AvailableDemos() {
const { t, i18n } = useTranslation();
const { isMd } = useMediaQueries();
const projects = useMemo(
() => getStreamingProjects(i18n.language),
[i18n.language]
);
const [current, setCurrent] = useState(0);
const slideCount = projects.length + 1;
const handlers = useSwipeable({
onSwipedLeft: () =>
setCurrent((prev) => (prev + 1) % Math.max(slideCount, 1)),
onSwipedRight: () =>
setCurrent((prev) => (prev + projects.length) % Math.max(slideCount, 1)),
trackMouse: true,
preventScrollOnSwipe: true,
touchEventOptions: { passive: false },
});
const sliderRef = useRef<HTMLDivElement>(null);
function onSliderMouseDown(e: ReactMouseEvent<HTMLDivElement>) {
const root = sliderRef.current;
if (!root) return;
const startX = e.clientX;
const startScrollLeft = root.scrollLeft;
function onMouseMove(ev: MouseEvent) {
if (!root) return;
const dx = ev.clientX - startX;
root.scrollLeft = startScrollLeft - dx;
}
function onMouseUp() {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}
return (
<div>
<div className="flex lg:flex-row flex-col lg:mb-[4.444vw] md:mb-[8.333vw] lg:gap-[0.833vw] md:gap-[3.125vw]">
<h2 className="line2 max-md:line1 w-full max-md:mb-[5.556vw]">
{t("demos.titleLine1")} <BR lg sm /> {t("demos.titleLine2")}
</h2>
<div
className="grid lg:hidden md:hidden grid-cols-3 gap-3 px-[5vw] [scrollbar-width:none] relative max-md:aspect-[340/344] [transform-style:preserve-3d] items-stretch mb-[5.556vw]"
{...handlers}
>
{projects.map((project, index, { length }) => (
<StreamingProject
key={project.id}
{...project}
index={index}
current={current}
count={length + 1}
href="/"
className=""
/>
))}
<div
className={`bg-gradient-to-r from-[#232425] to-[#1A1A1C] [background:linear-gradient(to_right,#232425,#1A1A1C)] p-0.5 rounded-2xl flex flex-1 justify-center !duration-500 items-center group max-md:absolute max-md:inset-x-5 max-md:w-auto self-stretch max-md:h-full transition-[scale,transform] will-change-[transform,scale] select-none ${
slideCount - 1 === current
? "max-md:[transform:translateZ(40px)]"
: slideCount - 1 === (current + 1) % slideCount
? "max-md:[transform:translateX(calc(7.5%+20px))_scale(0.85)]"
: slideCount - 1 === (current - 1 + slideCount) % slideCount
? "max-md:[transform:translateX(calc(-7.5%-20px))_scale(0.85)]"
: "max-md:[transform:scale(0.85)]"
}`}
>
<div className="md:bg-[#0F1011] h-full w-full lg:rounded-[1.111vw] rounded-2xl flex items-center justify-center p-6">
<div className="flex flex-col items-center space-y-6">
<p className="heading2 font-medium text-center">
{t("demos.ctaTitle")}
</p>
<a
href="#contacts"
className="btnm font-medium group-hover:scale-105 duration-500 lg:px-[1.667vw] lg:py-[1.181vw] px-6 py-[17px] transition-transform lg:rounded-[0.833vw] rounded-xl bg-gradient-saturated"
>
{t("demos.ctaButton")}
</a>
</div>
</div>
</div>
</div>
<div className="lg:headline1 headline2 text-[#7A7A7A] w-full md:max-w-[75vw]">
<p className="lg:mr-[7vw]">{t("demos.description")}</p>
</div>
</div>
<div
ref={sliderRef}
onMouseDown={isMd ? onSliderMouseDown : undefined}
className="max-md:hidden flex md:-mx-[2.604vw] md:w-[calc(100%+5.208vw)] md:px-[2.604vw] lg:gap-[0.833vw] md:gap-[1.563vw] lg:h-[27.5vw] md:h-[51.563vw] md:overflow-x-scroll hide-scrollbars lg:overflow-x-visible max-md:cursor-grab active:cursor-grabbing lg:cursor-default select-none touch-pan-x"
>
{projects.map((project, index, { length }) => (
<div
key={project.id}
className="w-full min-w-0 flex-1 basis-0 shrink"
>
<StreamingProject
{...project}
index={index}
current={current}
count={length + 1}
href="/"
className="w-full"
/>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import BR from "@/landing/components/Layout/LineBreak";
import { Button } from "@/landing/ui/Button";
import QuestionFormModal from "@/landing/components/modals/QuestionFormModal";
import { useModalStore } from "@/landing/stores/useModalStore";
export default function RequestForDemo() {
const { t } = useTranslation();
const { setModal } = useModalStore();
return (
<div
className="
flex max-md:flex-col flex-row lg:gap-[0.833vw] md:gap-[2.865vw] gap-[11.111vw]
lg:-mx-[1.389vw] lg:w-[calc(100%+2.778vw)] lg:pl-[1.389vw] lg:pr-0
md:-mx-[{...handlers}] md:w-[calc(100%+4.167vw)] md:pr-[2.083vw]
"
>
<div className="flex flex-col lg:max-w-[31.944vw] min-h-full">
<h2 className="line2 max-md:mb-[6.667vw]">
{t("requestDemo.titleLine1")} <BR lg md /> {t("requestDemo.titleLine2")}
<BR /> {t("requestDemo.titleLine3")}
</h2>
<div className="flex flex-col lg:gap-[2.222vw] md:gap-[4.167vw] mt-auto">
<p className="lg:headline1 headline2 text-[#7A7A7A]">
{t("requestDemo.description")}
</p>
<Button
onClick={() => {
setModal(
<QuestionFormModal products={[t("products.remoteDemo")]} />
);
}}
className="max-md:hidden btnl bg-gradient-saturated lg:py-[1.389vw] lg:px-[2.222vw] md:py-[2.604vw] md:px-[4.167vw] md:rounded-[2.083vw] lg:rounded-[1.111vw]"
>
{t("requestDemo.cta")}
</Button>
</div>
</div>
<video
poster="/img/showreel.png"
src="/videos/composition.mp4"
loop
autoPlay
muted
playsInline
className="lg:h-[44.444vw] md:h-[57.292vw] h-[122.222vw] lg:rounded-[1.111vw_0_0_1.111vw] md:rounded-[2.083vw_0_0_2.083vw] max-md:rounded-[4.444vw] object-cover overflow-hidden"
/>
</div>
);
}
@@ -0,0 +1,15 @@
import { Feedback } from "@/landing/components/Layout/Feedback";
import AvailableDemos from "./AvailableDemos";
import StreamPlayer from "./StreamPlayer";
export default function StreamDemo() {
return (
<div className="lg:space-y-[140px] space-y-[100px]">
<AvailableDemos />
<div className="w-full shrink-0 aspect-[340/600] max-h-[85dvh] md:max-lg:aspect-[736/480] md:max-lg:max-h-[80dvh] lg:aspect-auto lg:h-[44.444vw] lg:max-h-none">
<StreamPlayer className="h-full min-h-[12rem]" />
</div>
<Feedback />
</div>
);
}
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { VideoPlayer } from "@/landing/ui/VideoPlayer";
const viteBase = import.meta.env.BASE_URL;
const STREAMING_VIDEO_SRC =
!viteBase || viteBase === "/"
? "/videos/streaming.mp4"
: `${viteBase.replace(/\/$/, "")}/videos/streaming.mp4`;
export default function StreamPlayer({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<div className={`w-full ${className ?? ""}`}>
<VideoPlayer
src={STREAMING_VIDEO_SRC}
showMutingBtn
loop
autoPlay
muted
className="lg:aspect-[1400/640] max-h-dvh md:max-lg:aspect-[736/480] aspect-[340/600]"
>
<p className="absolute font-medium md:bottom-6 md:left-6 bottom-4 left-4 lg:max-w-[40%] md:max-lg:max-w-[80%] w-[85vw] accent">
{t("streamPlayer.caption")}
</p>
</VideoPlayer>
</div>
);
}
@@ -0,0 +1,100 @@
import { useTranslation } from "react-i18next";
import { streamDemoUrlFromBuild } from "@/landing/lib/streamDemoUrl";
import { resolveProjectImageSrc } from "@/landing/lib/resolveProjectImageSrc";
import type { IProject } from "@/landing/types";
import ArrowMoreIcon from "@/landing/components/icons/ArrowMoreIcon";
export function StreamingProject({
city,
title,
image,
href,
buildFilename,
company,
index,
current,
count,
className,
}: Pick<IProject, "city" | "title" | "image" | "company" | "buildFilename"> & {
href: string;
index: number;
current: number;
count: number;
className?: string;
}) {
const { t } = useTranslation();
const imgSrc = resolveProjectImageSrc(image);
const build = buildFilename?.trim() ?? "";
const streamHref = build ? streamDemoUrlFromBuild(build) : href;
return (
<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)]"
: index === (current + 1) % count
? "max-md:[transform:translateX(calc(7.5%+20px))_scale(0.85)]"
: index === (current - 1 + count) % count
? "max-md:[transform:translateX(calc(-7.5%-20px))_scale(0.85)]"
: "max-md:[transform:scale(0.85)]"
} ${className ?? ""}`}
>
<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="size-full object-cover object-bottom absolute inset-0 z-0"
draggable={false}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-[1] rounded-2xl bg-[linear-gradient(to_bottom,rgba(0,0,0,0.45),transparent)] lg:bg-[linear-gradient(to_top,rgba(0,0,0,0.45),transparent)]"
/>
</div>
<div className="relative z-[2] lg:self-end space-y-3 font-medium">
<p className="heading1 font-medium">{title}</p>
<div className="flex flex-wrap gap-1">
{company && (
<div className="px-2 py-1.5 flex lg:gap-[0.278vw] gap-1 items-center lg:rounded-[1.181vw] rounded-2xl [backdrop-filter:blur(12px)] bg-[#37393B99] btns">
<div
className="lg:w-[0.556vw] lg:h-[0.556vw] w-2 h-2 rounded-full m-1"
style={{ backgroundColor: company.color }}
/>
{company.title}
</div>
)}
<p className="lg:px-[0.833vw] lg:py-[0.486vw] px-3 py-[7px] bg-[#37393B99] [backdrop-filter:blur(12px)] lg:rounded-[1.181vw] rounded-2xl btns">
{city}
</p>
</div>
</div>
<div className="z-[2] lg:hidden absolute right-4 bottom-4">
<a
className="bg-gradient-saturated btns flex gap-2 items-center px-3 py-2 font-medium rounded-xl"
href={streamHref}
>
{t("streamingProject.watch")}
<div className="text-white lg:size-[1.389vw] size-4">
<ArrowMoreIcon />
</div>
</a>
</div>
<a
href={streamHref}
className="max-lg:hidden lg:group-hover:opacity-100 opacity-0 transition-opacity duration-500 absolute w-full h-full left-0 bottom-0 md:max-lg:rounded-2xl rounded-xl font-medium [backdrop-filter:blur(3px)] content-center text-center z-[3]"
>
<div className="btnl flex gap-2 justify-center">
{t("streamingProject.startDemo")}{" "}
<div className="text-white lg:size-[1.389vw] size-4">
<ArrowMoreIcon />
</div>
</div>
</a>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { useRefererStore } from "@/landing/stores/useRefererStore";
import { useEffect } from "react";
export default function useAddReferer() {
const { setReferer } = useRefererStore();
useEffect(() => {
const referer = new URLSearchParams(window.location.search).get("ref");
if (!referer) return;
setReferer(referer);
}, [setReferer]);
return null;
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import ky from "ky";
import { Bounce, toast } from "react-toastify";
import type { TFunction } from "i18next";
import type { NavigateFunction } from "react-router-dom";
import {
detectUserRegion,
getRegionHeaders,
getUserRegion,
} from "@/utils/api";
import { handleApiError, isErrorResponse } from "@/utils/errorHandler";
type AnyRecord = Record<string, unknown>;
/**
* Landing-level side-effect: when `?build=...` is present in the URL,
* reproduce the legacy behavior of the old main page — detect region,
* call the coord `/start` endpoint and navigate to `/stream/:id`.
*
* This keeps existing marketing links (`/?build=nksJukovaDev&type=demo&...`)
* working after replacing the main page design.
*/
export function useAutoStartFromQuery(
searchParams: URLSearchParams,
navigate: NavigateFunction,
t: TFunction
) {
const build = searchParams.get("build");
const type = searchParams.get("type") || "demo";
const endAt = searchParams.get("endAt");
const location = searchParams.get("location") || "a1";
const [streamUrl, setStreamUrl] = useState<string | undefined>();
const [regionDetected, setRegionDetected] = useState(false);
useEffect(() => {
async function initializeRegion() {
if (getUserRegion()) {
setRegionDetected(true);
return;
}
try {
await detectUserRegion();
} catch (error) {
console.error("Failed to detect user region:", error);
} finally {
setRegionDetected(true);
}
}
void initializeRegion();
}, []);
useEffect(() => {
if (!build || !regionDetected) return;
async function startStream(buildFilename: string) {
try {
const response = (await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/start?location=${location}&build=${buildFilename}&type=${type}&endAt=${endAt ?? ""}`,
{ headers: getRegionHeaders() }
)
.json()) as AnyRecord;
if (isErrorResponse(response)) {
handleApiError(response, t, navigate);
return;
}
if (typeof response.stream === "string" && response.stream) {
setStreamUrl(`/stream/${response.stream}`);
} else if (typeof response.error === "string" && response.error) {
toastError(response.error);
} else {
toastError(t("errors.unknownError") as string);
}
} catch (error) {
if (error instanceof Error) {
toastError(`${t("errors.networkError")}: ${error.message}`);
}
}
}
void startStream(build);
}, [build, type, endAt, location, regionDetected, navigate, t]);
useEffect(() => {
if (!streamUrl) return;
navigate(streamUrl);
}, [streamUrl, navigate]);
}
function toastError(text: string) {
toast.error(text, {
position: "top-center",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
transition: Bounce,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
/** Re-renders when `location.search` changes (popstate or custom `locationchange`). */
export function useLocationSearch(): string {
const [search, setSearch] = useState(() => window.location.search);
useEffect(() => {
const sync = () => setSearch(window.location.search);
window.addEventListener("popstate", sync);
window.addEventListener("locationchange", sync);
return () => {
window.removeEventListener("popstate", sync);
window.removeEventListener("locationchange", sync);
};
}, []);
return search;
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
function readBreakpoints(lg: number, md: number, sm: number) {
if (typeof window === "undefined" || typeof window.matchMedia === "undefined") {
return {
isLg: false,
isMd: false,
isSm: false,
isXs: false,
};
}
const lgMedia = matchMedia(`(min-width: ${lg}px)`);
const mdMedia = matchMedia(
`(max-width: ${lg - 1}px) and (min-width: ${md}px)`
);
const smMedia = matchMedia(
`(max-width: ${md - 1}px) and (min-width: ${sm}px)`
);
const xsMedia = matchMedia(`(max-width: ${sm - 1}px)`);
return {
isLg: lgMedia.matches,
isMd: mdMedia.matches,
isSm: smMedia.matches,
isXs: xsMedia.matches,
};
}
export function useMediaQueries(lg = 1440, md = 768, sm = 640) {
const [state, setState] = useState(() => readBreakpoints(lg, md, sm));
useEffect(() => {
const lgMedia = matchMedia(`(min-width: ${lg}px)`);
const mdMedia = matchMedia(
`(max-width: ${lg - 1}px) and (min-width: ${md}px)`
);
const smMedia = matchMedia(
`(max-width: ${md - 1}px) and (min-width: ${sm}px)`
);
const xsMedia = matchMedia(`(max-width: ${sm - 1}px)`);
const sync = () => setState(readBreakpoints(lg, md, sm));
lgMedia.addEventListener("change", sync);
mdMedia.addEventListener("change", sync);
smMedia.addEventListener("change", sync);
xsMedia.addEventListener("change", sync);
sync();
return () => {
lgMedia.removeEventListener("change", sync);
mdMedia.removeEventListener("change", sync);
smMedia.removeEventListener("change", sync);
xsMedia.removeEventListener("change", sync);
};
}, [lg, md, sm]);
return {
isLg: state.isLg,
isMd: state.isMd,
isSm: state.isSm,
isXs: state.isXs,
};
}
+1
View File
@@ -0,0 +1 @@
export type AppLocale = "ru" | "en";
+97
View File
@@ -0,0 +1,97 @@
{
"languageSwitcher": {
"ru": "RU",
"en": "EN",
"ariaLabel": "Language"
},
"legalLinks": {
"privacyConsent": "https://graffestate.ae/privacypolicy",
"policy": "https://graffestate.ae/terms-conditions"
},
"footer": {
"call": "Call",
"write": "Write",
"phoneDisplay": "+971 58 506 0097",
"phoneTel": "+971585060097",
"emailAddress": "sam@graff.tech",
"legalAddress": "Legal address:",
"addressLine1": "620063, Yekaterinburg,",
"addressLine2": "Bolshakova St., 66, apt. 6",
"mainStack": "Our core stack:",
"stackLine": "Unreal Engine 5, C++",
"requisites": "Company details:",
"inn": "TIN: 6679174128",
"kpp": "IEC: 667101001",
"company": "GRAFF.ESTATE LLC",
"ogrn": "PSRN 1246600010140",
"skolkovoAlt": "Skolkovo",
"privacy": "Privacy and personal data processing policy",
"copyright": "© 2026 GRAFF interactive FZCO. All rights reserved",
"site": "graff.tech"
},
"demos": {
"titleLine1": "Available",
"titleLine2": "demos",
"ctaTitle": "Well walk you through how it works on\u00a0a call",
"ctaButton": "Request a call",
"description": "Clients anywhere in the world can explore a residential complex, even at the zero stage of construction. They can pick the best layout and assess the view from the windows of their future apartment."
},
"streamingProject": {
"watch": "Watch",
"startDemo": "Start demo"
},
"requestDemo": {
"titleLine1": "Book",
"titleLine2": "a remote",
"titleLine3": "demo",
"description": "A demo booking can be embedded as a block on the developers or residential complexs website.",
"cta": "Request a call"
},
"streamPlayer": {
"caption": "GRAFF.estate Stream is available on\u00a0any device — all you need for a demo is an internet connection."
},
"feedback": {
"titleLead": "Want to improve conversion?",
"titleRest": "Lets discuss the details."
},
"leadForm": {
"submitError": "Could not submit the request. Please try again later.",
"needTitle": "We need",
"namePlaceholder": "Name*",
"emailPlaceholder": "Email*",
"submit": "Submit request",
"consentBefore": "*By submitting, you give",
"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": "+XXXXXXXXXXXXXXX"
},
"feedbackModal": {
"successRich": "Weve received your request <brMobile /> and well contact you <brDesktop /> soon!",
"sourcesTitle1": "Please tell us",
"sourcesTitle2": "how you heard about us",
"send": "Send",
"skip": "Skip"
},
"products": {
"interactivePresentation": "Interactive presentation",
"remoteDemo": "Remote demonstration",
"archViz": "Architectural visualization",
"webDev": "Website development",
"webTour360": "360° web tour"
},
"modalFeedbackSources": [
"Saw us at an exhibition or forum",
"Saw it with other developers",
"From rankings and articles",
"Found online",
"Came from an ad",
"From a newsletter",
"Other"
]
}
+97
View File
@@ -0,0 +1,97 @@
{
"languageSwitcher": {
"ru": "RU",
"en": "EN",
"ariaLabel": "Язык"
},
"legalLinks": {
"privacyConsent": "https://graff.estate/privacy-policy",
"policy": "https://graff.estate/policy"
},
"footer": {
"call": "Позвонить",
"write": "Написать",
"phoneDisplay": "8 800 770 00 67",
"phoneTel": "+78007700067",
"emailAddress": "info@graff.tech",
"legalAddress": "Юридический адрес:",
"addressLine1": "620063, г. Екатеринбург,",
"addressLine2": "ул. Большакова, д. 66, кв. 6",
"mainStack": "Наш основной стек:",
"stackLine": "Unreal Engine 5, C++",
"requisites": "Реквизиты:",
"inn": "ИНН: 6679174128",
"kpp": "КПП: 667101001",
"company": "ООО \"ГРАФФ.ЭСТЕЙТ\"",
"ogrn": "ОГРН 1246600010140",
"skolkovoAlt": "Сколково",
"privacy": "Политика конфиденциальности и обработки персональных данных",
"copyright": "© 2026 GRAFF interactive. Все права защищены",
"site": "graff.tech"
},
"demos": {
"titleLine1": "Доступные",
"titleLine2": "демонстрации",
"ctaTitle": "Расскажем и покажем как это работает на\u00a0созвоне",
"ctaButton": "Оставить заявку",
"description": "Клиент из любой точки мира может посмотреть жилой комплекс, даже на нулевом этапе строительства. Он выберет лучшую планировку и оценит вид из окон своей будущей квартиры."
},
"streamingProject": {
"watch": "Смотреть",
"startDemo": "Начать демонстрацию"
},
"requestDemo": {
"titleLine1": "Запись",
"titleLine2": "на удаленную",
"titleLine3": "демонстрацию",
"description": "Запись на демонстрацию может быть оформлена в виде блока на сайте застройщика или жилого комплекса.",
"cta": "Оставить заявку"
},
"streamPlayer": {
"caption": "Модуль удаленных продаж GRAFF.estate доступен на\u00a0любых устройствах, для\u00a0демонстрации нужен только\u00a0интернет."
},
"feedback": {
"titleLead": "Хотите увеличить конверсию?",
"titleRest": "Давайте обсудим детали."
},
"leadForm": {
"submitError": "Не удалось отправить заявку. Попробуйте позже.",
"needTitle": "Нам нужно",
"namePlaceholder": "Имя*",
"emailPlaceholder": "Email*",
"submit": "Оставить заявку",
"consentBefore": "*Нажимая кнопку отправить, вы даете",
"consentLinkData": "согласие на обработку персональных данных",
"consentMiddle": "и принимаете",
"consentLinkPolicy": "условия политики",
"phonePlaceholder": "+7 (XXX) XXX-XX-XX",
"phoneRequired": "Укажите номер телефона",
"phoneInvalid": "Введите корректный номер телефона"
},
"questionModal": {
"phonePlaceholder": "+7 (XXX) XXX-XX-XX"
},
"feedbackModal": {
"successRich": "Мы получили заявку <brMobile /> и скоро свяжемся <brDesktop /> с вами!",
"sourcesTitle1": "Расскажите, пожалуйста,",
"sourcesTitle2": "откуда вы узнали о нас?",
"send": "Отправить",
"skip": "Пропустить"
},
"products": {
"interactivePresentation": "Интерактивная презентация",
"remoteDemo": "Удаленная демонстрация",
"archViz": "Архитектурная визуализация",
"webDev": "Создание сайтов",
"webTour360": "Веб-тур по 360 сферам"
},
"modalFeedbackSources": [
"Увидели на выставке или форуме",
"Видели у других застройщиков",
"Из рейтингов и статей",
"Нашли в интернете",
"Перешли по рекламе",
"Из рассылки",
"Другое"
]
}
+116
View File
@@ -0,0 +1,116 @@
@import url("/fonts/TTHovesProAll/stylesheet.css");
/*
* Landing-only wrapper. Applied by `src/App.tsx` (the `/` route) on the root
* container. Keeps TTHovesPro font + dark background + overflow behavior
* scoped to the landing so that /stream, /history, /scheduled pages keep
* their Inter/Gilroy styles from `src/index.css`.
*/
.landing-shell {
font-family: "TTHovesPro", system-ui, -apple-system, "Segoe UI", Roboto,
sans-serif;
color: #fff;
background-color: #0f1011;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: none;
min-width: 0;
overflow-x: clip;
}
.landing-shell *::-webkit-scrollbar {
width: 12px;
}
.landing-shell *::-webkit-scrollbar-thumb {
background-color: #798fff;
border: none;
border-radius: 4px;
}
.landing-shell .scrollbar-gradient::-webkit-scrollbar-thumb {
background: linear-gradient(87deg, #798fff 15%, #d375ff 100%);
}
.landing-shell *::-webkit-scrollbar-thumb:hover {
border-width: 2px;
}
.landing-shell .hide-scrollbars {
-ms-overflow-style: none;
scrollbar-width: none;
}
.landing-shell .hide-scrollbars::-webkit-scrollbar {
width: 0;
height: 0;
}
/*
* Landing utility classes. We don't wrap these in `@layer utilities` because
* the `@tailwind utilities` directive lives in `src/index.css` (a different
* file) and PostCSS would error on a lone `@layer utilities` declaration.
* Plain class definitions still support `@apply` fine.
*/
.line1 {
@apply 2xl:text-[128px] lg:text-[clamp(96px,96px+(100vw-1440px)/96*32,128px)] md:text-[clamp(56px,56px+(100vw-768px)/672*40,96px)] xs:text-[clamp(40px,40px+(100vw-360px)/408*16,56px)] text-[40px] leading-[85%];
}
.line2 {
@apply lg:text-[clamp(64px,4.444vw,88px)] md:text-[clamp(40px,40px+(100vw-768px)/672*24,64px)] xs:text-[clamp(32px,32px+(100vw-360px)/408*8,40px)] max-xs:text-[32px] leading-[95%];
}
.heading1 {
@apply lg:text-[clamp(28px,1.944vw,42px)] md:text-[clamp(24px,24px+(100vw-768px)/672*4,28px)] text-2xl leading-[1.167];
}
.heading2 {
@apply lg:text-[clamp(24px,1.667vw,36px)] md:text-[clamp(20px,20px+(100vw-768px)/672*4,24px)] xs:text-[clamp(16px,16px+(100vw-360px)/408*4,20px)] text-base lg:leading-[1.2] leading-[1.125];
}
.accent {
@apply lg:text-[clamp(32px,2.222vw,56px)] md:text-[clamp(24px,24px+(100vw-768px)/672*8,32px)] text-2xl lg:leading-[1.1] leading-none;
}
.text1 {
@apply lg:text-[clamp(18px,1.25vw,24px)] md:text-[clamp(14px,14px+(100vw-768px)/672*4,18px)] text-sm leading-[1.35];
}
.text2 {
@apply lg:text-[clamp(12px,0.972vw,20px)] md:text-[clamp(12px,12px+(100vw-768px)/672*2,14px)] text-xs leading-[1.35];
}
.btnl {
@apply lg:text-[clamp(18px,1.25vw,28px)] md:text-[clamp(16px,16+(100vw-768px)/256*2,18px)] text-base leading-none;
}
.btnm {
@apply lg:text-[clamp(16px,1.111vw,24px)] md:text-[clamp(14px,14px+(100vw-768px)/256*2,16px)] text-sm leading-none;
}
.btns {
@apply lg:text-[clamp(14px,0.972vw,20px)] md:text-[clamp(12px,12px+(100vw-768px)/256*2,14px)] text-xs leading-none;
}
.headline1 {
@apply font-medium text-[1.667vw] leading-[1.944vw] tracking-[-0.02em] md:max-lg:text-[3.125vw] md:max-lg:leading-[3.646vw] max-md:text-[6.667vw] max-md:leading-[7.778vw];
}
.headline2 {
@apply font-medium text-[1.389vw] leading-[1.667vw] tracking-[-0.02em] md:max-lg:text-[2.083vw] md:max-lg:leading-[2.344vw] max-md:text-base max-md:leading-[4.444vw] max-md:font-normal;
}
/*
* `.bg-gradient` is already defined globally in `src/index.css` with a
* slightly different angle and `!important` — we reuse it here and skip
* redeclaring to avoid fighting the cascade.
*/
.bg-gradient-saturated {
background: linear-gradient(45deg, #7a55ff 0%, #c932e8 75%, #ff79d2 95%);
}
@media screen and (max-device-width: 480px) {
.landing-shell {
-webkit-text-size-adjust: 100%;
}
}
+20
View File
@@ -0,0 +1,20 @@
import ky from "ky";
function str(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
const raw = str(import.meta.env.VITE_API_URL);
const base = raw ? raw.replace(/\/?$/, "/") : "";
export const hasApiConfigured = base.length > 0;
export const api = ky.create({
prefixUrl: base || undefined,
credentials: "include",
});
/** Lead/mail endpoints are served from graff.estate, not `VITE_API_URL`. */
export const mailApi = ky.create({
prefixUrl: "https://graff.estate/api/",
});
+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, ""));
}
+64
View File
@@ -0,0 +1,64 @@
import { getExampleNumber } from "libphonenumber-js";
import examples from "libphonenumber-js/mobile/examples";
export const PHONE_CODE_RU = "+7";
export const PHONE_COUNTRY_RU = "RU" as const;
/** Локальная часть примера номера (без кода страны) для маски */
export function getRuMobileExampleLocal(): string | undefined {
return getExampleNumber(PHONE_COUNTRY_RU, examples)
?.formatInternational()
.split(" ")
.slice(1)
.join(" ");
}
export function buildRuPhoneMask(placeholderLocal: string | undefined): string {
return `${PHONE_CODE_RU} ${(placeholderLocal?.replace(/\d/g, "9") ?? "")}`;
}
/** Только цифры: 7 + до 10 цифр номера (без +) */
export function ruPhoneDigits(stored: string): string {
let d = stored.replace(/\D/g, "");
if (d.startsWith("8")) d = "7" + d.slice(1);
if (d.length === 0) return "";
if (!d.startsWith("7")) d = "7" + d;
return d.slice(0, 11);
}
/** Отображение +7 (XXX) XXX-XX-XX без react-input-mask (совместимо с React 19) */
export function formatRuPhoneDisplay(stored: string): string {
const d = ruPhoneDigits(stored);
if (!d) return "";
const n = d.slice(1);
if (n.length === 0) return "+7";
const a = n.slice(0, 3);
const b = n.slice(3, 6);
const c = n.slice(6, 8);
const e = n.slice(8, 10);
let s = "+7 (" + a;
if (n.length <= 3) return s;
s += ") " + b;
if (n.length <= 6) return s;
s += "-" + c;
if (n.length <= 8) return s;
s += "-" + e;
return s;
}
export function normalizeRuPhoneFromInput(
cleanValue: string,
inputType: string | undefined
): string {
const shouldAddPhoneCode =
inputType !== "insertFromPaste" &&
inputType !== "insertFromDrop" &&
inputType !== "insertCompositionText" &&
!cleanValue.startsWith("+") &&
!cleanValue.startsWith("7") &&
!cleanValue.startsWith(PHONE_CODE_RU.replace("+", ""));
return (shouldAddPhoneCode ? PHONE_CODE_RU : "") + cleanValue;
}
+14
View File
@@ -0,0 +1,14 @@
import type { TFunction } from "i18next";
/** Order of product checkboxes in lead forms. */
export const PRODUCT_I18N_KEYS = [
"products.interactivePresentation",
"products.remoteDemo",
"products.archViz",
"products.webDev",
"products.webTour360",
] as const;
export function productOptionsFromT(t: TFunction): string[] {
return PRODUCT_I18N_KEYS.map((key) => t(key));
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Как на основном сайте: в API может прийти уже полный URL
* (`https://storage.yandexcloud.net/bucket/projects/uuid.jpg`) или ключ
* (`projects/uuid.jpg`) — тогда к нему дописывается VITE_S3_BUCKET.
*/
export function resolveProjectImageSrc(image: string): string {
let src = image
.trim()
.replaceAll("&quot;", '"')
.replace(/^["']+|["']+$/g, "");
try {
if (src.includes("%")) src = decodeURIComponent(src);
} catch {
/* оставляем как есть */
}
if (src.startsWith("//")) {
src = `https:${src}`;
}
if (
src.startsWith("http://") ||
src.startsWith("https://") ||
src.startsWith("data:")
) {
return src;
}
if (src.startsWith("/")) {
const base = import.meta.env.BASE_URL;
if (!base || base === "/") return src;
return `${base.replace(/\/$/, "")}${src}`;
}
const s3BaseRaw =
typeof import.meta.env.VITE_S3_BUCKET === "string"
? import.meta.env.VITE_S3_BUCKET.trim()
: "";
if (!s3BaseRaw) return src;
const base = s3BaseRaw.replace(/\/?$/, "/");
const path = src.replace(/^\//, "");
return `${base}${path}`;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Строим ссылку запуска демо-стрима.
*
* Ничего не захардкоживаем: клиент сам обслуживается на stream.graff.tech,
* поэтому используем относительный путь — навигация остаётся в том же origin,
* а `useAutoStartFromQuery` на маршруте `/` подхватит `?build=...` и
* отредиректит на `/stream/:id`.
*/
export function streamDemoUrlFromBuild(buildFilename: string): string {
const build = buildFilename.trim();
const params = new URLSearchParams({ location: "a1" });
if (build) params.set("build", build);
return `/?${params.toString()}`;
}
+15
View File
@@ -0,0 +1,15 @@
import type { AppLocale } from "@/landing/i18n";
export function parseLangParam(v: string | null): AppLocale | null {
const x = v?.trim().toLowerCase();
if (x === "ru" || x === "en") return x;
return null;
}
/** Updates `?lang=` without reload; fires `locationchange` for listeners. */
export function setLangInUrl(lang: AppLocale): void {
const url = new URL(window.location.href);
url.searchParams.set("lang", lang);
window.history.replaceState({}, "", url.toString());
window.dispatchEvent(new Event("locationchange"));
}
+32
View File
@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import ky from "ky";
import { useLocationSearch } from "@/landing/hooks/useLocationSearch";
import { parseLangParam } from "@/landing/lib/urlLang";
import { queryKeys } from "@/landing/queries/keys";
/*
* Клиент обслуживается на том же origin, что и API, поэтому ходим
* относительным путём `/api/getCountryCode` — так запрос не привязан к
* конкретному домену stream.graff.tech и корректно работает под любым
* окружением (локальный vite-proxy, staging, prod).
*/
const COUNTRY_CODE_URL = "/api/getCountryCode";
export async function fetchCountryCode(): Promise<{ countryCode: string }> {
return ky.get(COUNTRY_CODE_URL).json<{ countryCode: string }>();
}
export function useCountryCodeQuery() {
const search = useLocationSearch();
const langFromUrl = parseLangParam(
new URLSearchParams(search).get("lang")
);
return useQuery({
queryKey: queryKeys.countryCode,
queryFn: fetchCountryCode,
enabled: langFromUrl === null,
staleTime: 24 * 60 * 60 * 1000,
retry: 2,
});
}
+3
View File
@@ -0,0 +1,3 @@
export const queryKeys = {
countryCode: ["countryCode"] as const,
};
+12
View File
@@ -0,0 +1,12 @@
import { ReactNode } from "react";
import { create } from "zustand";
interface IModalState {
modal: ReactNode | null;
setModal: (modal: ReactNode | null) => void;
}
export const useModalStore = create<IModalState>((set) => ({
modal: null,
setModal: (modal) => set({ modal }),
}));
+17
View File
@@ -0,0 +1,17 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useRefererStore = create<{
referer: string | null;
setReferer: (referer: string | null) => void;
}>()(
persist(
(set) => ({
referer: null,
setReferer: (referer) => set({ referer }),
}),
{
name: "referer-stream-demo",
}
)
);
+10
View File
@@ -0,0 +1,10 @@
import { IProject } from "./IProject";
export interface ICompany {
id: string;
title: string;
color: string;
mapIcon?: string;
logo?: string;
projects: IProject[];
}
+21
View File
@@ -0,0 +1,21 @@
import { ICompany } from "./ICompany";
import { Product } from "./Product";
export type Device = "Stream" | "Touch" | "Mobile" | "VR";
export interface IProject {
id: string;
title: string;
englishTitle: string;
description: string;
company?: ICompany;
companyId?: string;
city: string;
englishCity: string;
image: string;
/** Имя билда для URL-параметра `?build=...` на главной клиента. */
buildFilename?: string;
stage: number;
releaseDate: string;
tags: Product[];
}
+2
View File
@@ -0,0 +1,2 @@
/** Localized product label in the API payload (matches `t('products.*')` for the active locale). */
export type Product = string;
+3
View File
@@ -0,0 +1,3 @@
export type { ICompany } from "./ICompany";
export type { Device, IProject } from "./IProject";
export type { Product } from "./Product";
+53
View File
@@ -0,0 +1,53 @@
import { type ReactElement, type ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
icon?: ReactElement;
color?: "primary" | "secondary";
width?: "fit" | "full";
disabled?: boolean;
className?: string;
onClick?: () => void;
type?: "submit" | "reset" | "button";
rounded?: string;
}
export function Button({
children,
color = "primary",
icon,
width = "fit",
disabled = false,
className,
onClick,
type,
rounded,
}: ButtonProps) {
const widthClass = width === "full" ? "w-full" : "w-fit";
return (
<button
type={type}
disabled={disabled}
onClick={(e) => {
if (type !== "submit") e.preventDefault();
onClick?.();
}}
className={`group cursor-pointer relative px-6 py-2${
rounded ? " rounded-" + rounded : ""
} min-w-fit ${
(color === "primary" ? "bg-gradient-saturated" : "") ||
(color === "secondary" ? " outline-1 outline-[#3D425C]" : "")
} ${
icon ? "pr-4" : ""
} flex gap-1 items-center overflow-hidden ${widthClass} ${
className ?? ""
} justify-between`}
>
<span className="group-hover:opacity-10 absolute top-0 left-0 w-full h-full transition-opacity bg-black opacity-0"></span>
<span className={"relative font-medium" + (icon ? "" : " m-auto")}>
{children}
</span>
<span className="relative">{icon}</span>
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
import {
FieldValues,
Path,
useController,
useFormContext,
useWatch,
} from "react-hook-form";
export function CheckboxesGroup<IFieldValues extends FieldValues>({
options,
name,
}: {
options: string[];
name: Path<IFieldValues>;
}) {
const { control } = useFormContext<IFieldValues>();
const {
field: { ref, onChange, ...inputProps },
} = useController({ control, name });
const values: string[] = useWatch({ control, name });
return (
<div className="flex flex-wrap lg:gap-[0.556vw] gap-2">
{options.map((option) => (
<label
htmlFor={name + "_" + option}
key={option}
className={`cursor-pointer transition-colors lg:rounded-[1.111vw] rounded-2xl font-medium text-nowrap select-none lg:px-[1.667vw] px-6 lg:py-[1.181vw] py-[17px] btnm ${
values.includes(option)
? "bg-white text-black"
: "bg-[#37393B99] hover:bg-[#37393B]"
}`}
>
{option}
<input
id={name + "_" + option}
className="hidden"
type="checkbox"
{...inputProps}
checked={values.includes(option)}
ref={ref}
onChange={() => {
onChange(
values.includes(option)
? values.filter((x) => x !== option)
: [...values, option]
);
}}
/>
</label>
))}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import CheckIcon from "@/landing/components/icons/CheckIcon";
function CustomCheckbox({
value,
onChange,
}: {
value: string;
onChange: (value: string, checked: boolean) => void;
}) {
const [checked, setChecked] = useState<boolean>(false);
useEffect(() => {
onChange(value, checked);
}, [checked, onChange, value]);
return (
<>
<div
onClick={() => setChecked(!checked)}
className="flex gap-x-[10px] items-center hover:cursor-pointer"
>
<div
className={`${
checked ? "bg-gradient" : "bg-[#37393B]"
} w-[20px] h-[20px] radius-[5px] rounded relative`}
>
{checked && (
<div className="text-white lg:size-[1.389vw] size-4 absolute top-0">
<CheckIcon />
</div>
)}
</div>
<span className="md:text-sm">{value}</span>
</div>
</>
);
}
export default CustomCheckbox;
+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));
}}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { type Ref } from "react";
import {
formatRuPhoneDisplay,
normalizeRuPhoneFromInput,
} from "@/landing/lib/phoneRu";
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 PhoneInputRuProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
inputRef?: Ref<HTMLInputElement>;
id?: string;
placeholder?: string;
}
export function PhoneInputRu({
value,
onChange,
onBlur,
inputRef,
id = "tel",
placeholder = "+7 (XXX) XXX - XX - XX",
}: PhoneInputRuProps) {
return (
<input
ref={inputRef}
type="tel"
autoComplete="tel"
id={id}
placeholder={placeholder}
className={inputClassName}
value={formatRuPhoneDisplay(value)}
onBlur={onBlur}
onChange={(e) => {
if (!e.nativeEvent.type.startsWith("input")) return;
const cleanValue = e.target.value.replace(/\s/g, "");
const inputType = (e.nativeEvent as InputEvent).inputType;
onChange(normalizeRuPhoneFromInput(cleanValue, inputType));
}}
/>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from "react";
import MuteIcon from "@/landing/components/icons/MuteIcon";
import UnmutedIcon from "@/landing/components/icons/UnmutedIcon";
export function VideoMutingBtn({
handleClick,
muted,
}: {
muted: boolean;
handleClick: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [point, setPoint] = useState([0, 0]);
useEffect(() => {
const el = ref.current;
const move = (e: MouseEvent) => setPoint([e.clientX, e.clientY]);
el?.addEventListener("mousemove", move);
return () => {
el?.removeEventListener("mousemove", move);
};
}, []);
return (
<div className="absolute left-0 top-0 h-[calc(5/6*100%)] w-full z-[7]">
<div ref={ref} className="group relative w-full h-full">
<button
type="button"
className="bg-[#37393B99] p-[1.736vw] [backdrop-filter:blur(30.72px)] rounded-full group-hover:opacity-100 transition-opacity group-hover:cursor-none opacity-0 sticky outline-none"
style={{ left: point[0] - 32, top: point[1] - 32 }}
onClick={handleClick}
>
{muted ? (
<div className="text-white lg:size-[1.944vw] size-7">
<UnmutedIcon />
</div>
) : (
<div className="text-white lg:size-[1.944vw] size-7">
<MuteIcon />
</div>
)}
</button>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { AnimatePresence, motion } from "framer-motion";
import {
ComponentProps,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { VideoMutingBtn } from "./VideoMutingBtn";
import { VideoProgressBar } from "./VideoProgressBar";
export const VideoPlayer = forwardRef<
HTMLVideoElement,
{
src: string;
showMutingBtn: boolean;
children?: React.ReactNode;
} & ComponentProps<"video">
>(
(
{
src,
showMutingBtn,
children,
loop = true,
autoPlay = true,
className,
muted: mutedProp,
},
ref
) => {
const progressbarRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => videoRef.current!);
/** Начальное значение из `muted`; дальше громкость только из состояния (кнопка плеера). */
const [muted, setMuted] = useState(() => mutedProp ?? autoPlay);
const [playing, setPlaying] = useState(autoPlay);
const [progress, setProgress] = useState(0);
function handleProgressbarClick(e: React.MouseEvent) {
const video = videoRef.current;
const bar = progressbarRef.current;
if (!video || !bar) return;
video.currentTime =
(video.duration * (e.clientX - bar.getBoundingClientRect().x)) /
bar.clientWidth;
setProgress(
((video.currentTime ?? 0) / (video.duration ?? 1)) * 100
);
}
function handlePlaybackClick() {
if (!videoRef.current) return;
setPlaying(videoRef.current.paused);
videoRef.current[videoRef.current.paused ? "play" : "pause"]();
}
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const timeUpdateHandler = () =>
setProgress(((video.currentTime ?? 0) / (video.duration ?? 1)) * 100);
video.addEventListener("timeupdate", timeUpdateHandler);
return () => video.removeEventListener("timeupdate", timeUpdateHandler);
}, []);
return (
<div className="relative h-full">
<video
ref={videoRef}
src={src}
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline
className={`lg:rounded-[1.111vw] rounded-2xl w-full h-full object-cover${
className ? " " + className : ""
}`}
/>
{showMutingBtn && (
<VideoMutingBtn
handleClick={() => setMuted(!videoRef.current!.muted)}
muted={muted}
/>
)}
<div className="absolute inset-0 rounded-2xl [background:linear-gradient(to_top,rgba(20,22,31,0.6),rgba(20,22,31,0))]" />
<AnimatePresence>
{muted && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
<VideoProgressBar
muted={muted}
progress={progress}
progressbarRef={progressbarRef}
playing={playing}
handlePlaybackClick={handlePlaybackClick}
handleProgressbarClick={handleProgressbarClick}
/>
</div>
);
}
);
VideoPlayer.displayName = "VideoPlayer";
+69
View File
@@ -0,0 +1,69 @@
import { MouseEventHandler, RefObject, useState } from "react";
import PauseIcon from "@/landing/components/icons/PauseIcon";
import PlayIcon from "@/landing/components/icons/PlayIcon";
export function VideoProgressBar({
muted,
progressbarRef,
playing,
handlePlaybackClick,
handleProgressbarClick,
progress,
}: {
muted: boolean;
progress: number;
progressbarRef: RefObject<HTMLDivElement>;
playing: boolean;
handlePlaybackClick: MouseEventHandler<HTMLButtonElement>;
handleProgressbarClick: MouseEventHandler<HTMLDivElement>;
}) {
const [isMouseDown, setIsMouseDown] = useState(false);
return (
<div
className={`bottom-2 left-2 right-2 absolute z-10 select-none flex items-stretch gap-1 ${
muted ? "hidden" : ""
}`}
>
<button
type="button"
className="p-[18px] bg-[#37393B99] rounded-2xl cursor-pointer"
onClick={handlePlaybackClick}
>
{playing ? (
<div className="text-white lg:size-[1.389vw] size-5">
<PauseIcon />
</div>
) : (
<div className="text-white lg:size-[1.389vw] size-5">
<PlayIcon />
</div>
)}
</button>
<div
className="flex-1 rounded-2xl bg-[#37393B99] px-6 cursor-pointer flex items-center select-none"
onMouseDown={(e) => {
setIsMouseDown(true);
handleProgressbarClick(e);
}}
onMouseMove={(e) => {
if (isMouseDown) handleProgressbarClick(e);
}}
onMouseUp={() => setIsMouseDown(false)}
onMouseLeave={() => setIsMouseDown(false)}
>
<div
ref={progressbarRef}
className="h-1 bg-[#7A7A7A] relative rounded-3xl cursor-pointer w-full"
>
<div
className="left-0 h-1 bg-white rounded-3xl transition-all"
style={{
width: progress + "%",
}}
/>
</div>
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
/** Sent with graff.estate mail API payloads to identify this client. */
export const MAIL_REQUEST_FROM = "stream.graff.tech";
+37 -20
View File
@@ -1,37 +1,54 @@
// import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom";
import "./index.css";
import "@/landing/landing.css";
import "./i18n";
import App from "./App";
// import ErrorBoundary from "./ErrorBoundary";
import HistoryPage from "./HistoryPage";
import ScheduledPage from "./ScheduledPage";
import StreamPage from "./pages/StreamPage";
import LanguageDetector from "./components/LanguageDetector";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 3 * 60 * 1000,
retry: 2,
},
},
});
const router = createBrowserRouter([
{
path: "/",
element: <App />,
// errorElement: <ErrorBoundary />,
},
{
path: "/stream/:id",
element: <StreamPage />,
},
{
path: "/history",
element: <HistoryPage />,
},
{
path: "/scheduled/:sessionId",
element: <ScheduledPage />,
element: (
<LanguageDetector>
<Outlet />
</LanguageDetector>
),
children: [
{
path: "/",
element: <App />,
},
{
path: "/stream/:id",
element: <StreamPage />,
},
{
path: "/history",
element: <HistoryPage />,
},
{
path: "/scheduled/:sessionId",
element: <ScheduledPage />,
},
],
},
]);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<LanguageDetector>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</LanguageDetector>
</QueryClientProvider>
);
+169 -55
View File
@@ -80,7 +80,11 @@ function StreamPage() {
const [roomId] = useState<string>(params.id!);
const { socket, setSocket } = useSocketStore();
const { setModal } = useModalStore();
const { name } = useStreamStore();
const {
name,
setSelectedAudioDeviceId,
setSelectedVideoDeviceId,
} = useStreamStore();
const [isMicEnabled, setIsMicEnabled] = useState(true);
const [isCameraEnabled, setIsCameraEnabled] = useState(true);
const [isEnded, setIsEnded] = useState<boolean>();
@@ -123,19 +127,77 @@ function StreamPage() {
});
}
async function getUserMedia() {
async function getUserMedia(
existingAudioStream?: MediaStream | null,
audioDeviceId?: string,
existingVideoStream?: MediaStream | null,
videoDeviceId?: string
) {
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
let mediaStream: MediaStream;
if (!localVideoRef.current) return;
const hasAudio =
existingAudioStream && existingAudioStream.getAudioTracks().length;
const hasVideo =
existingVideoStream && existingVideoStream.getVideoTracks().length;
localVideoRef.current.srcObject = mediaStream;
localVideoRef.current.onloadedmetadata = () => {
localVideoRef.current?.play();
};
if (hasAudio && hasVideo) {
mediaStream = new MediaStream([
...existingAudioStream!.getAudioTracks(),
...existingVideoStream!.getVideoTracks(),
]);
} else if (hasAudio) {
try {
const videoStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
});
mediaStream = new MediaStream([
...existingAudioStream!.getAudioTracks(),
...videoStream.getVideoTracks(),
]);
} catch {
mediaStream = new MediaStream(
existingAudioStream!.getAudioTracks()
);
}
} else if (hasVideo) {
try {
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
});
mediaStream = new MediaStream([
...audioStream.getAudioTracks(),
...existingVideoStream!.getVideoTracks(),
]);
} catch {
mediaStream = new MediaStream(
existingVideoStream!.getVideoTracks()
);
}
} else {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
});
}
if (
localVideoRef.current &&
mediaStream.getVideoTracks().length > 0
) {
localVideoRef.current.srcObject = mediaStream;
localVideoRef.current.onloadedmetadata = () => {
localVideoRef.current?.play();
};
}
setLocalStream(mediaStream);
setPermission(true);
@@ -150,7 +212,9 @@ function StreamPage() {
console.log("initPeer");
const peer = new Peer({
host: "stream.graff.tech",
// PeerJS signaling живёт на том же origin, что и клиент — не хардкодим
// stream.graff.tech, иначе staging/локалка ломаются.
host: window.location.hostname,
config: {
iceServers: [
{
@@ -290,9 +354,21 @@ function StreamPage() {
}, 500);
}
function handleSetName() {
function handleSetName(
audioStream: MediaStream | null,
selectedAudioDeviceId: string,
videoStream: MediaStream | null,
selectedVideoDeviceId: string
) {
setSelectedAudioDeviceId(selectedAudioDeviceId);
setSelectedVideoDeviceId(selectedVideoDeviceId);
setStep(2);
getUserMedia();
getUserMedia(
audioStream,
selectedAudioDeviceId || undefined,
videoStream,
selectedVideoDeviceId || undefined
);
}
async function getWSUrl() {
@@ -420,6 +496,15 @@ function StreamPage() {
}, [users.length]);
useEffect(() => {
if (
localVideoRef.current &&
localStream.getVideoTracks().length > 0
) {
localVideoRef.current.srcObject = localStream;
localVideoRef.current.onloadedmetadata = () => {
localVideoRef.current?.play();
};
}
toggleCamera();
toggleMic();
}, [localStream]);
@@ -505,44 +590,52 @@ function StreamPage() {
{permission && (
<>
<div className="relative group">
<Button
variant="secondary"
icon={
isMicEnabled ? <MicroOnIcon /> : <MicroOffIcon />
}
onlyIcon
onClick={toggleMic}
/>
<Tooltip
text={
isMicEnabled
? t("tooltips.turnOffMic")
: t("tooltips.turnOnMic")
}
/>
</div>
<div className="relative group">
<Button
variant="secondary"
icon={
isCameraEnabled ? (
<CameraOnIcon />
) : (
<CameraOffIcon />
)
}
onlyIcon
onClick={toggleCamera}
/>
<Tooltip
text={
isCameraEnabled
? t("tooltips.turnOffCamera")
: t("tooltips.turnOnCamera")
}
/>
</div>
{localStream.getAudioTracks().length > 0 && (
<div className="relative group">
<Button
variant="secondary"
icon={
isMicEnabled ? (
<MicroOnIcon />
) : (
<MicroOffIcon />
)
}
onlyIcon
onClick={toggleMic}
/>
<Tooltip
text={
isMicEnabled
? t("tooltips.turnOffMic")
: t("tooltips.turnOnMic")
}
/>
</div>
)}
{localStream.getVideoTracks().length > 0 && (
<div className="relative group">
<Button
variant="secondary"
icon={
isCameraEnabled ? (
<CameraOnIcon />
) : (
<CameraOffIcon />
)
}
onlyIcon
onClick={toggleCamera}
/>
<Tooltip
text={
isCameraEnabled
? t("tooltips.turnOffCamera")
: t("tooltips.turnOnCamera")
}
/>
</div>
)}
</>
)}
</div>
@@ -674,7 +767,12 @@ function StreamPage() {
<div className="absolute top-2 space-y-2 lg:left-2 max-lg:right-2">
<div
className={`relative border-2 rounded-lg ${
!permission || !isCameraEnabled ? "hidden" : ""
!permission ? "hidden" : ""
} ${
localStream.getVideoTracks().length === 0 ||
!isCameraEnabled
? "h-8 overflow-hidden"
: ""
} ${
isMicEnabled && isSpeaking
? "border-green-500"
@@ -683,12 +781,28 @@ function StreamPage() {
>
<video
ref={localVideoRef}
className={`object-cover bg-gray-500 rounded-lg aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] -scale-x-100`}
className={`object-cover bg-gray-500 rounded-lg aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] -scale-x-100 ${
localStream.getVideoTracks().length === 0 ||
!isCameraEnabled
? "hidden"
: ""
}`}
playsInline
autoPlay
muted
></video>
<div className="absolute bottom-0 p-2">
{(localStream.getVideoTracks().length === 0 ||
!isCameraEnabled) && (
<div className="aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg bg-gray-500" />
)}
<div
className={`absolute -bottom-1.5 flex items-center lg:w-[216px] w-[160px] gap-2 p-2 ${
localStream.getVideoTracks().length === 0 ||
!isCameraEnabled
? "bg-black"
: ""
}`}
>
<p className="text-xs text-white truncate lg:text-sm">
{name}
</p>
+8
View File
@@ -3,10 +3,14 @@ import { devtools, persist } from "zustand/middleware";
interface State {
name: string;
selectedAudioDeviceId: string;
selectedVideoDeviceId: string;
}
interface Actions {
setName: (name: string) => void;
setSelectedAudioDeviceId: (id: string) => void;
setSelectedVideoDeviceId: (id: string) => void;
}
const useStreamStore = create<State & Actions>()(
@@ -14,7 +18,11 @@ const useStreamStore = create<State & Actions>()(
persist(
(set) => ({
name: "",
selectedAudioDeviceId: "",
selectedVideoDeviceId: "",
setName: (name) => set({ name }),
setSelectedAudioDeviceId: (id) => set({ selectedAudioDeviceId: id }),
setSelectedVideoDeviceId: (id) => set({ selectedVideoDeviceId: id }),
}),
{
name: "auth",

Some files were not shown because too many files have changed in this diff Show More