Compare commits
3 Commits
258568050c
...
99114120b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 99114120b9 | |||
| 3f8e11edf7 | |||
| 7d1e0fe078 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
Generated
+1148
-39
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"framer-motion": "^11.17.0",
|
"framer-motion": "^11.17.0",
|
||||||
|
"i18next": "^26.0.5",
|
||||||
"ky": "^1.4.0",
|
"ky": "^1.4.0",
|
||||||
"libphonenumber-js": "^1.11.7",
|
"libphonenumber-js": "^1.11.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-i18next": "^17.0.3",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4"
|
||||||
|
|||||||
+4
-2
@@ -1,13 +1,15 @@
|
|||||||
import { Footer } from "@/components/Layout/Footer";
|
import { Footer } from "@/components/Layout/Footer";
|
||||||
import StreamDemo from "@/features/stream-demo/StreamDemo";
|
|
||||||
import Header from "@/components/Layout/Header";
|
import Header from "@/components/Layout/Header";
|
||||||
|
import { LocaleSync } from "@/components/Layout/LocaleSync";
|
||||||
|
import StreamDemo from "@/features/stream-demo/StreamDemo";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<LocaleSync />
|
||||||
<Header />
|
<Header />
|
||||||
{/* Без overflow-clip: иначе flex-1 + clip часто даёт пустой/обрезанный экран */}
|
{/* Без overflow-clip: иначе flex-1 + clip часто даёт пустой/обрезанный экран */}
|
||||||
<div className="min-h-0 flex-1 md:px-4 lg:px-[1.389vw] px-[10px] py-8 md:max-lg:pt-6 pt-4">
|
<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 />
|
<StreamDemo />
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Product } from "@/types";
|
import type { Product } from "@/types";
|
||||||
import useAddReferer from "@/hooks/useAddReferer";
|
import useAddReferer from "@/hooks/useAddReferer";
|
||||||
import { useModalStore } from "@/stores/useModalStore";
|
import { useModalStore } from "@/stores/useModalStore";
|
||||||
import FeedbackModal from "@/components/modals/FeedbackFormModal";
|
import FeedbackModal from "@/components/modals/FeedbackFormModal";
|
||||||
import { LeadForm } from "@/features/lead-form/LeadForm";
|
import { LeadForm } from "@/features/lead-form/LeadForm";
|
||||||
|
|
||||||
const DEFAULT_STREAM_DEMO_PRODUCTS = ["Удаленная демонстрация"] as Product[];
|
|
||||||
|
|
||||||
export function Feedback() {
|
export function Feedback() {
|
||||||
useAddReferer();
|
useAddReferer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -15,9 +16,9 @@ export function Feedback() {
|
|||||||
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"
|
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%]">
|
<h2 className="line2 font-medium max-lg:mb-6 lg:max-w-[45%]">
|
||||||
<span className="text-[#7A7A7A]">Хотите увеличить конверсию?</span>
|
<span className="text-[#7A7A7A]">{t("feedback.titleLead")}</span>
|
||||||
<br />
|
<br />
|
||||||
Давайте обсудим детали.
|
{t("feedback.titleRest")}
|
||||||
</h2>
|
</h2>
|
||||||
<FeedbackForm />
|
<FeedbackForm />
|
||||||
</div>
|
</div>
|
||||||
@@ -25,13 +26,20 @@ export function Feedback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedbackForm() {
|
export function FeedbackForm() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const { setModal } = useModalStore();
|
const { setModal } = useModalStore();
|
||||||
|
|
||||||
|
const defaultProducts = useMemo(
|
||||||
|
(): Product[] => [t("products.remoteDemo")],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 lg:max-w-[47.431vw]">
|
<div className="flex-1 space-y-4 lg:max-w-[47.431vw]">
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<LeadForm
|
<LeadForm
|
||||||
defaultProducts={DEFAULT_STREAM_DEMO_PRODUCTS}
|
key={i18n.language}
|
||||||
|
defaultProducts={defaultProducts}
|
||||||
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
|
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import ArrowMoreIcon from "@/components/icons/ArrowMoreIcon";
|
import ArrowMoreIcon from "@/components/icons/ArrowMoreIcon";
|
||||||
import RutubeIcon from "@/components/icons/RutubeIcon";
|
import RutubeIcon from "@/components/icons/RutubeIcon";
|
||||||
import TelegramIcon from "@/components/icons/TgIcon";
|
import TelegramIcon from "@/components/icons/TgIcon";
|
||||||
@@ -6,6 +7,9 @@ import VkIcon from "@/components/icons/VKIcon";
|
|||||||
import YoutubeIcon from "@/components/icons/YoutubeIcon";
|
import YoutubeIcon from "@/components/icons/YoutubeIcon";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const showRuLegal = i18n.language.startsWith("ru");
|
||||||
|
|
||||||
return (
|
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">
|
<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]">
|
<div className="max-md:flex-col lg:gap-[0.833vw] md:max-lg:gap-[1.042vw] flex gap-[1.111vw]">
|
||||||
@@ -13,7 +17,7 @@ export function Footer() {
|
|||||||
href={"tel:" + "8 800 770 00 67".replaceAll(" ", "")}
|
href={"tel:" + "8 800 770 00 67".replaceAll(" ", "")}
|
||||||
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"
|
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">Позвонить</div>
|
<div className="text-[#7A7A7A] text1 font-medium">{t("footer.call")}</div>
|
||||||
<div className="lg:line2 md:max-lg:heading1 line2 flex items-center font-medium">
|
<div className="lg:line2 md:max-lg:heading1 line2 flex items-center font-medium">
|
||||||
8 800 770 00 67
|
8 800 770 00 67
|
||||||
<div className="text-white lg:size-[5.556vw] size-[10vw]">
|
<div className="text-white lg:size-[5.556vw] size-[10vw]">
|
||||||
@@ -25,7 +29,7 @@ export function Footer() {
|
|||||||
href={"mailto:" + "info@graff.tech"}
|
href={"mailto:" + "info@graff.tech"}
|
||||||
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"
|
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">Написать</div>
|
<div className="text-[#7A7A7A] text1 font-medium">{t("footer.write")}</div>
|
||||||
<div className="lg:line2 md:max-lg:heading1 line2 flex items-center font-medium">
|
<div className="lg:line2 md:max-lg:heading1 line2 flex items-center font-medium">
|
||||||
info@graff.tech
|
info@graff.tech
|
||||||
<div className="text-white lg:size-[5.556vw] size-[10vw]">
|
<div className="text-white lg:size-[5.556vw] size-[10vw]">
|
||||||
@@ -57,35 +61,44 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
{showRuLegal ? (
|
||||||
<div className="flex flex-col gap-y-[1.111vw] lg:min-w-[48.193vw] flex-1">
|
<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">
|
||||||
<span className=" text1 text-[#7A7A7A]">Юридический адрес:</span>
|
<div className="flex flex-col gap-y-[1.111vw] lg:min-w-[48.193vw] flex-1">
|
||||||
<span className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
<span className=" text1 text-[#7A7A7A]">{t("footer.legalAddress")}</span>
|
||||||
620063, г. Екатеринбург, <br /> ул. Большакова, д. 66, кв. 6
|
<span className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
||||||
</span>
|
{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">
|
<div className="flex flex-col gap-y-[1.111vw] lg:mt-[0px] md:mt-[2.083vw] mt-6">
|
||||||
<span className=" text1 text-[#7A7A7A]">Наш основной стек:</span>
|
<span className=" text1 text-[#7A7A7A]">{t("footer.mainStack")}</span>
|
||||||
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
||||||
<p>Unreal Engine 5, C++</p>
|
<p>{t("footer.stackLine")}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-y-[1.111vw] flex-1">
|
||||||
<div className="flex flex-col gap-y-[1.111vw] flex-1">
|
<span className="text1 text-[#7A7A7A]">{t("footer.requisites")}</span>
|
||||||
<span className="text1 text-[#7A7A7A]">Реквизиты:</span>
|
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
||||||
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
<p>{t("footer.inn")}</p>
|
||||||
<p>ИНН: 6679174128</p>
|
<p>{t("footer.kpp")}</p>
|
||||||
<p>КПП: 667101001</p>
|
<p>{t("footer.company")}</p>
|
||||||
<p>ООО "ГРАФФ.ЭСТЕЙТ"</p>
|
<p>{t("footer.ogrn")}</p>
|
||||||
<p>ОГРН 1246600010140</p>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<img
|
) : null}
|
||||||
src="/img/components/header/Sk.svg"
|
|
||||||
alt="Сколково"
|
|
||||||
className=" lg:size-[3.333vw] lg:mt-[2.292vw] md:size-[6.25vw] size-[13.333vw] max-md:absolute max-md:right-0 max-md:bottom-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:gap-x-[0.833vw] gap-y-2 flex max-lg:flex-col">
|
<div className="lg:gap-x-[0.833vw] gap-y-2 flex max-lg:flex-col">
|
||||||
<a
|
<a
|
||||||
@@ -94,10 +107,10 @@ export function Footer() {
|
|||||||
href={"/policy"}
|
href={"/policy"}
|
||||||
className="text-[#37393B] text1 font-medium leading-[18.9px] lg:w-[48.193vw] w-fit"
|
className="text-[#37393B] text1 font-medium leading-[18.9px] lg:w-[48.193vw] w-fit"
|
||||||
>
|
>
|
||||||
Политика конфиденциальности и обработки персональных данных
|
{t("footer.privacy")}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-[#37393B] text1 font-medium leading-[18.9px] col-start-1">
|
<p className="text-[#37393B] text1 font-medium leading-[18.9px] col-start-1">
|
||||||
© 2026 GRAFF interactive. Все права защищены
|
{t("footer.copyright")}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -105,7 +118,7 @@ export function Footer() {
|
|||||||
href={"https://graff.tech"}
|
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"
|
className="text-[#37393B] text1 font-medium leading-[18.9px] lg:ml-auto w-fit md:col-start-2 md:text-right"
|
||||||
>
|
>
|
||||||
graff.tech
|
{t("footer.site")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
import { useState } from "react";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { AppLocale } from "@/i18n";
|
||||||
|
import { setLangInUrl } from "@/lib/urlLang";
|
||||||
|
|
||||||
export default function LanguageSwitchButton({
|
export default function LanguageSwitchButton({
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [locale, setLocale] = useState<"ru" | "en">("ru");
|
const { i18n, t } = useTranslation();
|
||||||
|
const current = (i18n.language.startsWith("ru") ? "ru" : "en") as AppLocale;
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
return setLocale(locale === "ru" ? "en" : "ru");
|
const next: AppLocale = current === "ru" ? "en" : "ru";
|
||||||
|
void i18n.changeLanguage(next);
|
||||||
|
setLangInUrl(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={`btnm bg-[#37393B99] active:bg-[#37393B80]
|
className={`btnm bg-[#37393B99] active:bg-[#37393B80]
|
||||||
lg:px-[1.667vw] lg:py-[1.181vw] lg:rounded-[0.833vw]
|
lg:px-[1.667vw] lg:py-[1.181vw] lg:rounded-[0.833vw]
|
||||||
md:px-[2.604vw] md:py-[1.302vw] md:rounded-[1.563vw]
|
md:px-[2.604vw] md:py-[1.302vw] md:rounded-[1.563vw]
|
||||||
px-[5.556vw] py-[2.778vw] rounded-[3.333vw]
|
px-[5.556vw] py-[2.778vw] rounded-[3.333vw]
|
||||||
${className}
|
${className ?? ""}
|
||||||
`}
|
`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
aria-label={t("languageSwitcher.ariaLabel")}
|
||||||
>
|
>
|
||||||
{locale === "ru" ? "RU" : "EN"}
|
{current === "ru" ? "RU" : "EN"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { AppLocale } from "@/i18n";
|
||||||
|
import { useLocationSearch } from "@/hooks/useLocationSearch";
|
||||||
|
import { parseLangParam } from "@/lib/urlLang";
|
||||||
|
import { useCountryCodeQuery } from "@/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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/ui/Button";
|
import { Button } from "@/ui/Button";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useModalStore } from "@/stores/useModalStore";
|
import { useModalStore } from "@/stores/useModalStore";
|
||||||
import { modalOptions } from "@/consts/feedback";
|
|
||||||
import CustomCheckbox from "@/ui/CustomCheckbox";
|
import CustomCheckbox from "@/ui/CustomCheckbox";
|
||||||
import CheckIcon from "@/components/icons/CheckIcon";
|
import CheckIcon from "@/components/icons/CheckIcon";
|
||||||
|
|
||||||
function FeedbackModal({ id }: { id: string }) {
|
function FeedbackModal({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { setModal } = useModalStore();
|
const { setModal } = useModalStore();
|
||||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const modalOptions = useMemo(
|
||||||
|
() => t("modalFeedbackSources", { returnObjects: true }) as string[],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
async function sendSources() {
|
async function sendSources() {
|
||||||
await api.put(`mail/${id}`, { json: { source: selectedOptions } });
|
await api.put(`mail/${id}`, { json: { source: selectedOptions } });
|
||||||
setModal(null);
|
setModal(null);
|
||||||
@@ -34,8 +40,13 @@ function FeedbackModal({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xl leading-6 md:text-center text-start max-[360px]:text-base">
|
<span className="text-xl leading-6 md:text-center text-start max-[360px]:text-base">
|
||||||
Мы получили заявку <br className="md:hidden block" /> и скоро свяжемся{" "}
|
<Trans
|
||||||
<br className="md:block hidden" /> с вами!
|
i18nKey="feedbackModal.successRich"
|
||||||
|
components={{
|
||||||
|
brMobile: <br className="md:hidden block" />,
|
||||||
|
brDesktop: <br className="md:block hidden" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,7 +54,9 @@ function FeedbackModal({ id }: { id: string }) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl leading-6 mb-[20px] max-w-[250px] max-[360px]:text-base">
|
<div className="text-xl leading-6 mb-[20px] max-w-[250px] max-[360px]:text-base">
|
||||||
Расскажите, пожалуйста, <br /> откуда вы узнали о нас?
|
{t("feedbackModal.sourcesTitle1")}
|
||||||
|
<br />
|
||||||
|
{t("feedbackModal.sourcesTitle2")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="md:mb-[49px] mb-[58px] flex flex-col gap-y-[12px]">
|
<ul className="md:mb-[49px] mb-[58px] flex flex-col gap-y-[12px]">
|
||||||
@@ -59,14 +72,14 @@ function FeedbackModal({ id }: { id: string }) {
|
|||||||
onClick={sendSources}
|
onClick={sendSources}
|
||||||
className="md:px-[31px] max-[360px]:px-[32px] px-[43px] py-[15px] rounded-2xl max-[360px]:text-sm"
|
className="md:px-[31px] max-[360px]:px-[32px] px-[43px] py-[15px] rounded-2xl max-[360px]:text-sm"
|
||||||
>
|
>
|
||||||
Отправить
|
{t("feedbackModal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setModal(null)}
|
onClick={() => setModal(null)}
|
||||||
className="md:px-[31px] px-[43px] max-[360px]:px-[32px] py-[15px] rounded-2xl bg-[#37393B] max-[360px]:text-sm"
|
className="md:px-[31px] px-[43px] max-[360px]:px-[32px] py-[15px] rounded-2xl bg-[#37393B] max-[360px]:text-sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
>
|
>
|
||||||
Пропустить
|
{t("feedbackModal.skip")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { useRef, type RefObject } from "react";
|
import { useMemo, useRef, type RefObject } from "react";
|
||||||
import { useOnClickOutside } from "usehooks-ts";
|
import { useOnClickOutside } from "usehooks-ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useModalStore } from "@/stores/useModalStore";
|
import { useModalStore } from "@/stores/useModalStore";
|
||||||
import type { Product } from "@/types";
|
import type { Product } from "@/types";
|
||||||
import FeedbackModal from "./FeedbackFormModal";
|
import FeedbackModal from "./FeedbackFormModal";
|
||||||
import CloseIcon from "@/components/icons/CloseIcon";
|
import CloseIcon from "@/components/icons/CloseIcon";
|
||||||
import { LeadForm } from "@/features/lead-form/LeadForm";
|
import { LeadForm } from "@/features/lead-form/LeadForm";
|
||||||
|
|
||||||
const DEFAULT_MODAL_PRODUCTS = [
|
|
||||||
"Создание сайтов",
|
|
||||||
"Веб-тур по 360 сферам",
|
|
||||||
] as Product[];
|
|
||||||
|
|
||||||
interface QuestionFormModalProps {
|
interface QuestionFormModalProps {
|
||||||
products?: Product[];
|
products?: Product[];
|
||||||
}
|
}
|
||||||
@@ -18,8 +14,13 @@ interface QuestionFormModalProps {
|
|||||||
export default function QuestionFormModal({
|
export default function QuestionFormModal({
|
||||||
products,
|
products,
|
||||||
}: QuestionFormModalProps) {
|
}: QuestionFormModalProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const { setModal } = useModalStore();
|
const { setModal } = useModalStore();
|
||||||
|
|
||||||
|
const defaultModalProducts = useMemo((): Product[] => {
|
||||||
|
return [t("products.webDev"), t("products.webTour360")];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const formRef = useRef<HTMLDivElement>(null);
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useOnClickOutside(formRef as RefObject<HTMLElement>, () => {
|
useOnClickOutside(formRef as RefObject<HTMLElement>, () => {
|
||||||
@@ -41,9 +42,10 @@ export default function QuestionFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<LeadForm
|
<LeadForm
|
||||||
defaultProducts={products ?? DEFAULT_MODAL_PRODUCTS}
|
key={i18n.language}
|
||||||
|
defaultProducts={products ?? defaultModalProducts}
|
||||||
idPrefix="modal-"
|
idPrefix="modal-"
|
||||||
phonePlaceholder="+X (XXX) XXX - XX - XX"
|
phonePlaceholder={t("questionModal.phonePlaceholder")}
|
||||||
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
|
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export const modalOptions: string[] = [
|
|
||||||
"Увидели на выставке или форуме",
|
|
||||||
"Видели у других застройщиков",
|
|
||||||
"Из рейтингов и статей",
|
|
||||||
"Нашли в интернете",
|
|
||||||
"Перешли по рекламе",
|
|
||||||
"Из рассылки",
|
|
||||||
"Другое",
|
|
||||||
];
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { Product } from "@/types";
|
|
||||||
|
|
||||||
export const projectsTags: Product[] = [
|
|
||||||
"Интерактивная презентация",
|
|
||||||
"Удаленная демонстрация",
|
|
||||||
"Архитектурная визуализация",
|
|
||||||
"Создание сайтов",
|
|
||||||
"Веб-тур по 360 сферам",
|
|
||||||
];
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { projectsTags } from "@/consts/projectsTags";
|
import { productOptionsFromT } from "@/lib/productLabels";
|
||||||
import type { Product } from "@/types";
|
import type { Product } from "@/types";
|
||||||
import { Button } from "@/ui/Button";
|
import { Button } from "@/ui/Button";
|
||||||
import { CheckboxesGroup } from "@/ui/CheckboxesGroup";
|
import { CheckboxesGroup } from "@/ui/CheckboxesGroup";
|
||||||
@@ -16,8 +17,6 @@ import type { LeadFormValues } from "./types";
|
|||||||
|
|
||||||
export type { LeadFormValues } from "./types";
|
export type { LeadFormValues } from "./types";
|
||||||
|
|
||||||
const GENERIC_SUBMIT_ERROR = "Не удалось отправить заявку. Попробуйте позже.";
|
|
||||||
|
|
||||||
export function LeadForm({
|
export function LeadForm({
|
||||||
defaultProducts,
|
defaultProducts,
|
||||||
idPrefix = "",
|
idPrefix = "",
|
||||||
@@ -32,8 +31,10 @@ export function LeadForm({
|
|||||||
phonePlaceholder?: string;
|
phonePlaceholder?: string;
|
||||||
formClassName?: string;
|
formClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { referer } = useRefererStore();
|
const { referer } = useRefererStore();
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const projectOptions = useMemo(() => productOptionsFromT(t), [t]);
|
||||||
|
|
||||||
const form = useForm<LeadFormValues>({
|
const form = useForm<LeadFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -56,7 +57,7 @@ export function LeadForm({
|
|||||||
.json<{ id: string }>();
|
.json<{ id: string }>();
|
||||||
onSuccess(id);
|
onSuccess(id);
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError(GENERIC_SUBMIT_ERROR);
|
setSubmitError(t("leadForm.submitError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,10 +74,10 @@ export function LeadForm({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="lg:space-y-[1.111vw] space-y-4">
|
<div className="lg:space-y-[1.111vw] space-y-4">
|
||||||
<p className="heading2 font-medium">Нам нужно</p>
|
<p className="heading2 font-medium">{t("leadForm.needTitle")}</p>
|
||||||
<CheckboxesGroup<LeadFormValues>
|
<CheckboxesGroup<LeadFormValues>
|
||||||
name="products"
|
name="products"
|
||||||
options={projectsTags}
|
options={projectOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -84,7 +85,7 @@ export function LeadForm({
|
|||||||
autoComplete="none"
|
autoComplete="none"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="Имя*"
|
placeholder={t("leadForm.namePlaceholder")}
|
||||||
{...register("fullname")}
|
{...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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +94,7 @@ export function LeadForm({
|
|||||||
required
|
required
|
||||||
id={emailId}
|
id={emailId}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email*"
|
placeholder={t("leadForm.emailPlaceholder")}
|
||||||
{...register("email")}
|
{...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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -120,28 +121,26 @@ export function LeadForm({
|
|||||||
disabled={formState.isSubmitting}
|
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"
|
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>
|
</Button>
|
||||||
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
|
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
|
||||||
<span className="text-[#7A7A7A]">
|
<span className="text-[#7A7A7A]">{t("leadForm.consentBefore")}</span>{" "}
|
||||||
*Нажимая кнопку отправить, вы даете
|
|
||||||
</span>{" "}
|
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
href="https://graff.estate/privacy-policy"
|
href="https://graff.estate/privacy-policy"
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
согласие на обработку персональных данных
|
{t("leadForm.consentLinkData")}
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
<span className="text-[#7A7A7A]">и принимаете </span>
|
<span className="text-[#7A7A7A]">{t("leadForm.consentMiddle")} </span>
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
href="/policy"
|
href="/policy"
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
условия политики
|
{t("leadForm.consentLinkPolicy")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
import { useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||||
import { REMOTE_DEMO_TAG, useGetProjectsQuery } from "@/queries/getProjects";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import BR from "@/components/Layout/LineBreak";
|
||||||
|
import {
|
||||||
|
REMOTE_DEMO_TAG,
|
||||||
|
useGetProjectsQuery,
|
||||||
|
} from "@/queries/getProjects";
|
||||||
import { StreamingProject } from "./StreamingProject";
|
import { StreamingProject } from "./StreamingProject";
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
import { useMediaQueries } from "@/hooks/useMediaQueries";
|
import { useMediaQueries } from "@/hooks/useMediaQueries";
|
||||||
|
|
||||||
export default function AvailableDemos() {
|
export default function AvailableDemos() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isMd } = useMediaQueries();
|
const { isMd } = useMediaQueries();
|
||||||
const { data: streamingProjects } = useGetProjectsQuery(REMOTE_DEMO_TAG);
|
const { data: streamingProjects } = useGetProjectsQuery(REMOTE_DEMO_TAG);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const projects = streamingProjects ?? [];
|
const projects = streamingProjects ?? [];
|
||||||
|
|
||||||
// Свайп на мобилке
|
|
||||||
const slideCount = Math.min(projects.length + 1, 4);
|
const slideCount = Math.min(projects.length + 1, 4);
|
||||||
const handlers = useSwipeable({
|
const handlers = useSwipeable({
|
||||||
onSwipedLeft: () =>
|
onSwipedLeft: () =>
|
||||||
@@ -25,7 +30,6 @@ export default function AvailableDemos() {
|
|||||||
touchEventOptions: { passive: false },
|
touchEventOptions: { passive: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Скролл на десктопе
|
|
||||||
const sliderRef = useRef<HTMLDivElement>(null);
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
function onSliderMouseDown(e: ReactMouseEvent<HTMLDivElement>) {
|
function onSliderMouseDown(e: ReactMouseEvent<HTMLDivElement>) {
|
||||||
@@ -54,12 +58,11 @@ export default function AvailableDemos() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex lg:flex-row flex-col lg:mb-[4.444vw] md:mb-[8.333vw] md:gap-[3.125vw]">
|
<div className="flex lg:flex-row flex-col lg:mb-[4.444vw] md:mb-[8.333vw] md:gap-[3.125vw]">
|
||||||
<h2 className="line2 max-md:line1 w-full max-md:mb-[5.556vw]">
|
<h2 className="line2 max-md:line1 w-full max-md:mb-[5.556vw]">
|
||||||
Доступные <br className="lg:block md:hidden block" /> демонстрации
|
{t("demos.titleLine1")} <BR lg sm /> {t("demos.titleLine2")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Тиндер на мобилке */}
|
|
||||||
<div
|
<div
|
||||||
className="lg:hidden md:hidden grid-cols-4 gap-3 px-5 [scrollbar-width:none] relative max-md:aspect-[340/344] [transform-style:preserve-3d] items-stretch mb-[5.556vw]"
|
className="grid lg:hidden md:hidden grid-cols-4 gap-3 px-5 [scrollbar-width:none] relative max-md:aspect-[340/344] [transform-style:preserve-3d] items-stretch mb-[5.556vw]"
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
{projects.slice(0, 3).map((project, index, { length }) => (
|
{projects.slice(0, 3).map((project, index, { length }) => (
|
||||||
@@ -88,13 +91,13 @@ export default function AvailableDemos() {
|
|||||||
<div className="md:bg-[#0F1011] h-full w-full lg:rounded-[1.111vw] rounded-2xl flex items-center p-6">
|
<div className="md:bg-[#0F1011] h-full w-full lg:rounded-[1.111vw] rounded-2xl flex items-center p-6">
|
||||||
<div className="flex flex-col items-center space-y-6">
|
<div className="flex flex-col items-center space-y-6">
|
||||||
<p className="heading2 font-medium text-center">
|
<p className="heading2 font-medium text-center">
|
||||||
Расскажем и покажем как это работает на созвоне
|
{t("demos.ctaTitle")}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="#contacts"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Оставить заявку
|
{t("demos.ctaButton")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,13 +105,10 @@ export default function AvailableDemos() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="lg:headline1 headline2 text-[#7A7A7A] w-full pr-[8.333vw] md:max-w-[75vw]">
|
<p className="lg:headline1 headline2 text-[#7A7A7A] w-full pr-[8.333vw] md:max-w-[75vw]">
|
||||||
Клиент из любой точки мира может посмотреть жилой комплекс, даже на
|
{t("demos.description")}
|
||||||
нулевом этапе строительства. Он выберет лучшую планировку и оценит вид
|
|
||||||
из окон своей будущей квартиры.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Слайдер на десктопе */}
|
|
||||||
<div
|
<div
|
||||||
ref={sliderRef}
|
ref={sliderRef}
|
||||||
onMouseDown={isMd ? onSliderMouseDown : undefined}
|
onMouseDown={isMd ? onSliderMouseDown : undefined}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import BR from "@/components/Layout/LineBreak";
|
import BR from "@/components/Layout/LineBreak";
|
||||||
import { Button } from "@/ui/Button";
|
import { Button } from "@/ui/Button";
|
||||||
import QuestionFormModal from "@/components/modals/QuestionFormModal";
|
import QuestionFormModal from "@/components/modals/QuestionFormModal";
|
||||||
import { useModalStore } from "@/stores/useModalStore";
|
import { useModalStore } from "@/stores/useModalStore";
|
||||||
|
|
||||||
export default function RequestForDemo() {
|
export default function RequestForDemo() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { setModal } = useModalStore();
|
const { setModal } = useModalStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@@ -15,24 +18,23 @@ export default function RequestForDemo() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col lg:max-w-[31.944vw] min-h-full">
|
<div className="flex flex-col lg:max-w-[31.944vw] min-h-full">
|
||||||
<h2 className="line2 max-md:mb-[6.667vw]">
|
<h2 className="line2 max-md:mb-[6.667vw]">
|
||||||
Запись <BR lg md /> на удаленную
|
{t("requestDemo.titleLine1")} <BR lg md /> {t("requestDemo.titleLine2")}
|
||||||
<BR /> демонстрацию
|
<BR /> {t("requestDemo.titleLine3")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="flex flex-col lg:gap-[2.222vw] md:gap-[4.167vw] mt-auto">
|
<div className="flex flex-col lg:gap-[2.222vw] md:gap-[4.167vw] mt-auto">
|
||||||
<p className="lg:headline1 headline2 text-[#7A7A7A]">
|
<p className="lg:headline1 headline2 text-[#7A7A7A]">
|
||||||
Запись на демонстрацию может быть оформлена в виде блока на сайте
|
{t("requestDemo.description")}
|
||||||
застройщика или жилого комплекса.
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<QuestionFormModal products={["Удаленная демонстрация"]} />
|
<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]"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { VideoPlayer } from "@/ui/VideoPlayer";
|
import { VideoPlayer } from "@/ui/VideoPlayer";
|
||||||
|
|
||||||
const viteBase = import.meta.env.BASE_URL;
|
const viteBase = import.meta.env.BASE_URL;
|
||||||
@@ -7,6 +8,8 @@ const STREAMING_VIDEO_SRC =
|
|||||||
: `${viteBase.replace(/\/$/, "")}/videos/streaming.mp4`;
|
: `${viteBase.replace(/\/$/, "")}/videos/streaming.mp4`;
|
||||||
|
|
||||||
export default function StreamPlayer({ className }: { className?: string }) {
|
export default function StreamPlayer({ className }: { className?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full ${className ?? ""}`}>
|
<div className={`w-full ${className ?? ""}`}>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
@@ -18,8 +21,7 @@ export default function StreamPlayer({ className }: { className?: string }) {
|
|||||||
className="lg:aspect-[1400/640] max-h-dvh md:max-lg:aspect-[736/480] aspect-[340/600]"
|
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%] accent">
|
<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%] accent">
|
||||||
GRAFF.estate — модуль удаленной демонстрации — доступен на любых
|
{t("streamPlayer.caption")}
|
||||||
устройствах, для демонстрации нужен только интернет
|
|
||||||
</p>
|
</p>
|
||||||
</VideoPlayer>
|
</VideoPlayer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { streamDemoUrlFromBuild } from "@/lib/streamDemoUrl";
|
import { streamDemoUrlFromBuild } from "@/lib/streamDemoUrl";
|
||||||
import { resolveProjectImageSrc } from "@/lib/resolveProjectImageSrc";
|
import { resolveProjectImageSrc } from "@/lib/resolveProjectImageSrc";
|
||||||
import type { IProject } from "@/types";
|
import type { IProject } from "@/types";
|
||||||
@@ -21,6 +22,7 @@ export function StreamingProject({
|
|||||||
count: number;
|
count: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const imgSrc = resolveProjectImageSrc(image);
|
const imgSrc = resolveProjectImageSrc(image);
|
||||||
const build = buildFilename?.trim() ?? "";
|
const build = buildFilename?.trim() ?? "";
|
||||||
const streamHref = build ? streamDemoUrlFromBuild(build) : href;
|
const streamHref = build ? streamDemoUrlFromBuild(build) : href;
|
||||||
@@ -75,7 +77,7 @@ export function StreamingProject({
|
|||||||
className="bg-gradient btns flex gap-2 items-center px-3 py-2 font-medium rounded-xl"
|
className="bg-gradient btns flex gap-2 items-center px-3 py-2 font-medium rounded-xl"
|
||||||
href={streamHref}
|
href={streamHref}
|
||||||
>
|
>
|
||||||
Смотреть
|
{t("streamingProject.watch")}
|
||||||
<div className="text-white lg:size-[1.389vw] size-4">
|
<div className="text-white lg:size-[1.389vw] size-4">
|
||||||
<ArrowMoreIcon />
|
<ArrowMoreIcon />
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +88,7 @@ export function StreamingProject({
|
|||||||
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]"
|
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">
|
<div className="btnl flex gap-2 justify-center">
|
||||||
Начать демонстрацию{" "}
|
{t("streamingProject.startDemo")}{" "}
|
||||||
<div className="text-white lg:size-[1.389vw] size-4">
|
<div className="text-white lg:size-[1.389vw] size-4">
|
||||||
<ArrowMoreIcon />
|
<ArrowMoreIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,29 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import en from "./locales/en.json";
|
||||||
|
import ru from "./locales/ru.json";
|
||||||
|
|
||||||
|
export type AppLocale = "ru" | "en";
|
||||||
|
|
||||||
|
function langFromSearch(): AppLocale | null {
|
||||||
|
const raw = new URLSearchParams(window.location.search).get("lang");
|
||||||
|
const v = raw?.trim().toLowerCase();
|
||||||
|
if (v === "ru" || v === "en") return v;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialLng(): AppLocale {
|
||||||
|
return langFromSearch() ?? "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
ru: { translation: ru },
|
||||||
|
en: { translation: en },
|
||||||
|
},
|
||||||
|
lng: initialLng(),
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export { i18n };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"languageSwitcher": {
|
||||||
|
"ru": "RU",
|
||||||
|
"en": "EN",
|
||||||
|
"ariaLabel": "Language"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"call": "Call",
|
||||||
|
"write": "Write",
|
||||||
|
"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. 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 remote demo module works on\u00a0any device; all you need 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,87 @@
|
|||||||
|
{
|
||||||
|
"languageSwitcher": {
|
||||||
|
"ru": "RU",
|
||||||
|
"en": "EN",
|
||||||
|
"ariaLabel": "Язык"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"call": "Позвонить",
|
||||||
|
"write": "Написать",
|
||||||
|
"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демонстрации нужен только интернет"
|
||||||
|
},
|
||||||
|
"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,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,15 @@
|
|||||||
|
import type { AppLocale } from "@/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"));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "@/i18n";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ModalContainer } from "@/components/Layout/ModalContainer";
|
import { ModalContainer } from "@/components/Layout/ModalContainer";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import ky from "ky";
|
||||||
|
import { useLocationSearch } from "@/hooks/useLocationSearch";
|
||||||
|
import { parseLangParam } from "@/lib/urlLang";
|
||||||
|
import { queryKeys } from "@/queries/keys";
|
||||||
|
|
||||||
|
const COUNTRY_CODE_URL = "https://stream.graff.tech/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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
projects: ["projects"] as const,
|
projects: ["projects"] as const,
|
||||||
projectsWithTags: (tags: string[]) => ["projects", ...tags] as const,
|
projectsWithTags: (tags: string[]) => ["projects", ...tags] as const,
|
||||||
|
countryCode: ["countryCode"] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
export type Product =
|
/** Localized product label in the API payload (matches `t('products.*')` for the active locale). */
|
||||||
| "Интерактивная презентация"
|
export type Product = string;
|
||||||
| "Удаленная демонстрация"
|
|
||||||
| "Создание сайтов"
|
|
||||||
| "Архитектурная визуализация"
|
|
||||||
| "Веб-тур по 360 сферам";
|
|
||||||
|
|||||||
Reference in New Issue
Block a user