From 1627ec6fbcd3919b3980bada798ff3639b68bb29 Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Wed, 13 May 2026 22:33:01 +0300 Subject: [PATCH 1/3] Added cookie banner and scripts, fix code style --- COOKIE-CONSENT.md | 198 +++++++++++++++++ src/app/api/documents/[type]/route.ts | 1 + src/app/layout.tsx | 81 ++++--- .../AboutPage/AboutPage.module.scss | 2 +- src/components/Address/Address.module.scss | 2 +- .../Breadcrumbs/Breadcrumbs.module.scss | 2 +- src/components/Card/Card.module.scss | 2 +- src/components/Contacts/Contacts.module.scss | 2 +- .../ContactsPage/ContactsPage.module.scss | 2 +- .../CookieBanner/CookieBanner.module.scss | 200 ++++++++++++++++++ src/components/CookieBanner/CookieBanner.tsx | 6 + .../CookieBanner/CookieBannerClient.tsx | 146 +++++++++++++ src/components/Detail/Detail.module.scss | 2 +- .../Developer/Developer.module.scss | 2 +- src/components/DropZone/DropZone.module.scss | 2 +- .../EmptySection/EmptySection.module.scss | 2 +- src/components/Footer/Footer.module.scss | 2 +- .../Forms/FormContacts/FormContactsClient.tsx | 9 +- .../Forms/FormOrder/FormOrderClient.tsx | 9 +- .../Forms/FormReviews/FormReviewsClient.tsx | 9 +- .../GalleryBlock/GalleryBlock.module.scss | 2 +- .../GalleryBlockSlider.module.scss | 2 +- .../GalleryPage/GalleryPage.module.scss | 2 +- src/components/Header/Header.module.scss | 2 +- .../HeaderMenu/HeaderMenu.module.scss | 2 +- src/components/Heading/Heading.module.scss | 2 +- src/components/Logo/Logo.module.scss | 2 +- src/components/Master/Master.module.scss | 2 +- src/components/Menu/Menu.module.scss | 2 +- src/components/NotFound/NotFound.module.scss | 4 +- .../Pagination/Pagination.module.scss | 2 +- src/components/Payment/Payment.module.scss | 2 +- src/components/ServicesInit/ServicesInit.tsx | 26 +++ src/components/Social/Social.module.scss | 2 +- src/components/Works/Works.module.scss | 2 +- .../YandexMap/YandexMap.module.scss | 2 +- src/const/const.ts | 37 +++- src/context/CookieConsentContext.tsx | 88 ++++++++ src/styles/animations.scss | 11 + src/styles/blocks/section.scss | 2 +- src/styles/modules/form.module.scss | 2 +- src/styles/modules/slider.module.scss | 2 +- src/types/types.ts | 4 + 43 files changed, 812 insertions(+), 73 deletions(-) create mode 100644 COOKIE-CONSENT.md create mode 100644 src/components/CookieBanner/CookieBanner.module.scss create mode 100644 src/components/CookieBanner/CookieBanner.tsx create mode 100644 src/components/CookieBanner/CookieBannerClient.tsx create mode 100644 src/components/ServicesInit/ServicesInit.tsx create mode 100644 src/context/CookieConsentContext.tsx diff --git a/COOKIE-CONSENT.md b/COOKIE-CONSENT.md new file mode 100644 index 00000000..34b43fb4 --- /dev/null +++ b/COOKIE-CONSENT.md @@ -0,0 +1,198 @@ +# 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 +} +``` + +--- + +## Добавление нового сервиса + +Всё делается в `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/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 ( -