added stats, slider with stends, projects, form, footer, etc
@@ -11,15 +11,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.3.31",
|
||||
"ky": "^1.7.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"usehooks-ts": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-input-mask": "^3.0.5",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
import { Logo } from '../components/icons/Logo';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function Footer() {
|
||||
return <footer></footer>;
|
||||
return (
|
||||
<footer className="sm:grid xl:grid-cols-[2fr_1fr_1fr] sm:grid-cols-2 sm:max-xl:grid-rows-2">
|
||||
<div className="flex sm:items-center max-sm:flex-col sm:px-6 px-4 sm:py-9 py-4 border-t border-[#3D425C] gap-6 sm:max-xl:row-start-1 sm:max-xl:col-span-2">
|
||||
<Link to={'/'}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Link
|
||||
to="https://graff.tech/privacypolicy"
|
||||
className="sm:font-medium flex gap-4 m-text"
|
||||
>
|
||||
Политика конфиденциальности <span>graff.tech</span>
|
||||
</Link>
|
||||
<p className="opacity-40 sm:font-medium m-text">
|
||||
© 2024 GRAFF interactive. Все права защищены
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-10 py-[30px] flex items-center border-t xl:border-l sm:border-r border-[#3D425C] flex-1 justify-between sm:max-xl:row-start-2 sm:max-xl:col-start-1">
|
||||
<div>
|
||||
<Contact type="email" text="info@graff.tech" />
|
||||
<Contact type="phone" text="+7 800 770 00 67" />
|
||||
</div>
|
||||
<div className="font-medium p-[14px] border border-[#3D425C] rounded-full m-text">
|
||||
RU
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-10 py-[30px] flex items-center border-t border-[#3D425C] flex-1 justify-between sm:max-xl:row-start-2 sm:max-xl:col-start-2">
|
||||
<div>
|
||||
<Contact type="email" text="info@graff.tech" />
|
||||
<Contact type="phone" text="+971 58 506 0097" />
|
||||
</div>
|
||||
<div className="font-medium py-[14px] px-[10px] border border-[#3D425C] rounded-full m-text">
|
||||
UAE
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function Contact({
|
||||
text,
|
||||
className = '',
|
||||
type,
|
||||
}: {
|
||||
className?: string;
|
||||
text: string;
|
||||
type: 'email' | 'phone';
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={
|
||||
type === 'email' ? `mailto:${text}` : `tel:${text.replace(' ', '')}`
|
||||
}
|
||||
className={'m-text ' + className}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Header() {
|
||||
<HashLink key={link.path} {...link} />
|
||||
))}
|
||||
</nav>
|
||||
<Button className="-mr-6 rounded-none h-full px-10 btn-text">
|
||||
<Button className="-mr-6 rounded-none h-full px-10">
|
||||
Отправить заявку
|
||||
</Button>
|
||||
</header>
|
||||
@@ -29,7 +29,7 @@ export function Header() {
|
||||
function HashLink({ path, text }: { path: string; text: string }) {
|
||||
return (
|
||||
<Link
|
||||
className="border-l last:border-r border-[#3D425C] px-10 self-stretch text-[#9299BD] btn-text content-center hover:bg-[#3D425C]"
|
||||
className="border-l last:border-r border-[#3D425C] px-10 self-stretch text-[#9299BD] content-center hover:bg-[#3D425C]"
|
||||
to={path}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Footer } from './Footer';
|
||||
import { Header } from './Header';
|
||||
import { Feedback } from '../components/Feedback';
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
@@ -9,6 +10,7 @@ export function Layout() {
|
||||
<main className="lg:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Feedback />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import ky from 'ky';
|
||||
|
||||
export const api = ky.extend({
|
||||
prefixUrl: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,69 @@
|
||||
import { Button } from './ui/Button';
|
||||
import { MailIcon } from './icons/MailIcon';
|
||||
import { PhoneIcon } from './icons/PhoneIcon';
|
||||
import { SendIcon } from './icons/SendIcon';
|
||||
import { TelegramIcon } from './icons/TelegramIcon';
|
||||
import { VKIcon } from './icons/VKIcon';
|
||||
import { YoutubeIcon } from './icons/YoutubeIcon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function Feedback() {
|
||||
return (
|
||||
<div className="grid grid-cols-12 grid-rows-[repeat(min-content,2)] gap-x-4 gap-y-20 lg:px-6 pb-20 mt-[200px]">
|
||||
<h2 className="col-span-7 h2 font-medium">
|
||||
Хотите увеличить конверсию?{' '}
|
||||
<span className="text-gradient">Давайте обсудим детали.</span>
|
||||
</h2>
|
||||
<Button
|
||||
color="primary"
|
||||
icon={<SendIcon />}
|
||||
className="col-span-3 row-start-2 self-end px-6 py-4"
|
||||
width="full"
|
||||
>
|
||||
Оставить заявку
|
||||
</Button>
|
||||
<div className="space-y-3 col-start-9 col-span-4">
|
||||
<h4 className="h4 font-medium mb-1">Свяжитесь с нами</h4>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="py-4"
|
||||
width="full"
|
||||
icon={<MailIcon />}
|
||||
>
|
||||
<span className="btn-text opacity-80">Написать</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="py-4"
|
||||
width="full"
|
||||
icon={<PhoneIcon />}
|
||||
>
|
||||
<span className="btn-text opacity-80">Позвонить</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4 col-start-9 col-span-4 row-start-2 self-end">
|
||||
<h4 className="h4 font-medium">Социальные сети</h4>
|
||||
<div className="flex gap-x-2">
|
||||
<Link
|
||||
to={'https://www.youtube.com/@GRAFFtech'}
|
||||
className="p-4 rounded-full opacity-80 border-[#3D425C] border hover:border-[#52587A] transition-all"
|
||||
>
|
||||
<YoutubeIcon />
|
||||
</Link>
|
||||
<Link
|
||||
to={'https://vk.com/graff.interactive'}
|
||||
className="p-4 rounded-full opacity-80 border-[#3D425C] border hover:border-[#52587A] transition-all"
|
||||
>
|
||||
<VKIcon />
|
||||
</Link>
|
||||
<Link
|
||||
to={'https://t.me/graffestate'}
|
||||
className="p-4 rounded-full opacity-80 border-[#3D425C] border hover:border-[#52587A] transition-all"
|
||||
>
|
||||
<TelegramIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import ReactInputMask from 'react-input-mask';
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Title } from './ui/Title';
|
||||
import { api } from '../api';
|
||||
import { PhoneCode } from '../types/PhoneCode';
|
||||
import { Button } from './ui/Button';
|
||||
import { ClassNameWrapper } from '../hocs/ClassNameWrapper';
|
||||
import { LoaderIcon } from './icons/LoaderIcon';
|
||||
import { ChevronUpIcon } from './icons/ChevronUpIcon';
|
||||
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
||||
import { phoneCodes } from '../consts/phoneCodes';
|
||||
import { ArrowRightIcon } from './icons/ArrowRightIcon';
|
||||
|
||||
export function Form() {
|
||||
const [name, setName] = useState('');
|
||||
const [phoneCode, setPhoneCode] = useState<PhoneCode>('+7');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.style.height = 'auto';
|
||||
textAreaRef.current.style.height =
|
||||
textAreaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
}, [textAreaRef, description]);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
sendMail();
|
||||
}
|
||||
|
||||
async function sendMail() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await api
|
||||
.post('mail', {
|
||||
json: {
|
||||
fullname: name,
|
||||
phone: phoneCode + phone,
|
||||
email,
|
||||
request: description,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-[180px] py-6 space-y-20">
|
||||
<Title className="max-w-[58vw]">
|
||||
Хотите интерактивное решение для выставки?
|
||||
<br />
|
||||
<span className="text-gradient">Давайте обсудим детали.</span>
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit} className="space-y-12 max-w-[62vw]">
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-x-4 items-start">
|
||||
<div className="w-full">
|
||||
<label
|
||||
className="m-text text-[#9299BD] select-none"
|
||||
htmlFor="name"
|
||||
>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Ваше имя"
|
||||
className="bg-transparent border-b border-[#3D425C] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:h4 placeholder:font-medium placeholder:select-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label
|
||||
className="m-text text-[#9299BD] select-none"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email*
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="Ваш email"
|
||||
className="bg-transparent border-b border-[#3D425C] focus:border-white py-4 rounded-none outline-none transition-all w-full placeholder:h4 placeholder:font-medium placeholder:select-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label
|
||||
className="m-text text-[#9299BD] select-none"
|
||||
htmlFor="tel"
|
||||
>
|
||||
Телефон
|
||||
</label>
|
||||
<div className="flex gap-x-3 py-4 border-[#3D425C] relative">
|
||||
<SelectPhoneCode
|
||||
currentPhoneCode={phoneCode}
|
||||
onClick={setPhoneCode}
|
||||
/>
|
||||
<div className="border-l border-[#3D425C]" />
|
||||
<ReactInputMask
|
||||
required
|
||||
type="tel"
|
||||
id="tel"
|
||||
mask={'(999) 99 999 99'}
|
||||
maskChar={null}
|
||||
value={phone}
|
||||
placeholder="(900) 000 00 00"
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
className="bg-transparent rounded-none outline-none transition-all w-full placeholder:h4 placeholder:font-medium placeholder:select-none peer"
|
||||
/>
|
||||
<div className="bottom-0 absolute w-full border-b border-[#3D425C] peer-focus:border-white -mb-px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="m-text text-[#9299BD] select-none"
|
||||
htmlFor="description"
|
||||
>
|
||||
Задача
|
||||
</label>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
id="description"
|
||||
placeholder="Опишите вашу задачу"
|
||||
value={description}
|
||||
rows={1}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="bg-transparent border-b py-4 focus:border-white max-h-[300px] h-auto rounded-none border-[#3D425C] resize-none outline-none transition-all w-full placeholder:h4 placeholder:font-medium placeholder:select-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-w-[23vw]">
|
||||
<Button
|
||||
width="full"
|
||||
disabled={isLoading}
|
||||
className="p-2 pl-8"
|
||||
icon={
|
||||
isLoading ? (
|
||||
<ClassNameWrapper
|
||||
element={<LoaderIcon />}
|
||||
className="relative w-5 h-5 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-2 rounded-full bg-white">
|
||||
<ClassNameWrapper
|
||||
element={<ArrowRightIcon />}
|
||||
className="w-5 h-5 text-black"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
<p className="m-text text-[#52587A]">
|
||||
*нажимая кнопку отправить, вы принимаете
|
||||
<span className="text-gradient">
|
||||
{' '}
|
||||
условия использования и политику конфиденциальности
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectPhoneCode({
|
||||
currentPhoneCode,
|
||||
onClick,
|
||||
}: {
|
||||
currentPhoneCode: PhoneCode;
|
||||
onClick: (phoneCode: PhoneCode) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col">
|
||||
<button
|
||||
className="flex gap-x-4 items-center relative"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setOpen(prev => !prev);
|
||||
}}
|
||||
>
|
||||
<p className="h4">{currentPhoneCode}</p>
|
||||
{open ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-10 bg-[#14161F] top-[100%] w-[calc(100%+4px)] -left-1 border border-t-0 p-1 rounded-b-lg border-[#3D425C]">
|
||||
{phoneCodes
|
||||
.filter(phonecode => phonecode !== currentPhoneCode)
|
||||
.map(phoneCode => (
|
||||
<p
|
||||
key={phoneCode}
|
||||
className="h4 cursor-pointer hover:bg-[#3D425C] py-1"
|
||||
onClick={() => {
|
||||
onClick(phoneCode);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{phoneCode}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Title } from './ui/Title';
|
||||
|
||||
export function InfoCollecting() {
|
||||
return (
|
||||
<div className="space-y-20">
|
||||
<Title>
|
||||
<span className="text-gradient">Собираем информацию</span>
|
||||
<br /> о поведении пользователей
|
||||
</Title>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { projects } from '../consts/projects';
|
||||
import { IProject } from '../types/IProject';
|
||||
import { Title } from './ui/Title';
|
||||
|
||||
export function Projects() {
|
||||
return (
|
||||
<div className="space-y-6 mt-[180px]">
|
||||
<Title>Проекты</Title>
|
||||
<div className="flex flex-col gap-y-16">
|
||||
<div className="flex gap-x-4">{projects.slice(0, 2).map(Project)}</div>
|
||||
<Project {...projects[2]} className="max-w-[82vw] self-end" />
|
||||
<div className="flex gap-x-4">{projects.slice(3).map(Project)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Project({
|
||||
img,
|
||||
title,
|
||||
tags,
|
||||
className,
|
||||
}: IProject & { className?: string }) {
|
||||
return (
|
||||
<div className={'space-y-6 ' + className}>
|
||||
<img src={img} alt={title} />
|
||||
<div className="space-y-2">
|
||||
<h3 className="h3 font-medium">{title}</h3>
|
||||
<div className="flex gap-x-6">
|
||||
{tags.map(tag => (
|
||||
<div key={tag} className="flex items-center gap-x-2 py-2">
|
||||
<div className="w-3 h-3 bg-white" />
|
||||
<p className="h4 font-medium">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Title } from './ui/Title';
|
||||
import { promotionFeatures } from '../consts/promotionFeatures';
|
||||
import { useRef } from 'react';
|
||||
@@ -38,40 +38,53 @@ function Feature({
|
||||
const hovered = useHover(ref);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<div
|
||||
ref={ref}
|
||||
className="border-t border-x border-[#3D425C] last:border-b flex justify-between gap-x-16 items-stretch p-7 transition-all overflow-hidden"
|
||||
initial={{ height: 196 }}
|
||||
animate={hovered ? { height: 480 } : { height: 196 }}
|
||||
className="p-7 border-t border-x border-[#3D425C] last:border-b group"
|
||||
>
|
||||
<div className="max-w-[calc(540/1600*100vw)]">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<p className="l-text text-[#52587A] font-medium">[0{number}]</p>
|
||||
<p className="h3 font-medium">{title}</p>
|
||||
<motion.div
|
||||
className="flex justify-between gap-x-4 overflow-hidden"
|
||||
transition={{ duration: 0.7 }}
|
||||
animate={hovered ? { height: 424 } : { height: 140 }}
|
||||
>
|
||||
<div className="max-w-[calc(540/1600*100vw)]">
|
||||
<div className="mb-6 group-hover:space-y-[220px]">
|
||||
<p className="l-text text-[#52587A] font-medium">[0{number}]</p>
|
||||
<p className="h3 font-medium mt-12 group-hover:mt-[220px] transition-all duration-700">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<motion.p
|
||||
className="h4 opacity-60 font-medium"
|
||||
transition={{ delay: 0.5 }}
|
||||
animate={hovered ? { opacity: 0.6 } : { opacity: 0 }}
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
</div>
|
||||
<motion.p
|
||||
className="h4 opacity-60 font-medium mt-6"
|
||||
animate={hovered ? { opacity: 0.6 } : { opacity: 0 }}
|
||||
<motion.div
|
||||
className="flex gap-x-2 items-start flex-1 justify-end"
|
||||
transition={{ duration: 1 }}
|
||||
animate={{
|
||||
translateX: hovered
|
||||
? (images.length - 1) * 240 + (images.length - 2) * 8
|
||||
: 0,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
</div>
|
||||
<div className="flex gap-x-2 justify-end self-start max-w-[calc(736/1600*100vw)]">
|
||||
{images.map((image, index) => (
|
||||
<motion.img
|
||||
key={image}
|
||||
src={image}
|
||||
alt=""
|
||||
className=""
|
||||
initial={{ maxHeight: (140 / 1600) * window.innerWidth }}
|
||||
animate={
|
||||
index === 0 && hovered
|
||||
? { minHeight: (424 / 1600) * window.innerWidth }
|
||||
: { maxHeight: (140 / 1600) * window.innerWidth }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
{images.map((image, index) => (
|
||||
<AnimatePresence key={image}>
|
||||
<motion.img
|
||||
key={image}
|
||||
src={image}
|
||||
alt=""
|
||||
transition={{ duration: 0.5 }}
|
||||
className="transition-[height] h-full"
|
||||
animate={{ maxHeight: hovered && index === 0 ? 424 : 140 }}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FullScreenIcon } from './icons/FullScreenIcon';
|
||||
|
||||
export function Showreel() {
|
||||
return (
|
||||
<div className="aspect-[1552/616] [background:linear-gradient(rgba(0,0,0,0.2),rgba(0,0,0,0.2)),center_url(src/assets/motivation/Showreel.png)] flex justify-center items-center">
|
||||
<div className="aspect-[1552/616] [background:linear-gradient(rgba(0,0,0,0.2),rgba(0,0,0,0.2)),center/cover_url(src/assets/motivation/Showreel.png)_no-repeat] flex justify-center items-center">
|
||||
<button className="p-[22px] rounded-full border bg-[#14161F33]">
|
||||
<FullScreenIcon />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { stands } from '../consts/stands';
|
||||
import { IStand } from '../types/IStand';
|
||||
import { SliderWithScaling } from './ui/SliderWithScaling';
|
||||
import { Title } from './ui/Title';
|
||||
|
||||
export function Stands() {
|
||||
return (
|
||||
<div className="space-y-20 mt-[180px]">
|
||||
<Title>
|
||||
Мы разработчики с собственной
|
||||
<span className="text-gradient">
|
||||
{' '}
|
||||
производственной базой, опытной инженерной лабораторией,{' '}
|
||||
</span>
|
||||
командой разработчиков программного обеспечения и графики
|
||||
</Title>
|
||||
<SliderWithScaling
|
||||
slides={stands}
|
||||
SlideElement={Stand}
|
||||
slideSizes={['31.6vw', '31.8vw', '48vw', '48vw']}
|
||||
childClassName="flex flex-col justify-stretch h-full"
|
||||
controlsPosition={'bottom'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Stand = forwardRef<HTMLDivElement, IStand>(
|
||||
({ annotation, img, title, year }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col gap-y-4 h-full">
|
||||
<img
|
||||
src={img}
|
||||
alt={title}
|
||||
className="object-cover min-h-[31.6vw] flex-1 aspect-[507/431] object-center"
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="h4 font-medium">{title}</p>
|
||||
<p className="text-xs text-[#737AA1] font-medium">{annotation}</p>
|
||||
</div>
|
||||
<p className="h4 font-medium">{year}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,131 @@
|
||||
import { motion, useInView, useMotionValue, useSpring } from 'framer-motion';
|
||||
import { Title } from './ui/Title';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function Statistics() {
|
||||
return (
|
||||
<div className="mt-[180px] space-y-20 border-b border-[#3D425C]">
|
||||
<Title>
|
||||
За 15 лет работы cоздали более <br />
|
||||
<span className="text-gradient"> 250 интерактивных проектов </span>с 3D
|
||||
графикой
|
||||
</Title>
|
||||
<div className="py-24 flex gap-x-4 items-end">
|
||||
{statistics.map(Figure)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ color, percents, columnHeight, title }: StatisticsItem) {
|
||||
const percentsValue = useMotionValue<number>(0);
|
||||
const percentsSpringValue = useSpring(percentsValue, {
|
||||
damping: 100,
|
||||
stiffness: 100,
|
||||
});
|
||||
|
||||
const columnHeightValue = useMotionValue<number>(0);
|
||||
const columnHeightSpringValue = useSpring(columnHeightValue, {
|
||||
damping: 100,
|
||||
stiffness: 100,
|
||||
});
|
||||
|
||||
const figureRef = useRef<HTMLParagraphElement>(null);
|
||||
const columnRef = useRef<HTMLDivElement>(null);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
const inView = useInView(root);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
percentsValue.set(percents);
|
||||
}
|
||||
}, [percentsValue, inView, percents]);
|
||||
|
||||
useEffect(() => {
|
||||
percentsSpringValue.on('change', prev => {
|
||||
if (figureRef.current) {
|
||||
figureRef.current.textContent = prev.toFixed(0);
|
||||
}
|
||||
});
|
||||
}, [figureRef, percentsSpringValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
columnHeightValue.set(columnHeight);
|
||||
}
|
||||
}, [columnHeight, columnHeightValue, inView]);
|
||||
|
||||
useEffect(() => {
|
||||
columnHeightSpringValue.on('change', prev => {
|
||||
if (columnRef.current) {
|
||||
columnRef.current!.style.height = `${prev}px`;
|
||||
}
|
||||
});
|
||||
}, [columnHeightSpringValue, columnRef, percentsSpringValue]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={root}
|
||||
className="relative flex flex-col gap-y-2 justify-end w-full h-[400px]"
|
||||
>
|
||||
<p className="h4 font-medium text-center" style={{ color }}>
|
||||
{title}
|
||||
</p>
|
||||
<motion.div
|
||||
ref={columnRef}
|
||||
className="border-t-[5px] transition-[height]"
|
||||
style={{
|
||||
borderColor: color,
|
||||
background: `linear-gradient(${color}33,#798FFF00)`,
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
ref={figureRef}
|
||||
className="text-[96px] font-medium absolute self-center bottom-6 leading-none"
|
||||
>
|
||||
{percents}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatisticsItem {
|
||||
title: string;
|
||||
color: string;
|
||||
percents: number;
|
||||
columnHeight: number;
|
||||
}
|
||||
|
||||
const statistics: StatisticsItem[] = [
|
||||
{
|
||||
color: '#D375FF',
|
||||
percents: 15,
|
||||
title: 'AR',
|
||||
columnHeight: 224,
|
||||
},
|
||||
{
|
||||
color: '#79FFA6',
|
||||
percents: 62,
|
||||
title: 'VR',
|
||||
columnHeight: 301,
|
||||
},
|
||||
{
|
||||
color: '#9E75FF',
|
||||
percents: 97,
|
||||
title: '3D интерактив',
|
||||
columnHeight: 373,
|
||||
},
|
||||
{
|
||||
color: '#FF7575',
|
||||
percents: 64,
|
||||
title: 'Интеактивные макеты',
|
||||
columnHeight: 301,
|
||||
},
|
||||
{
|
||||
color: '#8F95FF',
|
||||
percents: 12,
|
||||
title: 'Мобильные приложения',
|
||||
columnHeight: 246,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
export function ArrowLeftIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.12124 12.707L19.707 12.707L17.707 10.707L8.12124 10.707L13.4141 5.41406L11.9999 3.99985L4.29282 11.707L11.9999 19.4141L13.4141 17.9998L8.12124 12.707Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function ArrowRightIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M15.8788 10.7071L4.29297 10.7071L6.29297 12.7071L15.8788 12.7071L10.5859 18L12.0001 19.4142L19.7072 11.7071L12.0001 4L10.5859 5.41421L15.8788 10.7071Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function ChevronDownIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.0001 17.707L19.7072 9.99992L18.293 8.58571L12.0001 14.8786L5.70718 8.58571L4.29297 9.99992L12.0001 17.707Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function ChevronUpIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-Rule="evenodd"
|
||||
clip-Rule="evenodd"
|
||||
d="M12.0001 6.58594L19.7072 14.293L18.293 15.7073L12.0001 9.41436L5.70718 15.7073L4.29297 14.293L12.0001 6.58594Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export function LoaderIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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_0_1327)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_angular_0_1327"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function MailIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
d="M4 11.3116C4 10.7934 4.5653 10.4733 5.00965 10.7399L15.314 16.9221C15.7363 17.1754 16.2637 17.1754 16.686 16.9221L26.9903 10.7395C27.4347 10.4728 28 10.7929 28 11.3111V22.667C28 23.4034 27.403 24.0003 26.6667 24.0003H5.33333C4.59695 24.0003 4 23.4034 4 22.667V11.3116Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M4.73055 7.90532C4.15076 7.55745 4.3974 6.66699 5.07354 6.66699H26.9265C27.6026 6.66699 27.8492 7.55745 27.2695 7.90532L16.686 14.2554C16.2638 14.5087 15.7362 14.5087 15.314 14.2554L4.73055 7.90532Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function PhoneIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
d="M13.2474 8.5059L10.4417 5.70834C9.93986 5.2079 9.12614 5.2079 8.62425 5.70834L6.2968 8.02908C3.40041 10.9171 7.65197 17.7995 10.956 21.094C14.2402 24.3687 21.0671 28.5919 23.9634 25.7038L26.2909 23.3831C26.7928 22.8826 26.7928 22.0712 26.2909 21.5708L23.4852 18.7732C22.9834 18.2728 22.1696 18.2728 21.6677 18.7732L19.5369 20.8979C19.4073 21.0271 19.2423 21.1042 19.0721 21.0367C18.6108 20.8537 17.4331 20.1365 14.6545 17.4061C11.865 14.665 11.1492 13.4307 10.9719 12.9332C10.9051 12.7456 10.9926 12.5664 11.1338 12.4256L13.248 10.3176C13.7499 9.81711 13.7493 9.00634 13.2474 8.5059Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function SendIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="currentColor"
|
||||
color="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.2649 8.42199C25.4319 7.92082 25.2873 7.36828 24.8962 7.01318C24.5051 6.65808 23.9412 6.56738 23.4584 6.78194L6.56109 14.2919C4.8229 15.0644 5.04294 17.5984 6.88829 18.0598L10.0481 18.8497C10.6607 19.0029 11.3096 18.8577 11.7986 18.4582L19.8248 11.9001C20.0112 11.7478 20.2583 11.994 20.1067 12.181L14.0759 19.6205C13.5817 20.2301 13.4897 21.0724 13.8407 21.7743L15.8654 25.8237C16.6622 27.4175 18.9881 27.2521 19.5516 25.5618L25.2649 8.42199Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function TelegramIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.44748 8H2.38424C1.50903 8 1.33398 8.39579 1.33398 8.83368C1.33398 9.61432 2.37198 13.4863 6.16953 18.6072C8.70064 22.1044 12.2671 24 15.5124 24C17.4598 24 17.7004 23.5789 17.7004 22.8539V20.2105C17.7004 19.3684 17.8842 19.2 18.5013 19.2C18.9564 19.2 19.7353 19.4189 21.5522 21.1048C23.6291 23.1032 23.9713 24 25.1397 24H28.203C29.0782 24 29.5158 23.5789 29.2629 22.7478C28.9872 21.92 27.9956 20.7183 26.6792 19.2943C25.9651 18.4825 24.8938 17.6076 24.57 17.1705C24.1149 16.6088 24.2462 16.3587 24.57 15.8594C24.57 15.8594 28.3019 10.8008 28.6922 9.08379C28.8865 8.45895 28.6922 8 27.7645 8H24.7021C23.9232 8 23.5644 8.39579 23.3701 8.83368C23.3701 8.83368 21.8122 12.4867 19.6058 14.8598C18.8916 15.5469 18.5669 15.7659 18.1774 15.7659C17.9831 15.7659 17.7013 15.5469 17.7013 14.9229V9.08379C17.7013 8.33432 17.4746 8 16.8261 8H12.0124C11.5258 8 11.2335 8.34779 11.2335 8.6779C11.2335 9.38779 12.3363 9.552 12.45 11.5495V15.8905C12.45 16.8421 12.2715 17.0147 11.8812 17.0147C10.8432 17.0147 8.3173 13.3448 6.81894 9.14611C6.52486 8.32842 6.22992 8 5.44748 8Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function VKIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M26.4181 7.23088C27.5654 7.54116 28.4689 8.45558 28.7756 9.6165C29.3327 11.7209 29.3327 16.1114 29.3327 16.1114C29.3327 16.1114 29.3327 20.502 28.7756 22.6063C28.4689 23.7671 27.5654 24.6816 26.4181 24.9918C24.3384 25.5559 15.9994 25.5559 15.9994 25.5559C15.9994 25.5559 7.66049 25.5559 5.58084 24.9918C4.43357 24.6816 3.5299 23.7671 3.22327 22.6063C2.66602 20.502 2.66602 16.1114 2.66602 16.1114C2.66602 16.1114 2.66602 11.7209 3.22327 9.6165C3.5299 8.45558 4.43357 7.54116 5.58084 7.23088C7.66049 6.66699 15.9994 6.66699 15.9994 6.66699C15.9994 6.66699 24.3384 6.66699 26.4181 7.23088ZM13.7771 12.2224V20.0002L20.4438 16.1114L13.7771 12.2224Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function YoutubeIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.8">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M26.4181 7.23088C27.5654 7.54116 28.4689 8.45558 28.7756 9.6165C29.3327 11.7209 29.3327 16.1114 29.3327 16.1114C29.3327 16.1114 29.3327 20.502 28.7756 22.6063C28.4689 23.7671 27.5654 24.6816 26.4181 24.9918C24.3384 25.5559 15.9994 25.5559 15.9994 25.5559C15.9994 25.5559 7.66049 25.5559 5.58084 24.9918C4.43357 24.6816 3.5299 23.7671 3.22327 22.6063C2.66602 20.502 2.66602 16.1114 2.66602 16.1114C2.66602 16.1114 2.66602 11.7209 3.22327 9.6165C3.5299 8.45558 4.43357 7.54116 5.58084 7.23088C7.66049 6.66699 15.9994 6.66699 15.9994 6.66699C15.9994 6.66699 24.3384 6.66699 26.4181 7.23088ZM13.7771 12.2224V20.0002L20.4438 16.1114L13.7771 12.2224Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ArrowLeftIcon } from '../icons/ArrowLeftIcon';
|
||||
import { ArrowRightIcon } from '../icons/ArrowRightIcon';
|
||||
|
||||
export function SliderControls({
|
||||
slidesCount = 6,
|
||||
height,
|
||||
width,
|
||||
slide = 0,
|
||||
onLeftClick,
|
||||
onRightClick,
|
||||
className,
|
||||
}: {
|
||||
slidesCount?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
slide: number;
|
||||
onLeftClick: () => void;
|
||||
onRightClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const length = 2 * Math.PI * (height / 2) + (width - height) * 2;
|
||||
|
||||
return (
|
||||
<div className={'flex items-center gap-2 ' + className}>
|
||||
<div className="relative flex justify-center max-sm:order-2">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="bg-[#14161F] rounded-full"
|
||||
>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width={width - 2}
|
||||
height={height - 2}
|
||||
rx={(height - 2) / 2}
|
||||
stroke="#3D425C"
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
<rect
|
||||
className="transition-all duration-300"
|
||||
x="1"
|
||||
y="1"
|
||||
width={width - 2}
|
||||
height={height - 2}
|
||||
rx={(height - 2) / 2}
|
||||
stroke="white"
|
||||
strokeLinecap="butt"
|
||||
strokeDasharray={`calc(${length}/${slidesCount}*${slide + 1}) ${length - (length / slidesCount) * (slide + 1)}`}
|
||||
strokeDashoffset={`-${height / 2}`}
|
||||
/>
|
||||
</svg>
|
||||
<p className="h4 font-medium absolute self-center">
|
||||
{Math.round(slide) + 1} из {slidesCount}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLeftClick}
|
||||
className="rounded-full sm:p-5 p-4 border border-[#3D425C] bg-[#14161F]"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRightClick}
|
||||
className="rounded-full sm:p-5 p-4 border border-[#3D425C] bg-[#14161F] max-sm:order-2"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useWindowWidth } from '../../hooks/useWindowWidth';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ReactNode, useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { SliderControls } from './SliderControls';
|
||||
|
||||
export function SliderWithScaling<T extends { title: string }>({
|
||||
slides,
|
||||
SlideElement,
|
||||
className = '',
|
||||
childClassName = '',
|
||||
alignItems = 'start',
|
||||
slideSizes: [minWidth, minHeight, minWidthScaled, minHeightScaled],
|
||||
controlsPosition,
|
||||
}: {
|
||||
slides: T[];
|
||||
SlideElement: (_: T) => ReactNode;
|
||||
className?: string;
|
||||
childClassName?: string;
|
||||
alignItems?: 'start' | 'center' | 'end';
|
||||
slideSizes: [string, string, string, string];
|
||||
controlsPosition: 'top' | 'bottom';
|
||||
}) {
|
||||
const width = useWindowWidth();
|
||||
const baseoffset =
|
||||
width >= 1024
|
||||
? (-width / 1600) * 507 + 8
|
||||
: (-width * +minWidth.slice(0, -2)) / 100 + 8;
|
||||
|
||||
const [slide, setSlide] = useState(0);
|
||||
const [sliderOffset, setSliderOffset] = useState(baseoffset);
|
||||
const [transiting, setTransiting] = useState(false);
|
||||
const [currentSliding, setCurrentSliding] = useState<
|
||||
'prev' | 'next' | null
|
||||
>();
|
||||
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [order, dispatch] = useReducer(
|
||||
(state: typeof slides, action: 'prev' | 'next') => {
|
||||
setTransiting(true);
|
||||
switch (action) {
|
||||
case 'prev':
|
||||
setSlide(slide => (slide === 0 ? slides.length - 1 : slide - 1));
|
||||
setSliderOffset(2 * baseoffset);
|
||||
return [state[state.length - 3], ...state.slice(0, -1)];
|
||||
case 'next':
|
||||
setSlide(slide => (slide === slides.length - 1 ? 0 : slide + 1));
|
||||
setSliderOffset(0);
|
||||
return [...state.slice(1), state[2]];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
[slides[slides.length - 1], ...slides, slides[0]],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const sliderRefCurrent = sliderRef.current;
|
||||
|
||||
sliderRefCurrent?.addEventListener('transitionend', () => {
|
||||
setTransiting(false);
|
||||
setCurrentSliding(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sliderRefCurrent?.removeEventListener('transitionend', () => {
|
||||
setTransiting(false);
|
||||
setCurrentSliding(null);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
function nextSlide() {
|
||||
// if (!transiting) {
|
||||
// }
|
||||
setCurrentSliding('next');
|
||||
dispatch('next');
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
// if (!transiting) {
|
||||
// }
|
||||
setCurrentSliding('prev');
|
||||
dispatch('prev');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSliderOffset(baseoffset);
|
||||
}, [baseoffset, order, slide]);
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: nextSlide,
|
||||
onSwipedRight: prevSlide,
|
||||
trackMouse: true,
|
||||
preventScrollOnSwipe: true,
|
||||
touchEventOptions: { passive: false },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col relative ' + className}>
|
||||
<div className="overflow-hidden sm:-mx-6 -mx-4 h-full">
|
||||
<div {...handlers} className="h-full">
|
||||
<div
|
||||
className={`flex items-${alignItems} gap-x-4 -mr-6 select-none`}
|
||||
style={{
|
||||
minHeight: minHeightScaled,
|
||||
transform: `translateX(${sliderOffset}px)`,
|
||||
transitionDuration: `${sliderOffset !== 0 && sliderOffset !== 2 * baseoffset ? 1 : 0}s`,
|
||||
}}
|
||||
ref={sliderRef}
|
||||
>
|
||||
{order.map((slide, index) => (
|
||||
<motion.div
|
||||
key={
|
||||
slide.title +
|
||||
(index < 1 ? '123' : index > slides.length ? '456' : '')
|
||||
}
|
||||
initial={
|
||||
currentSliding === 'next' && index === 0
|
||||
? { minWidth: minWidthScaled, minHeight: minHeightScaled }
|
||||
: index === 3
|
||||
? { minWidth, minHeight }
|
||||
: {}
|
||||
}
|
||||
transition={{ duration: 1, type: 'just' }}
|
||||
animate={
|
||||
index === 1
|
||||
? {
|
||||
minWidth: minWidthScaled,
|
||||
minHeight: minHeightScaled,
|
||||
}
|
||||
: {
|
||||
minWidth,
|
||||
minHeight,
|
||||
transition: { duration: index === 3 ? 0.0001 : 1 },
|
||||
}
|
||||
}
|
||||
className={'pointer-events-none ' + childClassName}
|
||||
>
|
||||
<SlideElement
|
||||
{...slide}
|
||||
ref={index === 1 && !transiting ? itemRef : null}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SliderControls
|
||||
slide={slide}
|
||||
onLeftClick={prevSlide}
|
||||
onRightClick={nextSlide}
|
||||
slidesCount={slides.length}
|
||||
width={width >= 640 ? 132 : ((width - 32) / 328) * 196}
|
||||
height={width >= 640 ? 66 : 58}
|
||||
className={
|
||||
'absolute ' +
|
||||
(controlsPosition === 'top'
|
||||
? 'top-[75px]'
|
||||
: 'bottom-0 sm:self-end self-center')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PhoneCode } from '../types/PhoneCode';
|
||||
|
||||
export const phoneCodes: PhoneCode[] = ['+7', '+375', '+380', '+44'];
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IProject } from '../types/IProject';
|
||||
|
||||
export const projects: IProject[] = [
|
||||
{
|
||||
img: 'src/assets/projects/sochi.png',
|
||||
title: 'Стенд GRAFF.estate на Всероссийском Жилищном конгрессе в Сочи',
|
||||
tags: ['Презентация', '3D-макет'],
|
||||
},
|
||||
{
|
||||
img: 'src/assets/projects/majordom.png',
|
||||
title:
|
||||
'Интерактивная презентация системы умного дома «Мажордом» для ГК Железно',
|
||||
tags: ['Презентация', '3D-макет'],
|
||||
},
|
||||
{
|
||||
img: 'src/assets/projects/warship.png',
|
||||
title: 'Интерактивная презентация военного корабля',
|
||||
tags: ['Презентация'],
|
||||
},
|
||||
{
|
||||
img: 'src/assets/projects/ivolga.png',
|
||||
title: 'Макет кабины машиниста «Иволга» на выставке ВДНХ',
|
||||
tags: ['Презентация', '3D-макет'],
|
||||
},
|
||||
{
|
||||
img: 'src/assets/projects/majordom2.png',
|
||||
title:
|
||||
'Интерактивная презентация системы умного дома «Мажордом» для ГК Железно',
|
||||
tags: ['Презентация', '3D-макет'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IStand } from '../types/IStand';
|
||||
|
||||
export const stands: IStand[] = [
|
||||
{
|
||||
img: 'src/assets/stands/1.png',
|
||||
title: 'Стенд 1',
|
||||
annotation: 'Презентация',
|
||||
year: '2024',
|
||||
},
|
||||
{
|
||||
img: 'src/assets/stands/2.png',
|
||||
title: 'Стенд 2',
|
||||
annotation: 'ГК Основа, Москва',
|
||||
year: '2024',
|
||||
},
|
||||
{
|
||||
img: 'src/assets/stands/3.png',
|
||||
title: 'Стенд 3',
|
||||
annotation: 'ГК Паритет Девелопмент, Тюмень',
|
||||
year: '2020',
|
||||
},
|
||||
];
|
||||
@@ -10,20 +10,37 @@ body {
|
||||
background-color: #14161f;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #798fff;
|
||||
/* border: 3.5px solid transparent; */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.h1 {
|
||||
@apply -tracking-[.02em] text-[clamp(36px,36px+(100vw-360px)/1240*76,112px)] leading-[clamp(36px,36px+(100vw-360px)/1240*50.4,86.4px)];
|
||||
@apply -tracking-[.02em] text-[clamp(36px,36px+(100vw-360px)/1240*76,112px)] leading-[90%]
|
||||
/* leading-[clamp(36px,36px+(100vw-360px)/1240*50.4,86.4px)]; */;
|
||||
}
|
||||
.h2 {
|
||||
@apply -tracking-[.02em] text-[clamp(24px,24px+(100vw-360px)/1240*48,72px)] leading-none;
|
||||
@apply -tracking-[.02em] text-[clamp(24px,24px+(100vw-360px)/1240*48,72px)] lg:leading-[90%] leading-none;
|
||||
}
|
||||
|
||||
.h3 {
|
||||
@apply text-[clamp(18px,18px+(100vw-360px)/1240*22,40px)] leading-[clamp(22px,22px+(100vw-360px)/1240*6.8,28.8px)];
|
||||
@apply text-[clamp(18px,18px+(100vw-360px)/1240*22,40px)] leading-none
|
||||
/* leading-[clamp(22px,22px+(100vw-360px)/1240*6.8,28.8px)]; */;
|
||||
}
|
||||
|
||||
.h4 {
|
||||
@apply text-[clamp(14px,14px+(100vw-360px)/1240*6,20px)] leading-[clamp(17.6px,17.6px+(100vw-360px)/1240*6.4,24px)];
|
||||
@apply text-[clamp(14px,14px+(100vw-360px)/1240*6,20px)] leading-[120%]
|
||||
/* leading-[clamp(17.6px,17.6px+(100vw-360px)/1240*6.4,24px)]; */;
|
||||
}
|
||||
|
||||
/* .accent {
|
||||
@@ -31,11 +48,13 @@ body {
|
||||
} */
|
||||
|
||||
.l-text {
|
||||
@apply text-[clamp(14px,14px+(100vw-360px)/1240*6,20px)] leading-[clamp(21.6px,21.6px+(100vw-360px)/1240*5.4,27px)];
|
||||
@apply text-[clamp(14px,14px+(100vw-360px)/1240*6,20px)] leading-[135%]
|
||||
/* leading-[clamp(21.6px,21.6px+(100vw-360px)/1240*5.4,27px)]; */;
|
||||
}
|
||||
|
||||
.m-text {
|
||||
@apply text-[clamp(12px,12px+(100vw-360px)/1240*4,16px)] leading-[clamp(19.6px,19.6px+(100vw-360px)/1240*2.4,22.4px)];
|
||||
@apply text-[clamp(12px,12px+(100vw-360px)/1240*4,16px)] leading-[140%]
|
||||
/* leading-[clamp(19.6px,19.6px+(100vw-360px)/1240*2.4,22.4px)]; */;
|
||||
}
|
||||
|
||||
/* .l-caption {
|
||||
@@ -51,11 +70,12 @@ body {
|
||||
}
|
||||
|
||||
.link-text {
|
||||
@apply text-[clamp(12px,12px+(100vw-360px)/1240*4,16px)] tracking-[.02em];
|
||||
@apply text-[clamp(12px,12px+(100vw-360px)/1240*4,16px)] tracking-[.02em] lg:leading-[120%] leading-[140%];
|
||||
}
|
||||
|
||||
.descriptor {
|
||||
@apply text-[clamp(14px,14px+(100vw-360px)/1240*2,16px)] leading-[clamp(15.6px,15.6px+(100vw-360px)/1240*2.6,18.2px)];
|
||||
@apply text-[clamp(14px,14px+(100vw-360px)/1240*2,16px)] leading-[120%]
|
||||
/* leading-[clamp(15.6px,15.6px+(100vw-360px)/1240*2.6,18.2px)]; */;
|
||||
}
|
||||
|
||||
/* .feedback-field:focus ~ .feedback-placeholder {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Stands } from '../components/Stands';
|
||||
import { Motivation } from '../components/Motivation';
|
||||
import { Projects } from '../components/Projects';
|
||||
import { Promotion } from '../components/Promotion';
|
||||
import { Statistics } from '../components/Statistics';
|
||||
import { Form } from '../components/Form';
|
||||
|
||||
export function MainPage() {
|
||||
return (
|
||||
<>
|
||||
<Motivation />
|
||||
<Promotion />
|
||||
<Projects />
|
||||
<Form />
|
||||
<Stands />
|
||||
<Statistics />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface IProject {
|
||||
img: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface IStand {
|
||||
img: string;
|
||||
title: string;
|
||||
annotation: string;
|
||||
year: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type PhoneCode = '+7' | '+375' | '+380' | '+44';
|
||||
@@ -562,6 +562,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-input-mask@^3.0.5":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-input-mask/-/react-input-mask-3.0.5.tgz#9fbe9a984b3299419a6071dbf697ac2cae2abd2d"
|
||||
integrity sha512-vQ1x6ykwjDrDrJZq1zw5/uQ+nqGHUV6bWscsVZJ/qsNwNXWxZm7KRBHLJ5k6TQt3MHjhpoYHzPH6FwjVSZODHA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^18.3.3":
|
||||
version "18.3.4"
|
||||
resolved "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz"
|
||||
@@ -1264,6 +1271,13 @@ imurmurhash@^0.1.4:
|
||||
resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz"
|
||||
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
|
||||
|
||||
invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
is-binary-path@~2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz"
|
||||
@@ -1368,6 +1382,11 @@ keyv@^4.5.4:
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
ky@^1.7.1:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/ky/-/ky-1.7.1.tgz#7fbeb9b7b3b5dc9d2a5b56ddb0964788aec36f06"
|
||||
integrity sha512-KJ/IXXkFhTDqxcN8wKqMXk1/UoOpc0UnOB6H7QcqlPInh/M2B5Mlj+i9exez1w4RSwJhNFmHiUDPriAYFwb5VA==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
|
||||
@@ -1408,7 +1427,7 @@ lodash.merge@^4.6.2:
|
||||
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
loose-envify@^1.1.0:
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
@@ -1670,6 +1689,14 @@ react-dom@^18.3.1:
|
||||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.2"
|
||||
|
||||
react-input-mask@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-input-mask/-/react-input-mask-2.0.4.tgz#9ade5cf8196f4a856dbf010820fe75a795f3eb14"
|
||||
integrity sha512-1hwzMr/aO9tXfiroiVCx5EtKohKwLk/NT8QlJXHQ4N+yJJFyUuMT+zfTpLBwX/lK3PkuMlievIffncpMZ3HGRQ==
|
||||
dependencies:
|
||||
invariant "^2.2.4"
|
||||
warning "^4.0.2"
|
||||
|
||||
react-refresh@^0.14.2:
|
||||
version "0.14.2"
|
||||
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz"
|
||||
@@ -1690,6 +1717,11 @@ react-router@6.26.1:
|
||||
dependencies:
|
||||
"@remix-run/router" "1.19.1"
|
||||
|
||||
react-swipeable@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-swipeable/-/react-swipeable-7.0.1.tgz#cd299f5986c5e4a7ee979839658c228f660e1e0c"
|
||||
integrity sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==
|
||||
|
||||
react@^18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
|
||||
@@ -2019,6 +2051,13 @@ vite@^5.4.1:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
warning@^4.0.2:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
|
||||
|
||||