Skip to content
Merged

Dev #36

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 58 additions & 13 deletions .info/project.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,76 @@
{
"date": "04.2026",
"complexity": "4",
"date": "05.2026",
"complexity": "5",
"category": "commercial",
"deploy": "https:\/\/icon-artel.ru",
"title": {
"ru": "Иконописная Артель",
"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"]
}
}
199 changes: 199 additions & 0 deletions COOKIE-CONSENT.md
Original file line number Diff line number Diff line change
@@ -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<CookieCategoryId, boolean>
```

---

## Как работает инициализация

```
SSR: layout.tsx читает куку cookie-consent из заголовков запроса
Передаёт initialConsent в <CookieConsentProvider 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 <div>Реклама</div>
}
```

---

### 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 <button onClick={openBanner}>Настройки cookie</button>
}
```

---

## Добавление нового сервиса

Всё делается в `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`.
1 change: 1 addition & 0 deletions src/app/api/documents/[type]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cockpit from '@/lib/CockpitAPI'
const DOCUMENT_TITLES: Record<string, string> = {
agreement: 'Согласие на обработку персональных данных - Иконописная Артель',
policy: 'Политика обработки персональных данных - Иконописная Артель',
cookie: 'Политика использования файлов cookie - Иконописная Артель',
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/app/gallery/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import {
buildGalleryBreadcrumbs,
} from '@/functions/gallery'

type PageParams = {
type PageProps = {
params: Promise<{
slug: string[]
}>
}

export async function generateMetadata({ params }: PageParams): Promise<Metadata> {
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const lastSlug = slug[slug.length - 1]
const galleryData: GalleryTreeItem[] | null = await fetchTree<GalleryTreeItem[]>('gallery')
Expand All @@ -38,7 +38,7 @@ export async function generateMetadata({ params }: PageParams): Promise<Metadata
}
}

export default async function Page({ params }: PageParams): Promise<JSX.Element> {
export default async function Page({ params }: PageProps): Promise<JSX.Element> {
const { slug } = await params
const lastSlug = slug[slug.length - 1]
const galleryData: GalleryTreeItem[] | null = await fetchTree<GalleryTreeItem[]>('gallery')
Expand Down
5 changes: 5 additions & 0 deletions src/app/in-stock/[in-stock-detail]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export default async function Page({ params }: PageProps): Promise<JSX.Element>
'@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' }
: {}),
},
}

Expand All @@ -117,6 +120,8 @@ export default async function Page({ params }: PageProps): Promise<JSX.Element>
description={work.description}
image={work.image}
slidesList={slidesList}
price={work.price}
size={work.size}
/>

{MasterInfo && <Master master={MasterInfo} />}
Expand Down
Loading