diff --git a/.husky/pre-commit b/.husky/pre-commit
index bc3ab45..258dc3d 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,5 @@
npx lint-staged
-echo "Running test build..."
+echo ""
+echo "🚀 Running test build..."
npm run build
\ No newline at end of file
diff --git a/README.md b/README.md
index 54662ec..6916a02 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,3 @@
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7f86eca..c765879 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -11,6 +11,11 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
+ {
+ rules: {
+ "@typescript-eslint/no-unused-vars": "off",
+ },
+ },
];
export default eslintConfig;
diff --git a/package-lock.json b/package-lock.json
index df7ece4..ac66e8f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,15 +8,24 @@
"name": "fire-exam",
"version": "0.1.0",
"dependencies": {
+ "@hookform/resolvers": "^5.1.1",
+ "@maskito/core": "^3.9.0",
+ "@maskito/phone": "^3.9.0",
+ "@maskito/react": "^3.9.0",
+ "libphonenumber-js": "^1.12.9",
"next": "15.3.2",
+ "nodemailer": "^7.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
- "swiper": "^11.2.8"
+ "swiper": "^11.2.8",
+ "zod": "^3.25.56"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
+ "@types/nodemailer": "^6.4.17",
"@types/react": "^19",
"@types/react-dom": "^19",
"clsx": "^2.1.1",
@@ -203,6 +212,18 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz",
+ "integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -665,6 +686,44 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@maskito/core": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.9.0.tgz",
+ "integrity": "sha512-OgzzgzJTXFZH79mqyHFVUZ5/bUhSW147+JzYVX+DdmQ5zc+mxmFQqsUS5ffVxd2C7/bnEmC7+savYbcae2IhBw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@maskito/kit": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.9.0.tgz",
+ "integrity": "sha512-CD7TQ7WUMtZ8jkhOsislbqht1gMuNHVQsLJG9tXcGvZbegkgJ6wdkggkol1y1/0F5eh/fT+RzzKD9dVjSQon2g==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@maskito/core": "^3.9.0"
+ }
+ },
+ "node_modules/@maskito/phone": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.9.0.tgz",
+ "integrity": "sha512-EUCOmOscoQM+vnJwOAiBXVpZVVYkHc7rhnqLqfkslXsZCg5VLNNpzAb8SuFQMxYJYM2NnMawErvL7CjOdVDmvQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@maskito/core": "^3.9.0",
+ "@maskito/kit": "^3.9.0",
+ "libphonenumber-js": ">=1.0.0"
+ }
+ },
+ "node_modules/@maskito/react": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/@maskito/react/-/react-3.9.0.tgz",
+ "integrity": "sha512-qYGncdyaPbi50rDkg0gwh64DHPBCT6YMdkWsMbqG57bVjE1S0X9zbZQICieRQXGVkRjlgJenoMu3b5svdH4ysQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@maskito/core": "^3.9.0",
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz",
@@ -884,6 +943,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -941,6 +1006,16 @@
"undici-types": "~6.19.2"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "6.4.17",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
+ "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
@@ -3992,6 +4067,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/libphonenumber-js": {
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz",
+ "integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==",
+ "license": "MIT"
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -4351,6 +4432,15 @@
}
}
},
+ "node_modules/nodemailer": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
+ "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4768,6 +4858,22 @@
"react": "^19.1.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.57.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz",
+ "integrity": "sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-hot-toast": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
@@ -5998,6 +6104,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.56",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz",
+ "integrity": "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index b7dc14a..b2ff3b1 100644
--- a/package.json
+++ b/package.json
@@ -11,15 +11,24 @@
"prepare": "husky"
},
"dependencies": {
+ "@hookform/resolvers": "^5.1.1",
+ "@maskito/core": "^3.9.0",
+ "@maskito/phone": "^3.9.0",
+ "@maskito/react": "^3.9.0",
+ "libphonenumber-js": "^1.12.9",
"next": "15.3.2",
+ "nodemailer": "^7.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
- "swiper": "^11.2.8"
+ "swiper": "^11.2.8",
+ "zod": "^3.25.56"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
+ "@types/nodemailer": "^6.4.17",
"@types/react": "^19",
"@types/react-dom": "^19",
"clsx": "^2.1.1",
diff --git a/public/images/dtr-logo.png b/public/images/dtr-logo.png
new file mode 100644
index 0000000..157e80b
Binary files /dev/null and b/public/images/dtr-logo.png differ
diff --git a/public/images/footer-man.png b/public/images/footer-man.png
new file mode 100644
index 0000000..f61b406
Binary files /dev/null and b/public/images/footer-man.png differ
diff --git a/public/images/license-dtr-eac.png b/public/images/license-dtr-eac.png
new file mode 100644
index 0000000..fd78b4b
Binary files /dev/null and b/public/images/license-dtr-eac.png differ
diff --git a/public/images/license-mcs.jpg b/public/images/license-mcs.jpg
new file mode 100644
index 0000000..1f10f69
Binary files /dev/null and b/public/images/license-mcs.jpg differ
diff --git a/public/svg/checkbox.svg b/public/svg/checkbox.svg
new file mode 100644
index 0000000..3c744ee
--- /dev/null
+++ b/public/svg/checkbox.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/public/svg/email-icon.svg b/public/svg/email-icon.svg
new file mode 100644
index 0000000..622dddd
--- /dev/null
+++ b/public/svg/email-icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/svg/sochi-adm-logo.svg b/public/svg/sochi-adm-logo.svg
new file mode 100644
index 0000000..581b53d
--- /dev/null
+++ b/public/svg/sochi-adm-logo.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts
new file mode 100644
index 0000000..4867ec7
--- /dev/null
+++ b/src/app/api/heartbeat/route.ts
@@ -0,0 +1,9 @@
+export async function GET(request: Request) {
+ return new Response('Heartbeat is OK!', {
+ status: 200,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ },
+ });
+}
diff --git a/src/app/api/sendform/route.ts b/src/app/api/sendform/route.ts
new file mode 100644
index 0000000..a083b63
--- /dev/null
+++ b/src/app/api/sendform/route.ts
@@ -0,0 +1,66 @@
+import nodemailer from 'nodemailer';
+import { TBaseForm } from '@shared/api/api.types';
+import { CORE } from '@shared/config/core';
+
+async function sendMail(data: TBaseForm) {
+ const { name, phone, message, form } = data;
+
+ const formattedBody = `
+
+
+ Сообщение с сайта "Пожарная экспертиза"
+ Форма отправки: ${form}
+ Имя отправителя: ${name ?? 'не указано'}
+ Номер телефона: ${phone}
+ Сообщение: ${message ?? 'отсутствует'}
+
+
+ `;
+
+ const transporter = nodemailer.createTransport({
+ service: 'yandex',
+ auth: {
+ user: CORE.MAIL_USER,
+ pass: CORE.MAIL_PASS,
+ },
+ });
+
+ return await transporter.sendMail({
+ from: CORE.MAIL_FROM,
+ to: CORE.MAIL_TO,
+ subject: 'Заявка с сайта FireExams',
+ html: formattedBody,
+ });
+}
+
+export async function POST(request: Request) {
+ try {
+ const payload = await request.json();
+
+ if (payload.secure !== CORE.MAIL_SECURE_KEY) {
+ await Promise.reject('Request failure!');
+ }
+
+ const sendResult = await sendMail({ ...payload });
+
+ const data = { message: 'Form accepted' };
+ const headers = new Headers({
+ 'Content-Type': 'application/json',
+ });
+ const options = {
+ status: 200,
+ statusText: 'OK',
+ headers: headers,
+ };
+
+ if (sendResult?.messageId) {
+ return new Response(JSON.stringify(data), options);
+ } else {
+ await Promise.reject('Sending request failure!');
+ }
+ } catch (error) {
+ return new Response(`Api error: ${error}`, {
+ status: 400,
+ });
+ }
+}
diff --git a/src/app/cookie/page.tsx b/src/app/cookie/page.tsx
new file mode 100644
index 0000000..1c0ca3f
--- /dev/null
+++ b/src/app/cookie/page.tsx
@@ -0,0 +1,9 @@
+import { Cookie } from '@views/cookie';
+
+export default function CookiePage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ed599af..716f773 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,8 @@ 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';
+import { CookieNotice } from '@/widgets';
const openSans = Open_Sans({
subsets: ['cyrillic'],
@@ -34,8 +36,9 @@ export default function RootLayout({
return (
- {children}
+ {children}
+
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index de0ec89..40cb571 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,9 +1,9 @@
-import { HomePage } from '@views/home';
+import { Home } from '@views/home';
-export default function Home() {
+export default function HomePage() {
return (
-
+
);
}
diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx
new file mode 100644
index 0000000..5346dc2
--- /dev/null
+++ b/src/app/privacy-policy/page.tsx
@@ -0,0 +1,9 @@
+import { PrivacyPolicy } from '@views/privacy-policy';
+
+export default function PrivacyPolicyPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/user-agreement/page.tsx b/src/app/user-agreement/page.tsx
new file mode 100644
index 0000000..3fdd265
--- /dev/null
+++ b/src/app/user-agreement/page.tsx
@@ -0,0 +1,9 @@
+import { UserAgreement } from '@views/user-agreement';
+
+export default function UserAgreementPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/core/constants/privacy-policy.ts b/src/core/constants/privacy-policy.ts
new file mode 100644
index 0000000..c76d67c
--- /dev/null
+++ b/src/core/constants/privacy-policy.ts
@@ -0,0 +1,5 @@
+const COMPANY = '«ООО ДИТРАСО»';
+const WEB = 'https://www.fire-expert.ru/';
+const EMAIL = 'spo-71@yandex.ru';
+
+export { COMPANY, WEB, EMAIL };
diff --git a/src/core/providers/modal-provider.tsx b/src/core/providers/modal-provider.tsx
new file mode 100644
index 0000000..af19951
--- /dev/null
+++ b/src/core/providers/modal-provider.tsx
@@ -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(null);
+
+ const showModal = useCallback((content: ReactNode) => {
+ setModalContent(content);
+ }, []);
+
+ const hideModal = useCallback(() => {
+ setModalContent(null);
+ }, []);
+
+ return (
+
+ {children}
+ {/* Ваш Modal компонент здесь */}
+
+ {modalContent}
+
+
+ );
+};
+
+export { useModal, ModalProvider };
diff --git a/src/core/styles/variables.scss b/src/core/styles/variables.scss
index c518824..9c570b1 100644
--- a/src/core/styles/variables.scss
+++ b/src/core/styles/variables.scss
@@ -1,4 +1,3 @@
-
//frontend breakpoint
$mobile: 360px;
$tablet: 768px;
@@ -19,10 +18,12 @@ $font-semi-bold: 600;
$color-white: #FFFFFF;
$color-black: #000000;
$color-orange: #E96526;
+$color-orange-hover: #ea4b05;
$color-lightgray: #E4E1E1;
$color-darkgray: #999999;
$color-text: #333333;
$color-text-light: #222222;
$color-mark: #E96526;
-$color-error: #FF0000;
+$color-error: #ff0000;
+$color-error-light: #ff9191;
$color-gray-border: #555555;
\ No newline at end of file
diff --git a/src/entities/home/ConsultationOrder/index.ts b/src/entities/home/ConsultationOrder/index.ts
deleted file mode 100644
index dac7ffd..0000000
--- a/src/entities/home/ConsultationOrder/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as ConsultationOrder } from './ui';
diff --git a/src/entities/home/ConsultationOrder/ui.tsx b/src/entities/home/ConsultationOrder/ui.tsx
deleted file mode 100644
index 5a410bb..0000000
--- a/src/entities/home/ConsultationOrder/ui.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/src/entities/home/callback-order/index.ts b/src/entities/home/callback-order/index.ts
new file mode 100644
index 0000000..84a1b20
--- /dev/null
+++ b/src/entities/home/callback-order/index.ts
@@ -0,0 +1 @@
+export { CallbackOrder } from './ui';
diff --git a/src/entities/home/callback-order/styles.module.scss b/src/entities/home/callback-order/styles.module.scss
new file mode 100644
index 0000000..5435b1a
--- /dev/null
+++ b/src/entities/home/callback-order/styles.module.scss
@@ -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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/entities/home/callback-order/ui.tsx b/src/entities/home/callback-order/ui.tsx
new file mode 100644
index 0000000..aed940a
--- /dev/null
+++ b/src/entities/home/callback-order/ui.tsx
@@ -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();
+
+ return (
+
+
+
+ );
+}
+
+export { CallbackOrder };
diff --git a/src/entities/home/consultation-modal/index.ts b/src/entities/home/consultation-modal/index.ts
new file mode 100644
index 0000000..e9f2654
--- /dev/null
+++ b/src/entities/home/consultation-modal/index.ts
@@ -0,0 +1 @@
+export { ConsultationModal } from './ui';
diff --git a/src/entities/home/consultation-modal/styles.module.scss b/src/entities/home/consultation-modal/styles.module.scss
new file mode 100644
index 0000000..141e086
--- /dev/null
+++ b/src/entities/home/consultation-modal/styles.module.scss
@@ -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;
+}
\ No newline at end of file
diff --git a/src/entities/home/consultation-modal/ui.tsx b/src/entities/home/consultation-modal/ui.tsx
new file mode 100644
index 0000000..5eaa309
--- /dev/null
+++ b/src/entities/home/consultation-modal/ui.tsx
@@ -0,0 +1,138 @@
+import s from './styles.module.scss';
+import { Button, Input } from '@/shared/ui';
+import { Checkbox, 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;
+
+const defaultValues = {
+ name: '',
+ phone: '',
+ message: '',
+};
+
+type ConsultationModalProps = {
+ className?: string;
+};
+
+function ConsultationModal({}: ConsultationModalProps) {
+ const {
+ handleSubmit,
+ control,
+ reset,
+ clearErrors,
+ formState: { errors },
+ } = useForm({
+ 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 (
+
+
+
+ );
+}
+
+export { ConsultationModal };
diff --git a/src/entities/home/consultation-order/index.ts b/src/entities/home/consultation-order/index.ts
new file mode 100644
index 0000000..73c36af
--- /dev/null
+++ b/src/entities/home/consultation-order/index.ts
@@ -0,0 +1 @@
+export { ConsultationOrder } from './ui';
diff --git a/src/entities/home/consultation-order/ui.tsx b/src/entities/home/consultation-order/ui.tsx
new file mode 100644
index 0000000..2a3524c
--- /dev/null
+++ b/src/entities/home/consultation-order/ui.tsx
@@ -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();
+
+ return (
+
+ );
+}
+
+export { ConsultationOrder };
diff --git a/src/shared/api/api.service.ts b/src/shared/api/api.service.ts
new file mode 100644
index 0000000..2ee7171
--- /dev/null
+++ b/src/shared/api/api.service.ts
@@ -0,0 +1,35 @@
+import { API_ROUTES } from '@shared/config/routes';
+import { TBaseForm } from '@shared/api/api.types';
+import { CORE } from '@shared/config/core';
+
+type TRequest = TBaseForm;
+
+const sendFormFn = async ({ ...props }: TRequest) => {
+ try {
+ const response = await fetch('/api' + API_ROUTES.SEND_FORM, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...props,
+ secure: CORE.MAIL_SECURE_KEY,
+ }),
+ });
+
+ if (response.ok) {
+ return response;
+ } else {
+ if (response.status === 400) throw new Error(`400 Bad request`);
+ if (response.status === 401) throw new Error(`401 Unauthorized`);
+ if (response.status === 500) throw new Error('500 Internal server error');
+
+ throw new Error(`${response.status} - Network response failure`);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+};
+
+export { sendFormFn };
diff --git a/src/shared/api/api.types.ts b/src/shared/api/api.types.ts
new file mode 100644
index 0000000..e46571f
--- /dev/null
+++ b/src/shared/api/api.types.ts
@@ -0,0 +1,6 @@
+export type TBaseForm = {
+ form: string;
+ name?: string;
+ phone: string;
+ message?: string;
+};
diff --git a/src/shared/config/core.ts b/src/shared/config/core.ts
new file mode 100644
index 0000000..ddba9d1
--- /dev/null
+++ b/src/shared/config/core.ts
@@ -0,0 +1,7 @@
+export const CORE = {
+ MAIL_USER: process.env.MAIL_USER,
+ MAIL_PASS: process.env.MAIL_PASS,
+ MAIL_FROM: process.env.MAIL_FROM,
+ MAIL_TO: process.env.MAIL_TO,
+ MAIL_SECURE_KEY: process.env.NEXT_PUBLIC_MAIL_SECURE_KEY,
+} as const;
diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts
new file mode 100644
index 0000000..cebba69
--- /dev/null
+++ b/src/shared/config/routes.ts
@@ -0,0 +1,4 @@
+export const API_ROUTES = {
+ SEND_FORM: '/sendform',
+ HEARTBEAT: '/heartbeat',
+} as const;
diff --git a/src/shared/lib/clickOutside/index.ts b/src/shared/lib/clickOutside/index.ts
new file mode 100644
index 0000000..145b15f
--- /dev/null
+++ b/src/shared/lib/clickOutside/index.ts
@@ -0,0 +1 @@
+export { useClickOutside } from './useClickOutside';
diff --git a/src/shared/lib/clickOutside/useClickOutside.tsx b/src/shared/lib/clickOutside/useClickOutside.tsx
new file mode 100644
index 0000000..aab6bb7
--- /dev/null
+++ b/src/shared/lib/clickOutside/useClickOutside.tsx
@@ -0,0 +1,28 @@
+import { RefObject, useEffect } from 'react';
+
+export function useClickOutside(
+ ref: RefObject,
+ 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]);
+}
diff --git a/src/shared/lib/detectIOS/detectIOS.ts b/src/shared/lib/detectIOS/detectIOS.ts
new file mode 100644
index 0000000..1084da2
--- /dev/null
+++ b/src/shared/lib/detectIOS/detectIOS.ts
@@ -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 };
diff --git a/src/shared/lib/detectIOS/index.ts b/src/shared/lib/detectIOS/index.ts
new file mode 100644
index 0000000..544abe3
--- /dev/null
+++ b/src/shared/lib/detectIOS/index.ts
@@ -0,0 +1 @@
+export { detectIOS } from './detectIOS';
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
new file mode 100644
index 0000000..c2178b3
--- /dev/null
+++ b/src/shared/lib/index.ts
@@ -0,0 +1,2 @@
+export { useClickOutside } from './clickOutside';
+export { detectIOS } from './detectIOS';
diff --git a/src/shared/ui/advanced-phone-input/advanced-phone-input.tsx b/src/shared/ui/advanced-phone-input/advanced-phone-input.tsx
deleted file mode 100644
index 179f8d1..0000000
--- a/src/shared/ui/advanced-phone-input/advanced-phone-input.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import s from './advancedPhoneInput.module.scss';
-import { DetailedHTMLProps, InputHTMLAttributes } from 'react';
-import { clsx } from 'clsx';
-import { Button, Input } from '@shared/ui';
-
-type AdvancedPhoneInputProps = {
- containerClassName?: string;
- inputClassName?: string;
- buttonClassName?: string;
- onClick?: () => void;
- text: string;
-} & DetailedHTMLProps, HTMLInputElement>;
-
-export default function AdvancedPhoneInput({
- containerClassName,
- inputClassName,
- buttonClassName,
- onClick,
- text,
- ...props
-}: AdvancedPhoneInputProps) {
- return (
-
-
-
-
- );
-}
diff --git a/src/shared/ui/button/button.tsx b/src/shared/ui/button/button.tsx
index ea39f3d..e2e6389 100644
--- a/src/shared/ui/button/button.tsx
+++ b/src/shared/ui/button/button.tsx
@@ -34,11 +34,11 @@ export default function Button({
return (