Remove unused files and update configuration for environment variables. Refactor feedback forms to use a new lead form component. Adjust API handling and project image resolution logic. Clean up imports and improve code structure across various components.

This commit is contained in:
2026-04-10 18:38:56 +05:00
parent f4b2b9b67e
commit 5007140a15
32 changed files with 298 additions and 410 deletions
-8
View File
@@ -1,8 +0,0 @@
# Скопируйте в .env — подойдут и имена из Next (NEXT_PUBLIC_*), и VITE_*.
VITE_API_URL=https://example.com/api/
NEXT_PUBLIC_API=https://example.com/api/
# База для полей image вида projects/uuid.jpg (со слэшем на конце).
# Пример: https://storage.yandexcloud.net/dult-faib-knac-fint/
VITE_S3_BUCKET=https://storage.yandexcloud.net/your-bucket/
NEXT_PUBLIC_S3_BUCKET=https://storage.yandexcloud.net/your-bucket/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

+6 -131
View File
@@ -1,19 +1,8 @@
import { api } from "@/lib/api";
import { projectsTags } from "@/consts/projectsTags";
import type { Product } from "@/types/Product";
import { Button } from "@/ui/Button";
import { CheckboxesGroup } from "@/ui/CheckboxesGroup";
import { PhoneInputRu } from "@/ui/PhoneInputRu";
import type { Product } from "@/types";
import useAddReferer from "@/hooks/useAddReferer";
import { useRefererStore } from "@/stores/useRefererStore";
import { useModalStore } from "@/stores/useModalStore";
import {
Controller,
FormProvider,
useForm,
} from "react-hook-form";
import CheckIcon from "@/components/icons/CheckIcon";
import FeedbackModal from "@/components/modals/FeedbackFormModal";
import { LeadForm } from "@/features/lead-form/LeadForm";
const DEFAULT_STREAM_DEMO_PRODUCTS = [
"Удаленная демонстрация",
@@ -37,130 +26,16 @@ export function Feedback() {
);
}
interface IInput {
fullname: string;
phone: string;
email: string;
products: Product[];
referer?: string | null;
}
export function FeedbackForm() {
const { referer } = useRefererStore();
const { setModal } = useModalStore();
const form = useForm<IInput>({
defaultValues: {
fullname: "",
email: "",
phone: "",
products: DEFAULT_STREAM_DEMO_PRODUCTS,
},
});
const { register, handleSubmit, formState, control } = form;
async function onSubmit(data: IInput) {
const { id } = await api
.post("mail", { json: { ...data, referer } })
.json<{ id: string }>();
setModal(<FeedbackModal id={id} />);
}
return (
<div className="flex-1 space-y-4 lg:max-w-[47.431vw]">
<div className="space-y-10">
{!formState.isSubmitted ? (
<FormProvider {...form}>
<form
className="lg:space-y-[1.944vw] md:max-lg:space-y-7 space-y-3"
onSubmit={handleSubmit(onSubmit)}
>
<div className="lg:space-y-[1.111vw] space-y-4">
<p className="heading2 font-medium">Нам нужно</p>
<CheckboxesGroup name="products" options={projectsTags} />
</div>
<input
id="name"
autoComplete="none"
type="text"
required
placeholder="Имя*"
{...register("fullname")}
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:btnl btnl placeholder:font-medium placeholder:select-none"
/>
<input
autoComplete="none"
required
id="email"
type="email"
placeholder="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"
/>
<div className="flex gap-x-3 py-2 border-[#3D425C] relative">
<Controller
name="phone"
control={control}
rules={{ required: true }}
render={({ field }) => (
<PhoneInputRu
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
/>
)}
/>
<div className="bottom-0 absolute w-full border-b border-[#37393B] peer-focus:border-white -mb-2" />
</div>
<div className="md:flex items-stretch lg:gap-[0.833vw] gap-3">
<Button
type="submit"
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"
>
Оставить заявку
</Button>
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
<span className="text-[#7A7A7A]">
*Нажимая кнопку отправить, вы даете
</span>{" "}
<a
target="_blank"
rel="noopener noreferrer"
href={"/privacy-policy"}
className="underline"
>
согласие на обработку персональных данных
</a>{" "}
<span className="text-[#7A7A7A]">и принимаете </span>
<a
target="_blank"
rel="noopener noreferrer"
href={"/policy"}
className="underline"
>
условия политики
</a>
</div>
</div>
</form>
</FormProvider>
) : (
<div className="bg-[#37393B99] aspect-[643/480] w-full rounded-2xl flex justify-center items-center">
<div className="flex gap-3 justify-center items-center">
<div className="bg-gradient p-3 rounded-full">
<div className="text-white lg:size-[1.667vw] size-6">
<CheckIcon />
</div>
</div>
<p className="heading2 font-medium">
Мы получили заявку
<br />и скоро свяжемся с вами!
</p>
</div>
</div>
)}
<LeadForm
defaultProducts={DEFAULT_STREAM_DEMO_PRODUCTS}
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
/>
</div>
</div>
);
-2
View File
@@ -1,5 +1,3 @@
import React from "react";
interface BRProps {
lg?: boolean;
md?: boolean;
+2 -2
View File
@@ -47,8 +47,8 @@ function FeedbackModal({ id }: { id: string }) {
</div>
<ul className="md:mb-[49px] mb-[58px] flex flex-col gap-y-[12px]">
{modalOptions.map((item, index) => (
<li key={index} className="flexfont-normal">
{modalOptions.map((item) => (
<li key={item} className="flexfont-normal">
<CustomCheckbox value={item} onChange={onCheckboxChange} />
</li>
))}
+17 -131
View File
@@ -1,59 +1,31 @@
import React, { useRef } from "react";
import { Button } from "@/ui/Button";
import { PhoneInputRu } from "@/ui/PhoneInputRu";
import { useRef, type RefObject } from "react";
import { useOnClickOutside } from "usehooks-ts";
import { useModalStore } from "@/stores/useModalStore";
import { Controller, FormProvider, useForm } from "react-hook-form";
import CheckIcon from "@/components/icons/CheckIcon";
import { projectsTags } from "@/consts/projectsTags";
import { CheckboxesGroup } from "@/ui/CheckboxesGroup";
import { Product } from "@/types/Product";
import type { Product } from "@/types";
import FeedbackModal from "./FeedbackFormModal";
import { api } from "@/lib/api";
import { useRefererStore } from "@/stores/useRefererStore";
import CloseIcon from "@/components/icons/CloseIcon";
import { LeadForm } from "@/features/lead-form/LeadForm";
interface IInput {
fullname: string;
phone: string;
email: string;
products1: Product[];
referer?: string | null;
}
const DEFAULT_MODAL_PRODUCTS = [
"Создание сайтов",
"Веб-тур по 360 сферам",
] as Product[];
interface IQuestionFormModalProps {
interface QuestionFormModalProps {
products?: Product[];
}
export default function QuestionFormModal({
products,
}: IQuestionFormModalProps) {
const { referer } = useRefererStore();
}: QuestionFormModalProps) {
const { setModal } = useModalStore();
const formRef = useRef(null);
const formRef = useRef<HTMLDivElement>(null);
useOnClickOutside(formRef, () => {
useOnClickOutside(formRef as RefObject<HTMLElement>, () => {
setModal(null);
});
const form = useForm<IInput>({
defaultValues: {
phone: "",
products1:
products || (["Создание сайтов", "Веб-тур по 360 сферам"] as Product[]),
},
});
const { register, handleSubmit, formState, control } = form;
async function onSubmit(data: IInput) {
const { id } = await api
.post("mail", { json: { ...data, products: data.products1, referer } })
.json<{ id: string }>();
setModal(<FeedbackModal id={id} />);
}
return (
<div
ref={formRef}
@@ -68,98 +40,12 @@ export default function QuestionFormModal({
<CloseIcon />
</div>
</button>
{!formState.isSubmitted ? (
<FormProvider {...form}>
<form
className="lg:space-y-[1.944vw] md:max-lg:space-y-7 space-y-3"
onSubmit={handleSubmit(onSubmit)}
>
<div className="lg:space-y-[1.111vw] space-y-4">
<p className="heading2 font-medium">Нам нужно</p>
<CheckboxesGroup name="products1" options={projectsTags} />
</div>
<input
id="name"
autoComplete="none"
type="text"
required
placeholder="Имя*"
{...register("fullname")}
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:btnl btnl placeholder:font-medium placeholder:select-none"
/>
<input
autoComplete="none"
required
id="email"
type="email"
placeholder="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"
/>
<div className="flex gap-x-3 py-2 border-[#3D425C] relative">
<Controller
name="phone"
control={control}
rules={{ required: true }}
render={({ field }) => (
<PhoneInputRu
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder="+X (XXX) XXX - XX - XX"
/>
)}
/>
<div className="bottom-0 absolute w-full border-b border-[#37393B] peer-focus:border-white -mb-2" />
</div>
<div className="md:flex items-stretch lg:gap-[0.833vw] gap-3">
<Button
type="submit"
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"
>
Оставить заявку
</Button>
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
<span className="text-[#7A7A7A]">
*Нажимая кнопку отправить, вы даете
</span>{" "}
<a
target="_blank"
rel="noreferrer"
href={"/privacy-policy"}
className="underline"
>
согласие на обработку персональных данных
</a>{" "}
<span className="text-[#7A7A7A]">и принимаете </span>
<a
target="_blank"
rel="noreferrer"
href={"/policy"}
className="underline"
>
условия политики
</a>
</div>
</div>
</form>
</FormProvider>
) : (
<div className="bg-[#37393B99] aspect-[643/480] w-full rounded-2xl flex justify-center items-center">
<div className="flex gap-3 justify-center items-center">
<div className="bg-gradient p-3 rounded-full">
<div className="text-white lg:size-[1.667vw] size-6">
<CheckIcon />
</div>
</div>
<p className="heading2 font-medium">
Мы получили заявку
<br />и скоро свяжемся с вами!
</p>
</div>
</div>
)}
<LeadForm
defaultProducts={products ?? DEFAULT_MODAL_PRODUCTS}
idPrefix="modal-"
phonePlaceholder="+X (XXX) XXX - XX - XX"
onSuccess={(id) => setModal(<FeedbackModal id={id} />)}
/>
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Product } from "@/types/Product";
import type { Product } from "@/types";
export const projectsTags: Product[] = [
"Интерактивная презентация",
-18
View File
@@ -1,18 +0,0 @@
export const streaming = [
{
title: "ЖК «Риваят»",
url: "https://stream.graff.tech/?build=PortovayaDev&location=a1",
},
{
title: "БК «Прокшино»",
url: "https://stream.graff.tech/?build=Prokshino&location=a1",
},
{
title: "ЖК «DNS Сити»",
url: "https://stream.graff.tech/?build=DNScity&location=a1",
},
{
title: "ЖК «Upside Towers»",
url: "https://stream.graff.tech/?build=upsideTowersDev&location=a1",
},
];
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { projectsTags } from "@/consts/projectsTags";
import type { Product } from "@/types";
import { Button } from "@/ui/Button";
import { CheckboxesGroup } from "@/ui/CheckboxesGroup";
import { PhoneInputRu } from "@/ui/PhoneInputRu";
import { useRefererStore } from "@/stores/useRefererStore";
import {
Controller,
FormProvider,
useForm,
type SubmitHandler,
} from "react-hook-form";
import type { LeadFormValues } from "./types";
export type { LeadFormValues } from "./types";
const GENERIC_SUBMIT_ERROR =
"Не удалось отправить заявку. Попробуйте позже.";
export function LeadForm({
defaultProducts,
idPrefix = "",
onSuccess,
phonePlaceholder,
formClassName = "lg:space-y-[1.944vw] md:max-lg:space-y-7 space-y-3",
}: {
defaultProducts: Product[];
/** Префикс для id полей (например `modal-`), чтобы избежать дублей в DOM */
idPrefix?: string;
onSuccess: (id: string) => void;
phonePlaceholder?: string;
formClassName?: string;
}) {
const { referer } = useRefererStore();
const [submitError, setSubmitError] = useState<string | null>(null);
const form = useForm<LeadFormValues>({
defaultValues: {
fullname: "",
email: "",
phone: "",
products: defaultProducts,
},
});
const { register, handleSubmit, formState, control } = form;
const nameId = idPrefix ? `${idPrefix}name` : "name";
const emailId = idPrefix ? `${idPrefix}email` : "email";
const onSubmit: SubmitHandler<LeadFormValues> = async (data) => {
setSubmitError(null);
try {
const { id } = await api
.post("mail", { json: { ...data, referer } })
.json<{ id: string }>();
onSuccess(id);
} catch {
setSubmitError(GENERIC_SUBMIT_ERROR);
}
};
return (
<FormProvider {...form}>
<form
className={formClassName}
onSubmit={handleSubmit(onSubmit)}
noValidate
>
{submitError ? (
<p className="text-sm text-red-400" role="alert">
{submitError}
</p>
) : null}
<div className="lg:space-y-[1.111vw] space-y-4">
<p className="heading2 font-medium">Нам нужно</p>
<CheckboxesGroup<LeadFormValues>
name="products"
options={projectsTags}
/>
</div>
<input
id={nameId}
autoComplete="none"
type="text"
required
placeholder="Имя*"
{...register("fullname")}
className="bg-transparent border-b border-[#37393B] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:btnl btnl placeholder:font-medium placeholder:select-none"
/>
<input
autoComplete="none"
required
id={emailId}
type="email"
placeholder="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"
/>
<div className="flex gap-x-3 py-2 border-[#3D425C] relative">
<Controller
name="phone"
control={control}
rules={{ required: true }}
render={({ field }) => (
<PhoneInputRu
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
placeholder={phonePlaceholder}
/>
)}
/>
<div className="bottom-0 absolute w-full border-b border-[#37393B] peer-focus:border-white -mb-2" />
</div>
<div className="md:flex items-stretch lg:gap-[0.833vw] gap-3">
<Button
type="submit"
disabled={formState.isSubmitting}
className="btnl max-md:mb-3 max-md:w-full lg:px-[2.222vw] lg:py-[1.389vw] px-8 py-5 cursor-pointer lg:rounded-[1.111vw] rounded-2xl disabled:opacity-60"
>
Оставить заявку
</Button>
<div className="text2 xl:max-w-[60%] md:max-lg:max-w-[40%] md:max-lg:py-1">
<span className="text-[#7A7A7A]">
*Нажимая кнопку отправить, вы даете
</span>{" "}
<a
target="_blank"
rel="noopener noreferrer"
href="/privacy-policy"
className="underline"
>
согласие на обработку персональных данных
</a>{" "}
<span className="text-[#7A7A7A]">и принимаете </span>
<a
target="_blank"
rel="noopener noreferrer"
href="/policy"
className="underline"
>
условия политики
</a>
</div>
</div>
</form>
</FormProvider>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Product } from "@/types";
export interface LeadFormValues {
fullname: string;
phone: string;
email: string;
products: Product[];
}
+6 -6
View File
@@ -1,15 +1,16 @@
import React, { useState } from "react";
import { useState } from "react";
import BR from "@/components/Layout/LineBreak";
import { useGetProjectsQuery } from "@/queries/getProjects";
import {
REMOTE_DEMO_TAG,
useGetProjectsQuery,
} from "@/queries/getProjects";
import { StreamingProject } from "./StreamingProject";
import { useSwipeable } from "react-swipeable";
import { useMediaQueries } from "@/hooks/useMediaQueries";
export default function AvailableDemos() {
const { isMd, isLg } = useMediaQueries();
const { data: streamingProjects } = useGetProjectsQuery(
"Удаленная демонстрация"
);
const { data: streamingProjects } = useGetProjectsQuery(REMOTE_DEMO_TAG);
const [current, setCurrent] = useState(0);
const projects = streamingProjects ?? [];
@@ -97,7 +98,6 @@ export default function AvailableDemos() {
className={`w-full ${index === 0 ? "flex-auto" : "flex-1"}`}
>
<StreamingProject
key={project.id}
{...project}
index={index}
current={current}
+1 -2
View File
@@ -1,4 +1,3 @@
import React from "react";
import BR from "@/components/Layout/LineBreak";
import { Button } from "@/ui/Button";
import QuestionFormModal from "@/components/modals/QuestionFormModal";
@@ -38,7 +37,7 @@ export default function RequestForDemo() {
</div>
<img
src="/img/pages/stream-demo/showreel.png"
src="/img/showreel.png"
alt=""
className="lg:h-[44.444vw] md:h-[57.292vw] h-[122.222vw] max-md:rounded-[4.444vw] object-cover"
/>
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { Feedback } from "@/components/Layout/Feedback";
import AvailableDemos from "./AvailableDemos";
import RequestForDemo from "./RequestForDemo";
+5 -5
View File
@@ -1,10 +1,10 @@
import React from "react";
import { resolvePublicPath } from "@/lib/env";
import { VideoPlayer } from "@/ui/VideoPlayer";
const STREAMING_VIDEO_SRC = resolvePublicPath(
"/videos/pages/home/streaming.mp4"
);
const viteBase = import.meta.env.BASE_URL;
const STREAMING_VIDEO_SRC =
!viteBase || viteBase === "/"
? "/videos/streaming.mp4"
: `${viteBase.replace(/\/$/, "")}/videos/streaming.mp4`;
export default function StreamPlayer({ className }: { className?: string }) {
return (
@@ -1,6 +1,6 @@
import { streaming } from "@/consts/streaming";
import { streamDemoUrlFromBuild } from "@/lib/streamDemoUrl";
import { resolveProjectImageSrc } from "@/lib/resolveProjectImageSrc";
import { IProject } from "@/types/IProject";
import type { IProject } from "@/types";
import ArrowMoreIcon from "@/components/icons/ArrowMoreIcon";
export function StreamingProject({
@@ -8,12 +8,13 @@ export function StreamingProject({
title,
image,
href,
buildFilename,
company,
index,
current,
count,
className,
}: Pick<IProject, "city" | "title" | "image" | "company"> & {
}: Pick<IProject, "city" | "title" | "image" | "company" | "buildFilename"> & {
href: string;
index: number;
current: number;
@@ -21,6 +22,8 @@ export function StreamingProject({
className?: string;
}) {
const imgSrc = resolveProjectImageSrc(image);
const build = buildFilename?.trim() ?? "";
const streamHref = build ? streamDemoUrlFromBuild(build) : href;
return (
<div
@@ -69,7 +72,7 @@ export function StreamingProject({
<div className="z-[2] lg:hidden absolute right-4 bottom-4">
<a
className="bg-gradient btns flex gap-2 items-center px-3 py-2 font-medium rounded-xl"
href={streaming.find((s) => s.title === title)?.url ?? href}
href={streamHref}
>
Смотреть
<div className="text-white lg:size-[1.389vw] size-4">
@@ -78,7 +81,7 @@ export function StreamingProject({
</a>
</div>
<a
href={streaming.find((s) => s.title === title)?.url ?? href}
href={streamHref}
className="max-lg:hidden lg:group-hover:opacity-100 opacity-0 transition-opacity duration-500 absolute w-full h-full left-0 bottom-0 md:max-lg:rounded-2xl rounded-xl font-medium [backdrop-filter:blur(3px)] content-center text-center z-[3]"
>
<div className="btnl flex gap-2 justify-center">
+8 -2
View File
@@ -1,7 +1,13 @@
import ky from "ky";
import { getApiBase } from "@/lib/env";
const base = getApiBase();
function str(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
const raw = str(import.meta.env.VITE_API_URL);
const base = raw ? raw.replace(/\/?$/, "/") : "";
export const hasApiConfigured = base.length > 0;
export const api = ky.create({
prefixUrl: base || undefined,
-40
View File
@@ -1,40 +0,0 @@
/**
* Доступ к публичным переменным окружения.
* Поддерживаются и VITE_*, и NEXT_PUBLIC_* (см. envPrefix в vite.config.ts).
*/
function str(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
export function getApiBase(): string {
const v =
str(import.meta.env.VITE_API_URL) || str(import.meta.env.NEXT_PUBLIC_API);
if (!v) return "";
return v.replace(/\/?$/, "/");
}
export function hasApiConfigured(): boolean {
return getApiBase().length > 0;
}
export function getS3BucketBase(): string {
return (
str(import.meta.env.VITE_S3_BUCKET) ||
str(import.meta.env.NEXT_PUBLIC_S3_BUCKET)
);
}
/** Путь из public/ (`/videos/...`) с учётом `base` в Vite */
export function resolvePublicPath(path: string): string {
if (
path.startsWith("http://") ||
path.startsWith("https://") ||
path.startsWith("data:")
) {
return path;
}
if (!path.startsWith("/")) return path;
const base = import.meta.env.BASE_URL;
if (!base || base === "/") return path;
return `${base.replace(/\/$/, "")}${path}`;
}
+5 -4
View File
@@ -1,9 +1,7 @@
import { getS3BucketBase } from "@/lib/env";
/**
* Как на основном сайте: в API может прийти уже полный URL
* (`https://storage.yandexcloud.net/bucket/projects/uuid.jpg`) или ключ
* (`projects/uuid.jpg`) — тогда к нему дописывается VITE_S3_BUCKET / NEXT_PUBLIC_S3_BUCKET.
* (`projects/uuid.jpg`) — тогда к нему дописывается VITE_S3_BUCKET.
*/
export function resolveProjectImageSrc(image: string): string {
let src = image
@@ -34,7 +32,10 @@ export function resolveProjectImageSrc(image: string): string {
return `${base.replace(/\/$/, "")}${src}`;
}
const s3BaseRaw = getS3BucketBase();
const s3BaseRaw =
typeof import.meta.env.VITE_S3_BUCKET === "string"
? import.meta.env.VITE_S3_BUCKET.trim()
: "";
if (!s3BaseRaw) return src;
const base = s3BaseRaw.replace(/\/?$/, "/");
const path = src.replace(/^\//, "");
+11
View File
@@ -0,0 +1,11 @@
/** Базовый URL демо-стрима из `.env` (`VITE_STREAM_DEMO_ORIGIN`). */
export const STREAM_DEMO_ORIGIN =
import.meta.env.VITE_STREAM_DEMO_ORIGIN?.replace(/\/$/, "") ??
"https://stream.graff.tech";
export function streamDemoUrlFromBuild(buildFilename: string): string {
const build = buildFilename.trim();
const params = new URLSearchParams({ location: "a1" });
if (build) params.set("build", build);
return `${STREAM_DEMO_ORIGIN}/?${params.toString()}`;
}
+8 -1
View File
@@ -5,7 +5,14 @@ import App from "./App";
import { ModalContainer } from "@/components/Layout/ModalContainer";
import "./index.css";
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 3 * 60 * 1000,
retry: 2,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
+38 -39
View File
@@ -1,48 +1,47 @@
import { api } from "@/lib/api";
import { streaming } from "@/consts/streaming";
import { hasApiConfigured } from "@/lib/env";
import { IProject } from "@/types/IProject";
import { api, hasApiConfigured } from "@/lib/api";
import type { IProject } from "@/types";
import { queryKeys } from "@/queries/keys";
import { queryOptions, useQuery } from "@tanstack/react-query";
const hasApi = hasApiConfigured();
/** Тег фильтра на стороне API (как в CMS). */
export const REMOTE_DEMO_TAG = "Удаленная демонстрация";
export const queryProjectsOptions = queryOptions({
queryKey: queryKeys.projects,
queryFn: () => api.get("projects").json<IProject[]>(),
enabled: hasApi,
});
function releaseTimestamp(p: IProject): number {
const t = Date.parse(p.releaseDate);
return Number.isNaN(t) ? 0 : t;
}
export function useGetProjectsQuery(tags?: string | string[]) {
return useQuery(
const tagList =
tags && tags.length > 0
? queryOptions({
queryKey: queryKeys.projectsWithTags(
Array.isArray(tags) ? tags : [tags]
),
queryFn: () =>
api
.get(
`projects?${
Array.isArray(tags)
? tags.map((tag) => `tags=${tag}`).join("&")
: "tags=" + tags
}`
)
.json<IProject[]>(),
enabled: hasApi,
select:
tags === "Удаленная демонстрация"
? (data) => {
const filtered = data.filter((p) =>
streaming.some((s) => s.title === p.title)
);
if (filtered.length > 0) return filtered;
/* Нет совпадений со списком streaming — показываем первые 3 проекта из ответа API */
return data.slice(0, 3);
}
: undefined,
})
: queryProjectsOptions
? Array.isArray(tags)
? tags
: [tags]
: [];
return useQuery(
queryOptions({
queryKey: queryKeys.projectsWithTags(tagList),
queryFn: () => {
if (tagList.length === 0) {
return api.get("projects").json<IProject[]>();
}
const qs = tagList
.map((tag) => `tags=${encodeURIComponent(tag)}`)
.join("&");
return api.get(`projects?${qs}`).json<IProject[]>();
},
enabled: hasApiConfigured,
select:
tags === REMOTE_DEMO_TAG ||
(Array.isArray(tags) &&
tags.length === 1 &&
tags[0] === REMOTE_DEMO_TAG)
? (data) =>
[...data]
.sort((a, b) => releaseTimestamp(b) - releaseTimestamp(a))
.slice(0, 3)
: undefined,
})
);
}
+1 -2
View File
@@ -1,5 +1,4 @@
export const queryKeys = {
projects: ["projects"] as const,
projectsWithTags: (tags: string[]) =>
["projects", ...(Array.isArray(tags) ? tags : [tags])] as const,
projectsWithTags: (tags: string[]) => ["projects", ...tags] as const,
};
+2
View File
@@ -13,6 +13,8 @@ export interface IProject {
city: string;
englishCity: string;
image: string;
/** Имя билда для stream.graff.tech (query `build`). */
buildFilename?: string;
stage: number;
releaseDate: string;
tags: Product[];
+3
View File
@@ -0,0 +1,3 @@
export type { ICompany } from "./ICompany";
export type { Device, IProject } from "./IProject";
export type { Product } from "./Product";
+2 -2
View File
@@ -1,8 +1,8 @@
import { ReactNode } from "react";
import { type ReactElement, type ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
icon?: JSX.Element;
icon?: ReactElement;
color?: "primary" | "secondary";
width?: "fit" | "full";
disabled?: boolean;
+11 -2
View File
@@ -19,7 +19,15 @@ export const VideoPlayer = forwardRef<
} & ComponentProps<"video">
>(
(
{ src, showMutingBtn, children, loop = true, autoPlay = true, className },
{
src,
showMutingBtn,
children,
loop = true,
autoPlay = true,
className,
muted: mutedProp,
},
ref
) => {
const progressbarRef = useRef<HTMLDivElement>(null);
@@ -27,7 +35,8 @@ export const VideoPlayer = forwardRef<
useImperativeHandle(ref, () => videoRef.current!);
const [muted, setMuted] = useState(autoPlay);
/** Начальное значение из `muted`; дальше громкость только из состояния (кнопка плеера). */
const [muted, setMuted] = useState(() => mutedProp ?? autoPlay);
const [playing, setPlaying] = useState(autoPlay);
const [progress, setProgress] = useState(0);
+1 -2
View File
@@ -3,8 +3,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_S3_BUCKET?: string;
readonly NEXT_PUBLIC_API?: string;
readonly NEXT_PUBLIC_S3_BUCKET?: string;
readonly VITE_STREAM_DEMO_ORIGIN?: string;
}
interface ImportMeta {
+2 -4
View File
@@ -3,10 +3,8 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
/** Подхватываем .env из корня репозитория (рядом с Next), если в подпапке своего нет */
envDir: path.resolve(__dirname, ".."),
/** Как в Next: можно копировать .env с NEXT_PUBLIC_API / NEXT_PUBLIC_S3_BUCKET */
envPrefix: ["VITE_", "NEXT_PUBLIC_"],
/** `.env` в корне этого проекта (`stream-demo-standalone/.env`). */
envDir: path.resolve(__dirname),
plugins: [react()],
resolve: {
alias: {