This commit is contained in:
2023-08-07 15:05:18 +05:00
commit 5e07a8952e
84 changed files with 8097 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@import url("https://gistcdn.githack.com/mfd/09b70eb47474836f25a21660282ce0fd/raw/e06a670afcb2b861ed2ac4a1ef752d062ef6b46b/Gilroy.css");
.bg-gradient-card {
background: linear-gradient(
180deg,
rgba(20, 22, 31, 0.27) 45.71%,
rgba(20, 22, 31, 0.9) 100%
);
}
.feedback-field:focus ~ .feedback-placeholder {
top: 0;
}
.feedback-field:focus ~ .feedback-placeholder-2 {
opacity: 0;
}
.feedback-field:valid ~ .feedback-placeholder {
top: 0;
}
.feedback-field:valid ~ .feedback-placeholder-2 {
opacity: 0;
}
.feedback-field::placeholder {
@apply lg:text-base text-sm font-semibold text-[#77787d];
}
+460
View File
@@ -0,0 +1,460 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable no-irregular-whitespace */
import "./App.css";
import { Trans, useTranslation } from "react-i18next";
import FeedbackForm from "./components/FeedbackForm";
import ArrowRightIcon from "./components/icons/ArrowRightIcon";
import LogoIcon from "./components/icons/LogoIcon";
import MailIcon from "./components/icons/MailIcon";
import PhoneIcon from "./components/icons/PhoneIcon";
import TelegramIcon from "./components/icons/TelegramIcon";
import VKIcon from "./components/icons/VKIcon";
import YouTubeIcon from "./components/icons/YouTubeIcon";
import Sidebar from "./components/Sidebar";
import Header from "./components/Header";
import { Transition } from "react-transition-group";
import useSidebarStore from "./stores/useSidebarStore";
import { useEffect, useState } from "react";
import ky from "ky";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ToastContainer, toast } from "react-toastify";
function App() {
const [isOpen] = useSidebarStore((state) => [state.isOpen]);
const navigate = useNavigate();
const [loading, setLoading] = useState<boolean>(false);
const [countdownTimer, setCountdownTimer] = useState(10);
// const host = window.location.host;
const [searchParams] = useSearchParams();
// const location = searchParams.get("location") || "a1";
const { t, i18n } = useTranslation();
const location = i18n.language === "ru" ? "a1" : "a2";
const build = searchParams.get("build") || null;
function toastError(text: string) {
toast.error(text, {
position: "bottom-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
});
}
async function startStream(title: string) {
setLoading(true);
const response: { stream: string } = await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/start?location=${location}&title=${title}`
)
.json();
if (response.stream) {
setInterval(() => {
setCountdownTimer((prev) => prev - 1);
}, 1000);
setTimeout(() => {
navigate(`/stream/${response.stream}`);
}, 10000);
} else {
setLoading(false);
toastError("Превышен лимит одновременных сессий, попробуйте позже.");
}
}
useEffect(() => {
if (build) {
void startStream(build);
}
}, []);
useEffect(() => {
document.title = t("title");
}, [i18n.language]);
return (
<>
{!loading ? (
<>
<div className="min-h-screen bg-[#14161F] text-white overflow-hidden">
<div className="container mx-auto 2xl:px-10 lg:px-8 sm:px-6 px-4 max-w-[1600px]">
<Header
handleChangeLang={(lang) => void i18n.changeLanguage(lang)}
/>
<div className="2xl:mt-[72px] lg:mt-16 sm:mt-[88px] mt-14 relative">
<div className="absolute flex justify-center items-center w-full -top-8 blur-sm">
<img src="/images/shapes/shape-1.svg" alt="" className="" />
</div>
<div className="grid sm:grid-cols-2 lg:gap-4 sm:gap-3 gap-4">
<p className="2xl:text-[64px] xl:text-[52px] text-[40px] text-gradient w-fit font-gilroy leading-none font-medium">
<Trans i18nKey={"main.title"}>
Доступные
<br />
демонстрации
</Trans>
</p>
<p className="lg:w-[368px] lg:text-base text-sm">
<Trans i18nKey={"main.desc"}>
Клиент из любой точки мира может посмотреть жилой
комплекс, даже на нулевом этапе строительства. Он выберет
лучшую планировку и оценит вид из окон своей будущей
квартиры.
</Trans>
</p>
</div>
<div className="sm:mt-16 mt-8 grid sm:grid-cols-2 lg:gap-4 sm:gap-3 gap-2">
<div
className="group relative sm:h-full h-[264px] bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{ backgroundImage: `url("/images/cards/nks.jpg")` }}
>
<div className="bg-gradient-card h-full transition-opacity group-hover:opacity-60 duration-300"></div>
<div className="p-6 absolute bottom-0 space-y-6">
<div>
<p className="xl:text-2xl text-xl font-gilroy font-semibold">
<Trans i18nKey={"main.cards.title1"}>
МФК «Revolution towers»
</Trans>
</p>
<p className="lg:text-sm text-xs">
<Trans i18nKey={"main.cards.city1"}>
Россия, Екатеринбург
</Trans>
</p>
</div>
<button
onClick={() => void startStream("nksJukovaDev")}
className="flex bg-gradient rounded-full p-2 gap-0 group-hover:gap-1 group-hover:pr-4 group-hover:pl-6 transition-all duration-300"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>Запустить</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
<div className="grid lg:grid-cols-2 lg:gap-4 sm:gap-3 gap-2">
{i18n.language === "ru" ? (
<>
<div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-1 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/liferes.jpg")`,
}}
>
<div className="bg-gradient-card h-full transition-opacity group-hover:opacity-50 duration-300"></div>
<div className="p-6 absolute bottom-0 space-y-6">
<div>
<p className="xl:text-2xl text-xl font-gilroy font-semibold">
<Trans i18nKey={"main.cards.title2"}>
ЖК «Life Резиденция»
</Trans>
</p>
<p className="lg:text-sm text-xs">
<Trans i18nKey={"main.cards.city2"}>
Россия, Тюмень
</Trans>
</p>
</div>
<button
onClick={() => void startStream("lifeResidence")}
className="flex bg-gradient rounded-full p-2 gap-0 group-hover:gap-1 group-hover:pr-4 group-hover:pl-6 transition-all duration-300"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>
Запустить
</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
<div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-1 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/aivaz.jpg")`,
}}
>
<div className="bg-gradient-card h-full transition-opacity group-hover:opacity-90 duration-300"></div>
<div className="p-6 absolute bottom-0 space-y-6">
<div>
<p className="xl:text-2xl text-xl font-gilroy font-semibold">
<Trans i18nKey={"main.cards.title3"}>
ЖК «Айвазовский City»
</Trans>
</p>
<p className="lg:text-sm text-xs">
<Trans i18nKey={"main.cards.city2"}>
Россия, Тюмень
</Trans>
</p>
</div>
<button
onClick={() => void startStream("Ivazowsky")}
className="flex bg-gradient rounded-full p-2 gap-0 group-hover:gap-1 group-hover:pr-4 group-hover:pl-6 transition-all duration-300"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>
Запустить
</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
</>
) : (
<div
className="group relative lg:h-[508px] sm:h-[284px] h-[264px] col-span-2 bg-gray-700 bg-no-repeat bg-center bg-cover"
style={{
backgroundImage: `url("/images/cards/shipyard.jpg")`,
}}
>
<div className="bg-gradient-card h-full transition-opacity group-hover:opacity-90 duration-300"></div>
<div className="p-6 absolute bottom-0 space-y-6">
<div>
<p className="xl:text-2xl text-xl font-gilroy font-semibold">
{/* <Trans i18nKey={"main.cards.title3"}> */}
IMI Saudi Shipyard
{/* </Trans> */}
</p>
<p className="lg:text-sm text-xs">
{/* <Trans i18nKey={"main.cards.city2"}> */}
Saudi Arabia
{/* </Trans> */}
</p>
</div>
<button
onClick={() => void startStream("ShipyardSaudiDev")}
className="flex bg-gradient rounded-full p-2 gap-0 group-hover:gap-1 group-hover:pr-4 group-hover:pl-6 transition-all duration-300"
>
<span className="font-medium w-0 opacity-0 group-hover:w-[82px] group-hover:opacity-100 overflow-hidden transition-all duration-300">
<Trans i18nKey={"main.cards.button"}>
Запустить
</Trans>
</span>
<ArrowRightIcon />
</button>
</div>
</div>
)}
</div>
</div>
</div>
<div className="lg:mt-40 sm:mt-[120px] mt-[88px]">
<div className="grid lg:grid-cols-4 grid-cols-1 lg:gap-4 gap-6">
<div className="col-span-1">
<div className="grid lg:grid-cols-1 sm:grid-cols-2 lg:gap-6 gap-4">
<p className="2xl:text-[64px] xl:text-5xl text-[40px] font-gilroy text-gradient font-medium w-fit leading-none">
<Trans i18nKey={"feedback.title"}>
Свяжитесь
<br />с нами
</Trans>
</p>
<p className="2xl:text-xl lg:text-lg font-gilroy font-semibold leading-tight">
<Trans i18nKey={"feedback.desc"}>
Хотите увеличить конверсию?
<br />
Давайте обсудим детали!
</Trans>
</p>
</div>
</div>
<div className="lg:col-span-3">
<FeedbackForm />
</div>
</div>
</div>
<div className="mt-[104px] relative">
<div className="absolute flex justify-center items-center w-full -top-8 left-32 blur-md">
<img src="/images/shapes/shape-2.svg" alt="" className="" />
</div>
<div className="relative grid lg:grid-cols-4 gap-4">
<div className="flex gap-4">
<p className="2xl:text-xl font-gilroy font-semibold">
<Trans i18nKey={"contacts.title"}>Горячая линия</Trans>
</p>
<div className="w-full h-px bg-[#3D425C]"></div>
</div>
<div className="space-y-2 2xl:pr-4 xl:pr-2">
<a
href="mailto:info@graff.tech"
className="2xl:h-16 h-14 px-6 py-4 2xl:text-base text-sm border rounded-full font-medium flex justify-between items-center w-full border-[#52587A] opacity-80 hover:opacity-100 transition-all"
>
<span>
<Trans i18nKey={"contacts.button1"}>Написать</Trans>
</span>
<MailIcon className="lg:w-8 lg:h-8 w-6 h-6" />
</a>
<a
href="tel:88007700076"
className="2xl:h-16 h-14 px-6 py-4 2xl:text-base text-sm border rounded-full font-medium flex justify-between items-center w-full border-[#52587A] opacity-80 hover:opacity-100 transition-all"
>
<span>
<Trans i18nKey={"contacts.button2"}>Позвонить</Trans>
</span>
<PhoneIcon className="lg:w-8 lg:h-8 w-6 h-6" />
</a>
</div>
<div className="sm:col-span-2 flex sm:justify-end lg:mt-0 mt-10">
<div className="lg:w-auto sm:w-1/2 w-full flex justify-between 2xl:gap-8 lg:gap-6 gap-4">
<p className="2xl:text-xl font-gilroy font-semibold 2xl:-mt-1.5 -mt-1">
<Trans i18nKey={"contacts.social.title"}>
Социальные
<br />
сети
</Trans>
</p>
<div className="flex gap-2 h-fit">
<a
href="https://www.youtube.com/@GRAFFtech"
target="_blank"
className="group border border-[#3D425C] xl:p-4 p-3 rounded-full hover:border-[#52587A] transition-all"
>
<YouTubeIcon className="2xl:w-8 2xl:h-8 w-6 h-6" />
</a>
<a
href="https://vk.com/graff.interactive"
target="_blank"
className="group border border-[#3D425C] xl:p-4 p-3 rounded-full hover:border-[#52587A] transition-all"
>
<VKIcon className="2xl:w-8 2xl:h-8 w-6 h-6" />
</a>
<a
href="https://t.me/GRAFFinteractive"
target="_blank"
className="border rounded-full border-[#52587A] xl:p-4 p-3 opacity-80 hover:opacity-100 transition-all"
>
<TelegramIcon className="2xl:w-8 2xl:h-8 w-6 h-6" />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-[50px] relative border-t border-[#3D425C] text-sm">
<div className="container mx-auto xl:px-8 max-w-[1600px]">
<div className="grid lg:grid-cols-4">
<div className="sm:col-span-2 lg:order-none order-last py-6 xl:px-0 px-6 flex sm:flex-row flex-col sm:gap-6 gap-4 lg:border-t-0 border-t border-[#3D425C]">
<div>
<LogoIcon />
</div>
<div className="flex flex-col sm:gap-1 gap-4">
<p className="flex sm:flex-row flex-col sm:gap-4 gap-1">
<a href="#" className="">
<Trans i18nKey={"footer.link"}>
Политика конфиденциальности
</Trans>
</a>
<a href="https://estate.graff.tech" className="">
estate.graff.tech
</a>
</p>
<p className="text-xs text-[#C5C7CE]">
© 2023 GRAFF interactive.{" "}
<Trans i18nKey={"footer.text"}>
Все права защищены.
</Trans>
</p>
</div>
</div>
<div className="col-span-1 lg:border-l sm:border-b-0 border-b border-[#3D425C] xl:px-8 sm:px-6 px-4 py-6 flex flex-col justify-center">
<div className="flex justify-between items-center">
<div className="text-[#EBEBEB] flex flex-col gap-1">
<a href="mailto:info@graff.tech">info@graff.tech</a>
<a href="tel:88007700076">8 800 770 00 76</a>
</div>
<div className="w-12 h-12 border border-[#3D425C] rounded-full flex justify-center items-center">
RU
</div>
</div>
</div>
<div className="col-span-1 sm:border-l border-[#3D425C] xl:pl-8 xl:pr-0 sm:px-6 px-4 py-6 flex flex-col justify-center">
<div className="flex justify-between items-center">
<div className="text-[#EBEBEB] flex flex-col gap-1">
<a href="mailto:waseem@graff.tech">waseem@graff.tech</a>
<a href="tel:+971509388902">+971 50 938 8902</a>
</div>
<div className="w-12 h-12 border border-[#3D425C] rounded-full flex justify-center items-center">
UAE
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<Transition in={isOpen} timeout={200} mountOnEnter unmountOnExit>
{(state) => <Sidebar className={state} />}
</Transition>
<ToastContainer />
</>
) : (
<div className="bg-[#14161F] h-screen flex justify-center items-center">
<p className="self-center text-white flex items-center w-fit font-gilroy">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>
<Trans i18nKey="loading">Загрузка</Trans>
... {countdownTimer} <Trans i18nKey="loadingSub">сек</Trans>
</span>
</p>
</div>
)}
</>
);
}
export default App;
+45
View File
@@ -0,0 +1,45 @@
.react-calendar {
font-family: "Gilroy";
@apply bg-[#151619] text-white border-none p-4
rounded-lg;
}
.react-calendar__navigation button:enabled:hover,
.react-calendar__navigation button:enabled:focus,
.react-calendar__tile:enabled:hover {
@apply bg-[rgba(255,255,255,0.05)];
}
.react-calendar__tile:enabled:focus {
@apply bg-[rgba(255,255,255,0.2)];
}
.react-calendar__month-view__days__day--weekend {
@apply text-[#F2F2F2];
}
.react-calendar__month-view__weekdays {
@apply font-normal lowercase text-base;
}
abbr[title] {
text-decoration: none;
}
.react-calendar__navigation button,
.react-calendar__tile {
@apply rounded;
}
.react-calendar__tile--now:enabled {
background-color: #212121;
}
.react-calendar__tile--active {
background: #1087ff !important;
}
.react-calendar__navigation button:disabled,
.react-calendar__tile:disabled {
@apply text-zinc-800 bg-transparent;
}
+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FormEvent, useEffect, useState } from "react";
import Calendar from "react-calendar";
import "react-calendar/dist/Calendar.css";
import "./CalendarPage.css";
import {
format,
isBefore,
differenceInBusinessDays,
setHours,
getHours,
setMinutes,
getMinutes,
setSeconds,
setMilliseconds,
} from "date-fns";
import ru from "date-fns/locale/ru";
import ky from "ky";
import { useParams } from "react-router-dom";
import InputMask from "react-input-mask";
function CalendarPage() {
const params = useParams();
const [step, setStep] = useState<number>(1);
const [date, setDate] = useState<Date>(new Date());
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [phone, setPhone] = useState<string>("");
const [link, setLink] = useState<string>("");
const [datesAndTimes, setDatesAndTimes] = useState<any[]>([]);
const [scheduledSessions, setScheduledSessions] = useState<any[]>([]);
async function selectDate(value: any) {
await getScheduledSessions(value);
setDate(value);
setStep((prev) => prev + 1);
}
async function getScheduledSessions(value: Date) {
const username = params.username;
const result: any[] = await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/scheduled_sessions/${username}?date=${value.toISOString()}`
)
.json();
console.log(scheduledSessions);
setScheduledSessions(result);
}
function selectTime(value: any) {
let newDate = date;
newDate = setHours(date, getHours(value));
newDate = setMinutes(newDate, getMinutes(value));
newDate = setSeconds(newDate, 0);
newDate = setMilliseconds(newDate, 0);
setDate(newDate);
setStep((prev) => prev + 1);
}
async function addSchedule(e: FormEvent) {
e.preventDefault();
const username = params.username;
const title = "nksJukovaDev";
const startAt = date;
try {
const result: any = await ky
.post(`${import.meta.env.VITE_COORD_URL}/scheduled_sessions`, {
json: {
username,
name,
email,
phone,
title,
startAt,
},
})
.json();
if (!result.userInviteLink) {
alert(result.error);
return;
}
setLink(result.userInviteLink);
setName("");
setEmail("");
setPhone("");
setStep(4);
} catch (error: any) {
alert(error.message);
}
}
async function getSessionScheduleSettings() {
const username = params.username;
try {
const result: any = await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/session_schedule_settings/${username}`
)
.json();
if (result.error) {
console.log("Error: ", result.error);
return;
}
const { datesAndTimes } = result;
console.log(datesAndTimes);
setDatesAndTimes(datesAndTimes);
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
}
useEffect(() => {
getSessionScheduleSettings();
}, []);
return (
<div className="min-h-screen flex justify-center items-center p-8 rounded-lg text-white">
{step === 1 && (
<div className="space-y-8 w-80">
<p className="text-4xl font-gilroy">
Выберите
<br />
дату
</p>
<Calendar onChange={selectDate} value={date} minDate={new Date()} />
</div>
)}
{step === 2 && (
<div className="space-y-8">
<p className="text-4xl font-gilroy">
Выберите
<br />
время
</p>
<div className="space-y-4">
<button
onClick={() => setStep((prev) => prev - 1)}
className="text-[#C5C7CE] flex items-center gap-1 bg-[#1C1D21] p-1 pr-4 text-xs rounded"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 19L8 12L15 5"
stroke="#C5C7CE"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Выбор даты</span>
</button>
<p className="text-xl font-gilroy">
{format(date, "dd MMMM", { locale: ru })}
</p>
<div className="grid grid-cols-4">
{datesAndTimes.map(
(dateAndTime: { value: Date; active: true }, index: number) =>
!differenceInBusinessDays(date, new Date()) ? (
isBefore(new Date(), new Date(dateAndTime.value)) && (
<button
key={index}
onClick={() => selectTime(new Date(dateAndTime.value))}
className="px-3 py-2 text-center rounded hover:bg-[#23242A] disabled:hover:bg-inherit disabled:opacity-25"
disabled={
scheduledSessions.filter(
(session) => session.startAt === dateAndTime.value
).length >= 3
}
>
{format(new Date(dateAndTime.value), "HH:mm")}
</button>
)
) : (
<button
key={index}
onClick={() => selectTime(new Date(dateAndTime.value))}
className="px-3 py-2 text-center rounded hover:bg-[#23242A] disabled:hover:bg-inherit disabled:opacity-25"
disabled={
scheduledSessions.filter(
(session) => session.startAt === dateAndTime.value
).length >= 3
}
>
{format(new Date(dateAndTime.value), "HH:mm")}
</button>
)
)}
</div>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-8 w-80">
<p className="text-4xl font-gilroy">
Расскажите
<br />о себе
</p>
<div className="space-y-4">
<button
onClick={() => setStep((prev) => prev - 1)}
className="text-[#C5C7CE] flex items-center gap-1 bg-[#1C1D21] p-1 pr-4 text-xs rounded"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 19L8 12L15 5"
stroke="#C5C7CE"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Выбор времени</span>
</button>
<p className="text-xl font-gilroy">
{format(date, "dd MMMM HH:mm", { locale: ru })}
</p>
<form onSubmit={addSchedule} className="space-y-12">
<div className="space-y-4">
<div className="space-y-1">
<p className="text-[#C5C7CE]">Имя</p>
<input
required
type="text"
className="px-4 py-3 bg-[#23242A] rounded outline-none w-full"
placeholder="Константин"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<p className="text-[#C5C7CE]">Email</p>
<input
required
type="email"
className="px-4 py-3 bg-[#23242A] rounded outline-none w-full"
placeholder="example@mail.ru"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1">
<p className="text-[#C5C7CE]">Телефон</p>
<InputMask
mask={"+999999999999999"}
maskChar={null}
required
type="tel"
className="px-4 py-3 bg-[#23242A] rounded outline-none w-full"
placeholder="+79009998877"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
</div>
<button
type="submit"
className="px-4 py-2 bg-gradient w-full rounded"
>
Запланировать
</button>
</form>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-8 w-80">
<p className="text-4xl font-gilroy">
Просмотр
<br />
запланирован
</p>
<div className="space-y-12">
<p className="text-[#C5C7CE]">
Ссылка для подключения и другая дополнительная информация будут
отправлены на ваш почтовый адрес.
</p>
<div className="space-y-4">
<div className="space-y-1">
<p>Скопируйте ссылку для поключения</p>
<input
type="text"
className="px-4 py-3 bg-[#23242A] rounded outline-none w-full"
defaultValue={link}
/>
</div>
<p className="text-center">или</p>
<a
href={link}
target="_blank"
className="inline-block px-4 py-2 bg-gradient w-full rounded text-center"
>
Подключиться
</a>
<div className="h-0.5 bg-[#23242A]"></div>
<a
onClick={() => setStep(1)}
href="#"
className="inline-block px-4 py-2 w-full rounded text-center bg-[#1C1D21] text-[#C5C7CE]"
>
На сайт жилого комплекса
</a>
</div>
</div>
</div>
)}
</div>
);
}
export default CalendarPage;
+39
View File
@@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ky from "ky";
import { useEffect, useState } from "react";
function HistoryPage() {
const [history, setHistory] = useState([]);
async function getHistory() {
const result: any = await ky
.get(`${import.meta.env.VITE_COORD_URL}/session_history`)
.json();
setHistory(result);
}
useEffect(() => {
getHistory();
}, []);
return (
<div className="text-[#F2F2F2] space-y-4 p-4">
<p>Всего сессий: {history.length}</p>
{history.map((item: any) => (
<div key={item.id} className="p-4 rounded-lg bg-[#22222A]">
<p>
Дата и время запуска: {new Date(item.createdAt).toLocaleString()}
</p>
<p>Сборка: "{item.title}"</p>
<p>Сервер: "{item.server}"</p>
<p>IP клиента: {item.headers["x-forwarded-for"]}</p>
<p>Город: {item.city}</p>
<p>Устройство: {item.headers["user-agent"]}</p>
</div>
))}
</div>
);
}
export default HistoryPage;
+149
View File
@@ -0,0 +1,149 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { differenceInSeconds, parseISO } from "date-fns";
import ky from "ky";
import { useEffect, useState } from "react";
import QRCode from "react-qr-code";
function MonitoringPage() {
const [sessions, setSessions] = useState<any>([]);
const [servers, setServers] = useState<any>([]);
async function getSessionServers() {
const response = await ky
.get(`${import.meta.env.VITE_COORD_URL}/session_servers`)
.json();
setServers(response);
setTimeout(() => {
getSessionServers();
}, 1000);
}
async function getActiveSessions() {
const response = await ky
.get(`${import.meta.env.VITE_COORD_URL}/active_sessions`)
.json();
setSessions(response);
setTimeout(() => {
getActiveSessions();
}, 1000);
}
async function endActiveSession(
location: string,
server: string,
uePort: number,
cirrusPort: number
) {
await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/end?location=${location}&server=${server}&uePort=${uePort}&cirrusPort=${cirrusPort}`
)
.json();
}
useEffect(() => {
getActiveSessions();
getSessionServers();
}, []);
return (
<div className="min-h-screen text-[#F2F2F2] p-4 gap-4 flex flex-col">
{servers.map((server: any) => (
<div key={server.id} className="p-4 bg-[#22222A]">
<div>
{differenceInSeconds(new Date(), parseISO(server.updatedAt)) >=
10 ? (
<p className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-500"></span>
<span>Не в сети</span>
</p>
) : (
<p className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-500"></span>
<span>В сети</span>
</p>
)}
</div>
<p>Локация: "{server.location}"</p>
<p>Имя сервера: "{server.name}"</p>
<p>Лимит процессов: {server.limit_process}</p>
<div>
<p>CPU: {server.cpu}</p>
<p>RAM: {server.ram}</p>
<p className="flex space-x-2">
<span>GPU: </span>
<span className="flex space-x-4">
{server.gpu?.map((item: any, index: number) => (
<span key={index}>
{item} {index === 2 && "°C"}
</span>
))}
</span>
</p>
</div>
</div>
))}
{sessions.map((session: any) => (
<div
key={session.id}
className="flex flex-wrap gap-4 justify-between items-center p-4 bg-[#22222A] rounded"
>
<div className="flex gap-4">
<div className="">
<QRCode
bgColor="#22222A"
fgColor="#F2F2F2"
size={128}
value={`https://stream.graff.tech/stream/?data=wss://${session.location}.sess.stream.graff.tech/${session.server}/${session.cirrusPort}/`}
viewBox={`0 0 128 128`}
/>
</div>
<div className="sm:text-base text-sm">
<p>Локация: "{session.location}"</p>
<p>Сервер: "{session.server}"</p>
<p>Сборка: "{session.title}"</p>
<p>Порт: {session.cirrusPort}</p>
<p>Пользователи: {session.connectedPlayersCount || 0}</p>
<p>
Время запуска: {new Date(session.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-x-4">
<a
href={`https://stream.graff.tech/stream/${session.id}`}
target="_blank"
className="inline-block px-4 py-2 bg-blue-600 hover:bg-blue-500 transition-colors rounded"
>
Открыть в новом окне
</a>
<button
onClick={() =>
endActiveSession(
session.location,
session.server,
session.uePort,
session.cirrusPort
)
}
className="px-4 py-2 bg-red-600 hover:bg-red-500 transition-colors rounded"
>
Завершить сессию
</button>
</div>
</div>
))}
</div>
);
}
export default MonitoringPage;
+303
View File
@@ -0,0 +1,303 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
// import { ChangeEvent, useEffect, useState } from "react";
// import useAuthStore from "./stores/useAuthStore";
// import {
// addHours,
// eachMinuteOfInterval,
// format,
// isBefore,
// parse,
// } from "date-fns";
// import ky from "ky";
import SessionScheduleSettings from "./components/SessionScheduleSettings";
function PersonalAreaDashboardPage() {
// const [user, removeAuthStore, accessToken] = useAuthStore((state) => [
// state.user,
// state.removeAuthStore,
// state.accessToken,
// ]);
// const [startTime, setStartTime] = useState<string>("09:00");
// const [endTime, setEndTime] = useState<string>("20:00");
// const [duration, setDuration] = useState<number>(30);
// const [datesAndTimes, setDatesAndTimes] = useState<any[]>([]);
// const [isLoading, setIsLoading] = useState<boolean>(true);
// function handleDuration(e: ChangeEvent<HTMLInputElement>) {
// e.preventDefault();
// let value = parseInt(e.target.value);
// const min = parseInt(e.target.min);
// const max = parseInt(e.target.max);
// if (!value || value < min) value = min;
// if (value > max) value = max;
// setDuration(value);
// }
// function calculateTimes() {
// if (startTime && endTime) {
// const startDateTime = parse(startTime, "HH:mm", new Date());
// const endDateTime = parse(endTime, "HH:mm", new Date());
// if (!isBefore(startDateTime, endDateTime)) {
// setEndTime(format(addHours(startDateTime, 1), "HH:mm"));
// return false;
// }
// const newDateAndTimes: any[] = eachMinuteOfInterval(
// {
// start: startDateTime,
// end: endDateTime,
// },
// { step: duration }
// );
// newDateAndTimes.forEach((value: Date, index: number) => {
// newDateAndTimes[index] = { value, active: true };
// });
// setDatesAndTimes(newDateAndTimes);
// }
// }
// async function getSessionScheduleSettings() {
// setIsLoading(true);
// try {
// const result: any = await ky
// .get(
// `${import.meta.env.VITE_COORD_URL}/users/session_schedule_settings`,
// {
// headers: {
// Authorization: `Bearer ${accessToken}`,
// },
// }
// )
// .json();
// if (result.error) {
// console.log("Error: ", result.error);
// return;
// }
// const { datesAndTimes, startTime, endTime, duration } = result;
// console.log("datesAndTimes", datesAndTimes);
// setStartTime(startTime);
// setEndTime(endTime);
// setDuration(duration);
// setIsLoading(false);
// } catch (error) {
// if (error instanceof Error) {
// console.log(error.message);
// }
// setIsLoading(false);
// }
// }
// async function saveSessionScheduleSettings() {
// try {
// await ky
// .post(
// `${import.meta.env.VITE_COORD_URL}/users/session_schedule_settings`,
// {
// headers: {
// Authorization: `Bearer ${accessToken}`,
// },
// json: { startTime, endTime, duration, datesAndTimes },
// }
// )
// .json();
// alert("Изменения сохранены!");
// } catch (error) {
// if (error instanceof Error) {
// console.log(error.message);
// }
// }
// }
// function changeActive(dateAndTimeValue: Date) {
// const newDatesAndTimes = datesAndTimes.map((dateAndTime) => {
// if (dateAndTime.value === dateAndTimeValue) {
// dateAndTime.active = !dateAndTime.active;
// }
// return dateAndTime;
// });
// setDatesAndTimes(newDatesAndTimes);
// }
// function logout() {
// removeAuthStore();
// }
// useEffect(() => {
// calculateTimes();
// }, [startTime, endTime, duration]);
// useEffect(() => {
// getSessionScheduleSettings();
// }, []);
// return (
// <div className="p-8 min-h-screen flex flex-col justify-center items-center text-[#F2F2F2] space-y-8">
// <div className="space-y-4">
// <p>
// <span className="text-[#C5C7CE] text-sm">Вы вошли как</span>{" "}
// {user?.username}
// </p>
// <button onClick={logout} className="text-yellow-600">
// Выйти
// </button>
// </div>
// <div className="flex gap-4">
// <div className="relative space-y-8 w-80 bg-[#151619] p-8 rounded-lg shadow min-h-[378px]">
// <p className="text-2xl font-gilroy">Настройки расписания сеансов</p>
// {!isLoading ? (
// <form
// onSubmit={(e) => e.preventDefault()}
// className="flex flex-col space-y-8"
// >
// <div className="space-y-4">
// <div className="flex gap-4 items-center">
// <div className="space-y-1">
// <p className="text-[#C5C7CE] text-sm">Начало</p>
// <input
// // ref={startTimeRef}
// required
// type="time"
// value={startTime}
// onChange={(e) => setStartTime(e.target.value)}
// className="px-3 py-2 rounded bg-[#1C1D21] outline-none focus:outline-[#BC75FF] w-full transition-all"
// />
// </div>
// <span className="mt-6">—</span>
// <div className="space-y-1">
// <p className="text-[#C5C7CE] text-sm">Конец</p>
// <input
// // ref={startTimeRef}
// required
// type="time"
// value={endTime}
// onChange={(e) => setEndTime(e.target.value)}
// className="px-3 py-2 rounded bg-[#1C1D21] outline-none focus:outline-[#BC75FF] w-full transition-all"
// />
// </div>
// </div>
// <div className="space-y-1">
// <p className="text-[#C5C7CE] text-sm">Длительность сеанса</p>
// <input
// // ref={sessionDurationRef}
// required
// type="number"
// min={15}
// max={60}
// step={5}
// value={duration}
// onChange={handleDuration}
// className="px-3 py-2 rounded bg-[#1C1D21] outline-none focus:outline-[#BC75FF] w-full transition-all"
// />
// </div>
// </div>
// <button
// onClick={saveSessionScheduleSettings}
// type="submit"
// className="px-4 py-2 rounded bg-gradient outline-none opacity-95 hover:opacity-100 transition-all disabled:opacity-50 flex justify-center items-center h-10 self-end"
// >
// Сохранить
// </button>
// </form>
// ) : (
// <div className="absolute top-0 left-0 w-full h-full flex justify-center items-center">
// <svg
// className="animate-spin h-5 w-5"
// xmlns="http://www.w3.org/2000/svg"
// fill="none"
// viewBox="0 0 24 24"
// >
// <circle
// className="opacity-25"
// cx="12"
// cy="12"
// r="10"
// stroke="currentColor"
// strokeWidth="4"
// ></circle>
// <path
// className="opacity-75"
// fill="currentColor"
// d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
// ></path>
// </svg>
// </div>
// )}
// </div>
// <div className="relative space-y-8 w-80 bg-[#151619] p-8 rounded-lg shadow min-h-[378px]">
// <p className="text-xl font-gilroy">Предпросмотр</p>
// {!isLoading ? (
// <div className="grid grid-cols-4">
// {datesAndTimes.map(
// (dateAndTime: { value: Date; active: true }, index: number) => (
// <button
// key={index}
// onClick={() => changeActive(dateAndTime.value)}
// className={[
// "px-3 py-2 text-center rounded hover:bg-[#23242A]",
// !dateAndTime.active ? "opacity-25" : "opacity-100",
// ].join(" ")}
// >
// {format(dateAndTime.value, "HH:mm")}
// </button>
// )
// )}
// </div>
// ) : (
// <div className="absolute top-0 left-0 w-full h-full flex justify-center items-center">
// <svg
// className="animate-spin h-5 w-5"
// xmlns="http://www.w3.org/2000/svg"
// fill="none"
// viewBox="0 0 24 24"
// >
// <circle
// className="opacity-25"
// cx="12"
// cy="12"
// r="10"
// stroke="currentColor"
// strokeWidth="4"
// ></circle>
// <path
// className="opacity-75"
// fill="currentColor"
// d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
// ></path>
// </svg>
// </div>
// )}
// </div>
// </div>
// </div>
// );
return (
<div className="min-h-screen flex flex-col justify-center items-center p-8">
<SessionScheduleSettings />
</div>
);
}
export default PersonalAreaDashboardPage;
+140
View File
@@ -0,0 +1,140 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ky from "ky";
import useAuthStore from "./stores/useAuthStore";
import { FormEvent, useRef, useState } from "react";
type User = {
id: string;
username: string;
};
interface IResult {
error?: number;
accessToken?: string;
user?: User;
}
function PersonalAreaLoginPage() {
const [setAccessToken, setUser] = useAuthStore((state) => [
state.setAccessToken,
state.setUser,
]);
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
async function auth(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
try {
const result: IResult = await ky
.post(import.meta.env.VITE_COORD_URL + "/login", {
json: { username, password },
})
.json();
setIsLoading(false);
if (result.error) {
passwordRef.current?.focus();
setPassword("");
setError("Неверное имя пользователя или пароль");
return;
}
if (!result.accessToken || !result.user) {
setError("Не удалось получить данные");
return;
}
setAccessToken(result.accessToken);
setUser(result.user);
} catch (error) {
setIsLoading(false);
if (error instanceof Error) {
if (error.message === "Failed to fetch") {
setError("Нет соединения с сервером, попробуйте позже");
} else {
setError(error.message);
}
}
}
}
return (
<div className="p-8 min-h-screen flex flex-col justify-center items-center text-[#F2F2F2]">
<div className="space-y-12 w-[400px] bg-[#151619] p-8 rounded-lg shadow">
<p className="text-2xl font-gilroy">Вход в личный кабинет</p>
<form onSubmit={auth} className="flex flex-col gap-12">
<div className="flex flex-col gap-8">
<div className="space-y-1">
<p className="text-[#C5C7CE] text-sm">Имя пользователя</p>
<input
ref={usernameRef}
required
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="px-3 py-2 rounded bg-[#1C1D21] outline-none focus:outline-[#BC75FF] w-full transition-all"
/>
</div>
<div className="space-y-1">
<p className="text-[#C5C7CE] text-sm">Пароль</p>
<input
ref={passwordRef}
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-3 py-2 rounded bg-[#1C1D21] outline-none focus:outline-[#BC75FF] w-full transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 rounded bg-gradient outline-none opacity-95 hover:opacity-100 transition-all disabled:opacity-50 flex justify-center items-center h-10"
>
{isLoading ? (
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<span>Войти</span>
)}
</button>
</form>
<p className="text-sm text-red-500 min-h-[40px]">{error && error}</p>
</div>
</div>
);
}
export default PersonalAreaLoginPage;
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
import ky from "ky";
import { useEffect, useState } from "react";
import Countdown from "react-countdown";
import { useNavigate, useSearchParams } from "react-router-dom";
function ScheduledPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [countdownSeconds, setCountdownSeconds] = useState<number>();
async function connect() {
const userInviteKey = searchParams.get("userInviteKey");
try {
const result: any = await ky
.get(
`${
import.meta.env.VITE_COORD_URL
}/active_sessions/scheduled?userInviteKey=${userInviteKey}`
)
.json();
if (!result.id) {
setCountdownSeconds(result.countdownSeconds);
return;
}
navigate(`/stream/${result.id}`);
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
}
useEffect(() => {
if (searchParams.get("userInviteKey")) {
// console.log(searchParams.get("userInviteKey"));
connect();
} else {
navigate("/");
}
}, []);
return (
<div className="min-h-screen p-8 text-[#F2F2F2] flex flex-col justify-center items-center">
{countdownSeconds && (
<div className="space-y-4 w-[340px]">
<p className="text-2xl font-gilroy">Сеанс начнется через:</p>
<p className="text-6xl font-gilroy">
<Countdown
date={Date.now() + countdownSeconds + 10000}
onComplete={() => window.location.reload()}
/>
</p>
</div>
)}
</div>
);
}
export default ScheduledPage;
+589
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+128
View File
@@ -0,0 +1,128 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
add,
eachDayOfInterval,
endOfMonth,
format,
getDay,
isEqual,
parse,
startOfToday,
} from "date-fns";
import { enUS, ru } from "date-fns/locale";
import { useEffect, useState } from "react";
import ChevronRightIcon from "./icons/ChevronRightIcon";
import ChevronLeftIcon from "./icons/ChevronLeftIcon";
import { Trans } from "react-i18next";
import i18n from "../i18n";
interface CalendarProps {
handleSelect: (day: Date) => void;
}
function classNames(...classes: (string | boolean)[]) {
return classes.filter(Boolean).join(" ");
}
function Calendar({ handleSelect }: CalendarProps) {
const today = startOfToday();
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const [currentMonth, setCurrentMonth] = useState(format(today, "MMM-yyyy"));
const firstDayCurrentMonth = parse(currentMonth, "MMM-yyyy", new Date());
const days = eachDayOfInterval({
start: firstDayCurrentMonth,
end: endOfMonth(firstDayCurrentMonth),
});
function previousMonth() {
const firstDayNextMonth = add(firstDayCurrentMonth, { months: -1 });
setCurrentMonth(format(firstDayNextMonth, "MMM-yyyy"));
}
function nextMonth() {
const firstDayNextMonth = add(firstDayCurrentMonth, { months: 1 });
setCurrentMonth(format(firstDayNextMonth, "MMM-yyyy"));
}
useEffect(() => {
if (selectedDay !== null) {
handleSelect(selectedDay);
}
}, [selectedDay]);
return (
<>
<div className="flex items-center justify-between">
<button onClick={previousMonth}>
<ChevronLeftIcon />
</button>
<p className="text-sm text-white first-letter:uppercase w-fit">
{format(
firstDayCurrentMonth,
"LLLL, yyyy",
i18n.language === "ru" ? { locale: ru } : { locale: enUS }
)}
</p>
<button onClick={nextMonth}>
<ChevronRightIcon />
</button>
</div>
<div className="sm:mt-8 mt-5 grid grid-cols-7 gap-2 font-gilroy text-sm text-center text-white font-semibold">
<div>{i18n.language === "ru" ? "пн" : "Mo"}</div>
<div>{i18n.language === "ru" ? "вт" : "Tu"}</div>
<div>{i18n.language === "ru" ? "ср" : "We"}</div>
<div>{i18n.language === "ru" ? "чт" : "Th"}</div>
<div>{i18n.language === "ru" ? "пт" : "Fr"}</div>
<div>{i18n.language === "ru" ? "сб" : "Sa"}</div>
<div>{i18n.language === "ru" ? "вс" : "Su"}</div>
</div>
<div className="grid grid-cols-7 gap-2 mt-2 text-sm font-semibold">
{days.map((day, dayIdx) => (
<div
key={day.toString()}
className={classNames(
dayIdx === 0 && colStartClasses[getDay(day) - 1]
)}
>
<button
type="button"
disabled={day < today}
onClick={() => setSelectedDay(day)}
className={classNames(
selectedDay !== null &&
isEqual(day, selectedDay) &&
"bg-[#798FFF] text-white hover:bg-opacity-100",
"mx-auto flex min-w-[40px] h-10 items-center justify-center rounded-full transition-all text-[#798FFF] border border-[#798FFF] hover:bg-[#798FFF] hover:bg-opacity-20 disabled:text-[#3D425C] disabled:border-transparent disabled:hover:bg-transparent"
)}
>
<time dateTime={format(day, "yyyy-MM-dd")}>
{format(day, "d")}
</time>
</button>
</div>
))}
</div>
<div className="mt-8 flex items-center gap-2">
<div className="w-4 h-4 bg-[#798FFF] rounded"></div>
<p className="text-[#798FFF]">
- <Trans i18nKey={"sidebar.available"}>запись доступна</Trans>
</p>
</div>
</>
);
}
const colStartClasses = [
"",
"col-start-2",
"col-start-3",
"col-start-4",
"col-start-5",
"col-start-6",
"col-start-7",
];
export default Calendar;
+37
View File
@@ -0,0 +1,37 @@
import { Trans } from "react-i18next";
interface CardProps {
icon: string;
image: string;
title: string | JSX.Element;
location: string | JSX.Element;
handleClick: () => void;
}
function Card({ icon, image, title, location, handleClick }: CardProps) {
return (
<div className="rounded-lg overflow-hidden flex flex-col justify-between bg-[#22222A]">
<div
className="aspect-video bg-no-repeat bg-center bg-cover"
style={{ backgroundImage: `url('${image}')` }}
></div>
<div className="p-8 space-y-8">
<div className="flex items-center space-x-4">
<img src={icon} alt="" className="w-12 h-12" />
<div>
<p className="sm:text-xl font-gilroy">{title}</p>
<p className="sm:text-base text-sm text-[#ABABBA]">{location}</p>
</div>
</div>
<button
onClick={handleClick}
className="px-4 py-2 bg-gradient rounded w-full opacity-90 hover:opacity-100 transition-opacity font-gilroy"
>
<Trans i18nKey="button" />
</button>
</div>
</div>
);
}
export default Card;
+20
View File
@@ -0,0 +1,20 @@
.contacts-field:focus ~ .contacts-placeholder {
top: 0;
}
.contacts-field:focus ~ .contacts-placeholder-2 {
opacity: 0;
}
.contacts-field:valid ~ .contacts-placeholder {
top: 0;
}
.contacts-field:valid ~ .contacts-placeholder-2 {
opacity: 0;
}
.contacts-field::placeholder {
font-weight: 600;
color: #77787d;
}
+93
View File
@@ -0,0 +1,93 @@
import { ChangeEvent } from "react";
import { Trans } from "react-i18next";
import AsteriskIcon from "./icons/AsteriskIcon";
import InputMask from "react-input-mask";
import "./ContactsForm.css";
import useSidebarStore from "../stores/useSidebarStore";
function ContactsForm() {
const [name, setName, phone, setPhone, email, setEmail] = useSidebarStore(
(state) => [
state.name,
state.setName,
state.phone,
state.setPhone,
state.email,
state.setEmail,
]
);
return (
<div>
<div className="relative">
<input
required
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="feedback-field bg-transparent border border-[#3D425C] rounded-none sm:pt-12 sm:pb-4 sm:px-4 pt-8 pb-3 px-3 outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full"
/>
<p className="feedback-placeholder lg:text-base text-sm absolute sm:pt-4 sm:pb-4 sm:px-4 sm:top-4 pt-3 pb-3 px-3 top-3 w-full opacity-50 transition-all pointer-events-none flex justify-between items-center">
<span>
<Trans i18nKey={"feedback.form.field1"}>Имя</Trans>
</span>
<AsteriskIcon />
</p>
</div>
<div className="relative">
<InputMask
required
type="tel"
mask={"+999999999999999"}
maskChar={null}
value={phone}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPhone(e.target.value)
}
className={[
"feedback-field bg-transparent border rounded-none border-t-0 border-[#3D425C] sm:pt-12 sm:pb-4 sm:px-4 pt-8 pb-3 px-3 outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full",
].join(" ")}
/>
<p className="feedback-placeholder lg:text-base text-sm absolute sm:pt-4 sm:pb-4 sm:px-4 sm:top-4 pt-3 pb-3 px-3 top-3 w-full opacity-50 transition-all pointer-events-none flex justify-between items-center">
<span>
<Trans i18nKey={"feedback.form.field2"}>Телефон</Trans>
</span>
<AsteriskIcon />
</p>
</div>
<div className="relative">
<input
required
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="feedback-field bg-transparent border rounded-none border-t-0 border-[#3D425C] sm:pt-12 sm:pb-4 sm:px-4 pt-8 pb-3 px-3 outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full"
/>
<p className="feedback-placeholder lg:text-base text-sm absolute sm:pt-4 sm:pb-4 sm:px-4 sm:top-4 pt-3 pb-3 px-3 top-3 w-full opacity-50 transition-all pointer-events-none flex justify-between items-center">
<span>Email</span>
<AsteriskIcon />
</p>
</div>
<div className="border border-t-0 border-[#3D425C] sm:px-4 sm:py-6 px-3 py-4 text-xs flex items-center gap-2">
<div className="flex gap-2">
<div className="">
<AsteriskIcon />
</div>
<p></p>
<p>
<Trans i18nKey={"feedback.form.desc2"}>
Звездочкой отмечены обязательные
<br />
для заполнения поля
</Trans>
</p>
</div>
</div>
</div>
);
}
export default ContactsForm;
+187
View File
@@ -0,0 +1,187 @@
import ky from "ky";
import { ChangeEvent, FormEvent, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import InputMask from "react-input-mask";
import AsteriskIcon from "./icons/AsteriskIcon";
import SendIcon from "./icons/SendIcon";
import CheckGradientIcon from "./icons/CheckGradientIcon";
import LoaderIcon from "./icons/LoaderIcon";
function FeedbackForm() {
const { t } = useTranslation();
const [name, setName] = useState<string>("");
const [phone, setPhone] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [isSend, setIsSend] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
async function sendMail(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
try {
await ky
.post(`https://estate.graff.tech/api/mail`, {
json: {
fullname: name,
phone,
email,
request: description,
},
})
.json();
setIsSend(true);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
if (error instanceof Error) {
alert(error.message);
}
}
}
return (
<form
className="grid lg:grid-cols-3 sm:grid-cols-2 relative"
onSubmit={(e) => void sendMail(e)}
>
<div className="relative col-span-1">
<input
required
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="feedback-field bg-transparent border border-[#3D425C] rounded-none lg:p-6 lg:pt-14 p-4 pt-12 outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full"
/>
<p className="feedback-placeholder lg:text-base text-sm absolute lg:top-4 top-5 left-0 w-full lg:p-6 p-4 opacity-50 transition-all pointer-events-none flex justify-between">
<span>
<Trans i18nKey={"feedback.form.field1"}>Имя</Trans>
</span>
<AsteriskIcon />
</p>
</div>
<div className="relative">
<InputMask
required
type="tel"
mask={"+999999999999999"}
maskChar={null}
value={phone}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPhone(e.target.value)
}
className={[
"feedback-field bg-transparent border rounded-none sm:border-l-0 sm:border-t border-t-0 border-l border-[#3D425C] lg:p-6 lg:pt-14 p-4 pt-12 outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full",
].join(" ")}
/>
<p className="feedback-placeholder lg:text-base text-sm absolute lg:top-4 top-5 left-0 w-full lg:p-6 p-4 opacity-50 transition-all pointer-events-none flex justify-between">
<span>
<Trans i18nKey={"feedback.form.field2"}>Телефон</Trans>
</span>
<AsteriskIcon />
</p>
</div>
<div className="relative lg:col-span-1 sm:col-span-2 col-span-1">
<input
required
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="feedback-field bg-transparent border rounded-none lg:border-l-0 lg:border-t border-t-0 border-[#3D425C] lg:p-6 lg:pt-14 p-4 pt-12 outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full"
/>
<p className="feedback-placeholder lg:text-base text-sm absolute lg:top-4 top-5 left-0 w-full lg:p-6 p-4 opacity-50 transition-all pointer-events-none flex justify-between">
<span>Email</span>
<AsteriskIcon />
</p>
</div>
<div className="relative lg:col-span-3 sm:col-span-2 h-[194px]">
<textarea
placeholder={t("feedback.form.field3").toString()}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="feedback-field bg-transparent resize-none border rounded-none border-t-0 border-[#3D425C] lg:p-6 p-4 h-full outline-none outline-1 -outline-offset-1 focus:outline-[#D375FF] transition-all w-full"
></textarea>
</div>
<div className="2xl:pt-6 2xl:pr-6 pt-4 sm:pr-4 lg:order-none order-last flex items-center">
<button
disabled={isLoading}
className="group relative px-6 py-4 2xl:text-base text-sm bg-gradient rounded-full font-medium flex justify-between items-center w-full transition-opacity disabled:opacity-75"
>
<div className="absolute top-0 left-0 w-full h-full rounded-full bg-black opacity-0 group-hover:opacity-10 transition-all"></div>
<span className="relative">
<Trans i18nKey={"feedback.form.button"}>Отправить</Trans>
</span>
{!isLoading ? (
<SendIcon className="relative 2xl:w-8 2xl:h-8 w-6 h-6" />
) : (
<LoaderIcon className="relative 2xl:w-8 2xl:h-8 w-6 h-6 animate-spin" />
)}
</button>
</div>
<div className="border sm:border-t-0 border-t border-[#3D425C] 2xl:p-6 p-4 sm:mt-0 mt-6 flex items-center">
<div className="text-xs leading-tight">
<Trans i18nKey={"feedback.form.desc1.text1"}>
Нажимая кнопку отправить, вы принимаете
</Trans>{" "}
<a className="text-[#798FFF] cursor-pointer opacity-95 hover:opacity-100 transition-all">
<Trans i18nKey={"feedback.form.desc1.link1"}>
условия использования
</Trans>
</a>{" "}
<Trans i18nKey={"feedback.form.desc1.text2"}>и</Trans>{" "}
<a className="text-[#798FFF] cursor-pointer opacity-95 hover:opacity-100 transition-all">
<Trans i18nKey={"feedback.form.desc1.link2"}>
политику конфиденциальности
</Trans>
</a>
</div>
</div>
<div className="border border-t-0 sm:border-l-0 border-[#3D425C] 2xl:p-6 p-4 text-xs flex items-center gap-2">
<div className="flex gap-2">
<div className="">
<AsteriskIcon />
</div>
<p></p>
<p className="leading-tight">
<Trans i18nKey={"feedback.form.desc2"}>
Звездочкой отмечены обязательные
<br />
для заполнения поля
</Trans>
</p>
</div>
</div>
{isSend && (
<div className="absolute top-0 left-0 w-full h-full bg-[#14161F] border border-[#3D425C] p-6 flex flex-col justify-between">
<p className="text-gradient text-xl font-gilroy leading-tight font-semibold flex items-center gap-2">
<span>Заявка отправлена</span>
<CheckGradientIcon className="lg:w-8 lg:h-8 w-6 h-6" />
</p>
<div className="flex flex-col gap-2">
<p className="font-gilroy leading-snug lg:text-2xl text-xl font-semibold">
Спасибо за подачу заявки!
</p>
<p className="lg:w-1/2 sm:w-2/3 lg:text-base text-sm">
Мы ценим ваш интерес к нашей компании и в ближайшее время свяжемся
с вами для уточнения деталей проекта.
</p>
</div>
</div>
)}
</form>
);
}
export default FeedbackForm;
+64
View File
@@ -0,0 +1,64 @@
import { Trans } from "react-i18next";
import LogoIcon from "./icons/LogoIcon";
import LogoMobileIcon from "./icons/LogoMobileIcon";
import i18n from "../i18n";
import useSidebarStore from "../stores/useSidebarStore";
interface HeaderProps {
handleChangeLang: (lang: string) => void;
}
function Header({ handleChangeLang }: HeaderProps) {
const [setIsOpen] = useSidebarStore((state) => [state.setIsOpen]);
return (
<header className="sm:py-6 py-4 flex justify-between">
<a href="/" className="sm:block hidden">
<LogoIcon />
</a>
<a href="/" className="sm:hidden block">
<LogoMobileIcon />
</a>
<div className="flex sm:gap-8 gap-2">
<button
onClick={() => setIsOpen(true)}
className="group relative sm:px-8 px-6 py-2 bg-gradient rounded-full lg:text-base text-sm font-medium leading-normal"
>
<div className="absolute top-0 left-0 w-full h-full rounded-full bg-black opacity-0 group-hover:opacity-10 transition-all"></div>
<span className="relative">
<Trans i18nKey={"header.buttonFirst"}>Записаться</Trans>{" "}
<span className="sm:inline hidden">
<Trans i18nKey={"header.buttonSecond"}>на демонстрацию</Trans>
</span>
</span>
</button>
<div className="flex gap-1">
<button
className={[
"px-3 py-1.5 border rounded-full",
i18n.language === "ru"
? "border-[#D375FF]"
: "border-transparent hover:bg-[#3D425C] transition-colors",
].join(" ")}
onClick={() => handleChangeLang("ru")}
>
RU
</button>
<button
className={[
"px-3 py-1.5 border rounded-full",
i18n.language === "en"
? "border-[#D375FF]"
: "border-transparent hover:bg-[#3D425C] transition-colors",
].join(" ")}
onClick={() => handleChangeLang("en")}
>
EN
</button>
</div>
</div>
</header>
);
}
export default Header;
+15
View File
@@ -0,0 +1,15 @@
.entering {
opacity: 1;
}
.entered {
opacity: 1;
}
.exiting {
opacity: 0;
}
.exited {
opacity: 0;
}
+42
View File
@@ -0,0 +1,42 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect } from "react";
import useModalStore from "../stores/useModalStore";
import "./ModalContainer.css";
interface ModalContainerProps {
className?: string;
}
function ModalContainer({ className }: ModalContainerProps) {
const [modal, setModal] = useModalStore((state) => [
state.modal,
state.setModal,
]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.code === "Escape") {
setModal(null);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div
onClick={() => setModal(null)}
className={[
"absolute w-full min-h-screen top-0 left-0 flex flex-col justify-center items-center p-8 bg-black bg-opacity-75 transition-opacity cursor-pointer",
className,
].join(" ")}
>
<div onClick={(e) => e.stopPropagation()} className="cursor-default">
{modal}
</div>
</div>
);
}
export default ModalContainer;
+43
View File
@@ -0,0 +1,43 @@
import { ChangeEvent, useState } from "react";
interface NumberInputProps {
min?: number;
max?: number;
step?: number;
defaultValue?: number;
onChange: (value: number) => void;
}
function NumberInput({
min = 1,
max = 100,
step = 1,
defaultValue = 1,
onChange,
}: NumberInputProps) {
const [value, setValue] = useState<number>(defaultValue);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
let value = +e.target.value;
if (value < min) value = min;
if (value > max) value = max;
setValue(value);
onChange(value);
}
return (
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={handleChange}
className="px-3 py-2 rounded w-full outline-none"
/>
);
}
export default NumberInput;
+159
View File
@@ -0,0 +1,159 @@
/* eslint-disable react-hooks/exhaustive-deps */
// Copyright Epic Games, Inc. All Rights Reserved.
import { useEffect, useRef, useState } from "react";
import {
Config,
AllSettings,
PixelStreaming,
} from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.3";
import { Trans } from "react-i18next";
export interface PixelStreamingWrapperProps {
initialSettings?: Partial<AllSettings>;
}
export const PixelStreamingWrapper = ({
initialSettings,
}: PixelStreamingWrapperProps) => {
// A reference to parent div element that the Pixel Streaming library attaches into:
const videoParent = useRef<HTMLDivElement>(null);
// Pixel streaming library instance is stored into this state variable after initialization:
const [pixelStreaming, setPixelStreaming] = useState<PixelStreaming>();
// A boolean state variable that determines if the Click to play overlay is shown:
const [clickToPlayVisible, setClickToPlayVisible] = useState<boolean>(false);
const [videoInitialized, setVideoInitialized] = useState<boolean>(false);
// Run on component mount:
useEffect(() => {
if (videoParent.current) {
// Attach Pixel Streaming library to videoParent element:
const config = new Config({ initialSettings });
const streaming = new PixelStreaming(config, {
videoElementParent: videoParent.current,
});
streaming.addEventListener("videoInitialized", () => {
setVideoInitialized(true);
});
// register a playStreamRejected handler to show Click to play overlay if needed:
streaming.addEventListener("playStreamRejected", () => {
setClickToPlayVisible(true);
});
// Save the library instance into component state so that it can be accessed later:
setPixelStreaming(streaming);
document.getElementById("hiddenInput")?.remove();
document.getElementById("editTextButton")?.remove();
// Clean up on component unmount:
return () => {
try {
streaming.disconnect();
} catch {
//
}
};
}
}, []);
return (
<div className="relative w-screen h-screen">
<div className="w-full h-[100svh]" ref={videoParent} />
{!videoInitialized && (
<div className="absolute top-0 left-0 w-full h-full flex justify-center items-center">
<Trans i18nKey="streamBuffering">Буферизация потока</Trans>
</div>
)}
{clickToPlayVisible && (
<div className="absolute top-0 left-0 w-full h-full flex justify-center items-center z-10 bg-[#131317]">
<div className="flex flex-col justify-center items-center w-[400px] p-10 space-y-10 rounded-lg">
<div className="space-y-4 text-center">
<p className="text-4xl font-gilroy">
<Trans i18nKey="demoStarted">Демонстрация начата</Trans>
</p>
<p className="text-[#C5C7CE]">
<Trans i18nKey="clickToContinue">
Нажмите, чтобы продолжить
</Trans>
</p>
</div>
<button
onClick={() => {
pixelStreaming?.play();
setClickToPlayVisible(false);
}}
>
<svg
width="88"
height="88"
viewBox="0 0 88 88"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_b_0_1121)">
<path
d="M55.6667 43.9999L34.6668 57.9999L34.6668 30L55.6667 43.9999Z"
fill="#F2F2F2"
stroke="#F2F2F2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
x="0.5"
y="0.5"
width="87"
height="87"
rx="43.5"
stroke="url(#paint0_linear_0_1121)"
/>
</g>
<defs>
<filter
id="filter0_b_0_1121"
x="-20"
y="-20"
width="128"
height="128"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="10" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_0_1121"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_0_1121"
result="shape"
/>
</filter>
<linearGradient
id="paint0_linear_0_1121"
x1="88"
y1="-2.6226e-06"
x2="2.6226e-06"
y2="88"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BC75FF" />
<stop offset="1" stopColor="#798FFF" />
</linearGradient>
</defs>
</svg>
</button>
</div>
</div>
)}
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import { PixelStreamingWrapper } from "./PixelStreamingWrapper";
interface PlayerProps {
ss: string;
}
export const Player = ({ ss }: PlayerProps) => {
return (
<div
style={{
height: "100%",
width: "100%",
}}
>
<PixelStreamingWrapper
initialSettings={{
AutoPlayVideo: true,
AutoConnect: true,
ss,
StartVideoMuted: false,
HoveringMouse: true,
}}
/>
</div>
);
};
@@ -0,0 +1,41 @@
import NumberInput from "./NumberInput";
import TimePicker from "./TimePicker";
function SessionScheduleSettings() {
return (
<div className="text-white flex gap-4">
<div className="bg-[#212121] rounded p-8 w-80 h-fit shadow space-y-8">
<p className="text-2xl font-gilroy">Настройки расписания сеансов</p>
<form className="space-y-4">
<div className="flex gap-2">
<div className="space-y-1">
<p>Время начала:</p>
<TimePicker />
</div>
<div></div>
<div className="space-y-1">
<p>Время конца:</p>
<TimePicker />
</div>
</div>
<div className="">
<p>Длительность:</p>
<NumberInput
min={15}
max={60}
step={5}
defaultValue={30}
onChange={(value) => console.log(value)}
/>
</div>
<button type="submit" className="px-4 py-2 bg-gradient rounded">
Сохранить
</button>
</form>
</div>
<div className="bg-[#212121] rounded p-4 w-80 h-fit shadow">preview</div>
</div>
);
}
export default SessionScheduleSettings;
+33
View File
@@ -0,0 +1,33 @@
.sidebar.entering {
opacity: 1;
}
.sidebar.entered {
opacity: 1;
}
.sidebar.exiting {
opacity: 0;
}
.sidebar.exited {
opacity: 0;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
+35
View File
@@ -0,0 +1,35 @@
/* eslint-disable no-irregular-whitespace */
import "./Sidebar.css";
import useSidebarTabStore from "../stores/useSidebarStore";
import SidebarTab1 from "./SidebarTab1";
import SidebarTab2 from "./SidebarTab2";
import SidebarTab3 from "./SidebarTab3";
import SidebarTab4 from "./SidebarTab4";
import SidebarTab5 from "./SidebarTab5";
interface SidebarProps {
className?: string;
}
function Sidebar({ className }: SidebarProps) {
const [currentTab] = useSidebarTabStore((state) => [state.currentTab]);
return (
<div
className={[
"sidebar fixed top-0 left-0 w-full h-full bg-[#14161F] bg-opacity-90 transition-opacity text-white",
className,
].join(" ")}
>
<div className="absolute right-0 h-full sm:w-[408px] w-full bg-[#14161F] overflow-y-auto">
{currentTab === 1 && <SidebarTab1 />}
{currentTab === 2 && <SidebarTab2 />}
{currentTab === 3 && <SidebarTab3 />}
{currentTab === 4 && <SidebarTab4 />}
{currentTab === 5 && <SidebarTab5 />}
</div>
</div>
);
}
export default Sidebar;
+69
View File
@@ -0,0 +1,69 @@
/* eslint-disable no-irregular-whitespace */
import { Trans } from "react-i18next";
import useSidebarTabStore from "../stores/useSidebarStore";
import Calendar from "./Calendar";
import CloseIcon from "./icons/CloseIcon";
function SidebarTab1() {
const [currentTab, setCurrentTab, setIsOpen, setSelectedDay] =
useSidebarTabStore((state) => [
state.currentTab,
state.setCurrentTab,
state.setIsOpen,
state.setSelectedDay,
]);
function handleSelectDay(day: Date) {
setSelectedDay(day);
setCurrentTab(currentTab + 1);
}
return (
<div className="sm:p-8 p-6 flex flex-col justify-between sm:gap-8 gap-6 min-h-full">
<div>
<div className="flex items-start justify-between gap-4">
<p className="text-2xl text-gradient font-semibold font-gilroy w-fit leading-snug">
<Trans i18nKey={"sidebar.title1"}>Дата и время</Trans>
</p>
<button
onClick={() => setIsOpen(false)}
className="transition-opacity hover:opacity-50"
>
<CloseIcon />
</button>
</div>
<div className="mt-2">
<div className="grid grid-cols-2 gap-2">
<div className="border-b border-[#798FFF] p-4 text-center">
<p className="leading-none font-gilroy font-semibold text-sm">
<Trans i18nKey={"sidebar.date"}>Дата</Trans>
</p>
</div>
<div className="border-b border-[#3D425C] p-4 text-center">
<p className="leading-none font-gilroy font-semibold text-sm">
<Trans i18nKey={"sidebar.time"}>Время</Trans>
</p>
</div>
</div>
</div>
<div className="sm:mt-8 mt-6">
<Calendar handleSelect={(day) => handleSelectDay(day)} />
</div>
</div>
<div className="flex flex-col sm:gap-6 gap-4">
<p className="text-center text-xs opacity-50 leading-tight">
<Trans i18nKey={"sidebar.notice"}>
Запись на демонстрацию работает в ознакомительном режиме и не
сохраняет введенные данные
</Trans>
</p>
</div>
</div>
);
}
export default SidebarTab1;
+102
View File
@@ -0,0 +1,102 @@
/* eslint-disable no-irregular-whitespace */
import { Trans } from "react-i18next";
import useSidebarTabStore from "../stores/useSidebarStore";
import TimeSelector from "./TimeSelector";
import CloseIcon from "./icons/CloseIcon";
import { format } from "date-fns";
import i18n from "../i18n";
import { enUS, ru } from "date-fns/locale";
function SidebarTab2() {
const [
currentTab,
setCurrentTab,
setIsOpen,
setSelectedTime,
selectedDay,
selectedTime,
] = useSidebarTabStore((state) => [
state.currentTab,
state.setCurrentTab,
state.setIsOpen,
state.setSelectedTime,
state.selectedDay,
state.selectedTime,
]);
function handleSelectTime(time: string) {
setSelectedTime(time);
setCurrentTab(currentTab + 1);
}
return (
<div className="sm:p-8 p-6 flex flex-col justify-between sm:gap-8 gap-6 min-h-full">
<div>
<div className="flex items-start justify-between">
<p className="text-2xl text-gradient font-semibold font-gilroy w-fit leading-snug">
<Trans i18nKey={"sidebar.title1"}>Дата и время</Trans>
</p>
<button
onClick={() => setIsOpen(false)}
className="transition-opacity hover:opacity-50"
>
<CloseIcon />
</button>
</div>
<div className="mt-2">
<div className="grid grid-cols-2 gap-2">
<div className="border-b border-[#3D425C] p-4 text-center">
<p className="leading-none font-gilroy font-semibold text-sm">
{selectedDay &&
format(
selectedDay,
"dd MMMM",
i18n.language === "ru" ? { locale: ru } : { locale: enUS }
)}
</p>
</div>
<div className="border-b border-[#798FFF] p-4 text-center">
<p className="leading-none font-gilroy font-semibold text-sm">
{selectedTime ? (
selectedTime
) : (
<Trans i18nKey={"sidebar.time"}>Время</Trans>
)}
</p>
</div>
</div>
</div>
<div className="mt-6">
<TimeSelector
handleSelect={(time: string) => handleSelectTime(time)}
/>
</div>
</div>
<div className="flex flex-col sm:gap-6 gap-4">
<p className="text-center text-xs opacity-50 leading-tight">
<Trans i18nKey={"sidebar.notice"}>
Запись на демонстрацию работает в ознакомительном режиме и не
сохраняет введенные данные
</Trans>
</p>
<div className="flex sm:gap-4 gap-2">
<button
onClick={() => setCurrentTab(currentTab - 1)}
className="px-6 sm:py-4 py-3.5 border border-[#3D425C] rounded-full font-medium group w-full"
>
<span className="opacity-80 transition-opacity group-hover:opacity-100 sm:text-base text-sm">
<Trans i18nKey={"sidebar.buttonBack"}>Назад</Trans>
</span>
</button>
</div>
</div>
</div>
);
}
export default SidebarTab2;
+91
View File
@@ -0,0 +1,91 @@
/* eslint-disable no-irregular-whitespace */
import { Trans } from "react-i18next";
import useSidebarTabStore from "../stores/useSidebarStore";
import ContactsForm from "./ContactsForm";
import ArrowRightIcon from "./icons/ArrowRightIcon";
import CloseIcon from "./icons/CloseIcon";
function SidebarTab3() {
const [
currentTab,
setCurrentTab,
setIsOpen,
name,
phone,
email,
] = useSidebarTabStore((state) => [
state.currentTab,
state.setCurrentTab,
state.setIsOpen,
state.name,
state.phone,
state.email,
state.selectedDay,
state.selectedTime,
]);
function handleSubmit() {
if (!name || !phone || !email) {
return;
}
setCurrentTab(currentTab + 1);
}
return (
<form
onSubmit={handleSubmit}
className="sm:p-8 p-6 flex flex-col justify-between sm:gap-8 gap-6 min-h-full"
>
<div>
<div className="flex items-start justify-between">
<p className="text-2xl text-gradient font-semibold font-gilroy w-fit leading-snug">
<Trans i18nKey={"sidebar.title2"}>Контакты</Trans>
</p>
<button
onClick={() => setIsOpen(false)}
className="transition-opacity hover:opacity-50"
>
<CloseIcon />
</button>
</div>
<div className="sm:mt-6 mt-4">
<ContactsForm />
</div>
</div>
<div className="flex flex-col sm:gap-6 gap-4">
<p className="text-center text-xs opacity-50 leading-tight">
<Trans i18nKey={"sidebar.notice"}>
Запись на демонстрацию работает в ознакомительном режиме и не
сохраняет введенные данные
</Trans>
</p>
<div className="flex sm:gap-4 gap-2">
<button
onClick={() => setCurrentTab(currentTab - 1)}
className="px-6 py-4 border border-[#3D425C] rounded-full sm:text-base text-sm font-medium group w-fit"
>
<span className="opacity-80 transition-opacity group-hover:opacity-100">
<Trans i18nKey={"sidebar.buttonBack"}>Назад</Trans>
</span>
</button>
<button
type="submit"
className="px-6 py-3 bg-gradient rounded-full sm:text-base text-sm font-medium flex items-center justify-between w-full"
>
<span>
<Trans i18nKey={"sidebar.buttonNext"}>Далее</Trans>
</span>
<ArrowRightIcon className="sm:w-8 sm:h-8 w-6 h-6" />
</button>
</div>
</div>
</form>
);
}
export default SidebarTab3;
+198
View File
@@ -0,0 +1,198 @@
/* eslint-disable no-irregular-whitespace */
import { format, parse } from "date-fns";
import useSidebarTabStore from "../stores/useSidebarStore";
import ArrowRightIcon from "./icons/ArrowRightIcon";
import CloseIcon from "./icons/CloseIcon";
import { enUS, ru } from "date-fns/locale";
import ky from "ky";
import { Trans } from "react-i18next";
import i18n from "../i18n";
import { useState } from "react";
import LoaderIcon from "./icons/LoaderIcon";
function SidebarTab4() {
const [
currentTab,
setCurrentTab,
setIsOpen,
selectedDay,
selectedTime,
name,
phone,
email,
] = useSidebarTabStore((state) => [
state.currentTab,
state.setCurrentTab,
state.setIsOpen,
state.selectedDay,
state.selectedTime,
state.name,
state.phone,
state.email,
]);
const [isLoading, setIsLoading] = useState<boolean>(false);
async function handleClickSignUp() {
if (!selectedTime || !selectedDay) {
return;
}
setIsLoading(true);
const startAt = parse(selectedTime, "HH:mm", selectedDay);
try {
await ky
.post("https://coord.graff.tech/scheduled_sessions", {
json: {
username: "test",
name,
phone,
email,
title: "nksJukovaDev",
startAt,
},
})
.json();
setCurrentTab(currentTab + 1);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
if (error instanceof Error) {
alert(error.message);
}
}
}
return (
<div className="sm:p-8 p-6 flex flex-col justify-between sm:gap-8 gap-6 min-h-full">
<div>
<div className="flex items-start justify-between">
<p className="text-2xl text-gradient font-semibold font-gilroy w-fit leading-snug">
<Trans i18nKey={"sidebar.title3"}>Проверка заявки</Trans>
</p>
<button
onClick={() => setIsOpen(false)}
className="transition-opacity hover:opacity-50"
>
<CloseIcon />
</button>
</div>
<div className="sm:mt-6 mt-4">
<div className="sm:mt-6 mt-4 sm:p-6 p-4 flex flex-col gap-6 font-semibold font-gilroy border border-[#3D425C] ">
<p className="leading-tight">
<Trans i18nKey={"sidebar.sessionDetails"}>Детали сеанса</Trans>
</p>
<div className="flex flex-col gap-2">
<div className="flex justify-between sm:text-base text-sm">
<p className="opacity-50">
<Trans i18nKey={"sidebar.date"}>Дата</Trans>
</p>
<p>
{selectedDay &&
format(
selectedDay,
"dd MMMM",
i18n.language === "ru" ? { locale: ru } : { locale: enUS }
)}
</p>
</div>
<div className="flex justify-between sm:text-base text-sm">
<p className="opacity-50">
<Trans i18nKey={"sidebar.time"}>Время</Trans>
</p>
<p>{selectedTime}</p>
</div>
</div>
</div>
<div className="sm:p-6 p-4 flex flex-col gap-6 font-semibold font-gilroy border border-[#3D425C] border-t-0">
<p className="leading-tight">
<Trans i18nKey={"sidebar.contactDetails"}>
Контактные данные
</Trans>
</p>
<div className="flex flex-col gap-2">
<div className="flex justify-between sm:text-base text-sm">
<p className="opacity-50">
<Trans i18nKey={"sidebar.name"}>Имя</Trans>
</p>
<p>{name}</p>
</div>
<div className="flex justify-between sm:text-base text-sm">
<p className="opacity-50">
<Trans i18nKey={"sidebar.phone"}>Телефон</Trans>
</p>
<p>{phone}</p>
</div>
<div className="flex justify-between sm:text-base text-sm">
<p className="opacity-50">Email</p>
<p>{email}</p>
</div>
</div>
</div>
<div className="sm:p-6 p-4 border border-t-0 border-[#3D425C]">
<p className="text-xs">
<Trans i18nKey={"sidebar.submitNotice1"}>
Нажимая кнопку записаться, вы принимаете
</Trans>{" "}
<a href="#" className="text-[#798FFF]">
<Trans i18nKey={"sidebar.submitNotice2"}>
условия использования
</Trans>
</a>{" "}
<Trans i18nKey={"sidebar.submitNotice3"}>и</Trans>{" "}
<a href="#" className="text-[#798FFF]">
<Trans i18nKey={"sidebar.submitNotice4"}>
политику конфиденциальности
</Trans>
</a>
</p>
</div>
</div>
</div>
<div className="flex flex-col sm:gap-6 gap-4">
<p className="text-center text-xs opacity-50 leading-tight">
<Trans i18nKey={"sidebar.notice"}>
Запись на демонстрацию работает в ознакомительном режиме и не
сохраняет введенные данные
</Trans>
</p>
<div className="flex sm:gap-4 gap-2">
<button
onClick={() => setCurrentTab(currentTab - 1)}
className="px-6 py-4 border border-[#3D425C] rounded-full sm:text-base text-sm font-medium group w-fit"
>
<span className="opacity-80 transition-opacity group-hover:opacity-100">
<Trans i18nKey={"sidebar.buttonBack"}>Назад</Trans>
</span>
</button>
<button
disabled={isLoading}
onClick={() => void handleClickSignUp()}
className="px-6 py-3 bg-gradient rounded-full sm:text-base text-sm font-medium flex items-center justify-between w-full disabled:opacity-75"
>
<span>
<Trans i18nKey={"sidebar.buttonSignUp"}>Записаться</Trans>
</span>
{!isLoading ? (
<ArrowRightIcon className="sm:w-8 sm:h-8 w-6 h-6" />
) : (
<LoaderIcon className="sm:w-8 sm:h-8 w-6 h-6 animate-spin" />
)}
</button>
</div>
</div>
</div>
);
}
export default SidebarTab4;
+97
View File
@@ -0,0 +1,97 @@
/* eslint-disable no-irregular-whitespace */
import { Trans } from "react-i18next";
import useSidebarTabStore from "../stores/useSidebarStore";
import ArrowRightIcon from "./icons/ArrowRightIcon";
import MailGradientIcon from "./icons/MailGradientIcon";
import PhoneGradientIcon from "./icons/PhoneGradientIcon";
import WebGradientIcon from "./icons/WebGradientIcon";
function SidebarTab5() {
const [setIsOpen, name] = useSidebarTabStore((state) => [
state.setIsOpen,
state.name,
]);
return (
<div className="sm:p-8 p-6 flex flex-col justify-between sm:gap-8 gap-6 min-h-full">
<div>
<div className="flex items-start justify-between">
<p className="text-2xl font-semibold font-gilroy w-fit leading-snug">
<span className="text-gradient">{name},</span>
<br />
<span className="text-gradient">
<Trans i18nKey={"sidebar.title4_1"}>спасибо за запись</Trans>
</span>
<br />
<Trans i18nKey={"sidebar.title4_2"}>
на удаленную демонстрацию!
</Trans>
</p>
</div>
<p className="sm:mt-6 mt-4 text-sm">
<Trans i18nKey={"sidebar.tab5text1"}>
В ближайшее время мы отправим на ваш почтовый адрес всю
дополнительную информацию о сеансе и ссылку для подключения.
</Trans>
</p>
<div className="mt-8 pb-6 font-gilroy font-semibold border-b border-[#3D425C]">
<p>
<Trans i18nKey={"sidebar.tab5text2"}>Возникли вопросы?</Trans>
</p>
<div className="mt-6 flex justify-between">
<div>
<p className="opacity-50 text-sm leading-none">
<Trans i18nKey={"sidebar.tab5text3"}>Свяжитесь с нами</Trans>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-gilroy flex gap-3 items-center justify-end leading-none">
<a href="tel:88007700076">8 800 770 00 76</a>
<PhoneGradientIcon />
</p>
<p className="text-sm font-gilroy flex gap-3 items-center justify-end leading-none">
<a href="mailto:info@graff.tech">info@graff.tech</a>
<MailGradientIcon />
</p>
<p className="text-sm font-gilroy flex gap-3 items-center justify-end leading-none">
<a href="https://estate.graff.tech" target="_blank">
estate.graff.tech
</a>
<WebGradientIcon />
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col sm:gap-6 gap-4">
<p className="text-center text-xs opacity-50 leading-tight">
<Trans i18nKey={"sidebar.notice"}>
Запись на демонстрацию работает в ознакомительном режиме и не
сохраняет введенные данные
</Trans>
</p>
<div className="flex sm:gap-4 gap-2">
<button
onClick={() => setIsOpen(false)}
className="px-6 py-3 bg-gradient rounded-full sm:text-base text-sm font-medium flex items-center justify-between w-full"
>
<span>
<Trans i18nKey={"sidebar.buttonHome"}>На главную</Trans>
</span>
<ArrowRightIcon className="w-8 h-8" />
</button>
</div>
</div>
</div>
);
}
export default SidebarTab5;
+5
View File
@@ -0,0 +1,5 @@
function TimePicker() {
return <div>TimePicker</div>;
}
export default TimePicker;
+60
View File
@@ -0,0 +1,60 @@
import { Trans } from "react-i18next";
import useSidebarStore from "../stores/useSidebarStore";
interface TimeSelectorProps {
handleSelect: (time: string) => void;
}
function TimeSelector({ handleSelect }: TimeSelectorProps) {
const [selectedTime] = useSidebarStore((state) => [state.selectedTime]);
const times = [
{ value: "10:00", active: true },
{ value: "10:30", active: false },
{ value: "11:00", active: true },
{ value: "11:30", active: true },
{ value: "12:00", active: true },
{ value: "12:30", active: true },
{ value: "13:00", active: true },
{ value: "13:30", active: true },
{ value: "14:00", active: false },
{ value: "14:30", active: false },
{ value: "15:30", active: true },
{ value: "15:00", active: true },
{ value: "16:30", active: true },
{ value: "16:00", active: true },
{ value: "17:30", active: true },
{ value: "17:00", active: true },
];
return (
<>
<div className="grid grid-cols-4 gap-2 font-medium">
{times.map((time, index) => (
<button
key={index}
onClick={() => handleSelect(time.value)}
disabled={!time.active}
className={[
"min-w-[40px] h-10 text-[#798FFF] text-sm rounded-full border border-[#798FFF] flex justify-center items-center transition-colors hover:bg-[#798FFF] hover:bg-opacity-20 hover:text-white disabled:text-[#3D425C] disabled:hover:bg-transparent disabled:border-transparent",
time.value === selectedTime
? "bg-[#798FFF] text-white"
: "text-[#798FFF]",
].join(" ")}
>
{time.value}
</button>
))}
</div>
<div className="mt-8 flex items-center gap-2">
<div className="w-4 h-4 bg-[#798FFF] rounded"></div>
<p className="text-[#798FFF]">
- <Trans i18nKey={"sidebar.available"}>запись доступна</Trans>
</p>
</div>
</>
);
}
export default TimeSelector;
+28
View File
@@ -0,0 +1,28 @@
interface IconProps {
className?: string;
}
function ArrowRightIcon({ className }: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Arrow_Right">
<path
id="Vector 106"
d="M18 12L6 12M18 12L11.6364 18M18 12L11.6364 6"
stroke="white"
strokeWidth="2"
strokeLinecap="square"
/>
</g>
</svg>
);
}
export default ArrowRightIcon;
+18
View File
@@ -0,0 +1,18 @@
function AsteriskIcon() {
return (
<svg
width="12"
height="13"
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.81534 12.2727L4.9858 7.58523L1.02273 10.0994L0 8.30966L4.17614 6.13636L0 3.96307L1.02273 2.1733L4.9858 4.6875L4.81534 0H6.8608L6.69034 4.6875L10.6534 2.1733L11.6761 3.96307L7.5 6.13636L11.6761 8.30966L10.6534 10.0994L6.69034 7.58523L6.8608 12.2727H4.81534Z"
fill="white"
/>
</svg>
);
}
export default AsteriskIcon;
@@ -0,0 +1,41 @@
interface IconProps {
className?: string;
}
function CheckGradientIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Check">
<path
id="Vector 1836 (Stroke)"
fillRule="evenodd"
clipRule="evenodd"
d="M26.3298 9.78103C26.819 10.3314 26.7694 11.1742 26.2191 11.6634L14.2191 22.3301C13.6914 22.7991 12.8896 22.7755 12.3904 22.2763L5.72378 15.6097C5.20308 15.089 5.20308 14.2447 5.72378 13.724C6.24448 13.2033 7.0887 13.2033 7.60939 13.724L13.3871 19.5017L24.4474 9.6703C24.9978 9.18107 25.8406 9.23065 26.3298 9.78103Z"
fill="url(#paint0_linear_53_10278)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_53_10278"
x1="5.33325"
y1="32.1907"
x2="29.4088"
y2="29.927"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.167052" stopColor="#798FFF" />
<stop offset="0.963542" stopColor="#D375FF" />
</linearGradient>
</defs>
</svg>
);
}
export default CheckGradientIcon;
+24
View File
@@ -0,0 +1,24 @@
function ChevronLeftIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Chevron_Left">
<path
id="Vector 106"
d="M15 19L8 12L15 5"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
);
}
export default ChevronLeftIcon;
+24
View File
@@ -0,0 +1,24 @@
function ChevronRightIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Chevron_Right">
<path
id="Vector 106"
d="M9.00002 19L16 12L9.00002 5"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
);
}
export default ChevronRightIcon;
+24
View File
@@ -0,0 +1,24 @@
function CloseIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Close">
<path
id="Vector 106"
d="M12.0002 11.9999L17.6572 6.34331M12.0002 11.9999L6.34337 6.34302M12.0002 11.9999L17.6571 17.6567M12.0002 11.9999L6.34326 17.6568"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
);
}
export default CloseIcon;
+39
View File
@@ -0,0 +1,39 @@
interface IconProps {
className?: string;
}
function LoaderIcon({ className }: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Circle">
<path
id="Ellipse 221"
d="M18.9999 12C19.5523 12 20.0064 11.5505 19.9376 11.0025C19.745 9.46994 19.1116 8.01808 18.1039 6.82871C16.8797 5.38372 15.1826 4.41989 13.3144 4.10872C11.4463 3.79755 9.52839 4.15922 7.90189 5.12938C6.27539 6.09953 5.04582 7.61525 4.43194 9.40685C3.81806 11.1985 3.85968 13.1497 4.54941 14.9135C5.23914 16.6773 6.53224 18.1392 8.19863 19.0391C9.86502 19.9391 11.7966 20.2186 13.6498 19.828C15.1751 19.5066 16.5658 18.7483 17.6578 17.6559C18.0483 17.2653 17.9652 16.6317 17.529 16.2929C17.0927 15.9542 16.4693 16.0409 16.0629 16.4149C15.2735 17.1413 14.2989 17.6472 13.2373 17.8709C11.8475 18.1638 10.3988 17.9541 9.14904 17.2792C7.89928 16.6043 6.92948 15.5079 6.4122 14.1851C5.89491 12.8623 5.86369 11.3989 6.32409 10.0552C6.78449 8.71152 7.70665 7.57476 8.92649 6.84716C10.1463 6.11956 11.5847 5.84832 12.9858 6.08169C14.3869 6.31506 15.6597 7.03791 16.5778 8.12163C17.2791 8.94938 17.7387 9.94667 17.9167 11.0045C18.0083 11.5492 18.4476 12 18.9999 12Z"
fill="url(#paint0_angular_16_1186)"
/>
</g>
<defs>
<radialGradient
id="paint0_angular_16_1186"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(12 12) rotate(45) scale(7.9196)"
>
<stop offset="0.874517" stopColor="white" />
<stop offset="0.982613" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
}
export default LoaderIcon;
File diff suppressed because one or more lines are too long
+175
View File
@@ -0,0 +1,175 @@
function LogoMobileIcon() {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="LogoMobile_GRAFFinteractive">
<path
id="G Base"
fillRule="evenodd"
clipRule="evenodd"
d="M20.008 39.7195C30.8979 39.7195 39.7259 30.828 39.7259 19.8597C39.7259 18.5165 39.5935 17.2043 39.3411 15.9358H27.4287V23.8103H31.2726C29.6515 28.4886 25.2322 31.845 20.0345 31.845C13.4625 31.845 8.13482 26.479 8.13482 19.8597C8.13482 13.2405 13.4625 7.8745 20.0345 7.8745V0H20.008C9.11807 0 0.290039 8.89152 0.290039 19.8597C0.290039 30.828 9.11807 39.7195 20.008 39.7195Z"
fill="#798FFF"
/>
<path
id="Inner G"
d="M19.0163 0.0241028C10.5443 1.34406 4.05957 8.70687 4.05957 17.592C4.05957 27.4109 11.9788 35.3706 21.7477 35.3706C31.5166 35.3706 39.4359 27.4109 39.4359 17.592C39.4359 17.0257 39.4096 16.4656 39.358 15.9128H27.4228V23.7759H31.269C29.647 28.4474 25.2249 31.7989 20.024 31.7989C13.4479 31.7989 8.11698 26.4407 8.11698 19.831C8.11698 13.5806 12.8841 8.44938 18.9632 7.90996C19.3127 7.87896 19.6665 7.8631 20.024 7.8631V0H19.9975C19.6685 0 19.3413 0.00810087 19.0163 0.0241028Z"
fill="#D375FF"
/>
<path
id="G_01"
opacity="0.3"
d="M17.7148 8.11785C18.4573 7.97602 19.2239 7.90176 20.0079 7.90176V0H19.981C18.3936 0 16.8493 0.183713 15.3684 0.530935L17.7148 8.11785Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_02"
opacity="0.3"
d="M7.31813 4.49037L6.08936 22.3241L7.84573 20.6509C7.8353 20.4452 7.83003 20.2381 7.83003 20.0298C7.83003 13.4214 13.14 8.05568 19.7179 7.98257L11.8297 1.7395C10.2002 2.45588 8.68475 3.38447 7.31813 4.49037Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_03"
opacity="0.3"
d="M0.290807 20.0851L9.2762 24.9334C8.5649 23.4064 8.16779 21.7041 8.16779 19.9091C8.16779 18.1118 8.56599 16.4072 9.27911 14.8786L6.52677 5.50854C2.68747 9.11382 0.290039 14.232 0.290039 19.9091C0.290039 19.9679 0.290296 20.0265 0.290807 20.0851Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_04"
opacity="0.3"
d="M0.290039 19.2891L12.2113 37.1621L25.2275 37.4002L20.1497 31.8045C20.0963 31.8052 20.0428 31.8056 19.9892 31.8056C13.4289 31.8056 8.1107 26.488 8.1107 19.9286C8.1107 19.6619 8.11948 19.3973 8.13679 19.135L0.295555 19.135C0.293519 19.1863 0.291681 19.2377 0.290039 19.2891Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_05"
opacity="0.3"
d="M8.94359 36.2405L16.5283 31.2537C11.6213 29.7362 8.06526 25.2578 8.06526 19.9692C8.06526 19.2897 8.12397 18.6236 8.23671 17.9753L3.1897 30.4732C4.6799 32.7607 6.6425 34.7267 8.94359 36.2405Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_06"
opacity="0.3"
d="M26.3874 38.6783C24.3639 39.3535 22.1958 39.7195 19.9409 39.7195C15.7761 39.7195 11.9074 38.4707 8.69922 36.3323L16.2931 31.3118C17.4522 31.6746 18.6869 31.8704 19.9679 31.8704C20.0152 31.8704 20.0624 31.8702 20.1095 31.8696L26.3874 38.6783Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_07"
opacity="0.3"
d="M24.3574 39.2353C22.9375 39.5522 21.4602 39.7195 19.9434 39.7195C19.2861 39.7195 18.6364 39.6881 17.9954 39.6268L16.2383 31.3118C16.4755 31.3881 16.716 31.4574 16.9594 31.5194L24.3574 39.2353Z"
fill="black"
fillOpacity="0.4"
/>
<path
id="G_08"
opacity="0.3"
d="M27.5471 19.135L39.4359 16.3252C39.414 16.1983 39.3911 16.0719 39.367 15.9458H27.5471V19.135Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_09"
opacity="0.3"
d="M30.0187 23.7737L39.436 15.9589C39.4351 15.9545 39.4343 15.9502 39.4334 15.9458L27.8372 23.7737H30.0187Z"
fill="black"
fillOpacity="0.4"
/>
<path
id="G_10"
opacity="0.3"
d="M35.5462 15.9458L26.9673 38.2699C30.3467 36.9788 33.2796 34.7825 35.4704 31.977L36.8263 15.9458H35.5462Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="G_11"
opacity="0.3"
d="M39.3486 15.9458L26.9673 38.2699C34.4249 35.4453 39.726 28.2432 39.726 19.8045C39.726 18.484 39.5961 17.1937 39.3486 15.9458Z"
fill="black"
fillOpacity="0.4"
/>
<g id="Cube base">
<path d="M31.6154 0H39.4359V7.82793H31.6154V0Z" fill="#798FFF" />
<path
d="M39.4359 7.82793H31.6154L27.5471 11.8869H35.078L39.4359 7.82793Z"
fill="#798FFF"
/>
<path
d="M31.6154 7.82793V0L27.5471 4.34885V11.8869L31.6154 7.82793Z"
fill="#798FFF"
/>
</g>
<path
id="Cube_01"
opacity="0.3"
d="M39.436 6.74606V7.83813L35.1277 11.8868H34.2166V6.66821L39.436 6.74606Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="Cube_02"
opacity="0.3"
d="M27.5471 11.8869V4.23088L33.0071 3.76904L34.7964 4.24201L27.5471 11.8869Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="Cube_03"
opacity="0.3"
d="M31.0253 0.869873L27.5471 4.45208V11.887H32.1866L31.0253 0.869873Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="Cube_04"
opacity="0.3"
d="M27.7534 4.04764L27.5471 4.26983V11.8868L31.9148 7.68376L33.9593 7.60343L38.566 8.37993L38.1279 7.72392L32.6052 1.7395L27.7534 4.04764Z"
fill="#D375FF"
/>
<path
id="Cube_05"
opacity="0.3"
d="M31.8966 0H31.6089L30.4468 1.23821L31.8179 2.02946L31.8966 0Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="Cube_06"
opacity="0.3"
d="M37.9861 9.33119L35.2733 11.887H34.7964L35.2224 6.7774L37.3326 6.37842L37.9861 9.33119Z"
fill="black"
fillOpacity="0.6"
/>
<path
id="Cube"
d="M31.6067 7.82793H39.4359V0H31.6067V7.82793Z"
fill="url(#paint0_linear_112_1536)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_112_1536"
x1="36.0617"
y1="0"
x2="36.0617"
y2="7.82793"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D375FF" />
<stop offset="1" stopColor="#798FFF" />
</linearGradient>
</defs>
</svg>
);
}
export default LogoMobileIcon;
+50
View File
@@ -0,0 +1,50 @@
function MailGradientIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Mail">
<g id="Vector">
<path
d="M3 8.48345C3 8.09481 3.42397 7.85476 3.75723 8.0547L11.4855 12.6913C11.8022 12.8813 12.1978 12.8813 12.5145 12.6913L20.2428 8.05435C20.576 7.85439 21 8.09445 21 8.4831V17C21 17.5523 20.5523 18 20 18H4C3.44772 18 3 17.5523 3 17V8.48345Z"
fill="url(#paint0_linear_56_11108)"
/>
<path
d="M3.54791 5.92875C3.11307 5.66784 3.29805 5 3.80516 5H20.1948C20.702 5 20.8869 5.66784 20.4521 5.92875L12.5145 10.6913C12.1978 10.8813 11.8022 10.8813 11.4855 10.6913L3.54791 5.92875Z"
fill="url(#paint1_linear_56_11108)"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_56_11108"
x1="3"
y1="27.2857"
x2="23.3585"
y2="25.6292"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.167052" stopColor="#798FFF" />
<stop offset="0.963542" stopColor="#D375FF" />
</linearGradient>
<linearGradient
id="paint1_linear_56_11108"
x1="3"
y1="27.2857"
x2="23.3585"
y2="25.6292"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.167052" stopColor="#798FFF" />
<stop offset="0.963542" stopColor="#D375FF" />
</linearGradient>
</defs>
</svg>
);
}
export default MailGradientIcon;
+31
View File
@@ -0,0 +1,31 @@
interface IconProps {
className?: string;
}
function MailIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Mail" opacity="0.8">
<g id="Vector">
<path
d="M4 11.3111C4 10.7929 4.5653 10.4728 5.00965 10.7394L15.314 16.9216C15.7363 17.1749 16.2637 17.1749 16.686 16.9216L26.9903 10.739C27.4347 10.4724 28 10.7924 28 11.3106V22.6665C28 23.4029 27.403 23.9998 26.6667 23.9998H5.33333C4.59695 23.9998 4 23.4029 4 22.6665V11.3111Z"
fill="white"
/>
<path
d="M4.73055 7.90483C4.15076 7.55696 4.3974 6.6665 5.07354 6.6665H26.9265C27.6026 6.6665 27.8492 7.55696 27.2695 7.90483L16.686 14.2549C16.2638 14.5083 15.7362 14.5083 15.314 14.2549L4.73055 7.90483Z"
fill="white"
/>
</g>
</g>
</svg>
);
}
export default MailIcon;
@@ -0,0 +1,34 @@
function PhoneGradientIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Phone">
<path
id="phone"
d="M9.93505 6.37967L7.83082 4.2815C7.45441 3.90617 6.84412 3.90617 6.4677 4.2815L4.72211 6.02206C2.54982 8.18809 5.73849 13.3499 8.21649 15.8207C10.6796 18.2768 15.7998 21.4441 17.9721 19.2781L19.7177 17.5375C20.0941 17.1622 20.0941 16.5537 19.7177 16.1783L17.6134 14.0802C17.237 13.7048 16.6267 13.7048 16.2503 14.0802L14.6522 15.6737C14.555 15.7706 14.4313 15.8284 14.3036 15.7778C13.9576 15.6405 13.0744 15.1026 10.9904 13.0548C8.89823 10.999 8.36142 10.0733 8.22847 9.70017C8.17833 9.55945 8.24399 9.42505 8.34989 9.31945L9.9355 7.73841C10.3119 7.36308 10.3115 6.755 9.93505 6.37967Z"
fill="url(#paint0_linear_56_11105)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_56_11105"
x1="4"
y1="31.4286"
x2="22.1536"
y2="30.3618"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.167052" stopColor="#798FFF" />
<stop offset="0.963542" stopColor="#D375FF" />
</linearGradient>
</defs>
</svg>
);
}
export default PhoneGradientIcon;
+26
View File
@@ -0,0 +1,26 @@
interface IconProps {
className?: string;
}
function PhoneIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Phone" opacity="0.8">
<path
id="phone"
d="M13.2467 8.50638L10.4411 5.70883C9.93919 5.20838 9.12547 5.20839 8.62358 5.70883L6.29613 8.02957C3.39974 10.9176 7.6513 17.8 10.9553 21.0945C14.2395 24.3692 21.0664 28.5923 23.9628 25.7043L26.2902 23.3835C26.7921 22.8831 26.7921 22.0717 26.2902 21.5713L23.4846 18.7737C22.9827 18.2733 22.169 18.2733 21.6671 18.7737L19.5362 20.8984C19.4067 21.0276 19.2417 21.1047 19.0714 21.0372C18.6101 20.8542 17.4325 20.137 14.6538 17.4066C11.8643 14.6655 11.1485 13.4312 10.9713 12.9337C10.9044 12.7461 10.992 12.5669 11.1332 12.4261L13.2473 10.318C13.7492 9.8176 13.7486 9.00683 13.2467 8.50638Z"
fill="white"
/>
</g>
</svg>
);
}
export default PhoneIcon;
+28
View File
@@ -0,0 +1,28 @@
interface IconProps {
className?: string;
}
function SendIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Send">
<path
id="Vector 164 (Stroke)"
fillRule="evenodd"
clipRule="evenodd"
d="M25.2649 8.4215C25.432 7.92033 25.2874 7.36779 24.8963 7.01269C24.5051 6.65759 23.9412 6.56689 23.4585 6.78145L6.56115 14.2914C4.82296 15.0639 5.043 17.5979 6.88835 18.0593L10.0482 18.8492C10.6608 19.0024 11.3097 18.8572 11.7987 18.4577L19.8248 11.8996C20.0112 11.7473 20.2583 11.9935 20.1068 12.1805L14.0759 19.62C13.5817 20.2296 13.4898 21.0719 13.8407 21.7738L15.8654 25.8233C16.6623 27.417 18.9882 27.2516 19.5517 25.5613L25.2649 8.4215Z"
fill="white"
/>
</g>
</svg>
);
}
export default SendIcon;
+25
View File
@@ -0,0 +1,25 @@
function ShareIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Share">
<path
id="Vector 1835"
d="M16 18L5 12L16 6"
stroke="#F2F2F2"
stroke-width="2"
/>
<circle id="Ellipse 226" cx="5" cy="12" r="3" fill="#F2F2F2" />
<circle id="Ellipse 227" cx="16" cy="6" r="3" fill="#F2F2F2" />
<circle id="Ellipse 228" cx="16" cy="18" r="3" fill="#F2F2F2" />
</g>
</svg>
);
}
export default ShareIcon;
+28
View File
@@ -0,0 +1,28 @@
interface IconProps {
className?: string;
}
function TelegramIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/Telegram" opacity="0.8">
<path
id="Path-3"
fillRule="evenodd"
clipRule="evenodd"
d="M4.35909 15.8481C10.7925 12.8123 15.0929 10.8501 17.2259 9.88748C23.3497 7.1478 24.6226 6.6665 25.4483 6.6665C25.6203 6.6665 26.0331 6.70353 26.3083 6.92566C26.5148 7.11078 26.5836 7.36994 26.618 7.55505C26.6524 7.74016 26.6868 8.14741 26.6524 8.48062C26.3083 12.2199 24.8978 21.3645 24.1409 25.5481C23.8313 27.3252 23.2121 27.9176 22.6272 27.9916C21.3543 28.1027 20.3566 27.066 19.1181 26.2145C17.1915 24.8447 16.0906 23.9932 14.1984 22.6603C12.031 21.1054 13.4415 20.2539 14.6801 18.884C14.9897 18.5138 20.6662 12.9974 20.7694 12.4791C20.7694 12.405 20.8038 12.1829 20.6662 12.0718C20.5286 11.9608 20.3566 11.9978 20.219 12.0348C20.0126 12.0718 16.9163 14.2932 10.8957 18.6619C10.0012 19.3283 9.20995 19.6245 8.48748 19.6245C7.69621 19.6245 6.18246 19.1432 5.04716 18.7359C3.67103 18.2546 2.57013 17.9955 2.67334 17.181C2.77655 16.7367 3.327 16.2924 4.35909 15.8481Z"
fill="white"
/>
</g>
</svg>
);
}
export default TelegramIcon;
+28
View File
@@ -0,0 +1,28 @@
interface IconProps {
className?: string;
}
function VKIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/VK" opacity="0.8">
<path
id="Vector"
fillRule="evenodd"
clipRule="evenodd"
d="M5.44687 8H2.38363C1.50842 8 1.33337 8.39579 1.33337 8.83368C1.33337 9.61432 2.37137 13.4863 6.16892 18.6072C8.70003 22.1044 12.2665 24 15.5118 24C17.4592 24 17.6998 23.5789 17.6998 22.8539V20.2105C17.6998 19.3684 17.8836 19.2 18.5007 19.2C18.9558 19.2 19.7347 19.4189 21.5516 21.1048C23.6285 23.1032 23.9707 24 25.1391 24H28.2024C29.0776 24 29.5152 23.5789 29.2623 22.7478C28.9866 21.92 27.9949 20.7183 26.6786 19.2943C25.9645 18.4825 24.8932 17.6076 24.5694 17.1705C24.1143 16.6088 24.2455 16.3587 24.5694 15.8594C24.5694 15.8594 28.3013 10.8008 28.6916 9.08379C28.8859 8.45895 28.6916 8 27.7639 8H24.7015C23.9226 8 23.5638 8.39579 23.3695 8.83368C23.3695 8.83368 21.8116 12.4867 19.6052 14.8598C18.891 15.5469 18.5663 15.7659 18.1768 15.7659C17.9825 15.7659 17.7007 15.5469 17.7007 14.9229V9.08379C17.7007 8.33432 17.474 8 16.8255 8H12.0118C11.5252 8 11.2329 8.34779 11.2329 8.6779C11.2329 9.38779 12.3357 9.552 12.4494 11.5495V15.8905C12.4494 16.8421 12.2709 17.0147 11.8805 17.0147C10.8425 17.0147 8.31669 13.3448 6.81833 9.14611C6.52425 8.32842 6.22931 8 5.44687 8Z"
fill="white"
/>
</g>
</svg>
);
}
export default VKIcon;
+36
View File
@@ -0,0 +1,36 @@
function WebGradientIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Icon/Web">
<path
id="globe"
d="M21 12C21 16.9706 16.9706 21 12 21M21 12C21 7.02944 16.9706 3 12 3M21 12H3M12 21C7.02944 21 3 16.9706 3 12M12 21V3M12 21C14.7614 21 17 16.9706 17 12M12 21C9.23858 21 7.00001 16.9706 7.00001 12M3 12C3 7.02944 7.02944 3 12 3M12 3C9.23858 3 7.00001 7.02944 7.00001 12M12 3C14.7614 3 17 7.02944 17 12M17 12H7.00001"
stroke="url(#paint0_linear_56_11111)"
strokeWidth="2"
strokeLinecap="square"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_56_11111"
x1="3"
y1="33.8571"
x2="23.4228"
y2="32.657"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.167052" stopColor="#798FFF" />
<stop offset="0.963542" stopColor="#D375FF" />
</linearGradient>
</defs>
</svg>
);
}
export default WebGradientIcon;
+28
View File
@@ -0,0 +1,28 @@
interface IconProps {
className?: string;
}
function YouTubeIcon({ className }: IconProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="Icon/YouTube" opacity="0.8">
<path
id="Exclude"
fillRule="evenodd"
clipRule="evenodd"
d="M26.4187 7.2304C27.566 7.54067 28.4695 8.45509 28.7762 9.61601C29.3333 11.7204 29.3333 16.1109 29.3333 16.1109C29.3333 16.1109 29.3333 20.5015 28.7762 22.6058C28.4695 23.7666 27.566 24.6811 26.4187 24.9913C24.339 25.5554 16 25.5554 16 25.5554C16 25.5554 7.66111 25.5554 5.58145 24.9913C4.43418 24.6811 3.53051 23.7666 3.22388 22.6058C2.66663 20.5015 2.66663 16.1109 2.66663 16.1109C2.66663 16.1109 2.66663 11.7204 3.22388 9.61601C3.53051 8.45509 4.43418 7.54067 5.58145 7.2304C7.66111 6.6665 16 6.6665 16 6.6665C16 6.6665 24.339 6.6665 26.4187 7.2304ZM13.7777 12.2219V19.9997L20.4444 16.1109L13.7777 12.2219Z"
fill="white"
/>
</g>
</svg>
);
}
export default YouTubeIcon;
+72
View File
@@ -0,0 +1,72 @@
import { useClipboard } from "use-clipboard-copy";
import ShareIcon from "../icons/ShareIcon";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import useModalStore from "../../stores/useModalStore";
import CloseIcon from "../icons/CloseIcon";
function ShareModal() {
const [setModal] = useModalStore((state) => [state.setModal]);
const clipboard = useClipboard();
function toastInfo(text: string) {
toast.info(text, {
position: "bottom-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
});
}
function handleClickCopy() {
clipboard.copy();
toastInfo("Ссылка скопирована в буфер обмена");
setModal(null);
}
return (
<>
<div className="relative p-10 bg-[#131317] rounded shadow-lg w-[320px]">
<div className="flex flex-col gap-8">
<p className="font-gilroy text-2xl">
Пригласить
<br />
на демонстрацию
</p>
<div className="flex flex-col gap-4">
<p className="font-gilroy text-xl">Ссылка для подключения</p>
<input
ref={clipboard.target}
readOnly
type="text"
className="bg-[#23242A] rounded px-4 py-3 outline-none"
value={window.location.href}
/>
<button
onClick={handleClickCopy}
className="pl-3 pr-4 py-2 bg-gradient rounded flex items-center gap-1 w-fit"
>
<ShareIcon />
<span className="text-sm">Скопировать</span>
</button>
</div>
<button
onClick={() => setModal(null)}
className="absolute top-3 right-3 p-2 rounded-full transition-colors hover:bg-white hover:bg-opacity-5"
>
<CloseIcon />
</button>
</div>
</div>
<ToastContainer />
</>
);
}
export default ShareModal;
+210
View File
@@ -0,0 +1,210 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import detector from "i18next-browser-languagedetector";
// the translations
// (tip move them in a JSON file and import them,
// or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
const resources = {
ru: {
translation: {
loading: "Загрузка",
loadingSub: "сек",
streamWaiting: "Ожидание потока",
streamBuffering: "Буферизация потока",
streamEnded: "Трансляция была завершена",
demoStarted: "Демонстрация начата",
clickToContinue: "Нажмите, чтобы продолжить",
fullscreenMode: "Полноэкранный режим",
windowedMode: "Оконный режим",
inviteByQR: "Пригласить по QR",
scanQRCode:
"Отсканируйте QR-код<br />чтобы подключиться<br />к текущей демонстрации",
title: "Удаленная демонстрация",
header: {
buttonFirst: "Записаться",
buttonSecond: "на демонстрацию",
},
main: {
title: "Доступные<br />демонстрации",
desc: "Клиент из любой точки мира может посмотреть жилой комплекс, даже на нулевом этапе строительства. Он выберет лучшую планировку и оценит вид из окон своей будущей квартиры.",
cards: {
title1: "МФК «Revolution towers»",
title2: "ЖК «Life Резиденция»",
title3: "ЖК «Айвазовский City»",
city1: "Россия, Екатеринбург",
city2: "Россия, Тюмень",
button: "Запустить",
},
},
feedback: {
title: "Свяжитесь<br />с нами",
desc: "Хотите увеличить конверсию?<br />Давайте обсудим детали!",
form: {
field1: "Имя",
field2: "Телефон",
field3: "Опишите вашу задачу",
button: "Отправить",
desc1: {
text1: "Нажимая кнопку «Отправить», вы принимаете",
text1_1: "Нажимая кнопку «Записаться», вы принимаете",
link1: "условия использования",
text2: "и",
link2: "политику конфиденциальности",
},
desc2: "Звездочкой отмечены обязательные<br />для заполнения поля",
},
},
contacts: {
title: "Горячая линия",
button1: "Написать",
button2: "Позвонить",
social: {
title: "Социальные<br />сети",
},
},
footer: {
link: "Политика конфиденциальности",
text: "Все права защищены.",
},
sidebar: {
title1: "Дата и время",
title2: "Контакты",
title3: "Проверка данных",
title4_1: "спасибо за запись",
title4_2: "на удаленную демонстрацию",
date: "Дата",
time: "Время",
contacts: "Контакты",
submitNotice1: "Нажимая кнопку «Записаться», вы принимаете",
submitNotice2: "условия использования",
submitNotice3: "и",
submitNotice4: "политику конфиденциальности",
available: "запись доступна",
buttonBack: "Назад",
buttonNext: "Далее",
buttonSignUp: "Записаться",
buttonHome: "На главную",
checkData: "Проверка заявки",
sessionDetails: "Детали сеанса",
contactDetails: "Контактные данные",
name: "Имя",
phone: "Телефон",
tab5text1:
"В ближайшее время мы отправим на ваш почтовый адрес всю дополнительную информацию о сеансе и ссылку для подключения.",
tab5text2: "Возникли вопросы?",
tab5text3: "Свяжитесь с нами",
notice:
"Запись на демонстрацию работает в ознакомительном режиме и не сохраняет введенные данные",
},
},
},
en: {
translation: {
loading: "Loading",
loadingSub: "sec",
streamWaiting: "Stream waiting",
streamBuffering: "Stream buffering",
streamEnded: "Stream has been ended",
demoStarted: "Demo started",
clickToContinue: "Click to continue",
fullscreenMode: "Fullscreen mode",
windowedMode: "Windowed mode",
inviteByQR: "Invite by QR code",
scanQRCode: "Scan the QR code to connect<br /> to the current demo",
title: "Remote demonstration",
header: {
buttonFirst: "Sign up",
buttonSecond: "for a demo",
},
main: {
title: "Available<br />demos",
desc: "A client from anywhere in the world can view the residential complex, even at the zero construction stage. He will choose the best layout and evaluate the view from the windows of his future apartment.",
cards: {
title1: "Revolution towers",
title2: "Life Residence",
title3: "Aivazovsky City",
city1: "Russia, Yekaterinburg",
city2: "Russia, Tyumen",
button: "Run demo",
},
},
feedback: {
title: "Contact us",
desc: "Want to increase conversion?<br />Let's discuss the details!",
form: {
field1: "Name",
field2: "Phone",
field3: "Describe your task",
button: "Send",
desc1: {
text1: 'By clicking the "Submit" button, you accept the',
text1_1: 'By clicking the "Sign up" button, you accept the',
link1: "terms of use",
text2: "and",
link2: "privacy policy",
},
desc2: "Required fields are marked<br />with an asterisk",
},
},
contacts: {
title: "Hot line",
button1: "Write to us",
button2: "Call us",
social: {
title: "Social<br />media",
},
},
footer: {
link: "Privacy policy",
text: "All rights reserved.",
},
sidebar: {
title1: "Date and time",
title2: "Contacts",
title3: "Data checking",
title4_1: "thank you for signing up",
title4_2: "for a remote demonstration",
date: "Date",
time: "Time",
contacts: "Contacts",
submitNotice1: 'By clicking the "sign up" button, you accept the',
submitNotice2: "terms of use",
submitNotice3: "and",
submitNotice4: "privacy policy",
available: "available",
buttonBack: "Back",
buttonNext: "Next",
buttonSignUp: "Sign up",
buttonHome: "Home",
checkData: "Check the data",
sessionDetails: "Session details",
contactDetails: "Contact details",
name: "Name",
phone: "Phone",
tab5text1:
"In the near future we will send all additional information about the session and a link to connect to your email address.",
tab5text2: "Any questions?",
tab5text3: "Contact us",
notice:
"Registration for the demonstration<br />is carried out in a trial mode",
},
},
},
};
void i18n
.use(detector)
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
fallbackLng: ["ru", "en"],
interpolation: {
escapeValue: false, // react already safes from xss
},
debug: true,
});
export default i18n;
+28
View File
@@ -0,0 +1,28 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@import url("https://gistcdn.githack.com/mfd/09b70eb47474836f25a21660282ce0fd/raw/e06a670afcb2b861ed2ac4a1ef752d062ef6b46b/Gilroy.css");
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: "Inter", sans-serif;
}
.font-gilroy {
font-family: "Gilroy", sans-serif;
}
input {
color-scheme: dark;
}
.bg-gradient {
background: linear-gradient(23deg, #798fff 16.71%, #d375ff 96.35%) !important;
}
.text-gradient {
background: linear-gradient(23deg, #798fff 16.71%, #d375ff 96.35%);
background-clip: text;
color: transparent;
}
+91
View File
@@ -0,0 +1,91 @@
// import React from "react";
import ReactDOM from "react-dom/client";
import {
createBrowserRouter,
Navigate,
RouterProvider,
} from "react-router-dom";
import "./index.css";
import App from "./App";
import "./i18n";
import StreamPage from "./StreamPage";
import MonitoringPage from "./MonitoringPage";
import HistoryPage from "./HistoryPage";
import ScheduledPage from "./ScheduledPage";
import CalendarPage from "./CalendarPage";
import useAuthStore from "./stores/useAuthStore";
import PersonalAreaLoginPage from "./PersonalAreaLoginPage";
import PersonalAreaDashboardPage from "./PersonalAreaDashboardPage";
const router = createBrowserRouter([
{
path: "/",
element: <App />,
},
{
path: "/stream/:id",
element: <StreamPage />,
},
{
path: "/monitoring",
element: <MonitoringPage />,
},
{
path: "/history",
element: <HistoryPage />,
},
{
path: "/scheduled",
element: <ScheduledPage />,
},
{
path: "/calendar/:username",
element: <CalendarPage />,
},
{
path: "/personal-area",
element: <Navigate to="/personal-area/login" replace />,
},
{
path: "/personal-area/login",
element: (
<ProtectedRoute>
<PersonalAreaLoginPage />
</ProtectedRoute>
),
},
{
path: "/personal-area/dashboard",
element: (
<ProtectedRoute>
<PersonalAreaDashboardPage />
</ProtectedRoute>
),
},
]);
export function ProtectedRoute({ children }: { children: JSX.Element }) {
const accessToken = useAuthStore((state) => state.accessToken);
if (accessToken) {
// console.log("location.pathname", location.pathname);
if (location.pathname === "/personal-area/login") {
return <Navigate to="/personal-area/dashboard" replace />;
} else {
return <>{children}</>;
}
} else {
if (location.pathname !== "/personal-area/login") {
return <Navigate to="/personal-area/login" replace />;
} else {
return <>{children}</>;
}
}
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
// <React.StrictMode>
<RouterProvider router={router} />
// </React.StrictMode>,
);
+37
View File
@@ -0,0 +1,37 @@
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
type User = {
id: string;
username: string;
};
interface State {
accessToken: string | null;
user: User | null;
}
interface Actions {
setAccessToken: (accessToken: string) => void;
setUser: (user: User) => void;
removeAuthStore: () => void;
}
const useAuthStore = create<State & Actions>()(
devtools(
persist(
(set) => ({
accessToken: null,
user: null,
setAccessToken: (accessToken) => set({ accessToken }),
setUser: (user) => set({ user }),
removeAuthStore: () => set({ accessToken: null, user: null }),
}),
{
name: "auth",
}
)
)
);
export default useAuthStore;
+16
View File
@@ -0,0 +1,16 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface ModalState {
modal: JSX.Element | null;
setModal: (modal: JSX.Element | null) => void;
}
const useModalStore = create<ModalState>()(
devtools((set) => ({
modal: null,
setModal: (modal) => set({ modal }),
}))
);
export default useModalStore;
+47
View File
@@ -0,0 +1,47 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface SidebarState {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
currentTab: number;
setCurrentTab: (tab: number) => void;
selectedDay: Date | null;
setSelectedDay: (day: Date) => void;
selectedTime: string | null;
setSelectedTime: (time: string) => void;
name: string;
setName: (name: string) => void;
phone: string;
setPhone: (phone: string) => void;
email: string;
setEmail: (email: string) => void;
}
const useSidebarStore = create<SidebarState>()(
devtools(
// persist(
(set) => ({
isOpen: false,
setIsOpen: (value) => set(() => ({ isOpen: value })),
currentTab: 1,
setCurrentTab: (tab) => set(() => ({ currentTab: tab })),
selectedDay: null,
setSelectedDay: (day) => set(() => ({ selectedDay: day })),
selectedTime: null,
setSelectedTime: (time) => set(() => ({ selectedTime: time })),
name: "",
setName: (name) => set(() => ({ name })),
phone: "",
setPhone: (phone) => set(() => ({ phone })),
email: "",
setEmail: (email) => set(() => ({ email })),
})
// {
// name: "tab-storage",
// }
// )
)
);
export default useSidebarStore;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />