updated slides, upgrade phone input with mask and country flag, started tablet etc

This commit is contained in:
2024-09-04 13:26:23 +05:00
parent 60d54fe4e7
commit d1caddbde5
35 changed files with 769 additions and 701 deletions
+2 -1
View File
@@ -10,8 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"countries-phone-masks": "^1.1.0",
"framer-motion": "^11.3.31",
"intl-tel-input": "^24.3.6",
"ky": "^1.7.1",
"libphonenumber-js": "^1.11.7",
"react": "^18.3.1",
@@ -20,6 +20,7 @@
"react-phone-number-input": "^3.4.5",
"react-router-dom": "^6.26.1",
"react-swipeable": "^7.0.1",
"react-usestateref": "^1.0.9",
"usehooks-ts": "^3.1.0",
"zustand": "^4.5.5"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+3 -236
View File
@@ -1,71 +1,7 @@
import examples from 'libphonenumber-js/mobile/examples';
import {
formatPhoneNumber,
getCountries,
getCountryCallingCode,
} from 'react-phone-number-input';
import ReactInputMask from 'react-input-mask';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { Title } from './ui/Title';
import { api } from '../api';
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 { ArrowRightIcon } from './icons/ArrowRightIcon';
import { CountryCode, getExampleNumber } from 'libphonenumber-js';
import { FeedbackForm } from './Layout/FeedbackForm';
export function Form() {
const [name, setName] = useState('');
const [[phoneCode, country], setPhoneCodeAndCountry] = useState<
[string, string]
>(['', '']);
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="py-6 space-y-20">
<Title className="max-w-[58vw]">
@@ -74,130 +10,9 @@ export function Form() {
<span className="text-gradient">Давайте обсудим детали.</span>
</Title>
<div className="relative">
<form onSubmit={handleSubmit} className="space-y-12 max-w-[66vw]">
<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={setPhoneCodeAndCountry}
/>
<div className="border-l border-[#3D425C]" />
<ReactInputMask
required
type="tel"
id="tel"
mask={
getExampleNumber(country as CountryCode, examples)
?.formatNational
}
maskChar={null}
value={phone}
placeholder={formatPhoneNumber(examples[country] as string)}
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 focus:overflow-y-scroll overflow-hidden"
/>
</div>
</div>
<div className="space-y-4 max-w-[25vw]">
<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>
<FeedbackForm />
<div className="absolute bottom-0 -right-[min(136px,calc(136/1600*100vw))] animate-[spin_10s_linear_infinite]">
<div className="relative w-[calc(512/1600*100vw)] aspect-square flex justify-center transition-all duration-700 hover:w-[calc(446/1600*100vw)] origin-center">
<div className="relative w-[calc(512/1600*100vw)] aspect-square flex justify-center transition-all duration-500 hover:w-[calc(446/1600*100vw)] origin-center">
<div className="w-[calc(116/1600*100vw)] flex flex-col justify-between h-full absolute">
<img src="/src/assets/form/1_1.png" alt="" />
<img src="/src/assets/form/1_2.png" alt="" />
@@ -244,51 +59,3 @@ export function Form() {
</div>
);
}
function SelectPhoneCode({
currentPhoneCode,
onClick,
}: {
currentPhoneCode: string;
onClick: ([phoneCode, country]: [string, string]) => void;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
console.log(getCountries().map(country => getCountryCallingCode(country)));
}, []);
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]">
{getCountries()
.map(country => [getCountryCallingCode(country), country])
.filter(([phonecode]) => phonecode !== currentPhoneCode)
.map(([phoneCode, country]) => (
<p
key={phoneCode}
className="h4 cursor-pointer hover:bg-[#3D425C] py-1"
onClick={() => {
onClick([phoneCode, country]);
setOpen(false);
}}
>
{phoneCode}
</p>
))}
</div>
)}
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import ReactInputMask from 'react-input-mask';
import { SelectPhoneCode } from '../SelectPhoneCode';
import { ClassNameWrapper } from '../../hocs/ClassNameWrapper';
import { LoaderIcon } from '../icons/LoaderIcon';
import { Button } from '../ui/Button';
import { ArrowRightIcon } from '../icons/ArrowRightIcon';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { Country } from 'react-phone-number-input';
import { api } from '../../api';
import { getExampleNumber } from 'libphonenumber-js';
import examples from 'libphonenumber-js/mobile/examples';
export function FeedbackForm({
inModal = false,
send = () => {},
}: {
inModal?: boolean;
send?: () => void;
}) {
const [name, setName] = useState('');
const [[phoneCode, country], setPhoneCodeAndCountry] = useState<
[string, Country]
>(['+7', 'RU']);
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);
send?.();
} catch (error) {
setIsLoading(false);
if (error instanceof Error) {
alert(error.message);
}
}
}
const placeholder = useMemo(
() =>
getExampleNumber(country, examples)
?.formatInternational()
.split(' ')
.slice(1)
.join(' '),
[country],
);
return (
<form
onSubmit={handleSubmit}
className={inModal ? 'space-y-6' : 'space-y-12 max-w-[66vw]'}
>
<div className="space-y-6">
<div
className={
'grid gap-x-4 items-start ' + (inModal ? 'gap-y-6' : 'grid-cols-3')
}
>
<div className="w-full">
<label
className="m-text text-[#9299BD] select-none"
htmlFor={'name' + +inModal}
>
Имя
</label>
<input
required
id={'name' + +inModal}
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' + +inModal}
>
Email*
</label>
<input
required
id={'email' + +inModal}
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' + +inModal}
>
Телефон
</label>
<div className="flex gap-x-3 py-4 border-[#3D425C] relative">
<SelectPhoneCode
currentPhoneCodeAndCountry={[phoneCode, country]}
onClick={setPhoneCodeAndCountry}
/>
<div className="border-l border-[#3D425C]" />
<ReactInputMask
required
type="tel"
id={'tel' + +inModal}
mask={placeholder?.replace(/\d/g, '9') ?? ''}
maskChar={null}
value={phone}
placeholder={placeholder}
onChange={e => setPhone(e.target.value.replace(/ /g, ''))}
className="bg-transparent rounded-none outline-none transition-all w-full h4 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' + +inModal}
>
Задача
</label>
<textarea
ref={textAreaRef}
id={'description' + +inModal}
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 focus:overflow-y-scroll overflow-hidden"
/>
</div>
</div>
<div className="space-y-4 max-w-[25vw]">
<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>
);
}
FeedbackForm.displayName = 'FeedbackForm';
+84 -5
View File
@@ -4,16 +4,30 @@ import { Button } from '../ui/Button';
import { ClassNameWrapper } from '../../hocs/ClassNameWrapper';
import { useModalStore } from '../../stores/modalStore';
import { ModalWithForm } from './ModalWithForm';
import { motion } from 'framer-motion';
import { useRef, useState } from 'react';
import { BurgerIcon } from '../icons/BurgerIcon';
import { CloseIcon } from '../icons/CloseIcon';
import { useOnClickOutside } from 'usehooks-ts';
import { useLanguageStore } from '../../stores/languageStore';
export function Header() {
const { setModal } = useModalStore();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
useOnClickOutside<HTMLDivElement | HTMLButtonElement>(
[menuRef, menuBtnRef],
() => setMenuOpen(false),
);
return (
<header className="lg:px-6 flex items-center h-16 border-b border-[#3D425C] bg-[#14161F]">
<header className="lg:px-6 sm:px-4 flex max-lg:justify-between items-center h-16 border-b border-[#3D425C] bg-[#14161F]">
<Link to={'/'}>
<ClassNameWrapper element={<Logo />} className="h-10" />
</Link>
<nav className="mx-auto self-stretch flex">
<nav className="mx-auto self-stretch flex max-lg:hidden">
{[
{ path: '/#products', text: 'Продукты' },
{ path: '/#devices', text: 'Оборудование' },
@@ -23,12 +37,59 @@ export function Header() {
<HashLink key={link.path} {...link} />
))}
</nav>
<div className="lg:-mr-6 sm:-mr-4 flex h-full">
<Button
className="-mr-6 rounded-none h-full px-10"
className="rounded-none h-full px-10"
onClick={() => setModal(<ModalWithForm />)}
>
Отправить заявку
Оставить заявку
</Button>
<button
ref={menuBtnRef}
className="p-3 mr-0 aspect-square h-full lg:hidden"
onClick={() => setMenuOpen(prev => !prev)}
>
<ClassNameWrapper
element={!menuOpen ? <BurgerIcon /> : <CloseIcon />}
className={'mx-auto'}
/>
</button>
</div>
<motion.div
initial={{ opacity: 0, visibility: 'hidden' }}
animate={{
opacity: +menuOpen,
visibility: menuOpen ? 'visible' : 'hidden',
}}
transition={{ duration: 0.2 }}
ref={menuRef}
onClick={() => setMenuOpen(false)}
className={
'absolute z-50 w-full lg:hidden sm:max-[1350px]:max-w-[340px] right-0 top-16' +
(menuOpen ? ' shadow-[0_0_0_9999px_rgba(0,0,0,.4)]' : '')
}
>
<div>
<HashLink text="Продукты" path={'#products'} />
<HashLink text="Оборудование" path={'#devices'} />
<HashLink text="Проекты" path={'#projects'} />
<HashLink text="Контакты" path={'#contacts'} />
</div>
<div className="grid grid-cols-[2fr_1fr_1fr] sm:grid-cols-2">
<Button
onClick={() => {
setMenuOpen(false);
setModal(<ModalWithForm />);
}}
width="full"
className="sm:hidden font-semibold btn-text rounded-none outline-none"
>
Оставить заявку
</Button>
<ChooseLang currentLang="RU" />
<ChooseLang currentLang="EN" />
</div>
</motion.div>
</header>
);
}
@@ -36,10 +97,28 @@ 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] content-center hover:bg-[#3D425C]"
className="border-l lg:last:border-r border-[#3D425C] px-10 self-stretch lg:text-[#9299BD] content-center hover:bg-[#3D425C] max-lg:flex max-lg:py-6 btn-text bg-[#14161F] w-full max-lg:[&:not(:last-child)]:border-b"
to={path}
>
{text}
</Link>
);
}
function ChooseLang({ currentLang }: { currentLang: 'RU' | 'EN' }) {
const { setLang, lang } = useLanguageStore();
return (
<button
onClick={() => setLang(currentLang)}
className={
'min-h-[72px] w-full h-full btn-text font-semibold bg-[#14161F] lg:hover:bg-[#3D425C] border active:bg-[#14161F] outline-none ' +
(lang === currentLang
? '[border-image:linear-gradient(to_right,#798FFF,#D375FF)_3]'
: 'border-[#3D425C]')
}
>
{currentLang}
</button>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function ModalMenu() {
return <div></div>;
}
+7 -216
View File
@@ -1,68 +1,15 @@
import { api } from '../../api';
import { phoneCodes } from '../../consts/phoneCodes';
import { ClassNameWrapper } from '../../hocs/ClassNameWrapper';
import { useModalStore } from '../../stores/modalStore';
import { PhoneCode } from '../../types/PhoneCode';
import { Button } from '../ui/Button';
import { FormEvent, useEffect, useRef, useState } from 'react';
import ReactInputMask from 'react-input-mask';
import { useEffect, useState } from 'react';
import { ArrowRightIcon } from '../icons/ArrowRightIcon';
import { ChevronDownIcon } from '../icons/ChevronDownIcon';
import { ChevronUpIcon } from '../icons/ChevronUpIcon';
import { CloseIcon } from '../icons/CloseIcon';
import { LoaderIcon } from '../icons/LoaderIcon';
import { MailIcon } from '../icons/MailIcon';
import { FeedbackForm } from './FeedbackForm';
export function ModalWithForm() {
const { setModal } = useModalStore();
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 [isSend, setIsSend] = 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();
setIsSend(true);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
if (error instanceof Error) {
alert(error.message);
}
}
}
const [sent, setSent] = useState(false);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
@@ -71,12 +18,13 @@ export function ModalWithForm() {
}
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [setModal]);
return (
<div className="fixed flex flex-col gap-4 top-0 right-0 h-full sm:w-[408px] w-full bg-[#14161F] overflow-y-auto sm:p-8 p-6">
{!isSend ? (
{!sent ? (
<div className="space-y-8">
<div className="flex justify-between items-center">
<p className="font-medium accent">Оставьте заявку</p>
@@ -87,124 +35,10 @@ export function ModalWithForm() {
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-6">
<hr className="border-[#3D425C]" />
<div>
<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>
<label
className="m-text text-[#9299BD] select-none"
htmlFor="tel"
>
Телефон
</label>
<div className="flex gap-x-3 py-2 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-2" />
</div>
</div>
<div>
<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>
<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>
<Button
width="full"
disabled={isLoading}
className="py-5 px-6 mt-[213px]"
icon={
isLoading ? (
<ClassNameWrapper
element={<LoaderIcon />}
className="relative w-6 h-6 animate-spin"
/>
) : (
<ClassNameWrapper
element={<MailIcon />}
className="relative w-6 h-6"
/>
)
}
>
Отправить
</Button>
<p className="m-caption mt-6 text-center text-[#52587A]">
Нажимая кнопку отправить, вы принимаете
<span className="text-gradient">
{' '}
условия использования и политику конфиденциальности
</span>
</p>
</form>
<FeedbackForm inModal send={() => setSent(true)} />
</div>
) : (
<div className="">
<div>
<div className="space-y-8">
<h2 className="h2 font-medium">Спасибо за отправку заявки!</h2>
<p className="m-text">
@@ -230,46 +64,3 @@ export function ModalWithForm() {
</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>
);
}
@@ -4,15 +4,16 @@ import { useLocation } from 'react-router-dom';
export function ScrollToHashElement() {
const { hash } = useLocation();
const hashElement = document.getElementById(hash.slice(1));
useEffect(() => {
const hashElement = document.getElementById(hash.slice(1));
console.log('scrolling to', hashElement);
if (hashElement)
hashElement.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
block: 'start',
});
}, [hashElement]);
}, [hash]);
return null;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export function Layout() {
<div className="overflow-clip">
<ScrollToHashElement />
<Header />
<main className="lg:px-6 relative">
<main className="lg:px-6 sm:px-4 relative">
<Ellipse />
<Outlet />
</main>
+7 -2
View File
@@ -1,3 +1,4 @@
import { Link } from 'react-router-dom';
import { Showreel } from './Showreel';
export function Motivation() {
@@ -14,11 +15,15 @@ export function Motivation() {
{ solution: 'Интерактивные приложения', count: 21 },
{ solution: '3D макеты', count: 14 },
].map(({ count, solution }, index) => (
<p key={solution} className="h4 font-medium flex gap-x-[7px]">
<Link
key={solution}
className="h4 font-medium flex gap-x-[7px]"
to={`#${index === 0 ? 'vr_ar' : index == 1 ? 'interactive' : '3d_makets'}`}
>
{solution}
<sup className="text-xs">{count}</sup>
{index !== 2 && <span>/</span>}
</p>
</Link>
))}
</div>
</div>
+56 -10
View File
@@ -3,10 +3,15 @@ import { Title } from './ui/Title';
import { promotionFeatures } from '../consts/promotionFeatures';
import { useRef } from 'react';
import { useHover } from 'usehooks-ts';
import { useLocation } from 'react-router-dom';
import { hashes } from '../consts/motivationHashes';
export function Promotion() {
return (
<div id="products" className="space-y-20 -mt-20">
<div
id="products"
className="lg:space-y-20 sm:space-y-10 lg:-mt-20 sm:-mt-10"
>
<Title className="max-w-[calc(1310/1600*100vw)]">
Повышаем количество посетителей на стенде,
<span className="text-gradient">
@@ -16,14 +21,14 @@ export function Promotion() {
</Title>
<div>
{promotionFeatures.map((feature, index) => (
<Feature key={feature.title} number={index + 1} {...feature} />
<DesktopFeature key={feature.title} number={index + 1} {...feature} />
))}
</div>
</div>
);
}
function Feature({
function DesktopFeature({
description,
images,
number,
@@ -34,30 +39,52 @@ function Feature({
description: string;
images: string[];
}) {
const { hash } = useLocation();
const ref = useRef<HTMLDivElement>(null);
const hovered = useHover(ref);
return (
<div
ref={ref}
className="p-7 border-t border-x border-[#3D425C] last:border-b group hover:bg-[url(/src/assets/promotion/Ellipse.png)] bg-no-repeat bg-center bg-cover"
id={hashes.get(number)}
className="p-7 border-t border-x border-[#3D425C] last:border-b hover:bg-[url(/src/assets/promotion/Ellipse.png)] bg-no-repeat bg-center bg-cover"
>
<motion.div
className="flex justify-between gap-x-4 [clip-path:polygon(0%_0%,100%_0%,100%_calc(100%+28px),0%_calc(100%+28px))]"
transition={{ duration: 0.7 }}
animate={hovered ? { height: 424 } : { height: 140 }}
animate={
hovered || hash.slice(1) === hashes.get(number)
? { height: 424 }
: { height: 140 }
}
>
<div className="max-w-[calc(540/1600*100vw)]">
<div className="mb-6 group-hover:space-y-[220px]">
<div
className={
'mb-6' +
(hovered || hash.slice(1) === hashes.get(number)
? ' 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">
<p
className={
'h3 font-medium mt-12 transition-all duration-700' +
(hovered || hash.slice(1) === hashes.get(number)
? ' mt-[220px]'
: '')
}
>
{title}
</p>
</div>
<motion.p
className="h4 opacity-60 font-medium"
animate={
hovered
hovered || hash.slice(1) === hashes.get(number)
? { opacity: 0.6, transition: { delay: 0.5 } }
: { opacity: 0 }
}
@@ -69,7 +96,8 @@ function Feature({
className="flex gap-x-2 items-start flex-1 justify-end"
transition={{ duration: 1 }}
animate={{
translateX: hovered
translateX:
hovered || hash.slice(1) === hashes.get(number)
? (images.length - 1) * 240 + (images.length - 2) * 8
: 0,
}}
@@ -82,7 +110,13 @@ function Feature({
alt=""
transition={{ duration: 0.5 }}
className="transition-[height] h-full"
animate={{ maxHeight: hovered && index === 0 ? 424 : 140 }}
animate={{
maxHeight:
(hovered || hash.slice(1) === hashes.get(number)) &&
index === 0
? 424
: 140,
}}
/>
</AnimatePresence>
))}
@@ -91,3 +125,15 @@ function Feature({
</div>
);
}
function Feature({
title,
description,
images,
}: {
title: string;
description: string;
images: string[];
}) {
return <div></div>;
}
+71
View File
@@ -0,0 +1,71 @@
import countries from 'countries-phone-masks';
import {
CountryCode,
getCountries,
getCountryCallingCode,
} from 'libphonenumber-js';
import { useRef, useState } from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { ChevronUpIcon } from './icons/ChevronUpIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
export function SelectPhoneCode({
currentPhoneCodeAndCountry: [currentPhoneCode, currentCountry],
onClick,
}: {
currentPhoneCodeAndCountry: [string, CountryCode];
onClick: ([phoneCode, country]: [string, CountryCode]) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOnClickOutside(ref, () => setOpen(false));
return (
<div ref={ref} className="relative flex flex-col w-1/3">
<button
className="flex gap-x-1 justify-between items-center relative"
onClick={e => {
e.preventDefault();
setOpen(prev => !prev);
}}
>
<img
src={countries.find(c => c.iso === currentCountry)?.flag}
className="w-6"
alt=""
/>
<p className="h4">{currentPhoneCode}</p>
{open ? <ChevronUpIcon /> : <ChevronDownIcon />}
</button>
{open && (
<div className="space-y-1 absolute z-10 bg-[#14161F] top-[100%] -left-1 border border-t-0 rounded-b-lg border-[#3D425C] max-h-[300px] overflow-y-auto overflow-x-hidden">
{getCountries()
.map(country => [`+${getCountryCallingCode(country)}`, country])
.filter(
([phonecode, country]) =>
phonecode !== currentPhoneCode || country !== currentCountry,
)
.map(([phoneCode, country]) => (
<div
key={country}
className="flex items-center gap-x-1 hover:bg-[#3D425C] px-1"
onClick={() => {
onClick([phoneCode, country as CountryCode]);
setOpen(false);
}}
>
<img
src={countries.find(c => c.iso === country)?.flag}
alt=""
className="w-6"
/>
<p className="flex-1 h4 cursor-pointer py-1">{phoneCode}</p>
</div>
))}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -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/cover_url(src/assets/motivation/Showreel.png)_no-repeat] flex justify-center items-center">
<div className="lg:aspect-[1552/616] sm:aspect-[720/440] [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>
+3
View File
@@ -38,8 +38,11 @@ const Stand = forwardRef<HTMLDivElement, IStand>(
<div className="flex justify-between">
<div className="space-y-2">
<p className="h4 font-medium">{title}</p>
<div className="flex gap-x-2 items-center">
<div className="w-2 h-2 bg-[#737AA1]" />
<p className="text-xs text-[#737AA1] font-medium">{annotation}</p>
</div>
</div>
<p className="h4 font-medium">{year}</p>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
export function BurgerIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19 8H5V6H17L19 8Z" fill="#9299BD" />
<path d="M5 11H19V13H5V11Z" fill="#9299BD" />
<path d="M5 16H19V18H7L5 16Z" fill="#9299BD" />
</svg>
);
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,7 +4,7 @@ import { ReactNode, useEffect, useReducer, useRef, useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { SliderControls } from './SliderControls';
export function SliderWithScaling<T extends { title: string }>({
export function SliderWithScaling<T extends { img: string }>({
slides,
SlideElement,
className = '',
@@ -114,7 +114,7 @@ export function SliderWithScaling<T extends { title: string }>({
{order.map((slide, index) => (
<motion.div
key={
slide.title +
slide.img +
(index < 1 ? '123' : index > slides.length ? '456' : '')
}
initial={
+5
View File
@@ -0,0 +1,5 @@
export const hashes = new Map<number, string>([
[1, 'interactive'],
[4, '3d_makets'],
[5, 'vr_ar'],
]);
-3
View File
@@ -1,3 +0,0 @@
import { PhoneCode } from '../types/PhoneCode';
export const phoneCodes: PhoneCode[] = ['+7', '+375', '+380', '+44'];
+52 -10
View File
@@ -2,21 +2,63 @@ import { IStand } from '../types/IStand';
export const stands: IStand[] = [
{
img: 'src/assets/stands/1.png',
title: 'Стенд 1',
annotation: 'Презентация',
img: 'src/assets/stands/graff_estate.png',
title: 'GRAFF.estate',
annotation: 'Движение',
year: '2024',
},
{
img: 'src/assets/stands/2.png',
title: 'Стенд 2',
annotation: 'ГК Основа, Москва',
img: 'src/assets/stands/graff_estate2.png',
title: 'GRAFF.estate',
annotation: 'Форум «ДСФ “Диалоги-2024”»',
year: '2024',
},
{
img: 'src/assets/stands/3.png',
title: 'Стенд 3',
annotation: 'ГК Паритет Девелопмент, Тюмень',
year: '2020',
img: 'src/assets/stands/nks_development.png',
title: 'НКС-девелопмент',
annotation: 'TechnoBuild',
year: '2023',
},
{
img: 'src/assets/stands/nks_development2.png',
title: 'НКС-девелопмент',
annotation: 'TechnoBuild',
year: '2023',
},
{
img: 'src/assets/stands/interactive_floor.png',
title: 'Interactive floor',
annotation: 'Выставка «Россия ВДНХ»',
year: '2022',
},
{
img: 'src/assets/stands/graff_estate_stream.png',
title: 'Graff.estate stream',
annotation: 'WOW FEST',
year: '2023',
},
{
img: 'src/assets/stands/vr_training.png',
title: 'VR-тренажер',
annotation: 'Иннопром',
year: '2019',
},
{
img: 'src/assets/stands/graff_estate3.png',
title: 'GRAFF.estate',
annotation: 'Иннопром',
year: '2019',
},
{
img: 'src/assets/stands/ugmk-telecom.png',
title: 'УГМК-Телеком',
annotation: 'Иннопром',
year: '2019',
},
{
img: 'src/assets/stands/interactive_presentation.png',
title: 'Интерактивная презентация',
annotation: 'Армия 2019',
year: '2019',
},
];
+1 -1
View File
@@ -8,7 +8,7 @@ import { Devices } from '../components/Devices';
export function MainPage() {
return (
<div className="space-y-[180px]">
<div className="lg:space-y-[180px] sm:space-y-[140px]">
<Motivation />
<Promotion />
<Projects />
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
export type Lang = 'RU' | 'EN';
export const useLanguageStore = create<{
lang: Lang;
setLang: (lang: Lang) => void;
}>()(
devtools(
persist(
set => ({
lang: 'RU',
setLang: (lang: Lang) => {
set({ lang: lang });
},
}),
{
name: 'lang',
},
),
),
);
+10 -5
View File
@@ -893,6 +893,11 @@ convert-source-map@^2.0.0:
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz"
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
countries-phone-masks@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/countries-phone-masks/-/countries-phone-masks-1.1.0.tgz#854ae21bf495a2bc3a467c47317220ea87ceac50"
integrity sha512-ns5+L+rvkfg6qfBUawAIU9WDCpbiwcPw6n7c87B4zppaxfIBFMyD5Fte6UDAdwynDtdxa2xV5/4IL/2+Ju9nVg==
country-flag-icons@^1.5.11:
version "1.5.13"
resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.13.tgz#963596b7fca6602b4b389a4e7b711ef3f33cc0b1"
@@ -1295,11 +1300,6 @@ input-format@^0.3.10:
dependencies:
prop-types "^15.8.1"
intl-tel-input@^24.3.6:
version "24.3.6"
resolved "https://registry.yarnpkg.com/intl-tel-input/-/intl-tel-input-24.3.6.tgz#e1deb7158888479041d72a1ab442730b9c59abc7"
integrity sha512-27kLHadPt9dSAqc5zR0VHxW2zOBThQ8ctrouVNcm3s+0otyKA3dsQFyuYRnlTV5Kux/PPv7RejQf1Fw32xwUcA==
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -1781,6 +1781,11 @@ react-swipeable@^7.0.1:
resolved "https://registry.yarnpkg.com/react-swipeable/-/react-swipeable-7.0.1.tgz#cd299f5986c5e4a7ee979839658c228f660e1e0c"
integrity sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==
react-usestateref@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/react-usestateref/-/react-usestateref-1.0.9.tgz#d40bc54db116e786b6b2bb1cd20fe06e7f8187f3"
integrity sha512-t8KLsI7oje0HzfzGhxFXzuwbf1z9vhBM1ptHLUIHhYqZDKFuI5tzdhEVxSNzUkYxwF8XdpOErzHlKxvP7sTERw==
react@^18.3.1:
version "18.3.1"
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"