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:
@@ -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 |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
interface BRProps {
|
||||
lg?: boolean;
|
||||
md?: boolean;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { Product } from "@/types/Product";
|
||||
import type { Product } from "@/types";
|
||||
|
||||
export const projectsTags: Product[] = [
|
||||
"Интерактивная презентация",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Product } from "@/types";
|
||||
|
||||
export interface LeadFormValues {
|
||||
fullname: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
products: Product[];
|
||||
}
|
||||
@@ -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,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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Feedback } from "@/components/Layout/Feedback";
|
||||
import AvailableDemos from "./AvailableDemos";
|
||||
import RequestForDemo from "./RequestForDemo";
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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(/^\//, "");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { ICompany } from "./ICompany";
|
||||
export type { Device, IProject } from "./IProject";
|
||||
export type { Product } from "./Product";
|
||||
+2
-2
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Vendored
+1
-2
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user