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.
This commit is contained in:
Generated
+4439
File diff suppressed because it is too large
Load Diff
@@ -11,13 +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",
|
||||
"ky": "^1.1.3",
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"peerjs": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-calendar": "^4.3.0",
|
||||
@@ -26,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.
@@ -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)">
|
||||
<link rel="preload" href="TTHovesPro-DmBd.woff2" as="font" type="font/woff2" crossorigin></pre>
|
||||
<div class="font-container" style="font-family: 'TTHovesPro-DmBd'; font-weight: 600; font-style: normal;">
|
||||
<p class="letters">
|
||||
abcdefghijklmnopqrstuvwxyz<br>
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
|
||||
0123456789.:,;()*!?'@#<>$%&^+-=~
|
||||
</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)">
|
||||
<link rel="preload" href="TTHovesPro-Md.woff2" as="font" type="font/woff2" crossorigin></pre>
|
||||
<div class="font-container" style="font-family: 'TTHovesPro-Md'; font-weight: 500; font-style: normal;">
|
||||
<p class="letters">
|
||||
abcdefghijklmnopqrstuvwxyz<br>
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
|
||||
0123456789.:,;()*!?'@#<>$%&^+-=~
|
||||
</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)">
|
||||
<link rel="preload" href="TTHovesPro-Rg.woff2" as="font" type="font/woff2" crossorigin></pre>
|
||||
<div class="font-container" style="font-family: 'TTHovesPro-Rg'; font-weight: normal; font-style: normal;">
|
||||
<p class="letters">
|
||||
abcdefghijklmnopqrstuvwxyz<br>
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
|
||||
0123456789.:,;()*!?'@#<>$%&^+-=~
|
||||
</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
+32
-544
@@ -1,563 +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, getUserRegion } 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]);
|
||||
|
||||
// Определяем регион пользователя при первой загрузке (пропускаем API, если ?lang= уже задал регион)
|
||||
useEffect(() => {
|
||||
async function initializeRegion() {
|
||||
if (getUserRegion()) {
|
||||
setRegionDetected(true);
|
||||
return;
|
||||
}
|
||||
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]">
|
||||
<div className="landing-shell flex min-h-dvh flex-col">
|
||||
<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="" />
|
||||
{/* Без 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>
|
||||
|
||||
<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] lg:col-span-2 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-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.title4"}>
|
||||
Upside Towers
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="text-xs lg:text-sm">
|
||||
<Trans i18nKey={"main.cards.city3"}>
|
||||
Russia, Moscow
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => void startStream("upsideTowersDevEn")}
|
||||
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"}>Run demo</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]">
|
||||
© 2026 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>
|
||||
|
||||
<Transition in={isOpen} timeout={200} mountOnEnter unmountOnExit>
|
||||
{(state) => <Sidebar className={state} />}
|
||||
</Transition>
|
||||
|
||||
<Footer />
|
||||
<ModalContainer />
|
||||
<ToastContainer />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+184
-36
@@ -49,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: "Горячая линия",
|
||||
@@ -75,9 +61,97 @@ 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: "условия политики",
|
||||
},
|
||||
questionModal: {
|
||||
phonePlaceholder: "+X (XXX) XXX - XX - XX",
|
||||
},
|
||||
feedbackModal: {
|
||||
successRich:
|
||||
"Мы получили заявку <brMobile /> и скоро свяжемся <brDesktop /> с вами!",
|
||||
sourcesTitle1: "Расскажите, пожалуйста,",
|
||||
sourcesTitle2: "откуда вы узнали о нас?",
|
||||
send: "Отправить",
|
||||
skip: "Пропустить",
|
||||
},
|
||||
products: {
|
||||
interactivePresentation: "Интерактивная презентация",
|
||||
remoteDemo: "Удаленная демонстрация",
|
||||
archViz: "Архитектурная визуализация",
|
||||
webDev: "Создание сайтов",
|
||||
webTour360: "Веб-тур по 360 сферам",
|
||||
},
|
||||
modalFeedbackSources: [
|
||||
"Увидели на выставке или форуме",
|
||||
"Видели у других застройщиков",
|
||||
"Из рейтингов и статей",
|
||||
"Нашли в интернете",
|
||||
"Перешли по рекламе",
|
||||
"Из рассылки",
|
||||
"Другое",
|
||||
],
|
||||
sidebar: {
|
||||
title1: "Дата и время",
|
||||
title2: "Контакты",
|
||||
@@ -288,22 +362,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",
|
||||
@@ -314,9 +374,97 @@ 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",
|
||||
},
|
||||
questionModal: {
|
||||
phonePlaceholder: "+X (XXX) XXX - XX - XX",
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,90 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/landing/ui/Button";
|
||||
import { api } 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 api.put(`mail/${id}`, { json: { source: selectedOptions } });
|
||||
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">
|
||||
<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="flexfont-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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } 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 { 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 } = useTranslation();
|
||||
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, control } = form;
|
||||
const nameId = idPrefix ? `${idPrefix}name` : "name";
|
||||
const emailId = idPrefix ? `${idPrefix}email` : "email";
|
||||
|
||||
const onSubmit: SubmitHandler<LeadFormValues> = async (data) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const { id } = await api
|
||||
.post("mail", { json: { ...data, referer } })
|
||||
.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={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<PhoneInputRu
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
inputRef={field.ref}
|
||||
placeholder={phonePlaceholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="bottom-0 absolute w-full border-b border-[#37393B] peer-focus:border-white -mb-2" />
|
||||
</div>
|
||||
<div className="md:flex items-stretch lg:gap-[0.833vw] gap-3 max-md:translate-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.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>
|
||||
);
|
||||
}
|
||||
@@ -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,98 @@
|
||||
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 (
|
||||
<a
|
||||
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="z-0 rounded-2xl absolute inset-0 overflow-hidden transition-transform duration-500 group-hover:scale-110">
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 z-0 size-full object-cover object-bottom"
|
||||
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>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type AppLocale = "ru" | "en";
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"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": "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."
|
||||
},
|
||||
"feedback": {
|
||||
"titleLead": "Want to improve conversion?",
|
||||
"titleRest": "Let’s 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"
|
||||
},
|
||||
"questionModal": {
|
||||
"phonePlaceholder": "+X (XXX) XXX - XX - XX"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"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": "условия политики"
|
||||
},
|
||||
"questionModal": {
|
||||
"phonePlaceholder": "+X (XXX) XXX - XX - XX"
|
||||
},
|
||||
"feedbackModal": {
|
||||
"successRich": "Мы получили заявку <brMobile /> и скоро свяжемся <brDesktop /> с вами!",
|
||||
"sourcesTitle1": "Расскажите, пожалуйста,",
|
||||
"sourcesTitle2": "откуда вы узнали о нас?",
|
||||
"send": "Отправить",
|
||||
"skip": "Пропустить"
|
||||
},
|
||||
"products": {
|
||||
"interactivePresentation": "Интерактивная презентация",
|
||||
"remoteDemo": "Удаленная демонстрация",
|
||||
"archViz": "Архитектурная визуализация",
|
||||
"webDev": "Создание сайтов",
|
||||
"webTour360": "Веб-тур по 360 сферам"
|
||||
},
|
||||
"modalFeedbackSources": [
|
||||
"Увидели на выставке или форуме",
|
||||
"Видели у других застройщиков",
|
||||
"Из рейтингов и статей",
|
||||
"Нашли в интернете",
|
||||
"Перешли по рекламе",
|
||||
"Из рассылки",
|
||||
"Другое"
|
||||
]
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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",
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(""", '"')
|
||||
.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}`;
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const queryKeys = {
|
||||
countryCode: ["countryCode"] as const,
|
||||
};
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IProject } from "./IProject";
|
||||
|
||||
export interface ICompany {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
mapIcon?: string;
|
||||
logo?: string;
|
||||
projects: IProject[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Localized product label in the API payload (matches `t('products.*')` for the active locale). */
|
||||
export type Product = string;
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { ICompany } from "./ICompany";
|
||||
export type { Device, IProject } from "./IProject";
|
||||
export type { Product } from "./Product";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+13
-3
@@ -1,15 +1,24 @@
|
||||
// import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ReactDOM from "react-dom/client";
|
||||
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([
|
||||
{
|
||||
element: (
|
||||
@@ -21,7 +30,6 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
// errorElement: <ErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: "/stream/:id",
|
||||
@@ -40,5 +48,7 @@ const router = createBrowserRouter([
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -212,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: [
|
||||
{
|
||||
|
||||
Submodule
+1
Submodule src/stream-demo-standalone added at b6bc0214ca
+7
-7
@@ -1,12 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
screens: {
|
||||
xs: "360px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
+8
-1
@@ -17,8 +17,15 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/stream-demo-standalone"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
@@ -7,6 +8,11 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5001,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user