Dev #1

Merged
redrockjs merged 66 commits from dev into main 2025-12-11 08:37:11 +00:00
24 changed files with 763 additions and 5 deletions
Showing only changes of commit ea8a152711 - Show all commits

View File

@@ -4,6 +4,7 @@ import { Montserrat, Roboto } from 'next/font/google';
import '@core/styles/globals.scss';
import '@core/styles/reset.scss';
import { Footer, Header } from '@/widgets';
import { ModalProvider } from '@core/providers/modal-provider';
const roboto = Roboto({
subsets: ['cyrillic'],
@@ -30,7 +31,9 @@ export default function RootLayout({
<html lang='en'>
<body className={`${roboto.variable} ${montseratt.variable}`}>
<Header />
<ModalProvider>
<main>{children}</main>
</ModalProvider>
<Footer />
</body>
</html>

View File

@@ -0,0 +1,41 @@
'use client';
import {
useState,
useContext,
useCallback,
ReactNode,
createContext,
} from 'react';
import { Modal } from '@shared/ui/modal';
const ModalContext = createContext({
hideModal: () => {},
showModal: (content: ReactNode) => {},
});
const useModal = () => useContext(ModalContext);
const ModalProvider = ({ children }: { children: ReactNode }) => {
const [modalContent, setModalContent] = useState<ReactNode>(null);
const showModal = useCallback((content: ReactNode) => {
setModalContent(content);
}, []);
const hideModal = useCallback(() => {
setModalContent(null);
}, []);
return (
<ModalContext.Provider value={{ hideModal, showModal }}>
{children}
{/* Ваш Modal компонент здесь */}
<Modal isOpen={modalContent !== null} onClose={hideModal}>
{modalContent}
</Modal>
</ModalContext.Provider>
);
};
export { useModal, ModalProvider };

View File

@@ -24,6 +24,7 @@ $color-darkgray: #999999;
$color-text: #333333;
$color-text-light: #777777;
$color-green: #23A455;
$color-green-hover: #23A455C2;
$color-link: #333333;
$color-link-hover: #009283;
$color-error: #ff0000;

View File

@@ -0,0 +1 @@
export * from './ui';

View File

@@ -0,0 +1,46 @@
.Form {
display: flex;
flex-direction: column;
gap: rem(16px);
@include iflaptop {
gap: rem(20px);
}
}
.Title {
font-family: $font-roboto;
font-weight: $font-medium;
font-size: rem(20px);
line-height: 130%;
color: $color-text;
@include ifdesktop {
font-size: rem(24px);
}
}
.Description {
font-family: $font-roboto;
font-weight: $font-regular;
font-size: rem(16px);
line-height: 100%;
color: $color-text;
}
.Agreement {
font-family: $font-roboto;
font-weight: $font-regular;
font-size: rem(14px);
line-height: 100%;
color: $color-text;
a {
color: $color-green;
&:hover {
color: $color-green;
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,145 @@
'use client';
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';
import Link from 'next/link';
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'}
/>
)}
/>
<span className={s.Agreement}>
Нажимая на кнопку, вы даете согласие на обработку своих персональных
данных и соглашаетесь с
<Link href={'/privacy-policy'}> Политикой конфиденциальности</Link>
</span>
<Button className={s.SendBtn} variant='green'>
Отправить
</Button>
</form>
</ModalContent>
);
}
export { ConsultationModal };

View File

@@ -1,3 +1,4 @@
export * from './related-articles';
export * from './consultation';
export * from './sidebar';
export * from './consultation-modal';

View File

@@ -2,7 +2,7 @@
.Sidebar {
display: none;
@include iflaptop{
@include iflaptop {
display: flex;
flex-direction: column;
gap: rem(40px);
@@ -54,7 +54,7 @@
color: $color-white;
text-transform: uppercase;
@include ifdesktop{
@include ifdesktop {
font-size: rem(32px);
}
}
@@ -90,6 +90,14 @@
line-height: 130%;
color: $color-text;
list-style: unset;
a {
color: $color-green;
}
a:hover {
color: $color-green-hover;
text-decoration: underline;
}
}
}
}

View File

@@ -1,7 +1,12 @@
'use client';
import s from './styles.module.scss';
import Link from 'next/link';
import { Button } from '@shared/ui';
import { TSidebar } from '@shared/types/sidebar';
import { useModal } from '@core/providers/modal-provider';
import { ConsultationModal } from '@/feature/article';
import { CONTACTS } from '@shared/const/contacts';
type SidebarProps = TSidebar;
@@ -12,6 +17,11 @@ function Sidebar({
warrantiesTitle,
warranties,
}: SidebarProps) {
const modal = useModal();
const openModal = () => modal.showModal(<ConsultationModal />);
const callTo = `tel:${CONTACTS.PHONE}`;
return (
<aside className={s.Sidebar}>
<div className={s.Estimation}>
@@ -22,8 +32,12 @@ function Sidebar({
определения точной стоимости.
</p>
<p className={s.Text}>Оставьте заявку или позвоните по телефону</p>
<Button variant={'white'}>Записаться</Button>
<Button variant={'white'} onClick={openModal}>
Записаться
</Button>
<a href={callTo}>
<p className={s.Phone}>+7 (900) 241-34-34</p>
</a>
</div>
{related && (
<div className={s.Related}>

View File

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

View File

@@ -0,0 +1,28 @@
import { RefObject, useEffect } from 'react';
export function useClickOutside(
ref: RefObject<HTMLDivElement | null>,
fn: (event: Event) => void,
) {
useEffect(() => {
const handleClickOutside = (event: Event) => {
const el = ref?.current;
if (event instanceof MouseEvent && window !== undefined) {
const x = event?.offsetX || 0;
const width = window?.innerWidth - 18;
if (x >= width) {
return;
}
}
if (!el || el.contains((event?.target as Node) || null)) {
return;
}
fn(event);
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, fn]);
}

View File

@@ -0,0 +1,15 @@
function detectIOS() {
const iosQuirkPresent = () => {
const audio = new Audio();
audio.volume = 0.5;
return audio.volume === 1; // volume cannot be changed from "1" on iOS 12 and below
};
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isAppleDevice = navigator.userAgent.includes('Macintosh');
const isTouchScreen = navigator.maxTouchPoints >= 1; // true for iOS 13 (and hopefully beyond)
return isIOS || (isAppleDevice && (isTouchScreen || iosQuirkPresent()));
}
export { detectIOS };

View File

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

2
src/shared/lib/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { useClickOutside } from './clickOutside';
export { detectIOS } from './detectIOS';

View File

@@ -1,3 +1,5 @@
export * from './input';
export * from './phone-input';
export * from './button';
export * from './modal';
export * from './text-area';

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

@@ -0,0 +1,2 @@
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

@@ -0,0 +1,54 @@
.ModalBackdrop {
z-index: 1000;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
padding: 0;
animation: fadeIn ease 0.3s;
@include iftablet {
position: fixed;
height: 100vh;
padding: 0 rem(20px);
}
@include iflaptop {
padding: 0 rem(48px);
}
&_open {
animation: fadeOut ease 0.3s;
}
&_isIOS {
height: 150vh !important;
@include iftablet {
height: unset;
}
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,44 @@
'use client';
import s from './modal.module.scss';
import { ReactNode, useEffect } from 'react';
import clsx from 'clsx';
import { detectIOS } from '@shared/lib';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
const Modal = ({ children, isOpen, onClose }: ModalProps) => {
useEffect(() => {
const html = document.documentElement;
if (isOpen) {
html.style.overflow = 'hidden';
}
return () => {
html.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
const isIOS = detectIOS();
return createPortal(
<div
className={clsx(
s.ModalBackdrop,
isOpen && s.ModalBackdrop__open,
isIOS && s.ModalBackdrop_isIOS,
)}
>
{children}
</div>,
document.body,
);
};
export { Modal };

View File

@@ -0,0 +1 @@
export * from './ui';

View File

@@ -0,0 +1,139 @@
.Container {
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: max-content;
/* Allows the `resize` property to work on the div */
overflow: hidden;
&_fullWidth {
width: 100%;
}
}
.Area {
background: $color-white;
border: 1px solid $color-darkgray;
border-radius: rem(16px);
padding: rem(10px) rem(10px);
transition: border ease .5s;
font-family: $font-roboto;
font-weight: $font-regular;
font-size: rem(16px);
line-height: 100%;
color: $color-text;
resize: none;
display: block;
width: 100%;
flex-grow: 1;
@include iftablet {
font-size: rem(18px);
border-radius: rem(20px);
padding: rem(10px) rem(24px);
}
@include iflaptop {
font-size: rem(20px);
padding: rem(10px) rem(20px);
}
@include ifdesktop {
font-size: rem(24px);
}
/* scrollbar */
& {
/* Arrow mouse cursor over the scrollbar */
cursor: auto;
}
&::-webkit-scrollbar {
width: rem(12px);
height: rem(12px);
}
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb {
background-clip: content-box;
border: rem(4px) solid transparent;
border-radius: rem(12px);
}
&::-webkit-scrollbar-track {
background-color: #333; // цвет дорожки
}
&::-webkit-scrollbar-thumb {
background-color: #999; // цвет указателя
}
@media (hover: hover) {
:where(&:not(:disabled))::-webkit-scrollbar-thumb:hover {
background-color: #999;
}
}
&:where(:autofill) {
/* Reliably removes native autofill colors */
background-clip: text;
-webkit-text-fill-color: #999;
}
&:focus {
border-color: $color-green;
transition: border-color ease .5s;
}
&:hover {
border-color: $color-text;
transition: border-color ease .5s;
}
&:focus:hover {
border-color: $color-green;
transition: border-color ease .5s;
}
&:focus-visible {
outline: none;
}
&_ghost {
background: transparent;
color: $color-white;
&:focus {
border-color: $color-green;
transition: border-color ease .5s;
}
&:hover {
border-color: $color-white;
transition: border-color ease .5s;
}
&:focus:hover {
border-color: $color-green;
transition: border-color ease .5s;
}
}
&_error {
border: 1px solid $color-error;
}
}
.Error {
margin-left: 8px;
margin-top: 4px;
font-family: $font-roboto;
font-weight: $font-light;
font-size: rem(12px);
line-height: 100%;
color: $color-error;
}

View File

@@ -0,0 +1,58 @@
import s from './styles.module.scss';
import {
DetailedHTMLProps,
forwardRef,
ReactNode,
Ref,
TextareaHTMLAttributes,
} from 'react';
import { clsx } from 'clsx';
type TextAreaProps = {
className?: string;
children?: ReactNode;
variant?: 'default' | 'ghost';
fullWidth?: boolean;
error?: string | boolean;
errorTextColor?: string;
} & DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>;
const TextArea = forwardRef(function TextArea(
{
className,
children,
variant = 'default',
fullWidth = false,
error = false,
errorTextColor,
...props
}: TextAreaProps,
ref: Ref<HTMLTextAreaElement>,
) {
return (
<div className={clsx(s.Container, fullWidth && s.Container_fullWidth)}>
<textarea
{...props}
ref={ref}
className={clsx(
className,
s.Area,
s['Area_' + variant],
error && s.Area_error,
)}
>
{children}
</textarea>
{error && (
<span className={s.Error} style={{ color: errorTextColor }}>
{error}
</span>
)}
</div>
);
});
export { TextArea };