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 +} +``` + +--- + +## Добавление нового сервиса + +Всё делается в `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 ( -