feat: update modal

This commit is contained in:
2025-06-17 11:56:31 +03:00
parent 39d4c3c362
commit 553e6da6db
25 changed files with 421 additions and 219 deletions

View File

@@ -1,2 +1,3 @@
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

View File

@@ -3,6 +3,7 @@ import { Open_Sans } from 'next/font/google';
import '@core/styles/reset.scss';
import '@core/styles/globals.scss';
import { Toaster } from 'react-hot-toast';
import { ModalProvider } from '@core/providers/modal-provider';
const openSans = Open_Sans({
subsets: ['cyrillic'],
@@ -34,7 +35,7 @@ export default function RootLayout({
return (
<html lang='en'>
<body className={`${openSans.variable}`}>
{children}
<ModalProvider>{children}</ModalProvider>
<Toaster />
</body>
</html>

View File

@@ -1 +0,0 @@
export { default as ConsultationOrder } from './ui';

View File

@@ -1,13 +0,0 @@
'use client';
import { Button } from '@shared/ui';
import toast from 'react-hot-toast';
export default function ConsultationOrder() {
const notify = () => toast.success('Заявка на консультацию принята');
return (
<Button variant='orange' onClick={notify}>
Получить консультацию
</Button>
);
}

View File

@@ -0,0 +1 @@
export { CallbackOrder } from './ui';

View File

@@ -0,0 +1,35 @@
.Container {
display: none;
@include iftablet {
display: block;
height: min-content;
}
}
.Btn {
@include iflaptop {
display: flex;
flex-direction: row;
justify-items: center;
align-items: center;
gap: rem(16px);
height: rem(40px);
padding: rem(20px)!important;
}
@include ifdesktop {
gap: rem(16px);
height: rem(48px);
padding: rem(24px);
}
img {
width: rem(30px);
height: rem(30px);
@include ifdesktop {
width: rem(40px);
height: rem(40px);
}
}
}

View File

@@ -0,0 +1,25 @@
'use client';
import s from './styles.module.scss';
import Image from 'next/image';
import { Button } from '@shared/ui';
import { useModal } from '@core/providers/modal-provider';
import { ConsultationModal } from '@/entities/home/consultation-modal';
import callBtn from '@public/svg/phone-calling.svg';
function CallbackOrder() {
const modal = useModal();
const openModal = () => modal.showModal(<ConsultationModal />);
return (
<div className={s.Container}>
<Button className={s.Btn} onClick={openModal}>
<Image src={callBtn} alt='Call' />
Обратный звонок
</Button>
</div>
);
}
export { CallbackOrder };

View File

@@ -0,0 +1 @@
export { ConsultationModal } from './ui';

View File

@@ -0,0 +1,29 @@
.Form {
display: flex;
flex-direction: column;
gap: rem(16px);
@include iflaptop {
gap: rem(20px);
}
}
.Title {
font-family: $font-open-sans;
font-weight: $font-medium;
font-size: rem(20px);
line-height: 130%;
color: $color-text;
@include ifdesktop {
font-size: rem(24px);
}
}
.Description {
font-family: $font-open-sans;
font-weight: $font-regular;
font-size: rem(16px);
line-height: 100%;
color: $color-text;
}

View File

@@ -0,0 +1,137 @@
import s from './styles.module.scss';
import { Button, Input } from '@/shared/ui';
import { PhoneInput, TextArea } from '@shared/ui';
import { z } from 'zod';
import { isValidPhoneNumber } from 'libphonenumber-js/min';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { sendFormFn } from '@shared/api/api.service';
import toast from 'react-hot-toast';
import { ModalContent } from '@shared/ui/modal/modal-content';
import { useModal } from '@core/providers/modal-provider';
const FormSchema = z.object({
name: z
.string()
.min(3, { message: 'Поле должно содержать не менее 3-х букв' })
.regex(/^[A-Za-zА-Яа-яЁё]+(?:[ '-][A-Za-zА-Яа-яЁё]+)*$/, {
message: 'Поле содержит некорректные символы',
}),
phone: z.string().refine(isValidPhoneNumber, 'Некорректный номер телефона'),
message: z
.string()
.min(21, { message: 'Оставьте сообщение мин. 20 символов' }),
});
type TForm = z.infer<typeof FormSchema>;
const defaultValues = {
name: '',
phone: '',
message: '',
};
type ConsultationModalProps = {
className?: string;
};
function ConsultationModal({}: ConsultationModalProps) {
const {
handleSubmit,
control,
reset,
clearErrors,
formState: { errors },
} = useForm<TForm>({
resolver: zodResolver(FormSchema),
defaultValues,
});
const modal = useModal();
const onSubmit = async (data: TForm) => {
const payload = {
...data,
form: 'consultation-modal-form',
};
try {
await sendFormFn(payload);
toast.success('Заявка на консультацию принята');
} catch (e) {
toast.error('Ошибка при отправке заявки...', {
duration: 3000,
});
} finally {
modal.hideModal();
reset(defaultValues);
}
};
return (
<ModalContent closeByClickOutside>
<form
className={s.Form}
id='consultation-modal-form'
onSubmit={handleSubmit(onSubmit)}
>
<h2 className={s.Title}>
Мы подскажем, как решить ваши вопросы по пожарной безопасности.
</h2>
<Controller
control={control}
name={'name'}
render={({ field }) => (
<Input
{...field}
placeholder={'Ваше имя'}
fullWidth
onChange={(e) => {
clearErrors('name');
field.onChange(e);
}}
error={errors && errors.name?.message}
/>
)}
/>
<Controller
control={control}
name={'phone'}
render={({ field }) => (
<PhoneInput
{...field}
placeholder={'+7 (999) 123-45-67'}
fullWidth
onChange={(e) => {
clearErrors('phone');
field.onChange(e);
}}
error={errors && errors.phone?.message}
/>
)}
/>
<p className={s.Description}>Кратко опишите интересующий Вас вопрос</p>
<Controller
control={control}
name={'message'}
render={({ field }) => (
<TextArea
{...field}
placeholder={''}
fullWidth
id='story'
name='story'
rows={6}
error={errors && errors.message?.message}
errorTextColor={'#ff9191'}
/>
)}
/>
<Button className={s.SendBtn} variant='orange'>
Отправить
</Button>
</form>
</ModalContent>
);
}
export { ConsultationModal };

View File

@@ -0,0 +1 @@
export { ConsultationOrder } from './ui';

View File

@@ -0,0 +1,18 @@
'use client';
import { Button } from '@shared/ui';
import { useModal } from '@core/providers/modal-provider';
import { ConsultationModal } from '@/entities/home/consultation-modal';
function ConsultationOrder() {
const modal = useModal();
const openModal = () => modal.showModal(<ConsultationModal />);
return (
<Button variant='orange' onClick={openModal}>
Получить консультацию
</Button>
);
}
export { ConsultationOrder };

View File

@@ -34,11 +34,11 @@ export default function Button({
return (
<button
className={clsx(
className,
s.Button,
disabled && s.Button_disabled,
s['Button_' + variant],
fullWidth && s.Button_fullWidth,
className,
)}
onClick={onClick}
disabled={disabled}

View File

@@ -3,3 +3,4 @@ export { Mark } from './mark';
export { Input } from './input';
export { TextArea } from './text-area';
export { PhoneInput } from './phone-input';
export { Modal, ModalContent } from './modal';

View File

@@ -0,0 +1,23 @@
import { SVGProps } from 'react';
const CloseIcon = ({ ...props }: SVGProps<SVGSVGElement>) => (
<svg
{...props}
width='17'
height='17'
viewBox='0 0 17 17'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M2.5 15L15.5 2M2.5 2L15.5 15'
stroke='#053635'
strokeOpacity='0.3'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
export { CloseIcon };

View File

@@ -1 +1,2 @@
export { Modal, ModalContent } from './modal';
export { Modal } from './modal';
export { ModalContent } from './modal-content';

View File

@@ -0,0 +1,79 @@
.ModalContent {
position: absolute;
width: rem(360px);
padding: rem(40px) rem(20px) rem(20px);
border-radius: rem(20px);
background: white;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
overflow: auto;
animation: fadeIn ease 0.3s;
@include iftablet {
top: unset;
width: rem(540px);
padding: rem(40px) rem(24px) rem(24px);
}
@include iflaptop {
padding: rem(40px) rem(30px) rem(30px);
}
@include ifdesktop {
width: rem(640px);
padding: rem(60px) rem(40px) rem(40px);
}
&_open {
animation: fadeOut ease 0.3s;
}
&_isIOS {
height: 100vh !important;
@include iftablet {
height: 100%;
}
}
}
.Inner {
position: relative;
overflow: hidden;
}
.CloseBtn {
position: absolute;
top: rem(20px);
right: rem(20px);
width: rem(24px);
height: rem(24px);
cursor: pointer;
transform: rotate(0deg);
transition: transform 0.3s;
&:hover,
&:active {
transform: rotate(90deg);
transition: transform 0.3s;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,49 @@
'use client';
import s from './modal-content.module.scss';
import { MouseEvent, ReactNode, useRef } from 'react';
import clsx from 'clsx';
import { useModal } from '@core/providers/modal-provider';
import { detectIOS, useClickOutside } from '@shared/lib';
import { CloseIcon } from '@shared/ui/modal/close-icon';
function ModalContent(props: {
children: ReactNode;
className?: string;
innerClassName?: string;
closeByClickOutside?: boolean;
}) {
const {
children,
className,
innerClassName,
closeByClickOutside = false,
} = props;
const modal = useModal();
const hideModal = () => modal.hideModal();
const modalRef = useRef<HTMLDivElement | null>(null);
const handleClickOutside = () => closeByClickOutside && hideModal();
useClickOutside(modalRef, handleClickOutside);
const isIOS = detectIOS();
const disableClick = (e: MouseEvent<HTMLDivElement>) => e.stopPropagation();
return (
<div
className={clsx(className, s.ModalContent, isIOS && s.ModalContent_isIOS)}
ref={modalRef}
onClick={disableClick}
>
<CloseIcon className={s.CloseBtn} onClick={hideModal} />
<div className={clsx(s.Inner, innerClassName)}>{children}</div>
</div>
);
}
export { ModalContent };

View File

@@ -3,7 +3,8 @@
position: absolute;
top: 0;
left: 0;
width: 100%;
bottom: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
@@ -34,97 +35,6 @@
}
}
.ModalContent {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: white;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
//max-height: 90%;
overflow: auto;
width: 100%;
//border-radius: 8px;
animation: fadeIn ease 0.3s;
@include iftablet {
position: relative;
top: unset;
bottom: unset;
left: unset;
right: unset;
height: 100%;
border-radius: rem(8px);
}
&_open {
animation: fadeOut ease 0.3s;
}
&_isIOS {
height: 100vh !important;
@include iftablet {
height: 100%;
}
}
&__Inner {
position: relative;
overflow: hidden;
height: fit-content;
}
}
.Modal {
position: relative;
&__Close {
position: absolute;
top: rem(5px);
right: rem(5px);
width: rem(36px);
height: rem(36px);
padding: rem(10px);
cursor: pointer;
margin-bottom: rem(4px);
transform: rotate(0deg);
transition: transform 0.3s;
@include iftablet {
top: rem(35px);
right: rem(35px);
margin-bottom: 0;
}
@include iflaptop {
top: rem(50px);
right: rem(50px);
}
&:hover,
&:active {
transform: rotate(45deg);
transition: transform 0.3s;
}
svg {
width: rem(17px);
height: rem(17px);
}
//svg {
// transform: rotate(0deg);
// transition: transform 0.3s;
// &:hover,
// &:active {
// transform: rotate(45deg);
// transition: transform 0.3s;
// }
//}
}
}
@keyframes fadeIn {
0% {
opacity: 0;

View File

@@ -1,11 +1,10 @@
'use client';
import s from './modal.module.scss';
import { ReactNode, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { ReactNode, useEffect } from 'react';
import clsx from 'clsx';
import { useModal } from '@core/providers/modal-provider';
import { detectIOS, useClickOutside } from '@shared/lib';
import { detectIOS } from '@shared/lib';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
@@ -28,7 +27,7 @@ const Modal = ({ children, isOpen, onClose }: ModalProps) => {
const isIOS = detectIOS();
return ReactDOM.createPortal(
return createPortal(
<div
className={clsx(
s.ModalBackdrop,
@@ -42,68 +41,4 @@ const Modal = ({ children, isOpen, onClose }: ModalProps) => {
);
};
function ModalContent(props: {
children: ReactNode;
className?: string;
innerClassName?: string;
closeByClickOutside?: boolean;
}) {
const {
children,
className,
innerClassName,
closeByClickOutside = false,
} = props;
const modal = useModal();
const hideModal = () => modal.hideModal();
const modalRef = useRef<HTMLDivElement | null>(null);
const handleClickOutside = () => {
if (closeByClickOutside) {
hideModal();
}
};
useClickOutside(modalRef, handleClickOutside);
const isIOS = detectIOS();
return (
<div
className={clsx(className, s.ModalContent, isIOS && s.ModalContent_isIOS)}
onClick={(e) => e.stopPropagation()}
ref={modalRef}
>
<div className={clsx(s.ModalContent__Inner, innerClassName)}>
<div className={s.Modal__Close} onClick={hideModal}>
<CloseIcon />
</div>
{children}
</div>
</div>
);
}
export { Modal, ModalContent };
const CloseIcon = () => (
<svg
width='17'
height='17'
viewBox='0 0 17 17'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M2.5 15L15.5 2M2.5 2L15.5 15'
stroke='#053635'
strokeOpacity='0.3'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
export { Modal };

View File

@@ -15,13 +15,13 @@
.Area {
background: $color-white;
border: 1px solid $color-darkgray;
border-radius: rem(20px);
padding: rem(10px) rem(24px);
border-radius: rem(16px);
padding: rem(10px) rem(10px);
transition: border ease .5s;
font-family: $font-open-sans;
font-weight: $font-regular;
font-size: rem(18px);
font-size: rem(16px);
line-height: 100%;
color: $color-text;
@@ -32,6 +32,8 @@
@include iftablet {
font-size: rem(18px);
border-radius: rem(20px);
padding: rem(10px) rem(24px);
}
@include iflaptop {

View File

@@ -118,33 +118,6 @@
height: rem(60px);
}
}
.Button {
display: none;
@include iflaptop {
display: flex;
flex-direction: row;
gap: rem(16px);
height: rem(40px);
padding: rem(20px);
}
@include ifdesktop {
gap: rem(16px);
height: rem(48px);
padding: rem(24px);
}
img {
width: rem(30px);
height: rem(30px);
@include ifdesktop {
width: rem(40px);
height: rem(40px);
}
}
}
}
}

View File

@@ -1,14 +1,15 @@
import s from './main.module.scss';
import { Button } from '@shared/ui';
import { ConsultationOrder } from '@/entities/home/ConsultationOrder';
import Image from 'next/image';
import { Button } from '@shared/ui';
import { ConsultationOrder } from '@/entities/home/consultation-order';
import bgStart from '@public/images/bg-start-desktop.jpg';
import logo from '@public/images/dtr-logo.png';
import waIcon from '@public/svg/whatsapp.svg';
import emailIcon from '@public/svg/email-icon.svg';
import callBtn from '@public/svg/phone-calling.svg';
import { CallbackOrder } from '@/entities/home/callback-order';
export default function Main() {
return (
@@ -48,10 +49,7 @@ export default function Main() {
>
<Image className={s.Icon} src={waIcon} alt='whatsapp' />
</a>
<Button className={s.Button}>
<Image src={callBtn} alt='Call' />
Обратный звонок
</Button>
<CallbackOrder />
</div>
</div>
<div className={s.Info}>

View File

@@ -6,8 +6,6 @@ import toast from 'react-hot-toast';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import man from '@public/images/footer-man.png';
import { sendFormFn } from '@shared/api/api.service';
import { isValidPhoneNumber } from 'libphonenumber-js/min';
@@ -39,8 +37,6 @@ export default function FooterForm() {
clearErrors,
formState: { errors },
} = useForm<TForm>({
mode: 'onSubmit',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues,
});