Compare commits

...

3 Commits

27 changed files with 1605 additions and 158 deletions
+1 -1
View File
@@ -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" />
+1148 -39
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
View File
@@ -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 />
+13 -5
View File
@@ -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>
+42 -29
View File
@@ -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>ООО &quot;ГРАФФ.ЭСТЕЙТ&quot;</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>
+12 -5
View File
@@ -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>
); );
} }
+30
View File
@@ -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;
}
+20 -7
View File
@@ -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>
+10 -8
View File
@@ -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>
-9
View File
@@ -1,9 +0,0 @@
export const modalOptions: string[] = [
"Увидели на выставке или форуме",
"Видели у других застройщиков",
"Из рейтингов и статей",
"Нашли в интернете",
"Перешли по рекламе",
"Из рассылки",
"Другое",
];
-9
View File
@@ -1,9 +0,0 @@
import type { Product } from "@/types";
export const projectsTags: Product[] = [
"Интерактивная презентация",
"Удаленная демонстрация",
"Архитектурная визуализация",
"Создание сайтов",
"Веб-тур по 360 сферам",
];
+15 -16
View File
@@ -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>
+12 -12
View File
@@ -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">
Расскажем и покажем как это работает на&nbsp;созвоне {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}
+8 -6
View File
@@ -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>
+4 -2
View File
@@ -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 модуль удаленной демонстрации доступен на&nbsp;любых {t("streamPlayer.caption")}
устройствах, для&nbsp;демонстрации нужен только интернет
</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>
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
/** Re-renders when `location.search` changes (popstate or custom `locationchange`). */
export function useLocationSearch(): string {
const [search, setSearch] = useState(() => window.location.search);
useEffect(() => {
const sync = () => setSearch(window.location.search);
window.addEventListener("popstate", sync);
window.addEventListener("locationchange", sync);
return () => {
window.removeEventListener("popstate", sync);
window.removeEventListener("locationchange", sync);
};
}, []);
return search;
}
+29
View File
@@ -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 };
+87
View File
@@ -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": "Well walk you through how it works on\u00a0a call",
"ctaButton": "Request a call",
"description": "Clients anywhere in the world can explore a residential complex, even at the zero stage of construction. They can pick the best layout and assess the view from the windows of their future apartment."
},
"streamingProject": {
"watch": "Watch",
"startDemo": "Start demo"
},
"requestDemo": {
"titleLine1": "Book",
"titleLine2": "a remote",
"titleLine3": "demo",
"description": "A demo booking can be embedded as a block on the developers or residential complexs website.",
"cta": "Request a call"
},
"streamPlayer": {
"caption": "GRAFF.estate remote demo module works on\u00a0any device; all you need is an internet connection"
},
"feedback": {
"titleLead": "Want to improve conversion?",
"titleRest": "Lets discuss the details."
},
"leadForm": {
"submitError": "Could not submit the request. Please try again later.",
"needTitle": "We need",
"namePlaceholder": "Name*",
"emailPlaceholder": "Email*",
"submit": "Submit request",
"consentBefore": "*By submitting, you give",
"consentLinkData": "consent to personal data processing",
"consentMiddle": "and accept the",
"consentLinkPolicy": "policy terms"
},
"questionModal": {
"phonePlaceholder": "+X (XXX) XXX - XX - XX"
},
"feedbackModal": {
"successRich": "Weve received your request <brMobile /> and well contact you <brDesktop /> soon!",
"sourcesTitle1": "Please tell us",
"sourcesTitle2": "how you heard about us",
"send": "Send",
"skip": "Skip"
},
"products": {
"interactivePresentation": "Interactive presentation",
"remoteDemo": "Remote demonstration",
"archViz": "Architectural visualization",
"webDev": "Website development",
"webTour360": "360° web tour"
},
"modalFeedbackSources": [
"Saw us at an exhibition or forum",
"Saw it with other developers",
"From rankings and articles",
"Found online",
"Came from an ad",
"From a newsletter",
"Other"
]
}
+87
View File
@@ -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": [
"Увидели на выставке или форуме",
"Видели у других застройщиков",
"Из рейтингов и статей",
"Нашли в интернете",
"Перешли по рекламе",
"Из рассылки",
"Другое"
]
}
+14
View File
@@ -0,0 +1,14 @@
import type { TFunction } from "i18next";
/** Order of product checkboxes in lead forms. */
export const PRODUCT_I18N_KEYS = [
"products.interactivePresentation",
"products.remoteDemo",
"products.archViz",
"products.webDev",
"products.webTour360",
] as const;
export function productOptionsFromT(t: TFunction): string[] {
return PRODUCT_I18N_KEYS.map((key) => t(key));
}
+15
View File
@@ -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
View File
@@ -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";
+26
View File
@@ -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
View File
@@ -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,
}; };
+2 -6
View File
@@ -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 сферам";