diff --git a/.info/project.json b/.info/project.json
index 4bd30a4e..dfd39d7c 100644
--- a/.info/project.json
+++ b/.info/project.json
@@ -1,6 +1,6 @@
{
- "date": "04.2026",
- "complexity": "4",
+ "date": "05.2026",
+ "complexity": "5",
"category": "commercial",
"deploy": "https:\/\/icon-artel.ru",
"title": {
@@ -8,24 +8,69 @@
"en": "Icon Painting Artel"
},
"textFirst": {
- "ru": "Частный проект на React + Next.js",
- "en": "A private project on React + Next.js"
+ "ru": "Сложный проект на Next.js + TypeScript + Cockpit CMS",
+ "en": "A difficult project on Next.js + TypeScript + Cockpit CMS"
},
"textSecond": {
- "ru": "",
- "en": ""
+ "ru": "Иконописная Артель — сайт иконописной мастерской: галерея работ, полноэкранная галерея и контакты с картой.",
+ "en": "Icon Painting Artel — a site for icon painting artel: gallery of works, fullscreen gallery and contact info with map."
},
"functionality": {
- "ru": ["Cockpit CMS", "Подключен Swiper"],
- "en": ["Cockpit CMS", "Swiper is integrated"]
+ "ru": [
+ "Получение и отправка данных в Cockpit CMS",
+ "Слайдер Swiper для галерей",
+ "Lightgallery для полноэкранного просмотра фотографий",
+ "Интеграция с Яндекс.Картами",
+ "Yandex Smart Captcha на формах обратной связи",
+ "Генерация sitemap.xml через route",
+ "Публикация через GitHub Actions и Docker на сервер"
+ ],
+ "en": [
+ "Data fetching and submission with Cockpit CMS",
+ "Swiper slider for galleries",
+ "Lightgallery for fullscreen photo viewing",
+ "Integration with Yandex.Maps",
+ "Yandex Smart Captcha on contact forms",
+ "sitemap.xml generation via route",
+ "Deployment via GitHub Actions and Docker on server"
+ ]
},
- "technologies": ["html", "scss", "javascript", "react", "next"],
+ "technologies": [
+ "html",
+ "scss",
+ "typescript",
+ "react",
+ "next",
+ "cockpit"
+ ],
"pages": {
- "ru": ["Главная"],
- "en": ["Main"]
+ "ru": [
+ "Главная",
+ "О нас",
+ "Категории",
+ "Контакты",
+ "Галерея",
+ "В наличии",
+ "Доставка и заказ",
+ "Новости",
+ "Отзывы",
+ "Работы"
+ ],
+ "en": [
+ "Main",
+ "About",
+ "Categories",
+ "Contacts",
+ "Gallery",
+ "In stock",
+ "Order & Delivery",
+ "News",
+ "Reviews",
+ "Works"
+ ]
},
"notImplemented": {
- "ru": ["Интеграция с Telegram и ВК"],
- "en": ["Integration with Telegram and VK"]
+ "ru": ["Онлайн-оплаты"],
+ "en": ["Online payments"]
}
}
diff --git a/COOKIE-CONSENT.md b/COOKIE-CONSENT.md
new file mode 100644
index 00000000..f5091bf4
--- /dev/null
+++ b/COOKIE-CONSENT.md
@@ -0,0 +1,199 @@
+# Cookie Consent — документация
+
+## Обзор
+
+Система управления согласием на использование cookie. Хранит выбор пользователя в одной куке `cookie-consent` (JSON), читает её на сервере через Next.js `cookies()`, не мерцает при загрузке.
+
+| Файл | Роль |
+| ---------------------------------------------------- | ---------------------------------- |
+| `src/context/CookieConsentContext.tsx` | Провайдер и хук `useCookieConsent` |
+| `src/components/CookieBanner/CookieBanner.tsx` | Server-обёртка баннера |
+| `src/components/CookieBanner/CookieBannerClient.tsx` | UI баннера (клиент) |
+| `src/components/ServicesInit/ServicesInit.tsx` | Регистрация колбеков сервисов |
+| `src/app/layout.tsx` | Инициализация провайдера |
+
+---
+
+## Категории cookie
+
+Категории `COOKIE_CATEGORIES` — в `src/const/const.ts`.
+Типы `CookieCategoryId` и `CookieConsent` — в `src/types/types.ts`.
+
+| id | Название | Обязательные |
+| ------------- | --------------------- | :----------: |
+| `necessary` | Обязательные cookie | ✅ да |
+| `functional` | Функциональные cookie | ❌ нет |
+| `statistical` | Статистические cookie | ❌ нет |
+| `marketing` | Маркетинговые cookie | ❌ нет |
+
+```typescript
+// src/types/types.ts
+export type CookieCategoryId = 'necessary' | 'functional' | 'statistical' | 'marketing'
+export type CookieConsent = Record
+```
+
+---
+
+## Как работает инициализация
+
+```
+SSR: layout.tsx читает куку cookie-consent из заголовков запроса
+ ↓
+Передаёт initialConsent в
+ ↓
+Провайдер сразу знает состояние — баннер либо скрыт, либо показан с первого рендера
+```
+
+- Кука есть → `initialConsent = { necessary: true, statistical: true, ... }` → баннер не рендерится
+- Куки нет → `initialConsent = null` → баннер показывается сразу
+
+---
+
+## API контекста
+
+```typescript
+const {
+ consent, // CookieConsent — текущее состояние согласия
+ saveConsent, // (c: CookieConsent) => void — сохранить выбор в куку
+ registerCallback, // (type: CookieCategoryId, fn: () => void) => void
+ isBannerVisible, // boolean — показывать ли баннер
+ openBanner, // () => void — открыть баннер повторно
+} = useCookieConsent()
+```
+
+> Хук `useCookieConsent` можно использовать только внутри `CookieConsentProvider`.
+
+---
+
+## Сценарии использования
+
+### 1. Проверить согласие в компоненте
+
+Показывать блок только при наличии согласия:
+
+```tsx
+'use client'
+import { useCookieConsent } from '@/context/CookieConsentContext'
+
+export default function AdBlock() {
+ const { consent } = useCookieConsent()
+
+ if (!consent.marketing) return null
+
+ return Реклама
+}
+```
+
+---
+
+### 2. Зарегистрировать колбек на принятие куки
+
+Основной способ подключения внешних сервисов. Логика `registerCallback`:
+
+- согласие **уже есть** при регистрации → `fn()` вызывается немедленно
+- согласия **нет** → `fn()` вызовется автоматически когда пользователь примет этот тип
+
+```tsx
+'use client'
+import { useEffect } from 'react'
+import { useCookieConsent } from '@/context/CookieConsentContext'
+
+export default function MyAnalytics() {
+ const { registerCallback } = useCookieConsent()
+
+ useEffect(() => {
+ registerCallback('statistical', () => {
+ console.log('Загружаем метрику...')
+ })
+
+ registerCallback('marketing', () => {
+ console.log('Загружаем VK Pixel...')
+ })
+ }, [registerCallback])
+
+ return null
+}
+```
+
+> `registerCallback` обёрнут в `useCallback([])` в провайдере — ссылка никогда не меняется, поэтому `[registerCallback]` в зависимостях безопасен и удовлетворяет правилу `exhaustive-deps`.
+
+---
+
+### 3. Открыть баннер повторно
+
+Например, кнопка «Настройки cookie» в футере:
+
+```tsx
+'use client'
+import { useCookieConsent } from '@/context/CookieConsentContext'
+
+export default function CookieSettingsButton() {
+ const { openBanner } = useCookieConsent()
+
+ return Настройки cookie
+}
+```
+
+---
+
+## Добавление нового сервиса
+
+Всё делается в `src/components/ServicesInit/ServicesInit.tsx`:
+
+```tsx
+useEffect(() => {
+ // Яндекс.Метрика (statistical)
+ registerCallback('statistical', () => {
+ const counterId = process.env.NEXT_PUBLIC_METRIKA_ID
+ if (!counterId || (window as any).ym) return
+ const script = document.createElement('script')
+ // ... код метрики
+ document.head.appendChild(script)
+ })
+
+ // VK Pixel или Google Ads (marketing)
+ registerCallback('marketing', () => {
+ // вставить скрипт пикселя
+ })
+
+ // Чат-виджет или сохранение настроек темы (functional)
+ registerCallback('functional', () => {
+ // загрузить виджет
+ })
+}, [registerCallback])
+```
+
+---
+
+## Схема жизненного цикла
+
+```
+Пользователь открыл сайт
+ → layout читает куку (SSR)
+ → CookieConsentProvider(initialConsent)
+ → ServicesInit монтируется: registerCallback('statistical', fn), ...
+ ├─ consent.statistical = true → fn() вызывается сразу
+ └─ consent.statistical = false → fn() ждёт
+
+Пользователь нажал «Принять все»
+ → saveConsent({ necessary:true, functional:true, statistical:true, marketing:true })
+ → document.cookie = 'cookie-consent=...' (max-age 1 год, SameSite=Lax)
+ → setConsent(...) → useEffect в провайдере срабатывает
+ → все зарегистрированные колбеки для принятых типов вызываются
+ → баннер скрывается
+```
+
+---
+
+## Кука
+
+```
+Имя: cookie-consent
+Формат: URL-encoded JSON
+Пример: %7B%22necessary%22%3Atrue%2C%22functional%22%3Afalse%2C%22statistical%22%3Atrue%2C%22marketing%22%3Afalse%7D
+Декод.: {"necessary":true,"functional":false,"statistical":true,"marketing":false}
+Срок: 1 год (max-age=31536000)
+Флаги: path=/; SameSite=Lax
+```
+
+Поле `necessary` всегда `true` — принудительно выставляется в `saveConsent`.
diff --git a/src/app/api/documents/[type]/route.ts b/src/app/api/documents/[type]/route.ts
index e0750a52..06afce8d 100644
--- a/src/app/api/documents/[type]/route.ts
+++ b/src/app/api/documents/[type]/route.ts
@@ -4,6 +4,7 @@ import cockpit from '@/lib/CockpitAPI'
const DOCUMENT_TITLES: Record = {
agreement: 'Согласие на обработку персональных данных - Иконописная Артель',
policy: 'Политика обработки персональных данных - Иконописная Артель',
+ cookie: 'Политика использования файлов cookie - Иконописная Артель',
}
/**
diff --git a/src/app/gallery/[...slug]/page.tsx b/src/app/gallery/[...slug]/page.tsx
index 6028c685..c70c5f35 100644
--- a/src/app/gallery/[...slug]/page.tsx
+++ b/src/app/gallery/[...slug]/page.tsx
@@ -11,13 +11,13 @@ import {
buildGalleryBreadcrumbs,
} from '@/functions/gallery'
-type PageParams = {
+type PageProps = {
params: Promise<{
slug: string[]
}>
}
-export async function generateMetadata({ params }: PageParams): Promise {
+export async function generateMetadata({ params }: PageProps): Promise {
const { slug } = await params
const lastSlug = slug[slug.length - 1]
const galleryData: GalleryTreeItem[] | null = await fetchTree('gallery')
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: PageParams): Promise {
+export default async function Page({ params }: PageProps): Promise {
const { slug } = await params
const lastSlug = slug[slug.length - 1]
const galleryData: GalleryTreeItem[] | null = await fetchTree('gallery')
diff --git a/src/app/in-stock/[in-stock-detail]/page.tsx b/src/app/in-stock/[in-stock-detail]/page.tsx
index a2f4e7bf..39bb35a4 100644
--- a/src/app/in-stock/[in-stock-detail]/page.tsx
+++ b/src/app/in-stock/[in-stock-detail]/page.tsx
@@ -100,6 +100,9 @@ export default async function Page({ params }: PageProps): Promise
'@type': 'Offer',
availability: 'https://schema.org/InStock',
url: `${process.env.SITE_URL || process.env.NEXT_PUBLIC_SITE_URL}/in-stock/${work.slug || work._id}`,
+ ...(work.price && !isNaN(parseFloat(work.price))
+ ? { price: parseFloat(work.price), priceCurrency: 'RUB' }
+ : {}),
},
}
@@ -117,6 +120,8 @@ export default async function Page({ params }: PageProps): Promise
description={work.description}
image={work.image}
slidesList={slidesList}
+ price={work.price}
+ size={work.size}
/>
{MasterInfo && }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 84ded6d7..c2088a7a 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -5,11 +5,16 @@ import type { Metadata } from 'next'
import { JSX, ReactNode } from 'react'
import clsx from 'clsx'
import Script from 'next/script'
+import { cookies } from 'next/headers'
import Header from '@/components/Header/Header'
import Footer from '@/components/Footer/Footer'
import ScrollButton from '@/components/ScrollButton/ScrollButton'
import AnimationObserver from '@/components/AnimationObserver/AnimationObserver'
import PageTransition from '@/components/PageTransition/PageTransition'
+import { CookieConsentProvider } from '@/context/CookieConsentContext'
+import CookieBanner from '@/components/CookieBanner/CookieBanner'
+import ServicesInit from '@/components/ServicesInit/ServicesInit'
+import { CookieConsent } from '@/types/types'
export const dynamic = 'force-dynamic'
@@ -62,42 +67,58 @@ export const metadata: Metadata = {
},
}
-export default function RootLayout({ children }: LayoutProps): JSX.Element {
+export default async function RootLayout({ children }: LayoutProps): Promise {
+ const cookieStore = await cookies()
+ const raw = cookieStore.get('cookie-consent')?.value
+ let initialConsent: CookieConsent | null = null
+ if (raw) {
+ try {
+ initialConsent = JSON.parse(decodeURIComponent(raw)) as CookieConsent
+ } catch {
+ initialConsent = null
+ }
+ }
+
return (
-
+
+
+
+
-
+
-
+
+
+ {children}
+
+
-
-
- {children}
-
-
-
-
+
+
+
+
+
)
diff --git a/src/app/news/[news-detail]/page.tsx b/src/app/news/[news-detail]/page.tsx
index 4abb9b8e..399bab72 100644
--- a/src/app/news/[news-detail]/page.tsx
+++ b/src/app/news/[news-detail]/page.tsx
@@ -6,13 +6,13 @@ import Heading from '@/components/Heading/Heading'
import Detail from '@/components/Detail/Detail'
import { fetchCollectionItem, getImageUrl } from '@/lib/api-client'
-type PageParams = {
+type PageProps = {
params: Promise<{
'news-detail': string
}>
}
-export async function generateMetadata({ params }: PageParams): Promise {
+export async function generateMetadata({ params }: PageProps): Promise {
const { ['news-detail']: slug } = await params
let news = await fetchCollectionItem('news', slug, { field: 'slug' })
@@ -42,7 +42,7 @@ export async function generateMetadata({ params }: PageParams): Promise {
+export default async function Page({ params }: PageProps): Promise {
const { ['news-detail']: slug } = await params
let news = await fetchCollectionItem('news', slug, { field: 'slug' })
diff --git a/src/app/404/page.tsx b/src/app/not-found.tsx
similarity index 93%
rename from src/app/404/page.tsx
rename to src/app/not-found.tsx
index 80783740..cd134d3d 100644
--- a/src/app/404/page.tsx
+++ b/src/app/not-found.tsx
@@ -20,7 +20,7 @@ const breadcrumbsList: BreadcrumbItem[] = [
},
]
-export default function Page(): JSX.Element {
+export default function NotFoundPage(): JSX.Element {
return (
<>
diff --git a/src/app/works/[works-detail]/page.tsx b/src/app/works/[works-detail]/page.tsx
index 7dc93978..b84bd2d8 100644
--- a/src/app/works/[works-detail]/page.tsx
+++ b/src/app/works/[works-detail]/page.tsx
@@ -100,6 +100,9 @@ export default async function Page({ params }: PageProps): Promise
'@type': 'Offer',
availability: 'https://schema.org/InStock',
url: `${process.env.SITE_URL || process.env.NEXT_PUBLIC_SITE_URL}/works/${work.slug || work._id}`,
+ ...(work.price && !isNaN(parseFloat(work.price))
+ ? { price: parseFloat(work.price), priceCurrency: 'RUB' }
+ : {}),
},
}
@@ -117,6 +120,8 @@ export default async function Page({ params }: PageProps): Promise
description={work.description}
image={work.image}
slidesList={slidesList}
+ price={work.price}
+ size={work.size}
/>
{MasterInfo && }
diff --git a/src/components/AboutPage/AboutPage.module.scss b/src/components/AboutPage/AboutPage.module.scss
index 7ba8d998..745a3b5c 100644
--- a/src/components/AboutPage/AboutPage.module.scss
+++ b/src/components/AboutPage/AboutPage.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.about {
}
diff --git a/src/components/AboutPage/AboutPage.tsx b/src/components/AboutPage/AboutPage.tsx
index 309d5436..cd55323d 100644
--- a/src/components/AboutPage/AboutPage.tsx
+++ b/src/components/AboutPage/AboutPage.tsx
@@ -1,21 +1,14 @@
import { JSX } from 'react'
import clsx from 'clsx'
-import { ImageItem, SlideItem } from '@/types/types'
+import { AboutFromServer, SlideItem } from '@/types/types'
import { createSanitizedHTML } from '@/functions/functions'
import aboutPageStyles from './AboutPage.module.scss'
import { fetchSingleton, getImageUrl } from '@/lib/api-client'
import EmptySection from '@/components/EmptySection/EmptySection'
import GalleryBlock from '@/components/GalleryBlock/GalleryBlock'
-type AboutPageProps = {
- title: string
- description: string
- image: ImageItem
- slider: ImageItem[]
-}
-
export default async function AboutPage(): Promise {
- const about: AboutPageProps | null = await fetchSingleton('about')
+ const about: AboutFromServer | null = await fetchSingleton('about')
if (!about) return
diff --git a/src/components/Address/Address.module.scss b/src/components/Address/Address.module.scss
index 644bd171..e5fbd128 100644
--- a/src/components/Address/Address.module.scss
+++ b/src/components/Address/Address.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.address {
font-size: 18px;
diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss
index 75e4c83e..c8dd0867 100644
--- a/src/components/Breadcrumbs/Breadcrumbs.module.scss
+++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.breadcrumbs {
}
diff --git a/src/components/Card/Card.module.scss b/src/components/Card/Card.module.scss
index dd44e733..8ab6b9c3 100644
--- a/src/components/Card/Card.module.scss
+++ b/src/components/Card/Card.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
@use '../../styles/mixins' as mixin;
.card {
diff --git a/src/components/Contacts/Contacts.module.scss b/src/components/Contacts/Contacts.module.scss
index 34bcb0ad..9f3eee0f 100644
--- a/src/components/Contacts/Contacts.module.scss
+++ b/src/components/Contacts/Contacts.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.contacts {
}
diff --git a/src/components/ContactsPage/ContactsPage.module.scss b/src/components/ContactsPage/ContactsPage.module.scss
index 47312d2d..254b3c8f 100644
--- a/src/components/ContactsPage/ContactsPage.module.scss
+++ b/src/components/ContactsPage/ContactsPage.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.contacts {
}
@@ -81,5 +81,5 @@
min-height: 500px;
background-color: $form-bg;
- border-radius: 12px;
+ border-radius: 40px;
}
diff --git a/src/components/CookieBanner/CookieBanner.module.scss b/src/components/CookieBanner/CookieBanner.module.scss
new file mode 100644
index 00000000..8697435f
--- /dev/null
+++ b/src/components/CookieBanner/CookieBanner.module.scss
@@ -0,0 +1,200 @@
+@use '../../styles/variables' as *;
+@use '../../styles/animations';
+
+.banner {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ z-index: 1000;
+
+ width: calc(100% - 40px);
+ max-width: 600px;
+ padding: 20px;
+
+ font-size: 12px;
+ line-height: 1.5;
+ color: $base-text-color;
+
+ background: $base-bg;
+ box-shadow: $cardShadow;
+ border-radius: 12px;
+
+ transform: translateX(-50%) translateY(calc(100% + 30px));
+ animation: 1s slideUp 0.6s ease-in-out forwards;
+}
+
+.banner__close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 10;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 28px;
+ height: 28px;
+
+ font-size: 14px;
+ color: $add-text-color;
+
+ background: none;
+ border: none;
+
+ cursor: pointer;
+
+ transition: color 0.3s ease-in-out;
+
+ &:focus-visible {
+ border-radius: 2px;
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ color: $accent-add-bg;
+ }
+ }
+}
+
+.banner__text {
+ margin-bottom: 14px;
+}
+
+.banner__link {
+ color: $accent-add-bg;
+
+ border-bottom: 1px solid $accent-add-bg;
+
+ transition: opacity 0.3s ease-in-out;
+
+ &:focus-visible {
+ border-radius: 2px;
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ opacity: 0.6;
+ }
+ }
+}
+
+.banner__buttons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 10px;
+
+ @media (max-width: $mobile-mid-width - 100) {
+ flex-direction: column;
+ }
+}
+
+.banner__button {
+ margin: 0;
+ padding: 7px 14px;
+
+ font-size: 12px;
+
+ border-radius: 8px;
+
+ @media (max-width: $mobile-mid-width - 100) {
+ width: 100%;
+ }
+}
+
+.banner__categories {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+
+.banner__category {
+ position: relative;
+
+ display: flex;
+ align-items: flex-start;
+ padding-left: 30px;
+
+ cursor: pointer;
+
+ &::before {
+ position: absolute;
+ top: 3px;
+ left: 0;
+
+ width: 20px;
+ height: 20px;
+
+ background-color: $accent-light-bg;
+ border: 1px solid $form-bg;
+ border-radius: 4px;
+
+ transition:
+ border-color 0.3s ease-in-out,
+ box-shadow 0.3s ease-in-out;
+
+ content: '';
+ }
+
+ &::after {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+
+ font-size: 18px;
+ line-height: 1;
+
+ color: $base-text-color;
+
+ opacity: 0;
+
+ transition: opacity 0.3s ease-in-out;
+
+ content: '✓';
+ }
+
+ &:has(input[type='checkbox']:checked)::before {
+ border-color: $accent-add-bg;
+ }
+
+ &:has(input[type='checkbox']:checked)::after {
+ opacity: 1;
+ }
+
+ &:has(input[type='checkbox']:disabled) {
+ opacity: 0.6;
+ cursor: default;
+ }
+
+ &:has(input[type='checkbox']:focus-visible)::before {
+ box-shadow: 0 0 0 3px rgba($accent-add-bg, 0.35);
+ border-color: $accent-add-bg;
+ }
+
+ @media (hover: hover) {
+ &:not(:has(input[type='checkbox']:disabled)):hover::before {
+ border-color: $accent-add-bg;
+ }
+ }
+}
+
+.banner__category-title {
+ display: block;
+
+ font-size: 14px;
+ font-weight: 600;
+ color: $base-text-color;
+}
+
+.banner__category-description {
+ display: block;
+
+ font-size: 12px;
+ color: $add-text-color;
+}
diff --git a/src/components/CookieBanner/CookieBanner.tsx b/src/components/CookieBanner/CookieBanner.tsx
new file mode 100644
index 00000000..f74934f4
--- /dev/null
+++ b/src/components/CookieBanner/CookieBanner.tsx
@@ -0,0 +1,6 @@
+import { JSX } from 'react'
+import CookieBannerClient from './CookieBannerClient'
+
+export default function CookieBanner(): JSX.Element {
+ return
+}
diff --git a/src/components/CookieBanner/CookieBannerClient.tsx b/src/components/CookieBanner/CookieBannerClient.tsx
new file mode 100644
index 00000000..e7986211
--- /dev/null
+++ b/src/components/CookieBanner/CookieBannerClient.tsx
@@ -0,0 +1,146 @@
+'use client'
+import { JSX, useState } from 'react'
+import Link from 'next/link'
+import { COOKIE_CATEGORIES } from '@/const/const'
+import { CookieConsent } from '@/types/types'
+import { useCookieConsent } from '@/context/CookieConsentContext'
+import styles from './CookieBanner.module.scss'
+import clsx from 'clsx'
+
+type Props = {
+ cookiePolicyUrl: string
+}
+
+export default function CookieBannerClient({ cookiePolicyUrl }: Props): JSX.Element | null {
+ const { isBannerVisible, saveConsent, consent } = useCookieConsent()
+
+ const [showDetails, setShowDetails] = useState(false)
+ const [selected, setSelected] = useState({
+ necessary: true,
+ functional: consent.functional,
+ statistical: consent.statistical,
+ marketing: consent.marketing,
+ })
+
+ if (!isBannerVisible) return null
+
+ const acceptAll = () => {
+ saveConsent({ necessary: true, functional: true, statistical: true, marketing: true })
+ }
+
+ const acceptNecessary = () => {
+ saveConsent({ necessary: true, functional: false, statistical: false, marketing: false })
+ }
+
+ const saveSelected = () => {
+ saveConsent({ ...selected, necessary: true })
+ }
+
+ const toggleCategory = (id: keyof CookieConsent) => {
+ setSelected((prev) => ({ ...prev, [id]: !prev[id] }))
+ }
+
+ return (
+
+ {showDetails ? (
+ <>
+
setShowDetails(false)}
+ >
+ ✕
+
+
+
+ {COOKIE_CATEGORIES.map((category) => (
+
+ {
+ if (!category.required) toggleCategory(category.id)
+ }}
+ />
+
+
+ {category.label}
+
+
+ {category.description}
+
+
+
+ ))}
+
+
+
+
+ Подтвердить выбор
+
+
+
+ Принять все
+
+
+
+ Принять необходимые
+
+
+ >
+ ) : (
+ <>
+
+ Мы используем файлы cookie для обеспечения работы сайта, анализа трафика и
+ персонализации контента. Подробнее в нашей
+
+ Политике использования cookie
+
+ .
+
+
+
+
+ Принять все
+
+
+
+ Принять необходимые
+
+
+ setShowDetails(true)}
+ >
+ Настроить параметры
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/Detail/Detail.module.scss b/src/components/Detail/Detail.module.scss
index bedb7ef3..aaa3a483 100644
--- a/src/components/Detail/Detail.module.scss
+++ b/src/components/Detail/Detail.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.detail__container {
display: grid;
@@ -21,4 +21,55 @@
.detail__text {
font-size: 22px;
+
+ @media (max-width: $tablet-min-width) {
+ font-size: 18px;
+ }
+}
+
+.detail__price-block {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ align-self: center;
+ gap: 20px 40px;
+ width: 100%;
+ max-width: max-content;
+ padding: 20px 24px;
+
+ background-color: $accent-light-bg;
+ box-shadow: $boxShadowOutset;
+ border: 1px solid $accent-add-bg;
+ border-radius: 12px;
+
+ @media (max-width: $tablet-min-width) {
+ padding: 15px 20px;
+ }
+}
+
+.detail__size {
+ flex-basis: 100%;
+
+ font-size: 22px;
+ text-align: center;
+
+ @media (max-width: $tablet-min-width) {
+ font-size: 18px;
+ }
+}
+
+.detail__price {
+ font-family: $font-decor;
+ font-size: 28px;
+ font-weight: 700;
+ color: $accent-bg;
+
+ @media (max-width: $tablet-min-width) {
+ font-size: 24px;
+ }
+}
+
+.detail__price-button {
+ margin: 0;
}
diff --git a/src/components/Detail/Detail.tsx b/src/components/Detail/Detail.tsx
index 6ed6e11f..044e2458 100644
--- a/src/components/Detail/Detail.tsx
+++ b/src/components/Detail/Detail.tsx
@@ -1,7 +1,8 @@
import { JSX } from 'react'
+import Link from 'next/link'
import clsx from 'clsx'
import { ImageItem, SlideItem } from '@/types/types'
-import { createSanitizedHTML } from '@/functions/functions'
+import { createSanitizedHTML, formatPrice } from '@/functions/functions'
import detailStyles from './Detail.module.scss'
import { getImageUrl } from '@/lib/api-client'
import GalleryBlock from '@/components/GalleryBlock/GalleryBlock'
@@ -11,6 +12,8 @@ type DetailProps = {
description: string
image: ImageItem
slidesList: SlideItem[]
+ price?: string
+ size?: string
}
export default function Detail({
@@ -18,6 +21,8 @@ export default function Detail({
image,
title,
description,
+ price,
+ size,
}: DetailProps): JSX.Element {
const src = getImageUrl(image._id, 800, 500)
const fullSrc = getImageUrl(image._id, 1600, 1000, { mode: 'bestFit' })
@@ -33,6 +38,21 @@ export default function Detail({
className={clsx('block-html', detailStyles['detail__text'])}
dangerouslySetInnerHTML={createSanitizedHTML(description)}
/>
+
+ {(price || size) && (
+
+ {size &&
Размер: {size}
}
+
+ {price &&
{formatPrice(price)}
}
+
+
+ Как заказать?
+
+
+ )}
void
getFiles: () => File[]
}
-type FilePreview = {
- name: string
- isImage: boolean
- preview: string | null
-}
-
type DropZoneProps = {
name: string
accept?: string
diff --git a/src/components/EmptySection/EmptySection.module.scss b/src/components/EmptySection/EmptySection.module.scss
index 47c0827a..3df5ba4b 100644
--- a/src/components/EmptySection/EmptySection.module.scss
+++ b/src/components/EmptySection/EmptySection.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.empty-section {
font-size: 28px;
diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss
index 491922b7..e38b9c7d 100644
--- a/src/components/Footer/Footer.module.scss
+++ b/src/components/Footer/Footer.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.footer {
margin-top: auto;
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
index 280aa11d..5d0d6ce5 100644
--- a/src/components/Footer/Footer.tsx
+++ b/src/components/Footer/Footer.tsx
@@ -5,6 +5,7 @@ import Logo from '@/components/Logo/Logo'
import Social from '@/components/Social/Social'
import Menu from '@/components/Menu/Menu'
import Developer from '@/components/Developer/Developer'
+import Link from 'next/link'
export default function Footer(): JSX.Element {
return (
@@ -32,14 +33,14 @@ export default function Footer(): JSX.Element {
© Иконописная Артель, {new Date().getFullYear()}
-
Политика обработки персональных данных
-
+
diff --git a/src/components/Forms/FormAgreementCheckbox/FormAgreementCheckbox.tsx b/src/components/Forms/FormAgreementCheckbox/FormAgreementCheckbox.tsx
new file mode 100644
index 00000000..3a09c173
--- /dev/null
+++ b/src/components/Forms/FormAgreementCheckbox/FormAgreementCheckbox.tsx
@@ -0,0 +1,57 @@
+'use client'
+
+import type { JSX } from 'react'
+import Link from 'next/link'
+import formStyles from '../../../styles/modules/form.module.scss'
+import clsx from 'clsx'
+
+const AGREEMENT_URL = '/api/documents/agreement'
+const POLICY_URL = '/api/documents/policy'
+
+type Props = {
+ error: string
+ onChangeAction: (checked: boolean) => void
+}
+
+export default function FormAgreementCheckbox({ error, onChangeAction }: Props): JSX.Element {
+ return (
+
+ onChangeAction(evt.target.checked)}
+ />
+
+
+
+ Я согласен
+
+ на обработку персональных данных в соответствии с условиями
+
+ Политики обработки персональных данных
+
+
+
+
+ {error}
+
+
+ )
+}
diff --git a/src/components/Forms/FormCalculation/FormCalculationClient.tsx b/src/components/Forms/FormCalculation/FormCalculationClient.tsx
index e8b3dbb9..c8cb3257 100644
--- a/src/components/Forms/FormCalculation/FormCalculationClient.tsx
+++ b/src/components/Forms/FormCalculation/FormCalculationClient.tsx
@@ -5,6 +5,7 @@ import formStyles from '../../../styles/modules/form.module.scss'
import clsx from 'clsx'
import type { PriceItem, GoldTypeValue } from '@/types/types'
import { GOLD_TYPE_OPTIONS } from '@/const/const'
+import { formatPrice } from '@/functions/functions'
type Props = {
prices: PriceItem[]
@@ -140,9 +141,9 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
if (goldType === 'all') value = item.all
if (goldType === 'halo') value = item.halo
if (item.price_for_inch) {
- setCalculated(`от ${value} руб./дм²`)
+ setCalculated(`от ${formatPrice(value)}/дм²`)
} else {
- setCalculated(`${value} руб.`)
+ setCalculated(formatPrice(value))
}
}, [selectedId, goldType, prices])
@@ -270,7 +271,7 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
Итоговая стоимость:
-
{calculated || '—'}
+
{calculated || '—'}
)
diff --git a/src/components/Forms/FormContacts/FormContacts.tsx b/src/components/Forms/FormContacts/FormContacts.tsx
index 3cd65ee5..edcfe419 100644
--- a/src/components/Forms/FormContacts/FormContacts.tsx
+++ b/src/components/Forms/FormContacts/FormContacts.tsx
@@ -1,8 +1,272 @@
-import type { JSX } from 'react'
-import FormContactsClient from './FormContactsClient'
+'use client'
+
+import { JSX, SubmitEvent, useEffect, useActionState, useRef, useState, useTransition } from 'react'
+import formStyles from '../../../styles/modules/form.module.scss'
+import clsx from 'clsx'
+import { submitMessage } from '@/actions/forms'
+import { messageFormSchema, validateFormField } from '@/lib/schemas'
+import { z } from 'zod'
+import FormAgreementCheckbox from '@/components/Forms/FormAgreementCheckbox/FormAgreementCheckbox'
export default function FormContacts(): JSX.Element {
+ const [state, formAction] = useActionState(submitMessage, null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isPending, startTransition] = useTransition()
+ const [clientErrors, setClientErrors] = useState>({})
+ const [touched, setTouched] = useState>({})
+
+ const formRef = useRef(null)
+ const captchaContainerRef = useRef(null)
+ const widgetIdRef = useRef(null)
+
+ useEffect(() => {
+ const siteKey = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY
+
+ if (!siteKey) {
+ console.error('Captcha key is not configured')
+ return
+ }
+
+ const interval = setInterval(() => {
+ if (window.smartCaptcha && captchaContainerRef.current && widgetIdRef.current === null) {
+ widgetIdRef.current = window.smartCaptcha.render('captcha-container-contacts', {
+ sitekey: siteKey,
+ invisible: true,
+ hideShield: true,
+ hl: 'ru',
+ callback: (token: string) => {
+ if (formRef.current) {
+ const formData = new FormData(formRef.current)
+ formData.set('smart-token', token)
+
+ startTransition(() => {
+ formAction(formData)
+ })
+ }
+ },
+ })
+
+ clearInterval(interval)
+ }
+ }, 100)
+
+ return () => {
+ clearInterval(interval)
+ }
+ }, [formAction])
+
+ useEffect(() => {
+ if (state?.success && formRef.current) {
+ formRef.current.reset()
+ setIsSubmitting(false)
+ setClientErrors({})
+ setTouched({})
+
+ if (widgetIdRef.current !== null && window.smartCaptcha) {
+ window.smartCaptcha.reset(widgetIdRef.current)
+ }
+ } else if (state && !state.success) {
+ setIsSubmitting(false)
+
+ if (widgetIdRef.current !== null && window.smartCaptcha) {
+ window.smartCaptcha.reset(widgetIdRef.current)
+ }
+ }
+ }, [state])
+
+ const getError = (field: string): string =>
+ touched[field] ? clientErrors[field] || '' : state?.errors?.[field] || ''
+
+ const handleBlur = (field: string, value: string) => {
+ setTouched((prev) => ({ ...prev, [field]: true }))
+ setClientErrors((prev) => ({
+ ...prev,
+ [field]: validateFormField(messageFormSchema, field, value),
+ }))
+ }
+
+ const handleAgreementChange = (checked: boolean) => {
+ setTouched((prev) => ({ ...prev, agreement: true }))
+ setClientErrors((prev) => ({
+ ...prev,
+ agreement: validateFormField(messageFormSchema, 'agreement', checked || undefined),
+ }))
+ }
+
+ const handleSubmit = async (evt: SubmitEvent) => {
+ evt.preventDefault()
+
+ if (isSubmitting || isPending) {
+ return
+ }
+
+ const formEl = formRef.current
+ const nameVal = (formEl?.elements.namedItem('name') as HTMLInputElement)?.value || ''
+ const phoneVal = (formEl?.elements.namedItem('phone') as HTMLInputElement)?.value || ''
+ const emailVal = (formEl?.elements.namedItem('email') as HTMLInputElement)?.value || ''
+ const messageVal = (formEl?.elements.namedItem('message') as HTMLTextAreaElement)?.value || ''
+ const agreementChecked =
+ (formEl?.elements.namedItem('agreement') as HTMLInputElement)?.checked || false
+
+ const parseResult = messageFormSchema.safeParse({
+ name: nameVal,
+ phone: phoneVal,
+ email: emailVal,
+ message: messageVal,
+ agreement: agreementChecked || undefined,
+ })
+
+ const allTouched = Object.fromEntries(
+ Object.keys(messageFormSchema.shape).map((key) => [key, true]),
+ )
+ setTouched(allTouched)
+
+ if (!parseResult.success) {
+ const flat = z.flattenError(parseResult.error).fieldErrors as Record<
+ string,
+ string[] | undefined
+ >
+ setClientErrors(
+ Object.fromEntries(
+ Object.keys(messageFormSchema.shape).map((key) => [key, flat[key]?.[0] ?? '']),
+ ),
+ )
+ return
+ }
+
+ setClientErrors({})
+ setIsSubmitting(true)
+
+ if (widgetIdRef.current !== null && window.smartCaptcha) {
+ try {
+ window.smartCaptcha.execute(widgetIdRef.current)
+ } catch {
+ setIsSubmitting(false)
+ }
+ } else {
+ setIsSubmitting(false)
+ }
+ }
+
return (
-
+
)
}
diff --git a/src/components/Forms/FormContacts/FormContactsClient.tsx b/src/components/Forms/FormContacts/FormContactsClient.tsx
deleted file mode 100644
index f55ed650..00000000
--- a/src/components/Forms/FormContacts/FormContactsClient.tsx
+++ /dev/null
@@ -1,310 +0,0 @@
-'use client'
-
-import { JSX, SubmitEvent, useEffect, useActionState, useRef, useState, useTransition } from 'react'
-import formStyles from '../../../styles/modules/form.module.scss'
-import clsx from 'clsx'
-import { submitMessage } from '@/actions/forms'
-import { messageFormSchema, validateFormField } from '@/lib/schemas'
-import { z } from 'zod'
-
-type Props = {
- agreementUrl: string
- policyUrl: string
-}
-
-export default function FormContactsClient({ agreementUrl, policyUrl }: Props): JSX.Element {
- const [state, formAction] = useActionState(submitMessage, null)
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isPending, startTransition] = useTransition()
- const [clientErrors, setClientErrors] = useState>({})
- const [touched, setTouched] = useState>({})
-
- const formRef = useRef(null)
- const captchaContainerRef = useRef(null)
- const widgetIdRef = useRef(null)
-
- useEffect(() => {
- const siteKey = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY
-
- if (!siteKey) {
- console.error('Captcha key is not configured')
- return
- }
-
- const interval = setInterval(() => {
- if (window.smartCaptcha && captchaContainerRef.current && widgetIdRef.current === null) {
- widgetIdRef.current = window.smartCaptcha.render('captcha-container-contacts', {
- sitekey: siteKey,
- invisible: true,
- hideShield: true,
- hl: 'ru',
- callback: (token: string) => {
- if (formRef.current) {
- const formData = new FormData(formRef.current)
- formData.set('smart-token', token)
-
- startTransition(() => {
- formAction(formData)
- })
- }
- },
- })
-
- clearInterval(interval)
- }
- }, 100)
-
- return () => {
- clearInterval(interval)
- }
- }, [formAction])
-
- useEffect(() => {
- if (state?.success && formRef.current) {
- formRef.current.reset()
- setIsSubmitting(false)
- setClientErrors({})
- setTouched({})
-
- if (widgetIdRef.current !== null && window.smartCaptcha) {
- window.smartCaptcha.reset(widgetIdRef.current)
- }
- } else if (state && !state.success) {
- setIsSubmitting(false)
-
- if (widgetIdRef.current !== null && window.smartCaptcha) {
- window.smartCaptcha.reset(widgetIdRef.current)
- }
- }
- }, [state])
-
- const getError = (field: string): string =>
- touched[field] ? clientErrors[field] || '' : state?.errors?.[field] || ''
-
- const handleBlur = (field: string, value: string) => {
- setTouched((prev) => ({ ...prev, [field]: true }))
- setClientErrors((prev) => ({
- ...prev,
- [field]: validateFormField(messageFormSchema, field, value),
- }))
- }
-
- const handleAgreementChange = (checked: boolean) => {
- setTouched((prev) => ({ ...prev, agreement: true }))
- setClientErrors((prev) => ({
- ...prev,
- agreement: validateFormField(messageFormSchema, 'agreement', checked || undefined),
- }))
- }
-
- const handleSubmit = async (evt: SubmitEvent) => {
- evt.preventDefault()
-
- if (isSubmitting || isPending) {
- return
- }
-
- const formEl = formRef.current
- const nameVal = (formEl?.elements.namedItem('name') as HTMLInputElement)?.value || ''
- const phoneVal = (formEl?.elements.namedItem('phone') as HTMLInputElement)?.value || ''
- const emailVal = (formEl?.elements.namedItem('email') as HTMLInputElement)?.value || ''
- const messageVal = (formEl?.elements.namedItem('message') as HTMLTextAreaElement)?.value || ''
- const agreementChecked =
- (formEl?.elements.namedItem('agreement') as HTMLInputElement)?.checked || false
-
- const parseResult = messageFormSchema.safeParse({
- name: nameVal,
- phone: phoneVal,
- email: emailVal,
- message: messageVal,
- agreement: agreementChecked || undefined,
- })
-
- const allTouched = Object.fromEntries(
- Object.keys(messageFormSchema.shape).map((key) => [key, true]),
- )
- setTouched(allTouched)
-
- if (!parseResult.success) {
- const flat = z.flattenError(parseResult.error).fieldErrors as Record<
- string,
- string[] | undefined
- >
- setClientErrors(
- Object.fromEntries(
- Object.keys(messageFormSchema.shape).map((key) => [key, flat[key]?.[0] ?? '']),
- ),
- )
- return
- }
-
- setClientErrors({})
- setIsSubmitting(true)
-
- if (widgetIdRef.current !== null && window.smartCaptcha) {
- try {
- window.smartCaptcha.execute(widgetIdRef.current)
- } catch {
- setIsSubmitting(false)
- }
- } else {
- setIsSubmitting(false)
- }
- }
-
- return (
-
- {state?.message && (
-
- {state.message}
-
- )}
-
- {(!state || !state.success) && (
- <>
-
- Ваше имя:
-
- handleBlur('name', evt.target.value)}
- />
-
-
- {getError('name')}
-
-
-
-
- Телефон:
-
- handleBlur('phone', evt.target.value)}
- />
-
-
- {getError('phone')}
-
-
-
-
- Email:
-
- handleBlur('email', evt.target.value)}
- />
-
-
- {getError('email')}
-
-
-
-
- Введите ваше сообщение:
-
- handleBlur('message', evt.target.value)}
- >
-
-
- {getError('message')}
-
-
-
-
-
- handleAgreementChange(evt.target.checked)}
- />
-
-
-
- Я согласен
-
- на обработку персональных данных в соответствии с условиями
-
- Политики обработки персональных данных
-
-
-
-
- {getError('agreement')}
-
-
-
-
- {isPending || isSubmitting ? 'Отправка...' : 'Отправить сообщение'}
-
-
- >
- )}
-
-
-
- )
-}
diff --git a/src/components/Forms/FormOrder/FormOrder.tsx b/src/components/Forms/FormOrder/FormOrder.tsx
index 1b2c6a81..5ae08adf 100644
--- a/src/components/Forms/FormOrder/FormOrder.tsx
+++ b/src/components/Forms/FormOrder/FormOrder.tsx
@@ -9,12 +9,5 @@ export default async function FormOrder(): Promise {
fetchCollection('price', { sort: { sort: 1 } }),
])
- return (
-
- )
+ return
}
diff --git a/src/components/Forms/FormOrder/FormOrderClient.tsx b/src/components/Forms/FormOrder/FormOrderClient.tsx
index 0fc4661b..294e9f51 100644
--- a/src/components/Forms/FormOrder/FormOrderClient.tsx
+++ b/src/components/Forms/FormOrder/FormOrderClient.tsx
@@ -20,20 +20,14 @@ import { applicationFormSchema, validateFormField } from '@/lib/schemas'
import type { CategoryFromServer, PriceItem, GoldTypeValue } from '@/types/types'
import { GOLD_TYPE_OPTIONS } from '@/const/const'
import DropZone, { DropZoneRef } from '@/components/DropZone/DropZone'
+import FormAgreementCheckbox from '@/components/Forms/FormAgreementCheckbox/FormAgreementCheckbox'
type Props = {
categories: CategoryFromServer[]
prices: PriceItem[]
- agreementUrl: string
- policyUrl: string
}
-export default function FormOrderClient({
- categories,
- prices: initialPrices,
- agreementUrl,
- policyUrl,
-}: Props): JSX.Element {
+export default function FormOrderClient({ categories, prices: initialPrices }: Props): JSX.Element {
const prices: PriceItem[] = useMemo(() => initialPrices || [], [initialPrices])
const [state, formAction] = useActionState(submitApplication, null)
@@ -668,44 +662,10 @@ export default function FormOrderClient({
-
- handleAgreementChange(evt.target.checked)}
- />
-
-
-
- Я согласен
-
- на обработку персональных данных в соответствии с условиями
-
- Политики обработки персональных данных
-
-
-
-
- {getError('agreement')}
-
-
+
>({})
+ const [touched, setTouched] = useState>({})
+
+ const formRef = useRef(null)
+ const captchaContainerRef = useRef(null)
+ const widgetIdRef = useRef(null)
+ const dropZoneRef = useRef(null)
+ const starRefs = useRef<(HTMLButtonElement | null)[]>([])
+
+ useEffect(() => {
+ const siteKey = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY
+
+ if (!siteKey) {
+ console.error('Captcha key is not configured')
+ return
+ }
+
+ const interval = setInterval(() => {
+ if (window.smartCaptcha && captchaContainerRef.current && widgetIdRef.current === null) {
+ widgetIdRef.current = window.smartCaptcha.render('captcha-container-reviews', {
+ sitekey: siteKey,
+ invisible: true,
+ hideShield: true,
+ hl: 'ru',
+ callback: (token: string) => {
+ if (formRef.current) {
+ const formData = new FormData(formRef.current)
+ formData.set('smart-token', token)
+
+ const files = dropZoneRef.current?.getFiles() ?? []
+ files.forEach((file) => formData.append('photos', file))
+
+ startTransition(() => {
+ formAction(formData)
+ })
+ }
+ },
+ })
+
+ clearInterval(interval)
+ }
+ }, 100)
+
+ return () => {
+ clearInterval(interval)
+ }
+ }, [formAction])
+
+ useEffect(() => {
+ if (state?.success && formRef.current) {
+ formRef.current.reset()
+ setIsSubmitting(false)
+ setSelectedStars(0)
+ setHoveredStars(0)
+ setClientErrors({})
+ setTouched({})
+ dropZoneRef.current?.reset()
+
+ if (widgetIdRef.current !== null && window.smartCaptcha) {
+ window.smartCaptcha.reset(widgetIdRef.current)
+ }
+ } else if (state && !state.success) {
+ setIsSubmitting(false)
+
+ if (widgetIdRef.current !== null && window.smartCaptcha) {
+ window.smartCaptcha.reset(widgetIdRef.current)
+ }
+ }
+ }, [state])
+
+ const getError = (field: string): string =>
+ touched[field] ? clientErrors[field] || '' : state?.errors?.[field] || ''
+
+ const handleBlur = (field: string, value: string) => {
+ setTouched((prev) => ({ ...prev, [field]: true }))
+ setClientErrors((prev) => ({
+ ...prev,
+ [field]: validateFormField(reviewFormSchema, field, value),
+ }))
+ }
+
+ const handleAgreementChange = (checked: boolean) => {
+ setTouched((prev) => ({ ...prev, agreement: true }))
+ setClientErrors((prev) => ({
+ ...prev,
+ agreement: validateFormField(reviewFormSchema, 'agreement', checked || undefined),
+ }))
+ }
+
+ const handleStarSelect = (star: number) => {
+ setSelectedStars(star)
+ setTouched((prev) => ({ ...prev, stars: true }))
+ setClientErrors((prev) => ({ ...prev, stars: '' }))
+ }
+
+ const handleStarKeyDown = (evt: KeyboardEvent, star: number) => {
+ let next: number | null = null
+
+ if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
+ evt.preventDefault()
+ next = Math.min(star + 1, STARS_COUNT)
+ } else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
+ evt.preventDefault()
+ next = Math.max(star - 1, 1)
+ }
+
+ if (next !== null) {
+ handleStarSelect(next)
+ starRefs.current[next - 1]?.focus()
+ }
+ }
+
+ const handleSubmit = async (evt: SubmitEvent) => {
+ evt.preventDefault()
+
+ if (isSubmitting || isPending) {
+ return
+ }
+
+ const formEl = formRef.current
+ const nameVal = (formEl?.elements.namedItem('name') as HTMLInputElement)?.value || ''
+ const phoneVal = (formEl?.elements.namedItem('phone') as HTMLInputElement)?.value || ''
+ const emailVal = (formEl?.elements.namedItem('email') as HTMLInputElement)?.value || ''
+ const reviewVal = (formEl?.elements.namedItem('review') as HTMLTextAreaElement)?.value || ''
+ const agreementChecked =
+ (formEl?.elements.namedItem('agreement') as HTMLInputElement)?.checked || false
+
+ const parseResult = reviewFormSchema.safeParse({
+ name: nameVal,
+ phone: phoneVal,
+ email: emailVal,
+ review: reviewVal,
+ stars: selectedStars,
+ agreement: agreementChecked || undefined,
+ })
+
+ const allTouched = Object.fromEntries(
+ Object.keys(reviewFormSchema.shape).map((key) => [key, true]),
+ )
+ setTouched(allTouched)
+
+ if (!parseResult.success) {
+ const flat = z.flattenError(parseResult.error).fieldErrors as Record<
+ string,
+ string[] | undefined
+ >
+ setClientErrors(
+ Object.fromEntries(
+ Object.keys(reviewFormSchema.shape).map((key) => [key, flat[key]?.[0] ?? '']),
+ ),
+ )
+ return
+ }
+
+ setClientErrors({})
+ setIsSubmitting(true)
+
+ if (widgetIdRef.current !== null && window.smartCaptcha) {
+ try {
+ window.smartCaptcha.execute(widgetIdRef.current)
+ } catch {
+ setIsSubmitting(false)
+ }
+ } else {
+ setIsSubmitting(false)
+ }
+ }
+
+ const activeStars = hoveredStars || selectedStars
+
return (
-
+
+ {state?.message && (
+
+ {state.message}
+
+ )}
+
+ {(!state || !state.success) && (
+ <>
+
+ Ваше имя:
+
+ handleBlur('name', evt.target.value)}
+ />
+
+
+ {getError('name')}
+
+
+
+
+ Телефон:
+
+ handleBlur('phone', evt.target.value)}
+ />
+
+
+ {getError('phone')}
+
+
+
+
+ Email:
+
+ handleBlur('email', evt.target.value)}
+ />
+
+
+ {getError('email')}
+
+
+
+
+ Ваша оценка:
+
+
+ {Array.from({ length: STARS_COUNT }, (_, i) => i + 1).map((star) => (
+
{
+ starRefs.current[star - 1] = el
+ }}
+ type="button"
+ role="radio"
+ aria-checked={star <= selectedStars}
+ aria-label={`${star} звезд${star === 1 ? 'а' : star < 5 ? 'ы' : ''}`}
+ tabIndex={star === (selectedStars || 1) ? 0 : -1}
+ className={clsx(formStyles['form__star'], {
+ [formStyles['form__star--active']]: star <= activeStars,
+ [formStyles['form__star--hovered']]: star === hoveredStars,
+ })}
+ onClick={() => handleStarSelect(star)}
+ onKeyDown={(evt) => handleStarKeyDown(evt, star)}
+ onMouseEnter={() => setHoveredStars(star)}
+ onMouseLeave={() => setHoveredStars(0)}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+ {getError('stars')}
+
+
+
+
+ Введите ваш отзыв:
+
+ handleBlur('review', evt.target.value)}
+ >
+
+
+ {getError('review')}
+
+
+
+
+
+
+
+
+
+ {isPending || isSubmitting ? 'Отправка...' : 'Отправить отзыв'}
+
+
+ >
+ )}
+
+
+
)
}
diff --git a/src/components/Forms/FormReviews/FormReviewsClient.tsx b/src/components/Forms/FormReviews/FormReviewsClient.tsx
deleted file mode 100644
index 75bbd0e6..00000000
--- a/src/components/Forms/FormReviews/FormReviewsClient.tsx
+++ /dev/null
@@ -1,419 +0,0 @@
-'use client'
-
-import {
- JSX,
- KeyboardEvent,
- SubmitEvent,
- useEffect,
- useActionState,
- useRef,
- useState,
- useTransition,
-} from 'react'
-import clsx from 'clsx'
-import { submitReview } from '@/actions/forms'
-import formStyles from '../../../styles/modules/form.module.scss'
-import DropZone, { DropZoneRef } from '@/components/DropZone/DropZone'
-import { reviewFormSchema, validateFormField } from '@/lib/schemas'
-import { z } from 'zod'
-import { STARS_COUNT, MAX_PHOTOS } from '@/const/const'
-
-type Props = {
- agreementUrl: string
- policyUrl: string
-}
-
-export default function FormReviewsClient({ agreementUrl, policyUrl }: Props): JSX.Element {
- const [state, formAction] = useActionState(submitReview, null)
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isPending, startTransition] = useTransition()
- const [selectedStars, setSelectedStars] = useState(0)
- const [hoveredStars, setHoveredStars] = useState(0)
- const [clientErrors, setClientErrors] = useState>({})
- const [touched, setTouched] = useState>({})
-
- const formRef = useRef(null)
- const captchaContainerRef = useRef(null)
- const widgetIdRef = useRef(null)
- const dropZoneRef = useRef(null)
- const starRefs = useRef<(HTMLButtonElement | null)[]>([])
-
- useEffect(() => {
- const siteKey = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY
-
- if (!siteKey) {
- console.error('Captcha key is not configured')
- return
- }
-
- const interval = setInterval(() => {
- if (window.smartCaptcha && captchaContainerRef.current && widgetIdRef.current === null) {
- widgetIdRef.current = window.smartCaptcha.render('captcha-container-reviews', {
- sitekey: siteKey,
- invisible: true,
- hideShield: true,
- hl: 'ru',
- callback: (token: string) => {
- if (formRef.current) {
- const formData = new FormData(formRef.current)
- formData.set('smart-token', token)
-
- const files = dropZoneRef.current?.getFiles() ?? []
- files.forEach((file) => formData.append('photos', file))
-
- startTransition(() => {
- formAction(formData)
- })
- }
- },
- })
-
- clearInterval(interval)
- }
- }, 100)
-
- return () => {
- clearInterval(interval)
- }
- }, [formAction])
-
- useEffect(() => {
- if (state?.success && formRef.current) {
- formRef.current.reset()
- setIsSubmitting(false)
- setSelectedStars(0)
- setHoveredStars(0)
- setClientErrors({})
- setTouched({})
- dropZoneRef.current?.reset()
-
- if (widgetIdRef.current !== null && window.smartCaptcha) {
- window.smartCaptcha.reset(widgetIdRef.current)
- }
- } else if (state && !state.success) {
- setIsSubmitting(false)
-
- if (widgetIdRef.current !== null && window.smartCaptcha) {
- window.smartCaptcha.reset(widgetIdRef.current)
- }
- }
- }, [state])
-
- const getError = (field: string): string =>
- touched[field] ? clientErrors[field] || '' : state?.errors?.[field] || ''
-
- const handleBlur = (field: string, value: string) => {
- setTouched((prev) => ({ ...prev, [field]: true }))
- setClientErrors((prev) => ({
- ...prev,
- [field]: validateFormField(reviewFormSchema, field, value),
- }))
- }
-
- const handleAgreementChange = (checked: boolean) => {
- setTouched((prev) => ({ ...prev, agreement: true }))
- setClientErrors((prev) => ({
- ...prev,
- agreement: validateFormField(reviewFormSchema, 'agreement', checked || undefined),
- }))
- }
-
- const handleStarSelect = (star: number) => {
- setSelectedStars(star)
- setTouched((prev) => ({ ...prev, stars: true }))
- setClientErrors((prev) => ({ ...prev, stars: '' }))
- }
-
- const handleStarKeyDown = (evt: KeyboardEvent, star: number) => {
- let next: number | null = null
-
- if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
- evt.preventDefault()
- next = Math.min(star + 1, STARS_COUNT)
- } else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
- evt.preventDefault()
- next = Math.max(star - 1, 1)
- }
-
- if (next !== null) {
- handleStarSelect(next)
- starRefs.current[next - 1]?.focus()
- }
- }
-
- const handleSubmit = async (evt: SubmitEvent) => {
- evt.preventDefault()
-
- if (isSubmitting || isPending) {
- return
- }
-
- const formEl = formRef.current
- const nameVal = (formEl?.elements.namedItem('name') as HTMLInputElement)?.value || ''
- const phoneVal = (formEl?.elements.namedItem('phone') as HTMLInputElement)?.value || ''
- const emailVal = (formEl?.elements.namedItem('email') as HTMLInputElement)?.value || ''
- const reviewVal = (formEl?.elements.namedItem('review') as HTMLTextAreaElement)?.value || ''
- const agreementChecked =
- (formEl?.elements.namedItem('agreement') as HTMLInputElement)?.checked || false
-
- const parseResult = reviewFormSchema.safeParse({
- name: nameVal,
- phone: phoneVal,
- email: emailVal,
- review: reviewVal,
- stars: selectedStars,
- agreement: agreementChecked || undefined,
- })
-
- const allTouched = Object.fromEntries(
- Object.keys(reviewFormSchema.shape).map((key) => [key, true]),
- )
- setTouched(allTouched)
-
- if (!parseResult.success) {
- const flat = z.flattenError(parseResult.error).fieldErrors as Record<
- string,
- string[] | undefined
- >
- setClientErrors(
- Object.fromEntries(
- Object.keys(reviewFormSchema.shape).map((key) => [key, flat[key]?.[0] ?? '']),
- ),
- )
- return
- }
-
- setClientErrors({})
- setIsSubmitting(true)
-
- if (widgetIdRef.current !== null && window.smartCaptcha) {
- try {
- window.smartCaptcha.execute(widgetIdRef.current)
- } catch {
- setIsSubmitting(false)
- }
- } else {
- setIsSubmitting(false)
- }
- }
-
- const activeStars = hoveredStars || selectedStars
-
- return (
-
- {state?.message && (
-
- {state.message}
-
- )}
-
- {(!state || !state.success) && (
- <>
-
- Ваше имя:
-
- handleBlur('name', evt.target.value)}
- />
-
-
- {getError('name')}
-
-
-
-
- Телефон:
-
- handleBlur('phone', evt.target.value)}
- />
-
-
- {getError('phone')}
-
-
-
-
- Email:
-
- handleBlur('email', evt.target.value)}
- />
-
-
- {getError('email')}
-
-
-
-
- Ваша оценка:
-
-
- {Array.from({ length: STARS_COUNT }, (_, i) => i + 1).map((star) => (
-
{
- starRefs.current[star - 1] = el
- }}
- type="button"
- role="radio"
- aria-checked={star <= selectedStars}
- aria-label={`${star} звезд${star === 1 ? 'а' : star < 5 ? 'ы' : ''}`}
- tabIndex={star === (selectedStars || 1) ? 0 : -1}
- className={clsx(formStyles['form__star'], {
- [formStyles['form__star--active']]: star <= activeStars,
- [formStyles['form__star--hovered']]: star === hoveredStars,
- })}
- onClick={() => handleStarSelect(star)}
- onKeyDown={(evt) => handleStarKeyDown(evt, star)}
- onMouseEnter={() => setHoveredStars(star)}
- onMouseLeave={() => setHoveredStars(0)}
- >
-
-
-
-
- ))}
-
-
-
-
-
- {getError('stars')}
-
-
-
-
- Введите ваш отзыв:
-
- handleBlur('review', evt.target.value)}
- >
-
-
- {getError('review')}
-
-
-
-
-
-
-
- handleAgreementChange(evt.target.checked)}
- />
-
-
-
- Я согласен
-
- на обработку персональных данных в соответствии с условиями
-
- Политики обработки персональных данных
-
-
-
-
- {getError('agreement')}
-
-
-
-
- {isPending || isSubmitting ? 'Отправка...' : 'Отправить отзыв'}
-
-
- >
- )}
-
-
-
- )
-}
diff --git a/src/components/GalleryBlock/GalleryBlock.module.scss b/src/components/GalleryBlock/GalleryBlock.module.scss
index 1ae683f7..de93fb27 100644
--- a/src/components/GalleryBlock/GalleryBlock.module.scss
+++ b/src/components/GalleryBlock/GalleryBlock.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.gallery-block__slider {
overflow: hidden;
@@ -19,7 +19,7 @@
}
}
-.gallery-block__image-btn {
+.gallery-block__image-button {
position: relative;
display: block;
diff --git a/src/components/GalleryBlock/GalleryBlock.tsx b/src/components/GalleryBlock/GalleryBlock.tsx
index 0525bd3a..47a58e7e 100644
--- a/src/components/GalleryBlock/GalleryBlock.tsx
+++ b/src/components/GalleryBlock/GalleryBlock.tsx
@@ -75,7 +75,7 @@ export default function GalleryBlock({
>
handleSlideClick(0)}
aria-label="Открыть изображение"
>
diff --git a/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss b/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss
index fa56dbed..9cdd0cf0 100644
--- a/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss
+++ b/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.slider-detail__item {
height: 500px;
@@ -8,7 +8,7 @@
}
}
-.slider-detail__item-btn {
+.slider-detail__item-button {
position: relative;
display: block;
diff --git a/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx b/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx
index 569e0e45..7de404e1 100644
--- a/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx
+++ b/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx
@@ -70,7 +70,7 @@ export default function GalleryBlockSlider({
{onSlideClick ? (
onSlideClick(index)}
aria-label={`Открыть изображение ${index + 1}`}
>
diff --git a/src/components/GalleryPage/GalleryPage.module.scss b/src/components/GalleryPage/GalleryPage.module.scss
index fac6ad1a..c1276ff0 100644
--- a/src/components/GalleryPage/GalleryPage.module.scss
+++ b/src/components/GalleryPage/GalleryPage.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.gallery {
}
diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss
index b5b89594..42ba6004 100644
--- a/src/components/Header/Header.module.scss
+++ b/src/components/Header/Header.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
@keyframes slideDown {
from {
diff --git a/src/components/HeaderMenu/HeaderMenu.module.scss b/src/components/HeaderMenu/HeaderMenu.module.scss
index 5dc105eb..d33b21d4 100644
--- a/src/components/HeaderMenu/HeaderMenu.module.scss
+++ b/src/components/HeaderMenu/HeaderMenu.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.menu-button {
display: flex;
diff --git a/src/components/Heading/Heading.module.scss b/src/components/Heading/Heading.module.scss
index 6f2a5ce1..9fe42beb 100644
--- a/src/components/Heading/Heading.module.scss
+++ b/src/components/Heading/Heading.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.heading {
padding: 30px 0;
diff --git a/src/components/Logo/Logo.module.scss b/src/components/Logo/Logo.module.scss
index 2fd639fc..a898620c 100644
--- a/src/components/Logo/Logo.module.scss
+++ b/src/components/Logo/Logo.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.logo {
display: flex;
diff --git a/src/components/Main/About/About.tsx b/src/components/Main/About/About.tsx
index d6afba4b..e5f608b9 100644
--- a/src/components/Main/About/About.tsx
+++ b/src/components/Main/About/About.tsx
@@ -4,20 +4,23 @@ import clsx from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { fetchSingleton, getImageUrl } from '@/lib/api-client'
-import { MainInfoFromServer } from '@/types/types'
+import { AboutFromServer, MainInfoFromServer } from '@/types/types'
import { createSanitizedHTML } from '@/functions/functions'
export default async function About(): Promise {
- const mainInfo: MainInfoFromServer | null = await fetchSingleton('maininfo')
+ const [mainInfo, about] = await Promise.all([
+ fetchSingleton('maininfo'),
+ fetchSingleton('about'),
+ ])
- if (!mainInfo) {
+ if (!mainInfo || !about) {
return null
}
const title = mainInfo.title
- const description = mainInfo.description
- const image = mainInfo.image ? getImageUrl(mainInfo.image._id, 800, 500) : ''
- const alt = mainInfo.image?.alt ?? mainInfo.title
+ const description = about.preview ?? ''
+ const image = about.image ? getImageUrl(about.image._id, 800, 500) : ''
+ const alt = about.image?.alt ?? title
return (
diff --git a/src/components/Main/Process/Process.module.scss b/src/components/Main/Process/Process.module.scss
index 220ecaf2..becbf3d2 100644
--- a/src/components/Main/Process/Process.module.scss
+++ b/src/components/Main/Process/Process.module.scss
@@ -72,7 +72,7 @@
grid-row: 1 / -1;
height: auto;
- max-height: 400px;
+ max-height: 500px;
object-fit: cover;
box-shadow: $cardShadow;
diff --git a/src/components/Main/SliderMain/SliderMain.module.scss b/src/components/Main/SliderMain/SliderMain.module.scss
index 56885ac3..b0c322fb 100644
--- a/src/components/Main/SliderMain/SliderMain.module.scss
+++ b/src/components/Main/SliderMain/SliderMain.module.scss
@@ -132,4 +132,12 @@
}
.slider-main__item-button {
+ font-size: 18px;
+
+ @media (max-width: $tablet-min-width) {
+ font-size: 16px;
+ }
+ @media (max-width: $mobile-mid-width) {
+ font-size: 14px;
+ }
}
diff --git a/src/components/Main/SliderMain/SliderMain.tsx b/src/components/Main/SliderMain/SliderMain.tsx
index 90e33f26..1467c949 100644
--- a/src/components/Main/SliderMain/SliderMain.tsx
+++ b/src/components/Main/SliderMain/SliderMain.tsx
@@ -1,6 +1,6 @@
import { JSX } from 'react'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
-import type { CardItem, MainSliderFromServer } from '@/types/types'
+import type { MainSliderItem, MainSliderFromServer } from '@/types/types'
import SliderMainClient from './SliderMainClient'
export default async function SliderMain(): Promise {
@@ -15,13 +15,16 @@ export default async function SliderMain(): Promise {
return null
}
- const slidesList: CardItem[] = mainSliderData.map((item) => ({
+ const slidesList: MainSliderItem[] = mainSliderData.map((item) => ({
id: item._id,
image: getImageUrl(item.image._id, 1920, 1080, { mode: 'bestFit' }),
alt: item.title || '',
title: item.title || '',
description: item.description || '',
- href: item.link || '',
+ button: {
+ link: item.button?.link || '',
+ name: item.button?.name || '',
+ },
}))
return
diff --git a/src/components/Main/SliderMain/SliderMainClient.tsx b/src/components/Main/SliderMain/SliderMainClient.tsx
index 3c8dbbfb..33c7c439 100644
--- a/src/components/Main/SliderMain/SliderMainClient.tsx
+++ b/src/components/Main/SliderMain/SliderMainClient.tsx
@@ -11,7 +11,7 @@ import 'swiper/css/navigation'
import 'swiper/css/pagination'
import type { NavigationOptions, PaginationOptions } from 'swiper/types'
-import { CardItem } from '@/types/types'
+import { MainSliderItem } from '@/types/types'
import Image from 'next/image'
import Link from 'next/link'
@@ -20,7 +20,7 @@ import stylesSliderMain from './SliderMain.module.scss'
import { createSanitizedHTML } from '@/functions/functions'
type SliderMainClientProps = {
- slidesList: CardItem[]
+ slidesList: MainSliderItem[]
}
export default function SliderMainClient({ slidesList }: SliderMainClientProps): JSX.Element {
@@ -87,9 +87,9 @@ export default function SliderMainClient({ slidesList }: SliderMainClientProps):
'button',
'button--arrow',
)}
- href={slide.href}
+ href={slide.button?.link ?? '#'}
>
- Подробнее
+ {slide.button?.name ?? 'Подробнее'}
diff --git a/src/components/Master/Master.module.scss b/src/components/Master/Master.module.scss
index 112bbdd7..e037b604 100644
--- a/src/components/Master/Master.module.scss
+++ b/src/components/Master/Master.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.master {
}
diff --git a/src/components/Menu/Menu.module.scss b/src/components/Menu/Menu.module.scss
index 53f3ae30..f79b53fc 100644
--- a/src/components/Menu/Menu.module.scss
+++ b/src/components/Menu/Menu.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.menu {
display: flex;
diff --git a/src/components/NotFound/NotFound.module.scss b/src/components/NotFound/NotFound.module.scss
index 309d2cbb..1761aa4d 100644
--- a/src/components/NotFound/NotFound.module.scss
+++ b/src/components/NotFound/NotFound.module.scss
@@ -1,6 +1,6 @@
@use 'sass:math';
-@use '../../styles/variables.scss' as *;
-@use '../../styles/animations.scss';
+@use '../../styles/variables' as *;
+@use '../../styles/animations';
.not-found {
z-index: 10;
diff --git a/src/components/NotFound/NotFound.tsx b/src/components/NotFound/NotFound.tsx
index 7656e6f1..fc2a11d7 100644
--- a/src/components/NotFound/NotFound.tsx
+++ b/src/components/NotFound/NotFound.tsx
@@ -16,6 +16,7 @@ export default function NotFound(): JSX.Element {
404
Страница затерялась в космосе
+
Перейти на главную
diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss
index 2d77d79f..95b657ad 100644
--- a/src/components/Pagination/Pagination.module.scss
+++ b/src/components/Pagination/Pagination.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.pagination {
display: flex;
diff --git a/src/components/Payment/Payment.module.scss b/src/components/Payment/Payment.module.scss
index cb91bf0b..d489ce02 100644
--- a/src/components/Payment/Payment.module.scss
+++ b/src/components/Payment/Payment.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.payment {
}
diff --git a/src/components/ReviewsList/ReviewPhoto.tsx b/src/components/ReviewsList/ReviewPhoto.tsx
index c2b960ac..ef3fb3be 100644
--- a/src/components/ReviewsList/ReviewPhoto.tsx
+++ b/src/components/ReviewsList/ReviewPhoto.tsx
@@ -10,8 +10,7 @@ import 'lightgallery/css/lg-thumbnail.css'
import 'lightgallery/css/lg-zoom.css'
import type { LightGallery } from 'lightgallery/lightgallery'
import reviewsListStyles from './ReviewsList.module.scss'
-
-type PhotoItem = { thumb: string; full: string }
+import { PhotoItem } from '@/types/types'
type ReviewPhotoProps = {
photos: PhotoItem[]
@@ -54,7 +53,7 @@ export default function ReviewPhoto({ photos, authorName, date }: ReviewPhotoPro
handleClick(index)}
aria-label={`Открыть фото ${index + 1}`}
>
diff --git a/src/components/ReviewsList/ReviewsList.module.scss b/src/components/ReviewsList/ReviewsList.module.scss
index 9619f3c3..dfc6e249 100644
--- a/src/components/ReviewsList/ReviewsList.module.scss
+++ b/src/components/ReviewsList/ReviewsList.module.scss
@@ -99,7 +99,7 @@
}
}
-.reviews__item-photo-btn {
+.reviews__item-photo-button {
position: relative;
overflow: hidden;
diff --git a/src/components/ServicesInit/ServicesInit.tsx b/src/components/ServicesInit/ServicesInit.tsx
new file mode 100644
index 00000000..5222bdc4
--- /dev/null
+++ b/src/components/ServicesInit/ServicesInit.tsx
@@ -0,0 +1,26 @@
+'use client'
+import { useEffect, JSX } from 'react'
+import { useCookieConsent } from '@/context/CookieConsentContext'
+
+export default function ServicesInit(): JSX.Element | null {
+ const { registerCallback } = useCookieConsent()
+
+ useEffect(() => {
+ registerCallback('statistical', () => {
+ // Placeholder for statistical
+ console.log('[CookieConsent] Statistical callbacks initialized')
+ })
+
+ registerCallback('marketing', () => {
+ // Placeholder for marketing
+ console.log('[CookieConsent] Marketing callbacks initialized')
+ })
+
+ registerCallback('functional', () => {
+ // Placeholder for functional
+ console.log('[CookieConsent] Functional callbacks initialized')
+ })
+ }, [registerCallback])
+
+ return null
+}
diff --git a/src/components/Social/Social.module.scss b/src/components/Social/Social.module.scss
index 309ba0d8..dd6d1c19 100644
--- a/src/components/Social/Social.module.scss
+++ b/src/components/Social/Social.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.social {
display: flex;
diff --git a/src/components/Works/Works.module.scss b/src/components/Works/Works.module.scss
index d7a15da4..51356b8c 100644
--- a/src/components/Works/Works.module.scss
+++ b/src/components/Works/Works.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.works {
}
diff --git a/src/components/YandexMap/YandexMap.module.scss b/src/components/YandexMap/YandexMap.module.scss
index 2d74ad25..d6f826c6 100644
--- a/src/components/YandexMap/YandexMap.module.scss
+++ b/src/components/YandexMap/YandexMap.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
@import '@yandex/ymaps3-default-ui-theme/dist/esm/index.css';
.map {
diff --git a/src/const/const.ts b/src/const/const.ts
index ca5fdfca..79b7e62a 100644
--- a/src/const/const.ts
+++ b/src/const/const.ts
@@ -1,4 +1,4 @@
-import { MenuItem } from '@/types/types'
+import { CookieCategoryId, MenuItem } from '@/types/types'
export const ITEMS_PER_PAGE = 12
export const REVIEWS_PER_PAGE = 1
@@ -85,3 +85,38 @@ export const ANCHOR_LINKS: MenuItem[] = [
href: '#faq',
},
]
+
+export const COOKIE_CATEGORIES: {
+ id: CookieCategoryId
+ label: string
+ description: string
+ required: boolean
+}[] = [
+ {
+ id: 'necessary',
+ label: 'Обязательные cookie',
+ description: 'Обеспечивают базовую функциональность сайта',
+ required: true,
+ },
+ {
+ id: 'functional',
+ label: 'Функциональные cookie',
+ description:
+ 'Адаптируют сайт к предпочтениям пользователя: запоминают выбранный язык, размер шрифта, тему оформления',
+ required: false,
+ },
+ {
+ id: 'statistical',
+ label: 'Статистические cookie',
+ description:
+ 'Собирают данные о том, как пользователи используют сайт: какие страницы посещают, сколько времени проводят на них',
+ required: false,
+ },
+ {
+ id: 'marketing',
+ label: 'Маркетинговые cookie',
+ description:
+ 'Отслеживают поведение пользователей на сайте для предоставления персонализированной рекламы',
+ required: false,
+ },
+]
diff --git a/src/context/CookieConsentContext.tsx b/src/context/CookieConsentContext.tsx
new file mode 100644
index 00000000..e3e3b967
--- /dev/null
+++ b/src/context/CookieConsentContext.tsx
@@ -0,0 +1,88 @@
+'use client'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ ReactNode,
+ JSX,
+} from 'react'
+import { CookieCategoryId, CookieConsent } from '@/types/types'
+
+type CallbackRegistry = Map void>>
+
+type CookieConsentContextValue = {
+ consent: CookieConsent
+ saveConsent: (newConsent: CookieConsent) => void
+ registerCallback: (type: CookieCategoryId, fn: () => void) => void
+ isBannerVisible: boolean
+ openBanner: () => void
+}
+
+const CookieConsentContext = createContext(null)
+
+type Props = {
+ initialConsent: CookieConsent | null
+ children: ReactNode
+}
+
+export function CookieConsentProvider({ initialConsent, children }: Props): JSX.Element {
+ const defaultConsent: CookieConsent = {
+ necessary: true,
+ functional: false,
+ statistical: false,
+ marketing: false,
+ }
+
+ const [consent, setConsent] = useState(initialConsent ?? defaultConsent)
+ const [isBannerVisible, setIsBannerVisible] = useState(initialConsent === null)
+ const callbackRegistry = useRef(new Map())
+ const consentRef = useRef(consent)
+
+ useEffect(() => {
+ consentRef.current = consent
+ const registry = callbackRegistry.current
+ for (const [type, fns] of registry.entries()) {
+ if (consent[type]) {
+ fns.forEach((fn) => fn())
+ }
+ }
+ }, [consent])
+
+ const saveConsent = (newConsent: CookieConsent) => {
+ const value = JSON.stringify({ ...newConsent, necessary: true })
+ document.cookie = `cookie-consent=${encodeURIComponent(value)}; max-age=31536000; path=/; SameSite=Lax`
+ setConsent({ ...newConsent, necessary: true })
+ setIsBannerVisible(false)
+ }
+
+ const registerCallback = useCallback((type: CookieCategoryId, fn: () => void) => {
+ const registry = callbackRegistry.current
+ if (!registry.has(type)) {
+ registry.set(type, new Set())
+ }
+ registry.get(type)!.add(fn)
+
+ if (consentRef.current[type]) {
+ fn()
+ }
+ }, [])
+
+ const openBanner = () => setIsBannerVisible(true)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useCookieConsent(): CookieConsentContextValue {
+ const ctx = useContext(CookieConsentContext)
+ if (!ctx) throw new Error('useCookieConsent must be used within CookieConsentProvider')
+ return ctx
+}
diff --git a/src/functions/functions.ts b/src/functions/functions.ts
index 59908dfb..496f7964 100644
--- a/src/functions/functions.ts
+++ b/src/functions/functions.ts
@@ -46,3 +46,13 @@ export function createMaxLink(max: string): string {
export function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim()
}
+
+export function formatPrice(price: string | number): string {
+ const num = typeof price === 'number' ? price : parseFloat(price)
+ if (isNaN(num)) return String(price)
+ return (
+ new Intl.NumberFormat('ru-RU', {
+ maximumFractionDigits: 0,
+ }).format(num) + '\u00a0₽'
+ )
+}
diff --git a/src/styles/animations.scss b/src/styles/animations.scss
index 9aa21210..79d7c663 100644
--- a/src/styles/animations.scss
+++ b/src/styles/animations.scss
@@ -83,3 +83,14 @@
opacity: 1;
}
}
+
+@keyframes slideUp {
+ from {
+ transform: translateX(-50%) translateY(calc(100% + 30px));
+ opacity: 0;
+ }
+ to {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/src/styles/blocks/section.scss b/src/styles/blocks/section.scss
index 010f7549..42ab136a 100644
--- a/src/styles/blocks/section.scss
+++ b/src/styles/blocks/section.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.section {
display: flex;
diff --git a/src/styles/modules/form.module.scss b/src/styles/modules/form.module.scss
index ea45c284..aa949d71 100644
--- a/src/styles/modules/form.module.scss
+++ b/src/styles/modules/form.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.form {
display: grid;
@@ -180,8 +180,9 @@
width: calc(100% / 3);
- background: $accent-light-bg;
- box-shadow: $cardShadow;
+ background-color: $accent-light-bg;
+ box-shadow: $boxShadowOutset;
+ border: 1px solid $accent-add-bg;
border-radius: 6px;
transition:
@@ -419,8 +420,13 @@
font-weight: 700;
background-color: $accent-light-bg;
- box-shadow: $cardShadow;
- border-radius: 6px;
+ box-shadow: $boxShadowOutset;
+ border: 1px solid $accent-add-bg;
+ border-radius: 12px;
+}
+
+.form__result-price {
+ color: $accent-bg;
}
.form__message {
diff --git a/src/styles/modules/slider.module.scss b/src/styles/modules/slider.module.scss
index 9347aea7..5b72443e 100644
--- a/src/styles/modules/slider.module.scss
+++ b/src/styles/modules/slider.module.scss
@@ -1,4 +1,4 @@
-@use '../../styles/variables.scss' as *;
+@use '../../styles/variables' as *;
.slider {
position: relative;
diff --git a/src/types/types.ts b/src/types/types.ts
index fec74cca..ae0f7001 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -90,6 +90,7 @@ export type MainInfoFromServer = {
max: string
agreement: AssetItem
policy: AssetItem
+ cookie: AssetItem
}
export type AdvantageFromServer = {
@@ -120,6 +121,8 @@ export type WorkFromServer = {
date: string
master: MasterFromServer | null
in_stock: boolean
+ price?: string
+ size?: string
slug?: string
_created?: number
_modified?: number
@@ -186,6 +189,14 @@ export type GalleryItemForClient = {
children: GalleryItemForClient[]
}
+export type AboutFromServer = {
+ title: string
+ description: string
+ preview: string
+ image: ImageItem
+ slider: ImageItem[]
+}
+
export type FaqFromServer = {
_id: string
question: string
@@ -199,14 +210,28 @@ export type RestorationFromServer = {
image: ImageItem
}
+export type ButtonLink = {
+ link?: string
+ name?: string
+}
+
export type MainSliderFromServer = {
_id: string
title?: string
description?: string
- link?: string
+ button?: ButtonLink
image: ImageItem
}
+export type MainSliderItem = {
+ id: string | number
+ image: string
+ alt: string
+ title?: string
+ description?: string
+ button?: ButtonLink
+}
+
export type FormState = {
success: boolean
message: string
@@ -221,3 +246,17 @@ export interface ImageOptions {
mime?: ImageMime
quality?: number
}
+
+export type CookieCategoryId = 'necessary' | 'functional' | 'statistical' | 'marketing'
+export type CookieConsent = Record
+
+export type FilePreview = {
+ name: string
+ isImage: boolean
+ preview: string | null
+}
+
+export type PhotoItem = {
+ thumb: string
+ full: string
+}