Update language support and localization across components. Change HTML language attribute to English, add i18next for translations, and refactor feedback forms to utilize localized strings. Remove unused constants and adjust product handling in lead forms. Enhance accessibility and improve code structure.
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"framer-motion": "^11.17.0",
|
||||
"i18next": "^26.0.5",
|
||||
"ky": "^1.4.0",
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^17.0.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"zustand": "^4.5.4"
|
||||
|
||||
+5
-1
@@ -1,11 +1,15 @@
|
||||
import { Footer } from "@/components/Layout/Footer";
|
||||
import { LanguageSwitcher } from "@/components/Layout/LanguageSwitcher";
|
||||
import { LocaleSync } from "@/components/Layout/LocaleSync";
|
||||
import StreamDemo from "@/features/stream-demo/StreamDemo";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<LocaleSync />
|
||||
<LanguageSwitcher />
|
||||
{/* Без 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 />
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Product } from "@/types";
|
||||
import useAddReferer from "@/hooks/useAddReferer";
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
import FeedbackModal from "@/components/modals/FeedbackFormModal";
|
||||
import { LeadForm } from "@/features/lead-form/LeadForm";
|
||||
|
||||
const DEFAULT_STREAM_DEMO_PRODUCTS = [
|
||||
"Удаленная демонстрация",
|
||||
] as Product[];
|
||||
|
||||
export function Feedback() {
|
||||
useAddReferer();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -17,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"
|
||||
>
|
||||
<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 />
|
||||
Давайте обсудим детали.
|
||||
{t("feedback.titleRest")}
|
||||
</h2>
|
||||
<FeedbackForm />
|
||||
</div>
|
||||
@@ -27,13 +26,20 @@ export function Feedback() {
|
||||
}
|
||||
|
||||
export function FeedbackForm() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const defaultProducts = useMemo(
|
||||
(): Product[] => [t("products.remoteDemo")],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 lg:max-w-[47.431vw]">
|
||||
<div className="space-y-10">
|
||||
<LeadForm
|
||||
defaultProducts={DEFAULT_STREAM_DEMO_PRODUCTS}
|
||||
key={i18n.language}
|
||||
defaultProducts={defaultProducts}
|
||||
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowMoreIcon from "@/components/icons/ArrowMoreIcon";
|
||||
import RutubeIcon from "@/components/icons/RutubeIcon";
|
||||
import TelegramIcon from "@/components/icons/TgIcon";
|
||||
@@ -6,6 +7,9 @@ import VkIcon from "@/components/icons/VKIcon";
|
||||
import YoutubeIcon from "@/components/icons/YoutubeIcon";
|
||||
|
||||
export function Footer() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const showRuLegal = i18n.language.startsWith("ru");
|
||||
|
||||
return (
|
||||
<footer className="lg:px-5 lg:pb-5 md:max-lg:px-4 md:max-lg:pb-4 px-[10px] pb-[10px] space-y-6 mb-0">
|
||||
<div className="max-md:flex-col lg:gap-[0.833vw] md:max-lg:gap-[1.042vw] flex gap-[1.111vw]">
|
||||
@@ -13,7 +17,7 @@ export function Footer() {
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
8 800 770 00 67
|
||||
<div className="text-white lg:size-[5.556vw] size-[10vw]">
|
||||
@@ -25,7 +29,7 @@ export function Footer() {
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
info@graff.tech
|
||||
<div className="text-white lg:size-[5.556vw] size-[10vw]">
|
||||
@@ -57,35 +61,39 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRuLegal ? (
|
||||
<div className="lg:w-full flex flex-col md:flex-row md:max-lg:gap-[1.042vw] lg:gap-x-[0.833vw] gap-6 lg:pb-6 max-lg:py-6 pb-10 md:max-lg:pt-4 !max-md:mt-[11.111vw] border-b border-[#232425] relative">
|
||||
<div className="flex flex-col gap-y-[1.111vw] lg:min-w-[48.193vw] flex-1">
|
||||
<span className=" text1 text-[#7A7A7A]">Юридический адрес:</span>
|
||||
<span className=" text1 text-[#7A7A7A]">{t("footer.legalAddress")}</span>
|
||||
<span className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
||||
620063, г. Екатеринбург, <br /> ул. Большакова, д. 66, кв. 6
|
||||
{t("footer.addressLine1")}
|
||||
<br />
|
||||
{t("footer.addressLine2")}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-y-[1.111vw] lg:mt-[0px] md:mt-[2.083vw] mt-6">
|
||||
<span className=" text1 text-[#7A7A7A]">Наш основной стек:</span>
|
||||
<span className=" text1 text-[#7A7A7A]">{t("footer.mainStack")}</span>
|
||||
<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 className="flex flex-col gap-y-[1.111vw] flex-1">
|
||||
<span className="text1 text-[#7A7A7A]">Реквизиты:</span>
|
||||
<span className="text1 text-[#7A7A7A]">{t("footer.requisites")}</span>
|
||||
<div className="headline2 max-md:leading-4 font-medium text-[#7A7A7A]">
|
||||
<p>ИНН: 6679174128</p>
|
||||
<p>КПП: 667101001</p>
|
||||
<p>ООО "ГРАФФ.ЭСТЕЙТ"</p>
|
||||
<p>ОГРН 1246600010140</p>
|
||||
<p>{t("footer.inn")}</p>
|
||||
<p>{t("footer.kpp")}</p>
|
||||
<p>{t("footer.company")}</p>
|
||||
<p>{t("footer.ogrn")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src="/img/components/header/Sk.svg"
|
||||
alt="Сколково"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lg:gap-x-[0.833vw] gap-y-2 flex max-lg:flex-col">
|
||||
<a
|
||||
@@ -94,10 +102,10 @@ export function Footer() {
|
||||
href={"/policy"}
|
||||
className="text-[#37393B] text1 font-medium leading-[18.9px] lg:w-[48.193vw] w-fit"
|
||||
>
|
||||
Политика конфиденциальности и обработки персональных данных
|
||||
{t("footer.privacy")}
|
||||
</a>
|
||||
<p className="text-[#37393B] text1 font-medium leading-[18.9px] col-start-1">
|
||||
© 2026 GRAFF interactive. Все права защищены
|
||||
{t("footer.copyright")}
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
@@ -105,7 +113,7 @@ export function Footer() {
|
||||
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"
|
||||
>
|
||||
graff.tech
|
||||
{t("footer.site")}
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AppLocale } from "@/i18n";
|
||||
import { setLangInUrl } from "@/lib/urlLang";
|
||||
|
||||
const LOCALES: AppLocale[] = ["ru", "en"];
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const current = (i18n.language.startsWith("ru") ? "ru" : "en") as AppLocale;
|
||||
|
||||
function select(lang: AppLocale) {
|
||||
if (lang === current) return;
|
||||
void i18n.changeLanguage(lang);
|
||||
setLangInUrl(lang);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-[10px] top-4 z-50 flex gap-1 md:right-4 md:top-6 lg:right-[1.389vw] lg:top-8"
|
||||
role="navigation"
|
||||
aria-label={t("languageSwitcher.ariaLabel")}
|
||||
>
|
||||
{LOCALES.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => select(lang)}
|
||||
className={`btns rounded-xl px-3 py-2 font-medium transition-colors lg:rounded-[0.833vw] lg:px-[0.833vw] lg:py-[0.486vw] ${
|
||||
current === lang
|
||||
? "bg-white text-black"
|
||||
: "bg-[#37393B99] text-white hover:bg-[#37393B]"
|
||||
}`}
|
||||
>
|
||||
{t(`languageSwitcher.${lang}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { api } from "@/lib/api";
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
import { modalOptions } from "@/consts/feedback";
|
||||
import CustomCheckbox from "@/ui/CustomCheckbox";
|
||||
import CheckIcon from "@/components/icons/CheckIcon";
|
||||
|
||||
function FeedbackModal({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { setModal } = useModalStore();
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
|
||||
const modalOptions = useMemo(
|
||||
() => t("modalFeedbackSources", { returnObjects: true }) as string[],
|
||||
[t]
|
||||
);
|
||||
|
||||
async function sendSources() {
|
||||
await api.put(`mail/${id}`, { json: { source: selectedOptions } });
|
||||
setModal(null);
|
||||
@@ -34,8 +40,13 @@ function FeedbackModal({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
<span className="text-xl leading-6 md:text-center text-start max-[360px]:text-base">
|
||||
Мы получили заявку <br className="md:hidden block" /> и скоро свяжемся{" "}
|
||||
<br className="md:block hidden" /> с вами!
|
||||
<Trans
|
||||
i18nKey="feedbackModal.successRich"
|
||||
components={{
|
||||
brMobile: <br className="md:hidden block" />,
|
||||
brDesktop: <br className="md:block hidden" />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +54,9 @@ function FeedbackModal({ id }: { id: string }) {
|
||||
|
||||
<div>
|
||||
<div className="text-xl leading-6 mb-[20px] max-w-[250px] max-[360px]:text-base">
|
||||
Расскажите, пожалуйста, <br /> откуда вы узнали о нас?
|
||||
{t("feedbackModal.sourcesTitle1")}
|
||||
<br />
|
||||
{t("feedbackModal.sourcesTitle2")}
|
||||
</div>
|
||||
|
||||
<ul className="md:mb-[49px] mb-[58px] flex flex-col gap-y-[12px]">
|
||||
@@ -59,14 +72,14 @@ function FeedbackModal({ id }: { id: string }) {
|
||||
onClick={sendSources}
|
||||
className="md:px-[31px] max-[360px]:px-[32px] px-[43px] py-[15px] rounded-2xl max-[360px]:text-sm"
|
||||
>
|
||||
Отправить
|
||||
{t("feedbackModal.send")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setModal(null)}
|
||||
className="md:px-[31px] px-[43px] max-[360px]:px-[32px] py-[15px] rounded-2xl bg-[#37393B] max-[360px]:text-sm"
|
||||
color="secondary"
|
||||
>
|
||||
Пропустить
|
||||
{t("feedbackModal.skip")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useRef, type RefObject } from "react";
|
||||
import { useMemo, useRef, type RefObject } from "react";
|
||||
import { useOnClickOutside } from "usehooks-ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
import type { Product } from "@/types";
|
||||
import FeedbackModal from "./FeedbackFormModal";
|
||||
import CloseIcon from "@/components/icons/CloseIcon";
|
||||
import { LeadForm } from "@/features/lead-form/LeadForm";
|
||||
|
||||
const DEFAULT_MODAL_PRODUCTS = [
|
||||
"Создание сайтов",
|
||||
"Веб-тур по 360 сферам",
|
||||
] as Product[];
|
||||
|
||||
interface QuestionFormModalProps {
|
||||
products?: Product[];
|
||||
}
|
||||
@@ -18,8 +14,13 @@ interface QuestionFormModalProps {
|
||||
export default function QuestionFormModal({
|
||||
products,
|
||||
}: QuestionFormModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const defaultModalProducts = useMemo((): Product[] => {
|
||||
return [t("products.webDev"), t("products.webTour360")];
|
||||
}, [t]);
|
||||
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOnClickOutside(formRef as RefObject<HTMLElement>, () => {
|
||||
@@ -41,9 +42,10 @@ export default function QuestionFormModal({
|
||||
</div>
|
||||
</button>
|
||||
<LeadForm
|
||||
defaultProducts={products ?? DEFAULT_MODAL_PRODUCTS}
|
||||
key={i18n.language}
|
||||
defaultProducts={products ?? defaultModalProducts}
|
||||
idPrefix="modal-"
|
||||
phonePlaceholder="+X (XXX) XXX - XX - XX"
|
||||
phonePlaceholder={t("questionModal.phonePlaceholder")}
|
||||
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
|
||||
/>
|
||||
</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 { projectsTags } from "@/consts/projectsTags";
|
||||
import { productOptionsFromT } from "@/lib/productLabels";
|
||||
import type { Product } from "@/types";
|
||||
import { Button } from "@/ui/Button";
|
||||
import { CheckboxesGroup } from "@/ui/CheckboxesGroup";
|
||||
@@ -16,9 +17,6 @@ import type { LeadFormValues } from "./types";
|
||||
|
||||
export type { LeadFormValues } from "./types";
|
||||
|
||||
const GENERIC_SUBMIT_ERROR =
|
||||
"Не удалось отправить заявку. Попробуйте позже.";
|
||||
|
||||
export function LeadForm({
|
||||
defaultProducts,
|
||||
idPrefix = "",
|
||||
@@ -33,8 +31,10 @@ export function LeadForm({
|
||||
phonePlaceholder?: string;
|
||||
formClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { referer } = useRefererStore();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const projectOptions = useMemo(() => productOptionsFromT(t), [t]);
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
defaultValues: {
|
||||
@@ -57,7 +57,7 @@ export function LeadForm({
|
||||
.json<{ id: string }>();
|
||||
onSuccess(id);
|
||||
} catch {
|
||||
setSubmitError(GENERIC_SUBMIT_ERROR);
|
||||
setSubmitError(t("leadForm.submitError"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,10 +74,10 @@ export function LeadForm({
|
||||
</p>
|
||||
) : null}
|
||||
<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>
|
||||
name="products"
|
||||
options={projectsTags}
|
||||
options={projectOptions}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -85,7 +85,7 @@ export function LeadForm({
|
||||
autoComplete="none"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Имя*"
|
||||
placeholder={t("leadForm.namePlaceholder")}
|
||||
{...register("fullname")}
|
||||
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:btnl btnl placeholder:font-medium placeholder:select-none"
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ export function LeadForm({
|
||||
required
|
||||
id={emailId}
|
||||
type="email"
|
||||
placeholder="Email*"
|
||||
placeholder={t("leadForm.emailPlaceholder")}
|
||||
{...register("email")}
|
||||
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none btnl outline-none transition-all w-full placeholder:btnl placeholder:font-medium placeholder:select-none"
|
||||
/>
|
||||
@@ -121,28 +121,26 @@ export function LeadForm({
|
||||
disabled={formState.isSubmitting}
|
||||
className="btnl max-md:mb-3 max-md:w-full lg:px-[2.222vw] lg:py-[1.389vw] px-8 py-5 cursor-pointer lg:rounded-[1.111vw] rounded-2xl disabled:opacity-60"
|
||||
>
|
||||
Оставить заявку
|
||||
{t("leadForm.submit")}
|
||||
</Button>
|
||||
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
|
||||
<span className="text-[#7A7A7A]">
|
||||
*Нажимая кнопку отправить, вы даете
|
||||
</span>{" "}
|
||||
<span className="text-[#7A7A7A]">{t("leadForm.consentBefore")}</span>{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="/privacy-policy"
|
||||
className="underline"
|
||||
>
|
||||
согласие на обработку персональных данных
|
||||
{t("leadForm.consentLinkData")}
|
||||
</a>{" "}
|
||||
<span className="text-[#7A7A7A]">и принимаете </span>
|
||||
<span className="text-[#7A7A7A]">{t("leadForm.consentMiddle")} </span>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="/policy"
|
||||
className="underline"
|
||||
>
|
||||
условия политики
|
||||
{t("leadForm.consentLinkPolicy")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BR from "@/components/Layout/LineBreak";
|
||||
import {
|
||||
REMOTE_DEMO_TAG,
|
||||
@@ -9,6 +10,7 @@ import { useSwipeable } from "react-swipeable";
|
||||
import { useMediaQueries } from "@/hooks/useMediaQueries";
|
||||
|
||||
export default function AvailableDemos() {
|
||||
const { t } = useTranslation();
|
||||
const { isMd, isLg } = useMediaQueries();
|
||||
const { data: streamingProjects } = useGetProjectsQuery(REMOTE_DEMO_TAG);
|
||||
const [current, setCurrent] = useState(0);
|
||||
@@ -34,7 +36,7 @@ export default function AvailableDemos() {
|
||||
<div>
|
||||
<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]">
|
||||
Доступные <BR lg sm /> демонстрации
|
||||
{t("demos.titleLine1")} <BR lg sm /> {t("demos.titleLine2")}
|
||||
</h2>
|
||||
|
||||
{!isLg && !isMd && (
|
||||
@@ -69,13 +71,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="flex flex-col items-center space-y-6">
|
||||
<p className="heading2 font-medium text-center">
|
||||
Расскажем и покажем как это работает на созвоне
|
||||
{t("demos.ctaTitle")}
|
||||
</p>
|
||||
<a
|
||||
href="/form"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,9 +86,7 @@ export default function AvailableDemos() {
|
||||
)}
|
||||
|
||||
<p className="lg:headline1 headline2 text-[#7A7A7A] w-full pr-[8.333vw] md:max-w-[75vw]">
|
||||
Клиент из любой точки мира может посмотреть жилой комплекс, даже на
|
||||
нулевом этапе строительства. Он выберет лучшую планировку и оценит вид
|
||||
из окон своей будущей квартиры.
|
||||
{t("demos.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BR from "@/components/Layout/LineBreak";
|
||||
import { Button } from "@/ui/Button";
|
||||
import QuestionFormModal from "@/components/modals/QuestionFormModal";
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
|
||||
export default function RequestForDemo() {
|
||||
const { t } = useTranslation();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
@@ -14,24 +17,23 @@ export default function RequestForDemo() {
|
||||
>
|
||||
<div className="flex flex-col lg:max-w-[31.944vw] min-h-full">
|
||||
<h2 className="line2 max-md:mb-[6.667vw]">
|
||||
Запись <BR lg md /> на удаленную
|
||||
<BR /> демонстрацию
|
||||
{t("requestDemo.titleLine1")} <BR lg md /> {t("requestDemo.titleLine2")}
|
||||
<BR /> {t("requestDemo.titleLine3")}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col lg:gap-[2.222vw] md:gap-[4.167vw] mt-auto">
|
||||
<p className="lg:headline1 headline2 text-[#7A7A7A]">
|
||||
Запись на демонстрацию может быть оформлена в виде блока на сайте
|
||||
застройщика или жилого комплекса.
|
||||
{t("requestDemo.description")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal(
|
||||
<QuestionFormModal products={["Удаленная демонстрация"]} />
|
||||
<QuestionFormModal products={[t("products.remoteDemo")]} />
|
||||
);
|
||||
}}
|
||||
className="max-md:hidden btnl bg-gradient-saturated lg:py-[1.389vw] lg:px-[2.222vw] md:py-[2.604vw] md:px-[4.167vw] md:rounded-[2.083vw] lg:rounded-[1.111vw]"
|
||||
>
|
||||
Оставить заявку
|
||||
{t("requestDemo.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VideoPlayer } from "@/ui/VideoPlayer";
|
||||
|
||||
const viteBase = import.meta.env.BASE_URL;
|
||||
@@ -7,6 +8,8 @@ const STREAMING_VIDEO_SRC =
|
||||
: `${viteBase.replace(/\/$/, "")}/videos/streaming.mp4`;
|
||||
|
||||
export default function StreamPlayer({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className ?? ""}`}>
|
||||
<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]"
|
||||
>
|
||||
<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>
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { streamDemoUrlFromBuild } from "@/lib/streamDemoUrl";
|
||||
import { resolveProjectImageSrc } from "@/lib/resolveProjectImageSrc";
|
||||
import type { IProject } from "@/types";
|
||||
@@ -21,6 +22,7 @@ export function StreamingProject({
|
||||
count: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const imgSrc = resolveProjectImageSrc(image);
|
||||
const build = buildFilename?.trim() ?? "";
|
||||
const streamHref = build ? streamDemoUrlFromBuild(build) : href;
|
||||
@@ -74,7 +76,7 @@ export function StreamingProject({
|
||||
className="bg-gradient btns flex gap-2 items-center px-3 py-2 font-medium rounded-xl"
|
||||
href={streamHref}
|
||||
>
|
||||
Смотреть
|
||||
{t("streamingProject.watch")}
|
||||
<div className="text-white lg:size-[1.389vw] size-4">
|
||||
<ArrowMoreIcon />
|
||||
</div>
|
||||
@@ -85,7 +87,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]"
|
||||
>
|
||||
<div className="btnl flex gap-2 justify-center">
|
||||
Начать демонстрацию{" "}
|
||||
{t("streamingProject.startDemo")}{" "}
|
||||
<div className="text-white lg:size-[1.389vw] size-4">
|
||||
<ArrowMoreIcon />
|
||||
</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 { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@/i18n";
|
||||
import App from "./App";
|
||||
import { ModalContainer } from "@/components/Layout/ModalContainer";
|
||||
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 = {
|
||||
projects: ["projects"] as const,
|
||||
projectsWithTags: (tags: string[]) => ["projects", ...tags] as const,
|
||||
countryCode: ["countryCode"] as const,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
export type Product =
|
||||
| "Интерактивная презентация"
|
||||
| "Удаленная демонстрация"
|
||||
| "Создание сайтов"
|
||||
| "Архитектурная визуализация"
|
||||
| "Веб-тур по 360 сферам";
|
||||
/** Localized product label in the API payload (matches `t('products.*')` for the active locale). */
|
||||
export type Product = string;
|
||||
|
||||
Reference in New Issue
Block a user