diff --git a/package-lock.json b/package-lock.json
index 44ba78e1..9ef81591 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-masonry-css": "^1.0.16",
- "swiper": "^12.0.2"
+ "swiper": "^12.0.2",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -6549,6 +6550,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index 2e70b254..ce60590d 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-masonry-css": "^1.0.16",
- "swiper": "^12.0.2"
+ "swiper": "^12.0.2",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
diff --git a/src/actions/forms.ts b/src/actions/forms.ts
index b12be1b2..31d70be0 100644
--- a/src/actions/forms.ts
+++ b/src/actions/forms.ts
@@ -5,12 +5,16 @@ import { revalidatePath } from 'next/cache'
import cockpit from '@/lib/CockpitAPI'
import { verifyCaptcha } from '@/lib/captcha'
import { formRateLimiter } from '@/lib/rate-limiter'
-
-export type FormState = {
- success: boolean
- message: string
- errors?: Record
-}
+import {
+ reviewFormSchema,
+ applicationFormSchema,
+ messageFormSchema,
+ zodErrors,
+ ReviewFormData,
+ ApplicationFormData,
+ MessageFormData,
+} from '@/lib/schemas'
+import type { FormState } from '@/types/types'
/**
* Получение IP адреса
@@ -27,22 +31,6 @@ async function getClientIp(): Promise {
return realIp || 'unknown'
}
-/**
- * Валидация Email
- */
-function isValidEmail(email: string): boolean {
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
- return emailRegex.test(email)
-}
-
-/**
- * Валидация номера телефона
- */
-function isValidPhone(phone: string): boolean {
- const phoneRegex = /^(\+?\d{1,4}?[\s\-]?)?(\(?\d{1,4}?\)?[\s\-]?)?[\d\s\-]{6,20}$/
- return phoneRegex.test(phone)
-}
-
/**
* Отправка формы отзывов
*/
@@ -54,52 +42,57 @@ export async function submitReview(
if (!formRateLimiter.check(clientIp)) {
const retryAfter = formRateLimiter.getRetryAfter(clientIp)
+
return {
success: false,
message: `Слишком много запросов. Попробуйте через ${Math.ceil(retryAfter / 60)} минут.`,
}
}
- const name = formData.get('name')?.toString().trim() || ''
- const phone = formData.get('phone')?.toString().trim() || ''
- const email = formData.get('email')?.toString().trim() || ''
- const review = formData.get('review')?.toString().trim() || ''
- const captchaToken = formData.get('smart-token')?.toString() || ''
- const agreed = formData.get('agreement') === 'on'
-
- const errors: Record = {}
+ const starsRaw = formData.get('stars')?.toString() || ''
- if (!name || name.length < 2) {
- errors.name = 'Имя должно содержать минимум 2 символа'
- }
+ const parsed = reviewFormSchema.safeParse({
+ name: formData.get('name')?.toString().trim() ?? '',
+ phone: formData.get('phone')?.toString().trim() ?? '',
+ email: formData.get('email')?.toString().trim() ?? '',
+ review: formData.get('review')?.toString().trim() ?? '',
+ stars: parseInt(starsRaw, 10),
+ agreement: formData.get('agreement') === 'on',
+ })
- if (phone && !isValidPhone(phone)) {
- errors.phone = 'Некорректный формат телефона'
+ if (!parsed.success) {
+ return {
+ success: false,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: zodErrors(parsed.error),
+ }
}
- if (!email) {
- errors.email = 'Email обязателен для заполнения'
- } else if (!isValidEmail(email)) {
- errors.email = 'Некорректный формат email'
- }
+ const { name, phone, email, review, stars }: ReviewFormData = parsed.data
- if (!review || review.length < 50) {
- errors.message = 'Отзыв должен содержать минимум 50 символов'
- }
+ const photoFiles = formData
+ .getAll('photos')
+ .filter((file): file is File => file instanceof File && file.size > 0)
- if (!agreed) {
- errors.agreement = 'Необходимо согласие на обработку данных'
+ if (photoFiles.length > 5) {
+ return {
+ success: false,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: { photo: 'Можно загрузить не более 5 фото' },
+ }
}
- if (Object.keys(errors).length > 0) {
+ if (photoFiles.some((file) => !file.type.startsWith('image/') || file.size > 5 * 1024 * 1024)) {
return {
success: false,
- message: 'Пожалуйста, исправьте ошибки в форме',
- errors,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: { photo: 'Допустимы только изображения до 5 МБ каждое' },
}
}
+ const captchaToken = formData.get('smart-token')?.toString() || ''
const captchaValid = await verifyCaptcha(captchaToken, clientIp)
+
if (!captchaValid) {
return {
success: false,
@@ -107,11 +100,27 @@ export async function submitReview(
}
}
+ const date = new Date().toISOString().slice(0, 10)
+ const safeName = name.replace(/\s+/g, '_').replace(/[^a-zA-Zа-яёА-ЯЁ0-9_]/g, '')
+
+ const renamedFiles = photoFiles.map((file, index) => {
+ const ext = file.name.includes('.') ? file.name.split('.').pop() : 'jpg'
+ const suffix = photoFiles.length > 1 ? `_${index + 1}` : ''
+ const newName = `${safeName}_${date}${suffix}.${ext}`
+
+ return new File([file], newName, { type: file.type })
+ })
+
+ const uploadedAssets = renamedFiles.length > 0 ? await cockpit.uploadAssets(renamedFiles) : []
+
const result = await cockpit.createItem('reviews', {
name,
phone: phone || null,
email,
review,
+ stars,
+ date,
+ ...(uploadedAssets.length > 0 ? { photos: uploadedAssets } : {}),
_state: false,
ip: clientIp,
})
@@ -132,7 +141,7 @@ export async function submitReview(
}
/**
- * Отправка формы обратной связи
+ * Отправка формы заказа
*/
export async function submitApplication(
prevState: FormState | null,
@@ -142,48 +151,142 @@ export async function submitApplication(
if (!formRateLimiter.check(clientIp)) {
const retryAfter = formRateLimiter.getRetryAfter(clientIp)
+
return {
success: false,
message: `Слишком много запросов. Попробуйте через ${Math.ceil(retryAfter / 60)} минут.`,
}
}
- const name = formData.get('name')?.toString().trim() || ''
- const phone = formData.get('phone')?.toString().trim() || ''
- const email = formData.get('email')?.toString().trim() || ''
- const message = formData.get('message')?.toString().trim() || ''
- const captchaToken = formData.get('smart-token')?.toString() || ''
- const agreed = formData.get('agreement') === 'on'
+ const parsed = applicationFormSchema.safeParse({
+ name: formData.get('name')?.toString().trim() ?? '',
+ phone: formData.get('phone')?.toString().trim() ?? '',
+ email: formData.get('email')?.toString().trim() ?? '',
+ message: formData.get('message')?.toString().trim() ?? '',
+ category: formData.get('category')?.toString().trim() || undefined,
+ size: formData.get('size')?.toString().trim() || undefined,
+ goldType: formData.get('goldType')?.toString() || undefined,
+ agreement: formData.get('agreement') === 'on',
+ })
+
+ if (!parsed.success) {
+ return {
+ success: false,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: zodErrors(parsed.error),
+ }
+ }
+
+ const { name, phone, email, message, category, size, goldType }: ApplicationFormData = parsed.data
+
+ const photoFiles = formData
+ .getAll('photos')
+ .filter((file): file is File => file instanceof File && file.size > 0)
+
+ if (photoFiles.length > 5) {
+ return {
+ success: false,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: { photos: 'Можно загрузить не более 5 фото' },
+ }
+ }
+
+ if (photoFiles.some((file) => !file.type.startsWith('image/') || file.size > 5 * 1024 * 1024)) {
+ return {
+ success: false,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: { photos: 'Допустимы только изображения до 5 МБ каждое' },
+ }
+ }
- const errors: Record = {}
+ const captchaToken = formData.get('smart-token')?.toString() || ''
+ const captchaValid = await verifyCaptcha(captchaToken, clientIp)
- if (!name || name.length < 2) {
- errors.name = 'Имя должно содержать минимум 2 символа'
+ if (!captchaValid) {
+ return {
+ success: false,
+ message: 'Ошибка проверки капчи. Попробуйте еще раз.',
+ }
}
- if (phone && !isValidPhone(phone)) {
- errors.phone = 'Некорректный формат телефона'
+ const date = new Date().toISOString().slice(0, 10)
+ const safeName = name.replace(/\s+/g, '_').replace(/[^a-zA-Zа-яёА-ЯЁ0-9_]/g, '')
+
+ const renamedFiles = photoFiles.map((file, index) => {
+ const ext = file.name.includes('.') ? file.name.split('.').pop() : 'jpg'
+ const suffix = photoFiles.length > 1 ? `_${index + 1}` : ''
+ const newName = `${safeName}_${date}${suffix}.${ext}`
+ return new File([file], newName, { type: file.type })
+ })
+
+ const uploadedAssets = renamedFiles.length > 0 ? await cockpit.uploadAssets(renamedFiles) : []
+
+ const result = await cockpit.createItem('applications', {
+ name,
+ phone: phone || null,
+ email,
+ message,
+ category: category || null,
+ size: size || null,
+ gold_type: goldType || null,
+ date,
+ ...(uploadedAssets.length > 0 ? { photos: uploadedAssets } : {}),
+ _state: false,
+ ip: clientIp,
+ })
+
+ if (!result) {
+ return {
+ success: false,
+ message: 'Ошибка при отправке заявки. Попробуйте позже.',
+ }
}
- if (!email) {
- errors.email = 'Email обязателен для заполнения'
- } else if (!isValidEmail(email)) {
- errors.email = 'Некорректный формат email'
+ return {
+ success: true,
+ message: 'Спасибо за обращение! Мы свяжемся с вами в ближайшее время.',
}
+}
+
+/**
+ * Отправка формы обратной связи (контакты)
+ */
+export async function submitMessage(
+ prevState: FormState | null,
+ formData: FormData,
+): Promise {
+ const clientIp = await getClientIp()
+
+ if (!formRateLimiter.check(clientIp)) {
+ const retryAfter = formRateLimiter.getRetryAfter(clientIp)
- if (!agreed) {
- errors.agreement = 'Необходимо согласие на обработку данных'
+ return {
+ success: false,
+ message: `Слишком много запросов. Попробуйте через ${Math.ceil(retryAfter / 60)} минут.`,
+ }
}
- if (Object.keys(errors).length > 0) {
+ const parsed = messageFormSchema.safeParse({
+ name: formData.get('name')?.toString().trim() ?? '',
+ phone: formData.get('phone')?.toString().trim() ?? '',
+ email: formData.get('email')?.toString().trim() ?? '',
+ message: formData.get('message')?.toString().trim() ?? '',
+ agreement: formData.get('agreement') === 'on',
+ })
+
+ if (!parsed.success) {
return {
success: false,
- message: 'Пожалуйста, исправьте ошибки в форме',
- errors,
+ message: 'Пожалуйста, заполните форму корректными значениями',
+ errors: zodErrors(parsed.error),
}
}
+ const { name, phone, email, message }: MessageFormData = parsed.data
+
+ const captchaToken = formData.get('smart-token')?.toString() || ''
const captchaValid = await verifyCaptcha(captchaToken, clientIp)
+
if (!captchaValid) {
return {
success: false,
@@ -191,11 +294,14 @@ export async function submitApplication(
}
}
- const result = await cockpit.createItem('applications', {
+ const date = new Date().toISOString().slice(0, 10)
+
+ const result = await cockpit.createItem('messages', {
name,
phone: phone || null,
email,
message: message || null,
+ date,
_state: false,
ip: clientIp,
})
@@ -203,7 +309,7 @@ export async function submitApplication(
if (!result) {
return {
success: false,
- message: 'Ошибка при отправке заявки. Попробуйте позже.',
+ message: 'Ошибка при отправке сообщения. Попробуйте позже.',
}
}
diff --git a/src/app/api/content/collection/[model]/[id]/route.ts b/src/app/api/content/collection/[model]/[id]/route.ts
index bed7e270..abbe3557 100644
--- a/src/app/api/content/collection/[model]/[id]/route.ts
+++ b/src/app/api/content/collection/[model]/[id]/route.ts
@@ -30,7 +30,7 @@ export async function GET(
const field = searchParams.get('field')
- let data
+ let data: unknown
if (field && field !== '_id') {
data = await cockpit.getCollectionItemByField(model, field, id, options)
diff --git a/src/app/api/content/collection/[model]/count/route.ts b/src/app/api/content/collection/[model]/count/route.ts
new file mode 100644
index 00000000..53904848
--- /dev/null
+++ b/src/app/api/content/collection/[model]/count/route.ts
@@ -0,0 +1,50 @@
+import { NextRequest } from 'next/server'
+import cockpit from '@/lib/CockpitAPI'
+
+/**
+ * GET /api/content/collection/[model]/count
+ * Возвращает общее количество записей в коллекции
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ model: string }> },
+) {
+ try {
+ const { model } = await params
+ const searchParams = request.nextUrl.searchParams
+ const options: { filter?: Record } = {}
+
+ if (searchParams.get('filter')) {
+ try {
+ options.filter = JSON.parse(searchParams.get('filter')!)
+ } catch (err) {
+ console.error('Invalid filter JSON:', err)
+ }
+ }
+
+ const data = await cockpit.getCollection(model, options)
+
+ let count = 0
+ if (Array.isArray(data)) {
+ count = data.length
+ } else if (typeof data?.meta?.total === 'number') {
+ count = data.meta.total
+ } else if (Array.isArray(data?.data)) {
+ count = data.data.length
+ }
+
+ return Response.json(
+ { count },
+ {
+ headers: {
+ 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
+ },
+ },
+ )
+ } catch (err) {
+ console.error('[API] Error counting collection:', err)
+ return Response.json({ error: 'Failed to count collection' }, { status: 500 })
+ }
+}
+
+export const revalidate = 3600
diff --git a/src/app/api/content/collection/[model]/route.ts b/src/app/api/content/collection/[model]/route.ts
index 438f0ecb..13330eff 100644
--- a/src/app/api/content/collection/[model]/route.ts
+++ b/src/app/api/content/collection/[model]/route.ts
@@ -21,7 +21,6 @@ export async function GET(
try {
const { model } = await params
const searchParams = request.nextUrl.searchParams
-
const options: CockpitCollectionOptions = {}
if (searchParams.get('locale')) {
@@ -58,7 +57,13 @@ export async function GET(
const data = await cockpit.getCollection(model, options)
- return Response.json(data, {
+ const entries: unknown[] = Array.isArray(data)
+ ? data
+ : Array.isArray(data?.data)
+ ? data.data
+ : []
+
+ return Response.json(entries, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
diff --git a/src/app/api/documents/[type]/route.ts b/src/app/api/documents/[type]/route.ts
new file mode 100644
index 00000000..e0750a52
--- /dev/null
+++ b/src/app/api/documents/[type]/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest } from 'next/server'
+import cockpit from '@/lib/CockpitAPI'
+
+const DOCUMENT_TITLES: Record = {
+ agreement: 'Согласие на обработку персональных данных - Иконописная Артель',
+ policy: 'Политика обработки персональных данных - Иконописная Артель',
+}
+
+/**
+ * GET /api/documents/[type]
+ * Возвращает PDF-документ из maininfo с читаемым именем файла в заголовке,
+ * чтобы браузер показывал его как заголовок вкладки.
+ */
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ type: string }> },
+) {
+ const { type } = await params
+
+ if (!DOCUMENT_TITLES[type]) {
+ return new Response('Not Found', { status: 404 })
+ }
+
+ const mainInfo = await cockpit.getSingleItem('maininfo')
+
+ if (!mainInfo) {
+ return new Response('Not Found', { status: 404 })
+ }
+
+ const asset = (mainInfo as Record)[type] as { path?: string } | undefined
+
+ if (!asset?.path) {
+ return new Response('Not Found', { status: 404 })
+ }
+
+ const cockpitUrl = (process.env.COCKPIT_API_URL || '').replace(/\/$/, '')
+ const fileUrl = `${cockpitUrl}/storage/uploads${asset.path}`
+
+ let fileResponse: Response
+ try {
+ fileResponse = await fetch(fileUrl)
+ } catch {
+ return new Response('Failed to fetch document', { status: 502 })
+ }
+
+ if (!fileResponse.ok) {
+ return new Response('Document not found', { status: 404 })
+ }
+
+ const contentType = fileResponse.headers.get('Content-Type') || 'application/pdf'
+ const ext = asset.path.split('.').pop() || 'pdf'
+ const filename = `${DOCUMENT_TITLES[type]}.${ext}`
+ const encodedFilename = encodeURIComponent(filename)
+
+ return new Response(fileResponse.body, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Content-Disposition': `inline; filename*=UTF-8''${encodedFilename}`,
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ })
+}
diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx
index 4a928840..cf1568e3 100644
--- a/src/app/categories/page.tsx
+++ b/src/app/categories/page.tsx
@@ -1,8 +1,11 @@
import type { Metadata } from 'next'
import { JSX } from 'react'
-import { BreadcrumbItem } from '@/types/types'
+import { BreadcrumbItem, CardItem, CategoryFromServer } from '@/types/types'
import Heading from '@/components/Heading/Heading'
import Categories from '@/components/Categories/Categories'
+import Pagination from '@/components/Pagination/Pagination'
+import { fetchCollection, fetchCollectionCount, getImageUrl } from '@/lib/api-client'
+import { ITEMS_PER_PAGE } from '@/const/const'
export const metadata: Metadata = {
title: 'Категории икон | Иконописная Артель',
@@ -23,16 +26,46 @@ const breadcrumbsList: BreadcrumbItem[] = [
},
]
-export default function Page(): JSX.Element {
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>
+}): Promise {
+ const { page } = await searchParams
+ const currentPage = Math.max(1, parseInt(page ?? '1') || 1)
+ const skip = (currentPage - 1) * ITEMS_PER_PAGE
+
const title = 'Категории икон'
const description =
'Выберите интересующую вас категорию икон, чтобы узнать подробнее о каждом направлении нашей иконописной мастерской.
'
+ const [categoriesData, total] = await Promise.all([
+ fetchCollection('category', {
+ sort: { sort: 1 },
+ limit: ITEMS_PER_PAGE,
+ skip,
+ }),
+ fetchCollectionCount('category'),
+ ])
+
+ const categoriesList: CardItem[] = (categoriesData || []).map((category) => ({
+ id: category.slug || category._id,
+ title: category.title,
+ description: category.description,
+ href: `/categories/${category.slug || category._id}`,
+ image: getImageUrl(category.image._id, 400, 400),
+ alt: category.image.title || category.title,
+ }))
+
+ const totalPages = Math.ceil(total / ITEMS_PER_PAGE)
+
return (
<>
-
+
+
+
>
)
}
diff --git a/src/app/in-stock/page.tsx b/src/app/in-stock/page.tsx
index 2fc29417..0f4e2089 100644
--- a/src/app/in-stock/page.tsx
+++ b/src/app/in-stock/page.tsx
@@ -3,7 +3,9 @@ import { JSX } from 'react'
import { BreadcrumbItem, CardItem, WorkFromServer } from '@/types/types'
import Heading from '@/components/Heading/Heading'
import Works from '@/components/Works/Works'
-import { fetchCollection, getImageUrl } from '@/lib/api-client'
+import Pagination from '@/components/Pagination/Pagination'
+import { fetchCollection, fetchCollectionCount, getImageUrl } from '@/lib/api-client'
+import { ITEMS_PER_PAGE } from '@/const/const'
export const metadata: Metadata = {
title: 'Рукописные иконы в наличии | Иконописная Артель',
@@ -26,15 +28,30 @@ const breadcrumbsList: BreadcrumbItem[] = [
},
]
-export default async function Page(): Promise {
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>
+}): Promise {
+ const { page } = await searchParams
+ const currentPage = Math.max(1, parseInt(page ?? '1') || 1)
+ const skip = (currentPage - 1) * ITEMS_PER_PAGE
+
const title = 'Рукописные иконы в наличии'
const description =
'Готовые рукописные иконы, которые можно приобрести без ожидания. Все образы написаны в строгом соответствии с каноном, в древней технологии яичной темперой на деревянной основе с золочением.
'
- const worksData: WorkFromServer[] = await fetchCollection('works', {
- filter: { in_stock: true },
- sort: { date: -1 },
- })
+ const inStockFilter = { in_stock: true }
+
+ const [worksData, total] = await Promise.all([
+ fetchCollection('works', {
+ filter: inStockFilter,
+ sort: { date: -1 },
+ limit: ITEMS_PER_PAGE,
+ skip,
+ }),
+ fetchCollectionCount('works', { filter: inStockFilter }),
+ ])
const inStockList: CardItem[] = (worksData || []).map((work) => ({
id: work.slug || work._id,
@@ -45,11 +62,15 @@ export default async function Page(): Promise {
alt: work.image.title || work.title,
}))
+ const totalPages = Math.ceil(total / ITEMS_PER_PAGE)
+
return (
<>
-
+
+
+
>
)
}
diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx
index 12666580..abcdcb3d 100644
--- a/src/app/news/page.tsx
+++ b/src/app/news/page.tsx
@@ -1,8 +1,11 @@
import type { Metadata } from 'next'
import { JSX } from 'react'
-import { BreadcrumbItem } from '@/types/types'
+import { BreadcrumbItem, CardItem, NewsFromServer } from '@/types/types'
import Heading from '@/components/Heading/Heading'
import News from '@/components/News/News'
+import Pagination from '@/components/Pagination/Pagination'
+import { fetchCollection, fetchCollectionCount, getImageUrl } from '@/lib/api-client'
+import { ITEMS_PER_PAGE } from '@/const/const'
export const metadata: Metadata = {
title: 'Новости | Иконописная Артель',
@@ -25,16 +28,46 @@ const breadcrumbsList: BreadcrumbItem[] = [
},
]
-export default function Page(): JSX.Element {
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>
+}): Promise {
+ const { page } = await searchParams
+ const currentPage = Math.max(1, parseInt(page ?? '1') || 1)
+ const skip = (currentPage - 1) * ITEMS_PER_PAGE
+
const title = 'Новости'
const description =
'Следите за новостями нашей артели: завершенные работы, мастер-классы по иконописи и другие события из жизни нашей мастерской.
'
+ const [newsData, total] = await Promise.all([
+ fetchCollection('news', {
+ sort: { date: -1 },
+ limit: ITEMS_PER_PAGE,
+ skip,
+ }),
+ fetchCollectionCount('news'),
+ ])
+
+ const newsList: CardItem[] = (newsData || []).map((news) => ({
+ id: news.slug || news._id,
+ title: news.title,
+ description: news.description,
+ href: `/news/${news.slug || news._id}`,
+ image: getImageUrl(news.image._id, 400, 400),
+ alt: news.image.title || news.title,
+ }))
+
+ const totalPages = Math.ceil(total / ITEMS_PER_PAGE)
+
return (
<>
-
+
+
+
>
)
}
diff --git a/src/app/order-delivery/page.tsx b/src/app/order-delivery/page.tsx
index 82d3a611..1f93fde0 100644
--- a/src/app/order-delivery/page.tsx
+++ b/src/app/order-delivery/page.tsx
@@ -2,9 +2,8 @@ import type { Metadata } from 'next'
import { JSX } from 'react'
import { BreadcrumbItem } from '@/types/types'
import Heading from '@/components/Heading/Heading'
-import FormCalculation from '@/components/Forms/FormCalculation/FormCalculation'
import Payment from '@/components/Payment/Payment'
-import FormContacts from '@/components/Forms/FormContacts/FormContacts'
+import FormOrder from '@/components/Forms/FormOrder/FormOrder'
export const metadata: Metadata = {
title: 'Заказ и доставка | Иконописная Артель',
@@ -41,24 +40,10 @@ export default function Page(): JSX.Element {
- Связаться с нами
+ Оформить заказ
-
-
-
-
-
-
-
- Расчёт примерной стоимости
-
-
-
- (выполнение гравировки и ассиста рассчитывается отдельно)
-
-
-
+
>
diff --git a/src/app/reviews/page.tsx b/src/app/reviews/page.tsx
index d9f08fc3..d3a9bc90 100644
--- a/src/app/reviews/page.tsx
+++ b/src/app/reviews/page.tsx
@@ -1,8 +1,12 @@
import type { Metadata } from 'next'
import { JSX } from 'react'
-import { BreadcrumbItem } from '@/types/types'
+import { BreadcrumbItem, ReviewFromServer, ReviewItem } from '@/types/types'
import Heading from '@/components/Heading/Heading'
import Reviews from '@/components/Reviews/Reviews'
+import Pagination from '@/components/Pagination/Pagination'
+import FormReviews from '@/components/Forms/FormReviews/FormReviews'
+import { fetchCollection, fetchCollectionCount, getImageUrl } from '@/lib/api-client'
+import { REVIEWS_PER_PAGE } from '@/const/const'
export const metadata: Metadata = {
title: 'Отзывы | Иконописная Артель',
@@ -25,16 +29,71 @@ const breadcrumbsList: BreadcrumbItem[] = [
},
]
-export default function Page(): JSX.Element {
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>
+}): Promise {
+ const { page } = await searchParams
+ const currentPage = Math.max(1, parseInt(page ?? '1') || 1)
+ const skip = (currentPage - 1) * REVIEWS_PER_PAGE
+
const title = 'Отзывы'
const description =
'Мы ценим мнение каждого клиента. Здесь Вы можете ознакомиться с отзывами о качестве наших икон, сроках исполнения заказов и профессионализме мастеров. Благодарим за доверие!
'
+ const [reviewsData, total] = await Promise.all([
+ fetchCollection('reviews', {
+ sort: { date: -1 },
+ limit: REVIEWS_PER_PAGE,
+ skip,
+ }),
+ fetchCollectionCount('reviews'),
+ ])
+
+ const reviewsList: ReviewItem[] = (reviewsData || []).map((review) => {
+ const photos: Array<{ thumb: string; full: string }> = []
+
+ if (review.photos && review.photos.length > 0) {
+ review.photos.forEach((img) => {
+ if (img._id) {
+ photos.push({
+ thumb: getImageUrl(img._id, 400, 300, 'thumbnail'),
+ full: getImageUrl(img._id, 1920, 1080, 'bestFit'),
+ })
+ }
+ })
+ }
+
+ return {
+ id: review._id,
+ date: review.date,
+ stars: review.stars,
+ name: review.name,
+ review: review.review,
+ ...(photos.length > 0 ? { photos } : {}),
+ }
+ })
+
+ const totalPages = Math.ceil(total / REVIEWS_PER_PAGE)
+
return (
<>
-
+
+
+
+
+
+
+
+ Оставить отзыв
+
+
+
+
+
>
)
}
diff --git a/src/app/works/page.tsx b/src/app/works/page.tsx
index 701c293a..b8d8b7d2 100644
--- a/src/app/works/page.tsx
+++ b/src/app/works/page.tsx
@@ -3,7 +3,9 @@ import { JSX } from 'react'
import { BreadcrumbItem, CardItem, WorkFromServer } from '@/types/types'
import Heading from '@/components/Heading/Heading'
import Works from '@/components/Works/Works'
-import { fetchCollection, getImageUrl } from '@/lib/api-client'
+import Pagination from '@/components/Pagination/Pagination'
+import { fetchCollection, fetchCollectionCount, getImageUrl } from '@/lib/api-client'
+import { ITEMS_PER_PAGE } from '@/const/const'
export const metadata: Metadata = {
title: 'Наши работы | Иконописная Артель',
@@ -26,14 +28,27 @@ const breadcrumbsList: BreadcrumbItem[] = [
},
]
-export default async function Page(): Promise {
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>
+}): Promise {
+ const { page } = await searchParams
+ const currentPage = Math.max(1, parseInt(page ?? '1') || 1)
+ const skip = (currentPage - 1) * ITEMS_PER_PAGE
+
const title = 'Наши работы'
const description =
'Ознакомьтесь с портфолио нашей артели. Здесь представлены выполненные заказы: храмовые образа, семейные и именные иконы, венчальные пары. Каждая икона создана с соблюдением канонических традиций.
'
- const worksData: WorkFromServer[] = await fetchCollection('works', {
- sort: { date: -1 },
- })
+ const [worksData, total] = await Promise.all([
+ fetchCollection('works', {
+ sort: { date: -1 },
+ limit: ITEMS_PER_PAGE,
+ skip,
+ }),
+ fetchCollectionCount('works'),
+ ])
const worksList: CardItem[] = (worksData || []).map((work) => ({
id: work.slug || work._id,
@@ -44,11 +59,15 @@ export default async function Page(): Promise {
alt: work.image.title || work.title,
}))
+ const totalPages = Math.ceil(total / ITEMS_PER_PAGE)
+
return (
<>
-
+
+
+
>
)
}
diff --git a/src/components/Address/Address.tsx b/src/components/Address/Address.tsx
index 0337b30e..b67688df 100644
--- a/src/components/Address/Address.tsx
+++ b/src/components/Address/Address.tsx
@@ -1,10 +1,10 @@
import { JSX } from 'react'
import addressStyles from './Address.module.scss'
-import { MainInfo } from '@/types/types'
+import { MainInfoFromServer } from '@/types/types'
import { fetchSingleton } from '@/lib/api-client'
export default async function Address(): Promise {
- const mainInfo: MainInfo | null = await fetchSingleton('maininfo')
+ const mainInfo: MainInfoFromServer | null = await fetchSingleton('maininfo')
if (!mainInfo || !mainInfo.address) {
return null
diff --git a/src/components/AnimationObserver/AnimationObserver.tsx b/src/components/AnimationObserver/AnimationObserver.tsx
index e0012b32..401865f7 100644
--- a/src/components/AnimationObserver/AnimationObserver.tsx
+++ b/src/components/AnimationObserver/AnimationObserver.tsx
@@ -1,15 +1,15 @@
'use client'
import { useEffect } from 'react'
-import { usePathname } from 'next/navigation'
+import { usePathname, useSearchParams } from 'next/navigation'
export default function AnimationObserver(): null {
const pathname = usePathname()
+ const searchParams = useSearchParams()
useEffect(() => {
let observer: IntersectionObserver | null = null
- // rAF ensures DOM has fully updated after Next.js navigation
const raf = requestAnimationFrame(() => {
const elements = document.querySelectorAll('[data-animate]:not(.is-visible)')
@@ -44,7 +44,7 @@ export default function AnimationObserver(): null {
cancelAnimationFrame(raf)
observer?.disconnect()
}
- }, [pathname])
+ }, [pathname, searchParams])
return null
}
diff --git a/src/components/Categories/Categories.tsx b/src/components/Categories/Categories.tsx
index 53b7eeb5..f3f72ca5 100644
--- a/src/components/Categories/Categories.tsx
+++ b/src/components/Categories/Categories.tsx
@@ -1,28 +1,22 @@
-import { JSX } from 'react'
+import { JSX, ReactNode } from 'react'
import clsx from 'clsx'
-import { CardItem, CategoryFromServer } from '@/types/types'
+import { CardItem } from '@/types/types'
import Card from '@/components/Card/Card'
import categoriesStyles from './Categories.module.scss'
-import { fetchCollection, getImageUrl } from '@/lib/api-client'
-export default async function Categories(): Promise {
- const categoriesData: CategoryFromServer[] = await fetchCollection('category', {
- sort: { sort: 1 },
- })
+type CategoriesProps = {
+ categoriesList: CardItem[]
+ children?: ReactNode
+}
- if (!categoriesData || categoriesData.length === 0) {
+export default function Categories({
+ categoriesList,
+ children,
+}: CategoriesProps): JSX.Element | null {
+ if (!categoriesList || categoriesList.length === 0) {
return null
}
- const categoriesList: CardItem[] = categoriesData.map((category) => ({
- id: category.slug || category._id,
- title: category.title,
- description: category.description,
- href: `/categories/${category.slug || category._id}`,
- image: getImageUrl(category.image._id, 400, 400),
- alt: category.image.title || category.title,
- }))
-
return (
@@ -42,6 +36,8 @@ export default async function Categories(): Promise {
)
})}
+
+ {children}
)
diff --git a/src/components/Contacts/Contacts.tsx b/src/components/Contacts/Contacts.tsx
index b0035474..3a0bebe6 100644
--- a/src/components/Contacts/Contacts.tsx
+++ b/src/components/Contacts/Contacts.tsx
@@ -1,7 +1,7 @@
import { JSX } from 'react'
import contactsStyles from './Contacts.module.scss'
import clsx from 'clsx'
-import { MainInfo } from '@/types/types'
+import { MainInfoFromServer } from '@/types/types'
import { fetchSingleton } from '@/lib/api-client'
import { createEmailLink, createPhoneLink } from '@/functions/functions'
@@ -10,7 +10,7 @@ type ContactsProps = {
}
export default async function Contacts({ addClass }: ContactsProps): Promise {
- const mainInfo: MainInfo | null = await fetchSingleton('maininfo')
+ const mainInfo: MainInfoFromServer | null = await fetchSingleton('maininfo')
if (!mainInfo) {
return null
diff --git a/src/components/ContactsPage/ContactsPage.tsx b/src/components/ContactsPage/ContactsPage.tsx
index f4f9b7f7..b8d67cad 100644
--- a/src/components/ContactsPage/ContactsPage.tsx
+++ b/src/components/ContactsPage/ContactsPage.tsx
@@ -4,12 +4,13 @@ import clsx from 'clsx'
import Social from '@/components/Social/Social'
import FormContacts from '@/components/Forms/FormContacts/FormContacts'
import YandexMap from '@/components/YandexMap/YandexMap'
-import { MainInfo } from '@/types/types'
+import { MainInfoFromServer } from '@/types/types'
import { fetchSingleton, getImageUrl } from '@/lib/api-client'
import { createEmailLink, createPhoneLink } from '@/functions/functions'
export default async function ContactsPage(): Promise {
- const contactsInfo: MainInfo | null = await fetchSingleton('maininfo')
+ const contactsInfo: MainInfoFromServer | null =
+ await fetchSingleton('maininfo')
if (!contactsInfo) {
return null
diff --git a/src/components/DropZone/DropZone.module.scss b/src/components/DropZone/DropZone.module.scss
new file mode 100644
index 00000000..2cfd6463
--- /dev/null
+++ b/src/components/DropZone/DropZone.module.scss
@@ -0,0 +1,228 @@
+@use '../../styles/variables.scss' as *;
+
+.drop-zone-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.drop-zone {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-height: 120px;
+
+ border: 1px dashed $accent-dark-bg;
+ border-radius: 12px;
+
+ cursor: pointer;
+ transition:
+ border-color 0.3s ease-in-out,
+ box-shadow 0.3s ease-in-out;
+
+ &:focus-visible {
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+
+ &--active {
+ border-color: $accent-add-bg;
+ }
+
+ &--limit {
+ cursor: default;
+ }
+
+ &:not(.drop-zone--has-files) {
+ justify-content: center;
+ align-items: center;
+
+ @media (hover: hover) {
+ &:hover {
+ border-color: $accent-add-bg;
+
+ .drop-zone__empty svg {
+ color: $accent-add-bg;
+ }
+ }
+ }
+ }
+
+ &:hover:not(:has(.drop-zone__file:hover)) .drop-zone__add {
+ @media (hover: hover) {
+ border-color: $accent-add-bg;
+
+ svg {
+ color: $accent-add-bg;
+ }
+ }
+ }
+}
+
+.drop-zone__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 10px;
+
+ text-align: center;
+ color: $accent-dark-bg;
+
+ svg {
+ width: 32px;
+ height: 32px;
+ transition: color 0.3s ease-in-out;
+ fill: none;
+ }
+}
+
+.drop-zone__label {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ line-height: 1.4;
+ user-select: none;
+}
+
+.drop-zone__hint {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1.4;
+ user-select: none;
+}
+
+.drop-zone__files {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 12px;
+}
+
+.drop-zone__file {
+ position: relative;
+ flex-shrink: 0;
+ width: 120px;
+ height: 90px;
+
+ img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ border-radius: 8px;
+
+ object-fit: cover;
+ transition: filter 0.3s ease-in-out;
+ }
+}
+
+.drop-zone__file--other {
+ border: 1px solid $accent-dark-bg;
+ border-radius: 8px;
+}
+
+.drop-zone__file-icon {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ height: 100%;
+ padding: 8px;
+
+ svg {
+ flex-shrink: 0;
+ width: 28px;
+ height: 28px;
+ color: $accent-dark-bg;
+ }
+}
+
+.drop-zone__file-name {
+ display: -webkit-box;
+ overflow: hidden;
+ max-width: 100%;
+
+ font-size: 11px;
+ line-height: 1.3;
+ text-align: center;
+ word-break: break-all;
+ color: $accent-dark-bg;
+
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+.drop-zone__remove {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ z-index: 2;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 22px;
+ height: 22px;
+
+ font-size: 11px;
+ color: $bg-text-color;
+
+ background-color: $error-color;
+ border: none;
+ border-radius: 50%;
+
+ cursor: pointer;
+ transition: background-color 0.3s ease-in-out;
+
+ &:focus-visible {
+ outline: 2px solid $error-color;
+ outline-offset: 2px;
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: rgba($error-color, 0.8);
+
+ ~ img {
+ filter: blur(2px);
+ }
+ }
+ }
+}
+
+.drop-zone__add {
+ display: flex;
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ width: 120px;
+ height: 90px;
+
+ background: none;
+ border: 1px dashed $accent-dark-bg;
+ border-radius: 8px;
+
+ pointer-events: none;
+ transition: border-color 0.3s ease-in-out;
+
+ svg {
+ width: 28px;
+ height: 28px;
+ fill: none;
+ transition: color 0.3s ease-in-out;
+ }
+}
+
+.drop-zone__error {
+ display: block;
+ font-size: 12px;
+ color: $error-color;
+}
diff --git a/src/components/DropZone/DropZone.tsx b/src/components/DropZone/DropZone.tsx
new file mode 100644
index 00000000..943f6be8
--- /dev/null
+++ b/src/components/DropZone/DropZone.tsx
@@ -0,0 +1,295 @@
+'use client'
+
+import {
+ JSX,
+ ChangeEvent,
+ DragEvent,
+ forwardRef,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react'
+import clsx from 'clsx'
+import dropZoneStyles from './DropZone.module.scss'
+import { DEFAULT_MAX_FILES, DEFAULT_MAX_SIZE_MB } from '@/const/const'
+
+export type DropZoneRef = {
+ reset: () => void
+ getFiles: () => File[]
+}
+
+type FilePreview = {
+ name: string
+ isImage: boolean
+ preview: string | null
+}
+
+type DropZoneProps = {
+ name: string
+ accept?: string
+ maxFiles?: number
+ maxSizeMB?: number
+ hint?: string
+ label?: string
+ className?: string
+ error?: string
+}
+
+const DropZone = forwardRef(function DropZone(
+ {
+ name,
+ accept,
+ maxFiles = DEFAULT_MAX_FILES,
+ maxSizeMB = DEFAULT_MAX_SIZE_MB,
+ hint,
+ label,
+ className,
+ error,
+ },
+ ref,
+): JSX.Element {
+ const [previewItems, setPreviewItems] = useState([])
+ const [isDragging, setIsDragging] = useState(false)
+
+ const fileInputRef = useRef(null)
+ const allFilesRef = useRef([])
+
+ useImperativeHandle(ref, () => ({
+ reset() {
+ allFilesRef.current = []
+ setPreviewItems([])
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+ },
+ getFiles() {
+ return allFilesRef.current
+ },
+ }))
+
+ const processFiles = (files: File[]) => {
+ if (files.length === 0) return
+
+ const combined = [...allFilesRef.current, ...files].slice(0, maxFiles)
+ allFilesRef.current = combined
+
+ if (fileInputRef.current) {
+ const dataTransfer = new DataTransfer()
+ combined.forEach((file) => dataTransfer.items.add(file))
+ fileInputRef.current.files = dataTransfer.files
+ }
+
+ const remainingSlots = maxFiles - previewItems.length
+ const filesToPreview = files.slice(0, remainingSlots)
+ const newPreviews: FilePreview[] = []
+ let loaded = 0
+
+ filesToPreview.forEach((file) => {
+ const isImage = file.type.startsWith('image/')
+
+ if (isImage) {
+ const reader = new FileReader()
+
+ reader.onload = (event) => {
+ newPreviews.push({
+ name: file.name,
+ isImage: true,
+ preview: event.target?.result as string,
+ })
+
+ loaded++
+
+ if (loaded === filesToPreview.length) {
+ setPreviewItems((prev) => [...prev, ...newPreviews].slice(0, maxFiles))
+ }
+ }
+ reader.readAsDataURL(file)
+ } else {
+ newPreviews.push({ name: file.name, isImage: false, preview: null })
+ loaded++
+
+ if (loaded === filesToPreview.length) {
+ setPreviewItems((prev) => [...prev, ...newPreviews].slice(0, maxFiles))
+ }
+ }
+ })
+ }
+
+ const handlePhotoChange = (event: ChangeEvent) => {
+ processFiles(Array.from(event.target.files || []))
+ event.target.value = ''
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ event.preventDefault()
+ }
+
+ const handleDragEnter = (event: DragEvent) => {
+ event.preventDefault()
+ setIsDragging(true)
+ }
+
+ const handleDragLeave = (event: DragEvent) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) {
+ setIsDragging(false)
+ }
+ }
+
+ const handleDrop = (event: DragEvent) => {
+ event.preventDefault()
+ setIsDragging(false)
+ processFiles(Array.from(event.dataTransfer.files))
+ }
+
+ const handleZoneClick = () => {
+ if (allFilesRef.current.length >= maxFiles) return
+ fileInputRef.current?.click()
+ }
+
+ const handleRemoveFile = (index: number) => {
+ allFilesRef.current = allFilesRef.current.filter((_, i) => i !== index)
+
+ if (fileInputRef.current) {
+ const dataTransfer = new DataTransfer()
+ allFilesRef.current.forEach((file) => dataTransfer.items.add(file))
+ fileInputRef.current.files = dataTransfer.files
+ }
+
+ setPreviewItems((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ const hasFiles = previewItems.length > 0
+ const limitReached = previewItems.length >= maxFiles
+ const canAddMore = !limitReached
+ const defaultHint = `до ${maxFiles} файлов · до ${maxSizeMB} МБ каждый`
+
+ return (
+
+
{
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ handleZoneClick()
+ }
+ }
+ : undefined
+ }
+ aria-label={limitReached ? undefined : (label ?? 'Прикрепить файлы')}
+ >
+
1}
+ aria-hidden="true"
+ tabIndex={-1}
+ className="visually-hidden"
+ onChange={handlePhotoChange}
+ />
+
+ {!hasFiles ? (
+
+
+
+
+
+
+ Перетащите файлы или нажмите для выбора
+
+
+
{hint ?? defaultHint}
+
+ ) : (
+
+ {previewItems.map((item, index) => (
+
event.stopPropagation()}
+ >
+
{
+ event.stopPropagation()
+ handleRemoveFile(index)
+ }}
+ aria-label={`Удалить файл ${item.name}`}
+ >
+ ✕
+
+
+ {item.isImage && item.preview ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+ ))}
+
+ {canAddMore && (
+
+ )}
+
+ )}
+
+
+ {error &&
{error} }
+
+ )
+})
+
+export default DropZone
diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss
index 3827ecf6..491922b7 100644
--- a/src/components/Footer/Footer.module.scss
+++ b/src/components/Footer/Footer.module.scss
@@ -27,8 +27,6 @@
.footer__logo {
grid-row: 1 / 2;
grid-column: 1 / 2;
-
- // Pass through display so Logo link fills the wrapper naturally
display: flex;
}
@@ -86,6 +84,24 @@
.footer__copyright {
}
+.footer__policy {
+ font-size: 14px;
+
+ transition: opacity 0.3s ease-in-out;
+
+ @media (hover: hover) {
+ &:hover {
+ opacity: 0.6;
+ }
+ }
+
+ &:focus-visible {
+ border-radius: 2px;
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+}
+
.footer__developer {
display: flex;
align-items: center;
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
index d4ad177b..280aa11d 100644
--- a/src/components/Footer/Footer.tsx
+++ b/src/components/Footer/Footer.tsx
@@ -32,6 +32,15 @@ export default function Footer(): JSX.Element {
© Иконописная Артель, {new Date().getFullYear()}
+
+ Политика обработки персональных данных
+
+
diff --git a/src/components/Forms/FormCalculation/FormCalculationClient.tsx b/src/components/Forms/FormCalculation/FormCalculationClient.tsx
index a05d044e..ad8daffd 100644
--- a/src/components/Forms/FormCalculation/FormCalculationClient.tsx
+++ b/src/components/Forms/FormCalculation/FormCalculationClient.tsx
@@ -1,6 +1,6 @@
'use client'
-import { JSX, useEffect, useRef, useState, useMemo } from 'react'
+import { JSX, PointerEvent, KeyboardEvent, useEffect, useRef, useState, useMemo } from 'react'
import formStyles from '../../../styles/modules/form.module.scss'
import clsx from 'clsx'
import type { PriceItem } from '@/types/types'
@@ -34,27 +34,23 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
closeSelect()
}
- const handleToggleClick = (e: React.MouseEvent) => {
- e.stopPropagation()
+ const handleToggleClick = (evt: PointerEvent) => {
+ evt.stopPropagation()
toggleSelect()
}
- const handleCurrentKeyDown = (e: React.KeyboardEvent) => {
- if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
- e.preventDefault()
+ const handleCurrentKeyDown = (evt: KeyboardEvent) => {
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(evt.key)) {
+ evt.preventDefault()
openSelect()
- const idx = prices.findIndex((p) => p._id === selectedId)
+ const idx = prices.findIndex((price) => price._id === selectedId)
const useIdx = idx >= 0 ? idx : 0
;(optionsRef.current[useIdx] as HTMLElement)?.focus()
}
- if (e.key === 'Escape') closeSelect()
+ if (evt.key === 'Escape') closeSelect()
}
- const handleOptionKeyDown = (
- evt: React.KeyboardEvent,
- index: number,
- id: string,
- ) => {
+ const handleOptionKeyDown = (evt: KeyboardEvent, index: number, id: string) => {
let nextIndex: number | undefined
const total = prices.length
switch (evt.key) {
@@ -133,7 +129,7 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
setCalculated('')
return
}
- const item = prices.find((p) => p._id === selectedId)
+ const item = prices.find((price) => price._id === selectedId)
if (!item) {
setCalculated('')
return
@@ -171,13 +167,13 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
aria-expanded={selectOpen}
id="calculationSortingButton"
onClick={handleToggleClick}
- onKeyDown={(e) => {
- e.stopPropagation()
- handleCurrentKeyDown(e)
+ onKeyDown={(evt) => {
+ evt.stopPropagation()
+ handleCurrentKeyDown(evt)
}}
>
{selectedId
- ? prices.find((p) => p._id === selectedId)?.size || '—'
+ ? prices.find((price) => price._id === selectedId)?.size || '—'
: 'Размер/категория'}
- setGoldType('without_gold')}
- />
- setGoldType('all')}
- />
- setGoldType('halo')}
- />
-
{
radioLabelRefs.current[0] = el
}}
- htmlFor="gold-without"
className={formStyles['form__radio-option']}
>
+ setGoldType('without_gold')}
+ />
Без золота
@@ -285,9 +260,16 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
ref={(el: HTMLLabelElement | null) => {
radioLabelRefs.current[1] = el
}}
- htmlFor="gold-all"
className={formStyles['form__radio-option']}
>
+ setGoldType('all')}
+ />
Золотой фон и нимб
@@ -295,9 +277,16 @@ export default function FormCalculationClient({ prices: initialPrices }: Props):
ref={(el: HTMLLabelElement | null) => {
radioLabelRefs.current[2] = el
}}
- htmlFor="gold-halo"
className={formStyles['form__radio-option']}
>
+ setGoldType('halo')}
+ />
Только нимб
diff --git a/src/components/Forms/FormContacts/FormContacts.tsx b/src/components/Forms/FormContacts/FormContacts.tsx
index c09c706b..3cd65ee5 100644
--- a/src/components/Forms/FormContacts/FormContacts.tsx
+++ b/src/components/Forms/FormContacts/FormContacts.tsx
@@ -1,226 +1,8 @@
-'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 { submitApplication } from '@/actions/forms'
+import type { JSX } from 'react'
+import FormContactsClient from './FormContactsClient'
export default function FormContacts(): JSX.Element {
- const [state, formAction] = useActionState(submitApplication, null)
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isPending, startTransition] = useTransition()
-
- 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)
-
- 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])
-
- // Handle form submit - execute captcha
- const handleSubmit = async (evt: SubmitEvent) => {
- evt.preventDefault()
-
- if (isSubmitting || isPending) {
- return
- }
-
- 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
new file mode 100644
index 00000000..9b80f5a4
--- /dev/null
+++ b/src/components/Forms/FormContacts/FormContactsClient.tsx
@@ -0,0 +1,308 @@
+'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')}
+
+
+
+
+ Введите ваше сообщение:
+
+
+
+
+ {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
new file mode 100644
index 00000000..56b1ed2e
--- /dev/null
+++ b/src/components/Forms/FormOrder/FormOrder.tsx
@@ -0,0 +1,20 @@
+import type { JSX } from 'react'
+import FormOrderClient from './FormOrderClient'
+import { fetchCollection } from '@/lib/api-client'
+import type { CategoryFromServer, PriceItem } from '@/types/types'
+
+export default async function FormOrder(): Promise {
+ const [categories, prices] = await Promise.all([
+ fetchCollection('categories', { sort: { sort: 1 } }),
+ fetchCollection('price', { sort: { sort: 1 } }),
+ ])
+
+ return (
+
+ )
+}
diff --git a/src/components/Forms/FormOrder/FormOrderClient.tsx b/src/components/Forms/FormOrder/FormOrderClient.tsx
new file mode 100644
index 00000000..9bf86041
--- /dev/null
+++ b/src/components/Forms/FormOrder/FormOrderClient.tsx
@@ -0,0 +1,729 @@
+'use client'
+
+import {
+ JSX,
+ PointerEvent,
+ KeyboardEvent,
+ SubmitEvent,
+ useEffect,
+ useActionState,
+ useRef,
+ useState,
+ useTransition,
+ useMemo,
+} from 'react'
+import clsx from 'clsx'
+import { z } from 'zod'
+import formStyles from '../../../styles/modules/form.module.scss'
+import { submitApplication } from '@/actions/forms'
+import { applicationFormSchema, validateFormField } from '@/lib/schemas'
+import type { CategoryFromServer, PriceItem } from '@/types/types'
+import DropZone, { DropZoneRef } from '@/components/DropZone/DropZone'
+
+type Props = {
+ categories: CategoryFromServer[]
+ prices: PriceItem[]
+ agreementUrl: string
+ policyUrl: string
+}
+
+export default function FormOrderClient({
+ categories,
+ prices: initialPrices,
+ agreementUrl,
+ policyUrl,
+}: Props): JSX.Element {
+ const prices: PriceItem[] = useMemo(() => initialPrices || [], [initialPrices])
+
+ const [state, formAction] = useActionState(submitApplication, null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isPending, startTransition] = useTransition()
+ const [clientErrors, setClientErrors] = useState>({})
+ const [touched, setTouched] = useState>({})
+
+ // Селект категории
+ const [categoryOpen, setCategoryOpen] = useState(false)
+ const [selectedCategory, setSelectedCategory] = useState('')
+ const categorySelectRef = useRef(null)
+ const categoryOptionsRef = useRef>([])
+
+ // Селект размера
+ const [sizeOpen, setSizeOpen] = useState(false)
+ const [selectedSizeId, setSelectedSizeId] = useState('')
+ const sizeSelectRef = useRef(null)
+ const sizeOptionsRef = useRef>([])
+
+ // Радио-переключатель типа золочения
+ const [goldType, setGoldType] = useState<'without_gold' | 'all' | 'halo'>('without_gold')
+ const radioSwitchRef = useRef(null)
+ const radioLabelRefs = useRef>([])
+ const radioIndicatorRef = useRef(null)
+
+ const formRef = useRef(null)
+ const captchaContainerRef = useRef(null)
+ const widgetIdRef = useRef(null)
+ const dropZoneRef = useRef(null)
+
+ // Категория
+
+ const selectCategory = (title: string) => {
+ setSelectedCategory(title)
+ setCategoryOpen(false)
+ }
+
+ const handleCategoryToggle = (evt: PointerEvent) => {
+ evt.stopPropagation()
+ setCategoryOpen((prev) => !prev)
+ }
+
+ const handleCategoryKeyDown = (evt: KeyboardEvent) => {
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(evt.key)) {
+ evt.preventDefault()
+ setCategoryOpen(true)
+ const idx = Math.max(
+ categories.findIndex((c) => c.title === selectedCategory),
+ 0,
+ )
+ ;(categoryOptionsRef.current[idx] as HTMLElement)?.focus()
+ }
+ if (evt.key === 'Escape') setCategoryOpen(false)
+ }
+
+ const handleCategoryOptionKeyDown = (
+ evt: KeyboardEvent,
+ index: number,
+ title: string,
+ ) => {
+ const total = categories.length
+ switch (evt.key) {
+ case 'ArrowDown':
+ evt.preventDefault()
+ ;(categoryOptionsRef.current[(index + 1) % total] as HTMLElement)?.focus()
+ break
+ case 'ArrowUp':
+ evt.preventDefault()
+ ;(categoryOptionsRef.current[(index - 1 + total) % total] as HTMLElement)?.focus()
+ break
+ case 'Home':
+ ;(categoryOptionsRef.current[0] as HTMLElement)?.focus()
+ break
+ case 'End':
+ evt.preventDefault()
+ ;(categoryOptionsRef.current[total - 1] as HTMLElement)?.focus()
+ break
+ case 'Enter':
+ case ' ':
+ evt.preventDefault()
+ selectCategory(title)
+ break
+ case 'Escape':
+ setCategoryOpen(false)
+ break
+ }
+ }
+
+ // Размер
+
+ const selectSize = (id: string) => {
+ setSelectedSizeId(id)
+ setSizeOpen(false)
+ }
+
+ const handleSizeToggle = (evt: PointerEvent) => {
+ evt.stopPropagation()
+ setSizeOpen((prev) => !prev)
+ }
+
+ const handleSizeKeyDown = (evt: KeyboardEvent) => {
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(evt.key)) {
+ evt.preventDefault()
+ setSizeOpen(true)
+ const idx = Math.max(
+ prices.findIndex((price) => price._id === selectedSizeId),
+ 0,
+ )
+ ;(sizeOptionsRef.current[idx] as HTMLElement)?.focus()
+ }
+ if (evt.key === 'Escape') setSizeOpen(false)
+ }
+
+ const handleSizeOptionKeyDown = (
+ evt: KeyboardEvent,
+ index: number,
+ id: string,
+ ) => {
+ const total = prices.length
+ switch (evt.key) {
+ case 'ArrowDown':
+ evt.preventDefault()
+ ;(sizeOptionsRef.current[(index + 1) % total] as HTMLElement)?.focus()
+ break
+ case 'ArrowUp':
+ evt.preventDefault()
+ ;(sizeOptionsRef.current[(index - 1 + total) % total] as HTMLElement)?.focus()
+ break
+ case 'Home':
+ ;(sizeOptionsRef.current[0] as HTMLElement)?.focus()
+ break
+ case 'End':
+ evt.preventDefault()
+ ;(sizeOptionsRef.current[total - 1] as HTMLElement)?.focus()
+ break
+ case 'Enter':
+ case ' ':
+ evt.preventDefault()
+ selectSize(id)
+ break
+ case 'Escape':
+ setSizeOpen(false)
+ break
+ }
+ }
+
+ // Закрытие селектов при клике вне
+
+ useEffect(() => {
+ const handleClickOutside = (evt: MouseEvent) => {
+ const target = evt.target as Node | null
+ if (!target) return
+ if (categorySelectRef.current && !categorySelectRef.current.contains(target)) {
+ setCategoryOpen(false)
+ }
+ if (sizeSelectRef.current && !sizeSelectRef.current.contains(target)) {
+ setSizeOpen(false)
+ }
+ }
+ document.addEventListener('click', handleClickOutside)
+ return () => document.removeEventListener('click', handleClickOutside)
+ }, [])
+
+ // Индикатор радио-переключателя
+
+ useEffect(() => {
+ const updateIndicator = () => {
+ const idx = goldType === 'without_gold' ? 0 : goldType === 'all' ? 1 : 2
+ const label = radioLabelRefs.current[idx]
+ const container = radioSwitchRef.current
+ const indicator = radioIndicatorRef.current
+ if (!label || !container || !indicator) return
+ const labelRect = label.getBoundingClientRect()
+ const containerRect = container.getBoundingClientRect()
+ indicator.style.width = `${labelRect.width}px`
+ indicator.style.transform = `translateX(${labelRect.left - containerRect.left}px)`
+ }
+ updateIndicator()
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateIndicator) : null
+ if (ro) radioLabelRefs.current.forEach((l) => l && ro.observe(l))
+ window.addEventListener('resize', updateIndicator)
+ return () => {
+ if (ro) ro.disconnect()
+ window.removeEventListener('resize', updateIndicator)
+ }
+ }, [goldType, prices])
+
+ // Капча
+
+ 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-order', {
+ 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)
+
+ // Добавляем файлы из DropZone вручную
+ 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()
+ setSelectedCategory('')
+ setSelectedSizeId('')
+ setGoldType('without_gold')
+ dropZoneRef.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(applicationFormSchema, field, value),
+ }))
+ }
+
+ const handleAgreementChange = (checked: boolean) => {
+ setTouched((prev) => ({ ...prev, agreement: true }))
+ setClientErrors((prev) => ({
+ ...prev,
+ agreement: validateFormField(applicationFormSchema, 'agreement', checked || undefined),
+ }))
+ }
+
+ // Submit
+
+ 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 = applicationFormSchema.safeParse({
+ name: nameVal,
+ phone: phoneVal,
+ email: emailVal,
+ message: messageVal,
+ category: selectedCategory || undefined,
+ size: selectedSizeId
+ ? prices.find((price) => price._id === selectedSizeId)?.size || undefined
+ : undefined,
+ goldType: goldType,
+ agreement: agreementChecked || undefined,
+ })
+
+ const allTouched = Object.fromEntries(
+ Object.keys(applicationFormSchema.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(applicationFormSchema.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)
+ }
+ }
+
+ // SVG-стрелка для селектов
+
+ const SelectArrow = () => (
+
+
+
+ )
+
+ const selectedSizeLabel = selectedSizeId
+ ? prices.find((price) => price._id === selectedSizeId)?.size || ''
+ : ''
+
+ return (
+
+ {state?.message && (
+
+ {state.message}
+
+ )}
+
+ {(!state || !state.success) && (
+ <>
+
+
+
+
+
+
Вид иконы
+
+
+ {categories.length > 0 && (
+
+
+
{
+ evt.stopPropagation()
+ handleCategoryKeyDown(evt)
+ }}
+ >
+ {selectedCategory || 'Категория иконы'}
+
+
+
+
+ {categories.map((cat, index) => (
+ {
+ categoryOptionsRef.current[index] = el
+ }}
+ className={clsx(formStyles['form__select__option'], {
+ [formStyles['form__select__option--selected']]:
+ cat.title === selectedCategory,
+ })}
+ aria-selected={cat.title === selectedCategory}
+ onPointerDown={(evt) => {
+ evt.preventDefault()
+ evt.stopPropagation()
+ if (cat.title !== selectedCategory) selectCategory(cat.title)
+ else setCategoryOpen(false)
+ }}
+ onKeyDown={(evt) => handleCategoryOptionKeyDown(evt, index, cat.title)}
+ >
+ {cat.title}
+
+ ))}
+
+
+
+ )}
+
+ {prices.length > 0 && (
+
+
+
{
+ evt.stopPropagation()
+ handleSizeKeyDown(evt)
+ }}
+ >
+ {selectedSizeLabel || 'Размер иконы'}
+
+
+
+
+ {prices.map((item, index) => (
+ {
+ sizeOptionsRef.current[index] = el
+ }}
+ className={clsx(formStyles['form__select__option'], {
+ [formStyles['form__select__option--selected']]:
+ item._id === selectedSizeId,
+ })}
+ aria-selected={item._id === selectedSizeId}
+ onPointerDown={(evt) => {
+ evt.preventDefault()
+ evt.stopPropagation()
+ if (item._id !== selectedSizeId) selectSize(item._id)
+ else setSizeOpen(false)
+ }}
+ onKeyDown={(evt) => handleSizeOptionKeyDown(evt, index, item._id)}
+ >
+ {item.size}
+
+ ))}
+
+
+
+ )}
+
+
+
Тип золочения:
+
+
+ {(
+ [
+ { value: 'without_gold', label: 'Без золота' },
+ { value: 'all', label: 'Золотой фон и нимб' },
+ { value: 'halo', label: 'Только нимб' },
+ ] as const
+ ).map((opt, idx) => (
+ {
+ radioLabelRefs.current[idx] = el
+ }}
+ className={formStyles['form__radio-option']}
+ >
+ setGoldType(opt.value)}
+ />
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ Ваше сообщение:
+
+ handleBlur('message', evt.target.value)}
+ />
+
+
+ {getError('message')}
+
+
+
+
+
+ handleAgreementChange(evt.target.checked)}
+ />
+
+
+
+ Я согласен
+
+ на обработку персональных данных в соответствии с условиями
+
+ Политики обработки персональных данных
+
+
+
+
+ {getError('agreement')}
+
+
+
+
+ {isPending || isSubmitting ? 'Отправка...' : 'Отправить заявку'}
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/components/Forms/FormReviews/FormReviews.tsx b/src/components/Forms/FormReviews/FormReviews.tsx
index 3e1acfee..0b8827a7 100644
--- a/src/components/Forms/FormReviews/FormReviews.tsx
+++ b/src/components/Forms/FormReviews/FormReviews.tsx
@@ -1,222 +1,8 @@
-'use client'
-
-import { JSX, 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 type { JSX } from 'react'
+import FormReviewsClient from './FormReviewsClient'
export default function FormReviews(): JSX.Element {
- const [state, formAction] = useActionState(submitReview, null)
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isPending, startTransition] = useTransition()
-
- 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-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)
-
- startTransition(() => {
- formAction(formData)
- })
- }
- },
- })
-
- clearInterval(interval)
- }
- }, 100)
-
- return () => {
- clearInterval(interval)
- }
- }, [formAction])
-
- useEffect(() => {
- if (state?.success && formRef.current) {
- formRef.current.reset()
- setIsSubmitting(false)
-
- 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 handleSubmit = async (evt: SubmitEvent) => {
- evt.preventDefault()
-
- if (isSubmitting || isPending) {
- return
- }
-
- 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) && (
- <>
-
- Ваше имя:
-
-
-
- {state?.errors?.name && (
- {state.errors.name}
- )}
-
-
- Телефон:
-
-
-
- {state?.errors?.phone && (
- {state.errors.phone}
- )}
-
-
- Email:
-
-
-
- {state?.errors?.email && (
- {state.errors.email}
- )}
-
-
- Введите ваш отзыв:
-
-
-
- {state?.errors?.message && (
- {state.errors.message}
- )}
-
-
-
-
- Я согласен
-
- на обработку персональных данных в соответствии с условиями
-
- Политикой обработки данных
-
-
-
-
-
- {state?.errors?.agreement && (
- {state.errors.agreement}
- )}
-
-
- {isPending ? 'Отправка...' : 'Отправить отзыв'}
-
- >
- )}
-
-
-
+
)
}
diff --git a/src/components/Forms/FormReviews/FormReviewsClient.tsx b/src/components/Forms/FormReviews/FormReviewsClient.tsx
new file mode 100644
index 00000000..6663bb93
--- /dev/null
+++ b/src/components/Forms/FormReviews/FormReviewsClient.tsx
@@ -0,0 +1,416 @@
+'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)
+
+ 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/GalleryPage/GalleryPageClient.tsx b/src/components/GalleryPage/GalleryPageClient.tsx
index 10545f90..c5a5940d 100644
--- a/src/components/GalleryPage/GalleryPageClient.tsx
+++ b/src/components/GalleryPage/GalleryPageClient.tsx
@@ -15,18 +15,12 @@ import type { LightGallery } from 'lightgallery/lightgallery'
import { GalleryItemForClient } from '@/types/types'
import galleryPageStyles from './GalleryPage.module.scss'
import GalleryBadge from './GalleryBadge'
+import { GALLERY_BREAKPOINT_COLUMNS } from '@/const/const'
type GalleryPageClientProps = {
items: GalleryItemForClient[]
}
-const breakpointColumnsObj = {
- default: 4,
- 1200: 3,
- 768: 2,
- 480: 1,
-}
-
export default function GalleryPageClient({ items }: GalleryPageClientProps): JSX.Element {
const router = useRouter()
const galleryRefs = useRef>(new Map())
@@ -104,7 +98,7 @@ export default function GalleryPageClient({ items }: GalleryPageClientProps): JS
{items.map((item, index) => {
diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx
index dd0569ce..437c5455 100644
--- a/src/components/Logo/Logo.tsx
+++ b/src/components/Logo/Logo.tsx
@@ -2,7 +2,7 @@ import { JSX } from 'react'
import logoStyles from './Logo.module.scss'
import Link from 'next/link'
import Image from 'next/image'
-import { MainInfo } from '@/types/types'
+import { MainInfoFromServer } from '@/types/types'
import { fetchSingleton, getImageUrl } from '@/lib/api-client'
import clsx from 'clsx'
@@ -12,7 +12,7 @@ type LogoProps = {
}
export default async function Logo({ addClass, isFooter }: LogoProps): Promise {
- const mainInfo: MainInfo | null = await fetchSingleton('maininfo')
+ const mainInfo: MainInfoFromServer | null = await fetchSingleton('maininfo')
if (!mainInfo || !mainInfo.logo) {
return null
diff --git a/src/components/Main/About/About.tsx b/src/components/Main/About/About.tsx
index 1f0f35c8..d6afba4b 100644
--- a/src/components/Main/About/About.tsx
+++ b/src/components/Main/About/About.tsx
@@ -4,11 +4,11 @@ import clsx from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { fetchSingleton, getImageUrl } from '@/lib/api-client'
-import { MainInfo } from '@/types/types'
+import { MainInfoFromServer } from '@/types/types'
import { createSanitizedHTML } from '@/functions/functions'
export default async function About(): Promise {
- const mainInfo: MainInfo | null = await fetchSingleton('maininfo')
+ const mainInfo: MainInfoFromServer | null = await fetchSingleton('maininfo')
if (!mainInfo) {
return null
diff --git a/src/components/Main/Advantages/Advantages.tsx b/src/components/Main/Advantages/Advantages.tsx
index f64c5f78..7c7a2afc 100644
--- a/src/components/Main/Advantages/Advantages.tsx
+++ b/src/components/Main/Advantages/Advantages.tsx
@@ -1,15 +1,18 @@
import { JSX } from 'react'
import advantagesStyles from './Advantages.module.scss'
-import { AdvantageItem } from '@/types/types'
+import { AdvantageFromServer } from '@/types/types'
import clsx from 'clsx'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
import Image from 'next/image'
import logoStyles from '@/components/Logo/Logo.module.scss'
export default async function Advantages(): Promise {
- const advantagesList: AdvantageItem[] = await fetchCollection('advantages', {
- sort: { sort: 1 },
- })
+ const advantagesList: AdvantageFromServer[] = await fetchCollection(
+ 'advantages',
+ {
+ sort: { sort: 1 },
+ },
+ )
if (!advantagesList || advantagesList.length === 0) {
return null
diff --git a/src/components/Main/Anchor/Anchor.tsx b/src/components/Main/Anchor/Anchor.tsx
index 3650ff36..c3eee815 100644
--- a/src/components/Main/Anchor/Anchor.tsx
+++ b/src/components/Main/Anchor/Anchor.tsx
@@ -4,40 +4,14 @@ import React, { JSX, useEffect, useState } from 'react'
import anchorStyles from './Anchor.module.scss'
import { MenuItem } from '@/types/types'
import clsx from 'clsx'
-
-const anchorLinks: MenuItem[] = [
- {
- label: 'Наши мастера',
- href: '#masters',
- },
- {
- label: 'Процесс сотворения образа',
- href: '#process',
- },
- {
- label: 'Категории икон',
- href: '#categories',
- },
- {
- label: 'Расчёт стоимости',
- href: '#calculation',
- },
- {
- label: 'Реставрация',
- href: '#restoration',
- },
- {
- label: 'Вопрос-ответ',
- href: '#faq',
- },
-]
+import { ANCHOR_LINKS } from '@/const/const'
export default function Anchor(): JSX.Element {
const [visibleLinks, setVisibleLinks] = useState([])
useEffect(() => {
const checkLinks = () => {
- const present = anchorLinks.filter((link) => {
+ const present = ANCHOR_LINKS.filter((link) => {
if (link.href && link.href.startsWith('#')) {
const id = link.href.slice(1)
try {
diff --git a/src/components/Main/Faq/Faq.tsx b/src/components/Main/Faq/Faq.tsx
index 30793ff2..a3f7e199 100644
--- a/src/components/Main/Faq/Faq.tsx
+++ b/src/components/Main/Faq/Faq.tsx
@@ -7,7 +7,7 @@ import { fetchCollection } from '@/lib/api-client'
import { createSanitizedHTML, stripHtml } from '@/functions/functions'
export default async function Faq(): Promise {
- const faqData: FaqFromServer[] = await fetchCollection('faq', {
+ const faqData: FaqFromServer[] = await fetchCollection('faq', {
sort: { sort: 1 },
})
diff --git a/src/components/Main/InStock/InStock.tsx b/src/components/Main/InStock/InStock.tsx
index ca1bbab6..df1d7d6f 100644
--- a/src/components/Main/InStock/InStock.tsx
+++ b/src/components/Main/InStock/InStock.tsx
@@ -8,7 +8,7 @@ import inStockStyles from './InStock.module.scss'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
export default async function InStock(): Promise {
- const worksData: WorkFromServer[] = await fetchCollection('works', {
+ const worksData: WorkFromServer[] = await fetchCollection('works', {
filter: { in_stock: true },
sort: { date: -1 },
})
diff --git a/src/components/Main/LastWorks/LastWorks.tsx b/src/components/Main/LastWorks/LastWorks.tsx
index 2fb61146..c13af978 100644
--- a/src/components/Main/LastWorks/LastWorks.tsx
+++ b/src/components/Main/LastWorks/LastWorks.tsx
@@ -8,7 +8,7 @@ import lastWorksStyles from './LastWorks.module.scss'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
export default async function LastWorks(): Promise {
- const worksData: WorkFromServer[] = await fetchCollection('works', {
+ const worksData: WorkFromServer[] = await fetchCollection('works', {
sort: { date: -1 },
})
diff --git a/src/components/Main/Masters/Masters.tsx b/src/components/Main/Masters/Masters.tsx
index 9a1868de..248d5f99 100644
--- a/src/components/Main/Masters/Masters.tsx
+++ b/src/components/Main/Masters/Masters.tsx
@@ -8,7 +8,7 @@ import mastersStyles from './Masters.module.scss'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
export default async function Masters(): Promise {
- const mastersData: MasterFromServer[] = await fetchCollection('masters', {
+ const mastersData: MasterFromServer[] = await fetchCollection('masters', {
sort: { sort: 1 },
})
diff --git a/src/components/Main/News/News.tsx b/src/components/Main/News/News.tsx
index e960510e..2c00ff52 100644
--- a/src/components/Main/News/News.tsx
+++ b/src/components/Main/News/News.tsx
@@ -7,7 +7,7 @@ import newsStyles from './News.module.scss'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
export default async function News(): Promise {
- const newsData: NewsFromServer[] = await fetchCollection('news', {
+ const newsData: NewsFromServer[] = await fetchCollection('news', {
sort: { date: -1 },
limit: 10,
})
diff --git a/src/components/Main/Process/Process.tsx b/src/components/Main/Process/Process.tsx
index 40e93d4e..6b7511bc 100644
--- a/src/components/Main/Process/Process.tsx
+++ b/src/components/Main/Process/Process.tsx
@@ -1,15 +1,18 @@
import { JSX } from 'react'
import processStyles from './Process.module.scss'
-import { ProcessItem } from '@/types/types'
+import { ProcessFromServer } from '@/types/types'
import Image from 'next/image'
import clsx from 'clsx'
import { fetchCollection, getImageUrl } from '@/lib/api-client'
import { createSanitizedHTML } from '@/functions/functions'
export default async function Process(): Promise {
- const processList: ProcessItem[] = await fetchCollection('createprocess', {
- sort: { sort: 1 },
- })
+ const processList: ProcessFromServer[] = await fetchCollection(
+ 'createprocess',
+ {
+ sort: { sort: 1 },
+ },
+ )
if (!processList || processList.length === 0) {
return null
diff --git a/src/components/Main/Restoration/Restoration.tsx b/src/components/Main/Restoration/Restoration.tsx
index 8effc3f8..6fabc09b 100644
--- a/src/components/Main/Restoration/Restoration.tsx
+++ b/src/components/Main/Restoration/Restoration.tsx
@@ -8,7 +8,8 @@ import { fetchSingleton, getImageUrl } from '@/lib/api-client'
import { createSanitizedHTML } from '@/functions/functions'
export default async function Restoration(): Promise {
- const restorationData: RestorationFromServer | null = await fetchSingleton('restoration')
+ const restorationData: RestorationFromServer | null =
+ await fetchSingleton('restoration')
if (!restorationData) {
return null
diff --git a/src/components/Main/Reviews/Reviews.tsx b/src/components/Main/Reviews/Reviews.tsx
index b7dc3248..8a0745ef 100644
--- a/src/components/Main/Reviews/Reviews.tsx
+++ b/src/components/Main/Reviews/Reviews.tsx
@@ -3,10 +3,10 @@ import Link from 'next/link'
import { ReviewFromServer, ReviewItem } from '@/types/types'
import ReviewsList from '@/components/ReviewsList/ReviewsList'
-import { fetchCollection } from '@/lib/api-client'
+import { fetchCollection, getImageUrl } from '@/lib/api-client'
export default async function Reviews(): Promise {
- const reviewsData: ReviewFromServer[] = await fetchCollection('reviews', {
+ const reviewsData: ReviewFromServer[] = await fetchCollection('reviews', {
sort: { date: -1 },
limit: 6,
})
@@ -15,13 +15,29 @@ export default async function Reviews(): Promise {
return null
}
- const reviewsList: ReviewItem[] = reviewsData.map((review) => ({
- id: review._id,
- date: review.date,
- stars: review.stars,
- name: review.name,
- review: review.review,
- }))
+ const reviewsList: ReviewItem[] = reviewsData.map((review) => {
+ const photos: Array<{ thumb: string; full: string }> = []
+
+ if (review.photos && review.photos.length > 0) {
+ review.photos.forEach((img) => {
+ if (img._id) {
+ photos.push({
+ thumb: getImageUrl(img._id, 400, 300, 'thumbnail'),
+ full: getImageUrl(img._id, 1920, 1080, 'bestFit'),
+ })
+ }
+ })
+ }
+
+ return {
+ id: review._id,
+ date: review.date,
+ stars: review.stars,
+ name: review.name,
+ review: review.review,
+ ...(photos.length > 0 ? { photos } : {}),
+ }
+ })
return (
diff --git a/src/components/Main/SliderMain/SliderMain.tsx b/src/components/Main/SliderMain/SliderMain.tsx
index a65ea742..ec5b199c 100644
--- a/src/components/Main/SliderMain/SliderMain.tsx
+++ b/src/components/Main/SliderMain/SliderMain.tsx
@@ -4,9 +4,12 @@ import type { CardItem, MainSliderFromServer } from '@/types/types'
import SliderMainClient from './SliderMainClient'
export default async function SliderMain(): Promise {
- const mainSliderData: MainSliderFromServer[] = await fetchCollection('mainslider', {
- sort: { sort: 1 },
- })
+ const mainSliderData: MainSliderFromServer[] = await fetchCollection(
+ 'mainslider',
+ {
+ sort: { sort: 1 },
+ },
+ )
if (!mainSliderData || mainSliderData.length === 0) {
return null
diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx
index 70a494a9..5cd87bbe 100644
--- a/src/components/Menu/Menu.tsx
+++ b/src/components/Menu/Menu.tsx
@@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation'
import clsx from 'clsx'
import Link from 'next/link'
import type { MenuItem } from '@/types/types'
-import { menuItems } from '@/const/const'
+import { MENU_ITEMS } from '@/const/const'
import menuStyles from './Menu.module.scss'
type MenuProps = {
@@ -25,7 +25,7 @@ export default function Menu({
return (
- {menuItems.map((menuItem: MenuItem, index) => {
+ {MENU_ITEMS.map((menuItem: MenuItem, index) => {
const isActive =
menuItem.href === '/' ? currentPath === '/' : currentPath.startsWith(menuItem.href)
diff --git a/src/components/News/News.tsx b/src/components/News/News.tsx
index c9da77d6..0034f501 100644
--- a/src/components/News/News.tsx
+++ b/src/components/News/News.tsx
@@ -1,27 +1,20 @@
-import { JSX } from 'react'
+import { JSX, ReactNode } from 'react'
import newsStyles from './News.module.scss'
-import { CardItem, NewsFromServer } from '@/types/types'
+import { CardItem } from '@/types/types'
import EmptySection from '@/components/EmptySection/EmptySection'
import Card from '@/components/Card/Card'
import clsx from 'clsx'
-import { fetchCollection, getImageUrl } from '@/lib/api-client'
-export default async function News(): Promise {
- const newsData: NewsFromServer[] = await fetchCollection('news')
+type NewsProps = {
+ newsList: CardItem[]
+ children?: ReactNode
+}
- if (!newsData || newsData.length === 0) {
+export default function News({ newsList, children }: NewsProps): JSX.Element {
+ if (!newsList || newsList.length === 0) {
return
}
- const newsList: CardItem[] = newsData.map((news) => ({
- id: news.slug || news._id,
- title: news.title,
- description: news.description,
- href: `/news/${news.slug || news._id}`,
- image: getImageUrl(news.image._id, 400, 400),
- alt: news.image.title || news.title,
- }))
-
return (
@@ -41,6 +34,8 @@ export default async function News(): Promise {
)
})}
+
+ {children}
)
diff --git a/src/components/NotFound/NotFound.tsx b/src/components/NotFound/NotFound.tsx
index 06804842..7656e6f1 100644
--- a/src/components/NotFound/NotFound.tsx
+++ b/src/components/NotFound/NotFound.tsx
@@ -4,7 +4,7 @@ import clsx from 'clsx'
import notfoundStyles from './NotFound.module.scss'
export default function NotFound(): JSX.Element {
- const stars = []
+ const stars: JSX.Element[] = []
for (let i = 1; i <= 100; i++) {
stars.push(
)
diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss
new file mode 100644
index 00000000..2d77d79f
--- /dev/null
+++ b/src/components/Pagination/Pagination.module.scss
@@ -0,0 +1,100 @@
+@use '../../styles/variables.scss' as *;
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+}
+
+.pagination__list {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+
+ list-style: none;
+}
+
+.pagination__link {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-width: 40px;
+ height: 40px;
+ padding: 0 8px;
+
+ font-size: 14px;
+ text-decoration: none;
+ color: $base-text-color;
+
+ background: transparent;
+ border: 1px solid $add-bg;
+ border-radius: 4px;
+
+ cursor: pointer;
+
+ transition:
+ background 0.3s ease-in-out,
+ color 0.3s ease-in-out,
+ border-color 0.3s ease-in-out;
+
+ &:hover {
+ background: $add-bg;
+ border-color: $accent-text-color;
+ }
+
+ &--active {
+ color: $bg-text-color;
+
+ background: $accent-bg;
+ border-color: $accent-bg;
+
+ pointer-events: none;
+ }
+}
+
+.pagination__prev,
+.pagination__next {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 40px;
+ height: 40px;
+
+ text-decoration: none;
+ color: $base-text-color;
+
+ border: 1px solid $add-bg;
+ border-radius: 4px;
+
+ transition:
+ background 0.3s ease-in-out,
+ border-color 0.3s ease-in-out;
+
+ &:hover {
+ background: $add-bg;
+ border-color: $accent-text-color;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+
+ fill: currentColor;
+ }
+}
+
+.pagination__ellipsis {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 40px;
+ height: 40px;
+
+ font-size: 14px;
+ color: $add-text-color;
+
+ user-select: none;
+}
diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx
new file mode 100644
index 00000000..d1ea8e97
--- /dev/null
+++ b/src/components/Pagination/Pagination.tsx
@@ -0,0 +1,104 @@
+import { JSX } from 'react'
+import Link from 'next/link'
+import clsx from 'clsx'
+import styles from './Pagination.module.scss'
+
+type PaginationProps = {
+ currentPage: number
+ totalPages: number
+ basePath: string
+}
+
+function getPageNumbers(current: number, total: number): (number | '...')[] {
+ if (total <= 7) {
+ return Array.from({ length: total }, (_, i) => i + 1)
+ }
+
+ const pages: (number | '...')[] = [1]
+
+ if (current > 3) {
+ pages.push('...')
+ }
+
+ const start = Math.max(2, current - 1)
+ const end = Math.min(total - 1, current + 1)
+
+ for (let i = start; i <= end; i++) {
+ pages.push(i)
+ }
+
+ if (current < total - 2) {
+ pages.push('...')
+ }
+
+ pages.push(total)
+
+ return pages
+}
+
+export default function Pagination({
+ currentPage,
+ totalPages,
+ basePath,
+}: PaginationProps): JSX.Element | null {
+ if (totalPages <= 1) return null
+
+ const pages = getPageNumbers(currentPage, totalPages)
+
+ const getHref = (page: number) => (page === 1 ? basePath : `${basePath}?page=${page}`)
+
+ return (
+
+ {currentPage > 1 && (
+
+
+
+
+
+ )}
+
+
+ {pages.map((page, index) =>
+ page === '...' ? (
+
+ ···
+
+ ) : (
+
+
+ {page}
+
+
+ ),
+ )}
+
+
+ {currentPage < totalPages && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/Payment/Payment.tsx b/src/components/Payment/Payment.tsx
index 14830856..e2a282ed 100644
--- a/src/components/Payment/Payment.tsx
+++ b/src/components/Payment/Payment.tsx
@@ -6,7 +6,7 @@ import { OrderFromServer } from '@/types/types'
import { fetchSingleton } from '@/lib/api-client'
export default async function Payment(): Promise {
- const orderInfo: OrderFromServer | null = await fetchSingleton('order')
+ const orderInfo: OrderFromServer | null = await fetchSingleton('order')
if (!orderInfo) {
return null
diff --git a/src/components/Reviews/Reviews.tsx b/src/components/Reviews/Reviews.tsx
index 336afb0b..cce99589 100644
--- a/src/components/Reviews/Reviews.tsx
+++ b/src/components/Reviews/Reviews.tsx
@@ -1,21 +1,14 @@
-import { JSX } from 'react'
-import { ReviewFromServer, ReviewItem } from '@/types/types'
-import { fetchCollection } from '@/lib/api-client'
+import { JSX, ReactNode } from 'react'
+import { ReviewItem } from '@/types/types'
import ReviewsList from '@/components/ReviewsList/ReviewsList'
-import FormReviews from '@/components/Forms/FormReviews/FormReviews'
import EmptySection from '@/components/EmptySection/EmptySection'
-export default async function Reviews(): Promise {
- const reviewsData: ReviewFromServer[] | null = await fetchCollection('reviews')
-
- const reviewsList: ReviewItem[] = (reviewsData || []).map((review) => ({
- id: review._id,
- date: review.date,
- stars: review.stars,
- name: review.name,
- review: review.review,
- }))
+type ReviewsProps = {
+ reviewsList: ReviewItem[]
+ children?: ReactNode
+}
+export default function Reviews({ reviewsList, children }: ReviewsProps): JSX.Element {
return (
<>
{reviewsList.length > 0 ? (
@@ -24,21 +17,13 @@ export default async function Reviews(): Promise {
Отзывы о нас
+
+ {children}
) : (
)}
-
-
-
-
- Оставить отзыв
-
-
-
-
-
>
)
}
diff --git a/src/components/ReviewsList/ReviewPhoto.tsx b/src/components/ReviewsList/ReviewPhoto.tsx
new file mode 100644
index 00000000..0e7e5bb1
--- /dev/null
+++ b/src/components/ReviewsList/ReviewPhoto.tsx
@@ -0,0 +1,90 @@
+'use client'
+
+import { JSX, useEffect, useRef } from 'react'
+import Image from 'next/image'
+import lightGallery from 'lightgallery'
+import 'lightgallery/css/lightgallery.css'
+import lgThumbnail from 'lightgallery/plugins/thumbnail'
+import lgZoom from 'lightgallery/plugins/zoom'
+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 }
+
+type ReviewPhotoProps = {
+ photos: PhotoItem[]
+ authorName: string
+ date: string
+}
+
+export default function ReviewPhoto({ photos, authorName, date }: ReviewPhotoProps): JSX.Element {
+ const containerRef = useRef(null)
+ const lgInstanceRef = useRef(null)
+
+ useEffect(() => {
+ if (!containerRef.current || photos.length === 0) return
+
+ lgInstanceRef.current = lightGallery(containerRef.current, {
+ dynamic: true,
+ dynamicEl: photos.map((photo) => ({
+ src: photo.full,
+ thumb: photo.thumb,
+ subHtml: `${authorName} (${date})
`,
+ })),
+ plugins: [lgThumbnail, lgZoom],
+ speed: 400,
+ counter: photos.length > 1,
+ })
+
+ return () => {
+ lgInstanceRef.current?.destroy()
+ lgInstanceRef.current = null
+ }
+ }, [photos, authorName, date])
+
+ const handleClick = (index: number) => {
+ lgInstanceRef.current?.openGallery(index)
+ }
+
+ return (
+
+ {photos.map((photo, index) => (
+ handleClick(index)}
+ aria-label={`Открыть фото ${index + 1}`}
+ >
+ 1 ? `, фото ${index + 1}` : ''}`}
+ fill
+ sizes="(max-width: 500px) 25vw, (max-width: 1100px) 20vw, 12vw"
+ className={reviewsListStyles['reviews__item-photo-img']}
+ />
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/ReviewsList/ReviewsList.module.scss b/src/components/ReviewsList/ReviewsList.module.scss
index 06dd31a1..9619f3c3 100644
--- a/src/components/ReviewsList/ReviewsList.module.scss
+++ b/src/components/ReviewsList/ReviewsList.module.scss
@@ -72,11 +72,10 @@
.reviews__item-rating {
display: flex;
+ justify-content: end;
align-items: center;
gap: 5px;
-}
-
-.reviews__item-rating-value {
+ width: 120px;
}
.reviews__item-rating-icon {
@@ -85,3 +84,87 @@
fill: $accent-add-bg;
}
+
+.reviews__item-photo {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 6px;
+ height: 110px;
+
+ @media (max-width: $tablet-mid-width) {
+ height: 130px;
+ }
+ @media (max-width: $mobile-mid-width) {
+ height: 72px;
+ }
+}
+
+.reviews__item-photo-btn {
+ position: relative;
+
+ overflow: hidden;
+ flex: 1 1 0;
+ min-width: 0;
+ max-width: calc(20% - 6px);
+ height: 100%;
+ padding: 0;
+
+ background: none;
+ border: none;
+ border-radius: 12px;
+ cursor: pointer;
+
+ @media (max-width: $mobile-mid-width) {
+ border-radius: 8px;
+ }
+
+ @media (hover: hover) {
+ &:hover {
+ .reviews__item-photo-img {
+ transform: scale(1.05);
+ }
+
+ .reviews__item-photo-overlay {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.reviews__item-photo-overlay {
+ position: absolute;
+ inset: 0;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ opacity: 0;
+ background-color: rgba(0, 0, 0, 0.35);
+
+ pointer-events: none;
+
+ transition: opacity 0.3s ease-in-out;
+}
+
+.reviews__item-photo-overlay-icon {
+ width: 32px;
+ height: 32px;
+
+ color: $base-bg;
+
+ @media (max-width: $mobile-mid-width) {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.reviews__item-photo-img {
+ display: block;
+ width: 100%;
+ height: 100%;
+
+ transition: transform 0.3s ease-in-out;
+
+ object-fit: cover;
+}
diff --git a/src/components/ReviewsList/ReviewsList.tsx b/src/components/ReviewsList/ReviewsList.tsx
index d3360bc0..c5c6b6cc 100644
--- a/src/components/ReviewsList/ReviewsList.tsx
+++ b/src/components/ReviewsList/ReviewsList.tsx
@@ -2,6 +2,7 @@ import { JSX } from 'react'
import reviewsListStyles from './ReviewsList.module.scss'
import { ReviewItem } from '@/types/types'
import { convertDateToLocale } from '@/functions/functions'
+import ReviewPhoto from './ReviewPhoto'
type ReviewsListProps = {
reviewsList: ReviewItem[]
@@ -36,6 +37,14 @@ export default function ReviewsList({ reviewsList }: ReviewsListProps): JSX.Elem
+ {review.photos && review.photos.length > 0 && (
+
+ )}
+
{review.review}
@@ -45,23 +54,26 @@ export default function ReviewsList({ reviewsList }: ReviewsListProps): JSX.Elem
{review.name}
-
-
- {review.stars}
-
-
-
-
-
-
+ {review.stars && (
+
+ {Array.from({ length: review.stars }, (_, i) => (
+ 0}
+ aria-label={i === 0 ? `${review.stars} из 5` : undefined}
+ >
+
+
+ ))}
+
+ )}
)
diff --git a/src/components/ScrollButton/ScrollButton.tsx b/src/components/ScrollButton/ScrollButton.tsx
index ad485faa..c7bfd589 100644
--- a/src/components/ScrollButton/ScrollButton.tsx
+++ b/src/components/ScrollButton/ScrollButton.tsx
@@ -1,10 +1,9 @@
'use client'
-import React, { JSX, useEffect, useState, useRef } from 'react'
+import React, { JSX, MouseEventHandler, useEffect, useState, useRef } from 'react'
import clsx from 'clsx'
import scrollButtonStyles from './ScrollButton.module.scss'
-
-const SCROLL_THRESHOLD = 500
+import { SCROLL_THRESHOLD } from '@/const/const'
export default function ScrollButton(): JSX.Element {
const [visible, setVisible] = useState(false)
@@ -32,8 +31,8 @@ export default function ScrollButton(): JSX.Element {
}
}, [])
- const handleClick: React.MouseEventHandler = (e) => {
- e.preventDefault()
+ const handleClick: MouseEventHandler = (evt) => {
+ evt.preventDefault()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
diff --git a/src/components/Social/Social.tsx b/src/components/Social/Social.tsx
index aaa12acf..97eaed68 100644
--- a/src/components/Social/Social.tsx
+++ b/src/components/Social/Social.tsx
@@ -1,7 +1,7 @@
import { JSX } from 'react'
import socialStyles from './Social.module.scss'
import clsx from 'clsx'
-import { MainInfo } from '@/types/types'
+import { MainInfoFromServer } from '@/types/types'
import { fetchSingleton } from '@/lib/api-client'
import {
createMaxLink,
@@ -15,7 +15,7 @@ type SocialProps = {
}
export default async function Social({ addClass }: SocialProps): Promise {
- const mainInfo: MainInfo | null = await fetchSingleton('maininfo')
+ const mainInfo: MainInfoFromServer | null = await fetchSingleton('maininfo')
if (!mainInfo) {
return null
diff --git a/src/components/Works/Works.tsx b/src/components/Works/Works.tsx
index 2bf20271..6987ab25 100644
--- a/src/components/Works/Works.tsx
+++ b/src/components/Works/Works.tsx
@@ -1,4 +1,4 @@
-import { JSX } from 'react'
+import { JSX, ReactNode } from 'react'
import { CardItem } from '@/types/types'
import EmptySection from '@/components/EmptySection/EmptySection'
import Card from '@/components/Card/Card'
@@ -7,9 +7,10 @@ import clsx from 'clsx'
type WorksProps = {
worksList: CardItem[]
+ children?: ReactNode
}
-export default function Works({ worksList }: WorksProps): JSX.Element {
+export default function Works({ worksList, children }: WorksProps): JSX.Element {
return worksList.length > 0 ? (
@@ -29,6 +30,8 @@ export default function Works({ worksList }: WorksProps): JSX.Element {
)
})}
+
+ {children}
) : (
diff --git a/src/components/YandexMap/YandexMap.tsx b/src/components/YandexMap/YandexMap.tsx
index b7251ff0..76c6225d 100644
--- a/src/components/YandexMap/YandexMap.tsx
+++ b/src/components/YandexMap/YandexMap.tsx
@@ -78,8 +78,8 @@ export default function YandexMap({ logo, coordinates }: YandexMapProps): JSX.El
setReactifiedModule(ymapsApi)
setUiControls(uiThemeReactified)
})
- .catch((error) => {
- console.error('Ошибка загрузки Яндекс.Карт:', error)
+ .catch((err) => {
+ console.error('Ошибка загрузки Яндекс.Карт:', err)
})
}, [])
diff --git a/src/const/const.ts b/src/const/const.ts
index 63248f2a..d85edf97 100644
--- a/src/const/const.ts
+++ b/src/const/const.ts
@@ -1,6 +1,24 @@
import { MenuItem } from '@/types/types'
-export const menuItems: MenuItem[] = [
+export const ITEMS_PER_PAGE = 12
+export const REVIEWS_PER_PAGE = 1
+
+export const SCROLL_THRESHOLD = 500
+
+export const STARS_COUNT = 5
+export const MAX_PHOTOS = 5
+
+export const DEFAULT_MAX_FILES = 5
+export const DEFAULT_MAX_SIZE_MB = 5
+
+export const GALLERY_BREAKPOINT_COLUMNS = {
+ default: 4,
+ 1200: 3,
+ 768: 2,
+ 480: 1,
+}
+
+export const MENU_ITEMS: MenuItem[] = [
{
href: '/about',
label: 'О нас',
@@ -34,3 +52,30 @@ export const menuItems: MenuItem[] = [
label: 'Контакты',
},
]
+
+export const ANCHOR_LINKS: MenuItem[] = [
+ {
+ label: 'Наши мастера',
+ href: '#masters',
+ },
+ {
+ label: 'Процесс сотворения образа',
+ href: '#process',
+ },
+ {
+ label: 'Категории икон',
+ href: '#categories',
+ },
+ {
+ label: 'Расчёт стоимости',
+ href: '#calculation',
+ },
+ {
+ label: 'Реставрация',
+ href: '#restoration',
+ },
+ {
+ label: 'Вопрос-ответ',
+ href: '#faq',
+ },
+]
diff --git a/src/functions/functions.ts b/src/functions/functions.ts
index 661b9234..59908dfb 100644
--- a/src/functions/functions.ts
+++ b/src/functions/functions.ts
@@ -15,31 +15,31 @@ export function createSanitizedHTML(htmlString: string | null | undefined): { __
return { __html: sanitized }
}
-export function normalizePhone(phone: string) {
+export function normalizePhone(phone: string): string {
return phone.replace(/[^+0-9]+/g, '').replace(/^[78]/, '+7')
}
-export function createEmailLink(email: string) {
+export function createEmailLink(email: string): string {
return 'mailto:' + email
}
-export function createPhoneLink(phone: string) {
+export function createPhoneLink(phone: string): string {
return 'tel:' + normalizePhone(phone)
}
-export function createTelegramLink(telegram: string) {
+export function createTelegramLink(telegram: string): string {
return 'https://t.me/' + telegram
}
-export function createWhatsappLink(whatsapp: string) {
+export function createWhatsappLink(whatsapp: string): string {
return 'https://wa.me/' + normalizePhone(whatsapp)
}
-export function createVkLink(vk: string) {
+export function createVkLink(vk: string): string {
return 'https://vk.ru/' + vk
}
-export function createMaxLink(max: string) {
+export function createMaxLink(max: string): string {
return 'https://max.ru/' + max
}
diff --git a/src/lib/CockpitAPI.ts b/src/lib/CockpitAPI.ts
index 03e49b11..3a9c5ca6 100644
--- a/src/lib/CockpitAPI.ts
+++ b/src/lib/CockpitAPI.ts
@@ -1,6 +1,5 @@
interface CockpitOptions {
locale?: string
- // filter values can be string, number, boolean or complex objects (e.g. { field: value })
filter?: Record
sort?: Record
limit?: number
@@ -23,8 +22,8 @@ class CockpitClient {
try {
return await this.cockpitFetch(endpoint)
- } catch (e) {
- console.error('Cockpit getSingleItem error', e)
+ } catch (err) {
+ console.error('[Cockpit] getSingleItem error', err)
return null
}
}
@@ -35,8 +34,8 @@ class CockpitClient {
try {
return await this.cockpitFetch(endpoint)
- } catch (e) {
- console.error('Cockpit getCollection error', e)
+ } catch (err) {
+ console.error('[Cockpit] getCollection error', err)
return []
}
}
@@ -47,16 +46,12 @@ class CockpitClient {
try {
return await this.cockpitFetch(endpoint)
- } catch (e) {
- console.error('Cockpit getCollectionItem error', e)
+ } catch (err) {
+ console.error('[Cockpit] getCollectionItem error', err)
return null
}
}
- /**
- * Fetch single item from a collection by arbitrary field (e.g. slug)
- * Returns first matched item or null
- */
async getCollectionItemByField(
modelId: string,
field: string,
@@ -67,10 +62,10 @@ class CockpitClient {
const filter = { ...(options.filter || {}), [field]: value }
const items: unknown[] = await this.getCollection(modelId, { ...options, filter, limit: 1 })
- if (Array.isArray(items) && items.length > 0) return items[0] as unknown
+ if (Array.isArray(items) && items.length > 0) return items[0]
return null
- } catch (e) {
- console.error('Cockpit getCollectionItemByField error', e)
+ } catch (err) {
+ console.error('[Cockpit] getCollectionItemByField error', err)
return null
}
}
@@ -81,8 +76,8 @@ class CockpitClient {
try {
return await this.cockpitFetch(endpoint)
- } catch (e) {
- console.error('Cockpit getTree error', e)
+ } catch (err) {
+ console.error('[Cockpit] getTree error', err)
return null
}
}
@@ -95,17 +90,61 @@ class CockpitClient {
method: 'POST',
body: JSON.stringify({ data }),
})
- } catch (e) {
- console.error(`[Cockpit] createItem error in ${modelId}:`, e)
+ } catch (err) {
+ console.error(`[Cockpit] createItem error in ${modelId}:`, err)
return null
}
}
+ private async uploadSingleAsset(file: File): Promise | null> {
+ const formData = new FormData()
+ const folder = '6a00a163c6c8763d26aad9a3'
+
+ formData.append('file', file, file.name)
+
+ try {
+ const url = `${this.baseUrl.replace(/\/$/, '')}/api/upload?folder=${folder}`
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'api-key': this.apiKey },
+ body: formData,
+ })
+
+ if (!response.ok) {
+ console.error(`[Cockpit] uploadSingleAsset error: ${response.statusText}`)
+ return null
+ }
+
+ const result = await response.json()
+ const assets = result?.asset?.assets as Array> | undefined
+
+ if (Array.isArray(assets) && assets.length > 0) return assets[0]
+
+ return null
+ } catch (err) {
+ console.error(`[Cockpit] uploadSingleAsset error for "${file.name}":`, err)
+ return null
+ }
+ }
+
+ async uploadAssets(files: File[]): Promise[]> {
+ const assets: Record[] = []
+
+ for (const file of files) {
+ const asset = await this.uploadSingleAsset(file)
+ if (asset) {
+ assets.push(asset)
+ }
+ }
+
+ return assets
+ }
+
getImageUrl(imageId: string, width: number, height: number) {
return `${this.baseUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=80&o=1`
}
- private createQueryString(options: object = {}) {
+ private createQueryString(options: CockpitOptions = {}) {
const params = new URLSearchParams()
Object.entries(options).forEach(([key, value]) => {
@@ -121,7 +160,7 @@ class CockpitClient {
private async cockpitFetch(endpoint: string, options: RequestInit = {}) {
if (!this.baseUrl) {
- throw new Error('Cockpit base URL is not configured. Set COCKPIT_API_URL in environment')
+ throw new Error('[Cockpit] base URL is not configured. Set COCKPIT_API_URL in environment')
}
const url = `${this.baseUrl.replace(/\/$/, '')}/api/${endpoint}`
@@ -137,7 +176,7 @@ class CockpitClient {
const response = await fetch(url, config)
if (!response.ok) {
- throw new Error(`Cockpit error: ${response.statusText}`)
+ throw new Error(`[Cockpit] error: ${response.statusText}`)
}
return response.json()
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index e2e59538..da6715a3 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -40,7 +40,7 @@ function buildUrl(path: string, options: FetchOptions = {}): string {
if (options.filter) params.set('filter', JSON.stringify(options.filter))
if (options.sort) params.set('sort', JSON.stringify(options.sort))
if (options.limit) params.set('limit', String(options.limit))
- if (options.skip) params.set('skip', String(options.skip))
+ if (options.skip !== undefined && options.skip !== null) params.set('skip', String(options.skip))
if (options.populate) params.set('populate', String(options.populate))
if (options.field) params.set('field', options.field)
@@ -83,8 +83,8 @@ export async function fetchSingleton(
}
return await response.json()
- } catch (error) {
- console.error(`Error fetching singleton ${model}:`, error)
+ } catch (err) {
+ console.error(`Error fetching singleton ${model}:`, err)
return null
}
}
@@ -118,8 +118,8 @@ export async function fetchCollection(
}
return await response.json()
- } catch (error) {
- console.error(`Error fetching collection ${model}:`, error)
+ } catch (err) {
+ console.error(`Error fetching collection ${model}:`, err)
return []
}
}
@@ -159,8 +159,8 @@ export async function fetchCollectionItem(
}
return await response.json()
- } catch (error) {
- console.error(`Error fetching collection item ${model}/${id}:`, error)
+ } catch (err) {
+ console.error(`Error fetching collection item ${model}/${id}:`, err)
return null
}
}
@@ -197,19 +197,71 @@ export async function fetchTree(
}
return await response.json()
- } catch (error) {
- console.error(`Error fetching tree ${model}:`, error)
+ } catch (err) {
+ console.error(`Error fetching tree ${model}:`, err)
return null
}
}
+/**
+ * Получение количества записей в коллекции
+ * @param model - Название модели
+ * @param options - Опции запроса (filter, cache, revalidate, tags)
+ * @returns Общее количество записей
+ */
+export async function fetchCollectionCount(
+ model: string,
+ options: Pick = {},
+): Promise {
+ const params = new URLSearchParams()
+
+ if (options.filter) params.set('filter', JSON.stringify(options.filter))
+
+ const baseUrl = getBaseUrl()
+ const queryString = params.toString()
+ const url = `${baseUrl}/api/content/collection/${model}/count${queryString ? `?${queryString}` : ''}`
+
+ try {
+ const response = await fetch(url, {
+ cache: options.cache ?? 'force-cache',
+ next: {
+ ...(options.revalidate !== undefined && { revalidate: options.revalidate }),
+ tags: options.tags || [`collection-${model}-count`],
+ },
+ })
+
+ if (!response.ok) return 0
+
+ const data = await response.json()
+ return data.count ?? 0
+ } catch (err) {
+ console.error(`Error fetching collection count ${model}:`, err)
+ return 0
+ }
+}
+
/**
* Получение ссылки на изображение
* @param imageId - ID изображения
* @param width - ширина
* @param height - высота
+ * @param mode - режим ресайза
+ */
+export function getImageUrl(
+ imageId: string,
+ width: number,
+ height: number,
+ mode?: string | null,
+): string {
+ const cockpitUrl = process.env.COCKPIT_API_URL || ''
+ return `${cockpitUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=80&o=1${mode ? `&m=${mode}` : ''}`
+}
+
+/**
+ * Получение ссылки на файл
+ * @param path - путь к файлу из объекта ассета (поле path)
*/
-export function getImageUrl(imageId: string, width: number, height: number): string {
+export function getAssetUrl(path: string): string {
const cockpitUrl = process.env.COCKPIT_API_URL || ''
- return `${cockpitUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=80&o=1`
+ return `${cockpitUrl}storage/uploads${path}`
}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
new file mode 100644
index 00000000..69b69004
--- /dev/null
+++ b/src/lib/schemas.ts
@@ -0,0 +1,80 @@
+import { z } from 'zod'
+
+const PHONE_REGEX = /^(\+?\d{1,4}?[\s\-]?)?(\(?\d{1,4}?\)?[\s\-]?)?[\d\s\-]{6,20}$/
+
+const phoneField = z
+ .string()
+ .refine((val) => !val || PHONE_REGEX.test(val), 'Некорректный формат телефона')
+
+const agreementField = z.literal(true, 'Необходимо согласие на обработку данных')
+
+// Отзывы
+export const reviewFormSchema = z.object({
+ name: z.string().min(2, 'Имя должно содержать минимум 2 символа'),
+ phone: phoneField,
+ email: z
+ .string()
+ .min(1, 'Email обязателен для заполнения')
+ .check(z.email('Некорректный формат email')),
+ review: z.string().min(50, 'Отзыв должен содержать минимум 50 символов'),
+ stars: z
+ .number()
+ .min(1, 'Пожалуйста, укажите оценку от 1 до 5')
+ .max(5, 'Пожалуйста, укажите оценку от 1 до 5'),
+ agreement: agreementField,
+})
+
+export type ReviewFormData = z.infer
+
+// Заказ
+export const applicationFormSchema = z.object({
+ name: z.string().min(2, 'Имя должно содержать минимум 2 символа'),
+ phone: phoneField,
+ email: z
+ .string()
+ .min(1, 'Email обязателен для заполнения')
+ .check(z.email('Некорректный формат email')),
+ message: z.string().min(1, 'Сообщение обязательно для заполнения'),
+ category: z.string().optional(),
+ size: z.string().optional(),
+ goldType: z.enum(['without_gold', 'all', 'halo']).optional(),
+ agreement: agreementField,
+})
+
+export type ApplicationFormData = z.infer
+
+// Контакты (обратная связь → коллекция messages)
+export const messageFormSchema = z.object({
+ name: z.string().min(2, 'Имя должно содержать минимум 2 символа'),
+ phone: phoneField,
+ email: z
+ .string()
+ .min(1, 'Email обязателен для заполнения')
+ .check(z.email('Некорректный формат email')),
+ message: z.string().optional(),
+ agreement: agreementField,
+})
+
+export type MessageFormData = z.infer
+
+// ошибки Zod → плоский Record
+export function zodErrors(error: z.ZodError): Record {
+ const flat = z.flattenError(error).fieldErrors as Record
+ return Object.fromEntries(
+ Object.entries(flat)
+ .filter(([, messages]) => Array.isArray(messages) && messages.length > 0)
+ .map(([key, messages]) => [key, (messages as string[])[0]]),
+ )
+}
+
+// валидация одного поля на клиенте
+export function validateFormField(
+ schema: z.ZodObject,
+ field: string,
+ value: unknown,
+): string {
+ const fieldSchema = (schema.shape as Record)[field]
+ if (!fieldSchema) return ''
+ const result = fieldSchema.safeParse(value)
+ return result.success ? '' : (result.error.issues[0]?.message ?? '')
+}
diff --git a/src/styles/blocks/button.scss b/src/styles/blocks/button.scss
index 65387d5b..5eed05f0 100644
--- a/src/styles/blocks/button.scss
+++ b/src/styles/blocks/button.scss
@@ -17,7 +17,12 @@
cursor: pointer;
- transition: opacity 0.4s ease-in-out;
+ transition: opacity 0.3s ease-in-out;
+
+ &:focus-visible {
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
@media (max-width: $tablet-min-width) {
padding: 10px 20px;
@@ -32,6 +37,11 @@
}
}
+ &--default {
+ background-color: $accent-light-bg;
+ border: 1px dashed $accent-add-bg;
+ }
+
&--accent {
color: $bg-text-color;
diff --git a/src/styles/globals.scss b/src/styles/globals.scss
index 3d15ed9f..a9bc6105 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -37,6 +37,10 @@ html {
&.lg-on {
overflow: hidden;
}
+
+ .lg-outer .lg-thumb-outer {
+ background-color: transparent;
+ }
}
body {
diff --git a/src/styles/modules/form.module.scss b/src/styles/modules/form.module.scss
index 55ba168c..ea45c284 100644
--- a/src/styles/modules/form.module.scss
+++ b/src/styles/modules/form.module.scss
@@ -19,8 +19,6 @@
display: flex;
flex-direction: column;
}
- @media (max-width: $mobile-mid-width) {
- }
}
.form--calculation {
@@ -38,13 +36,11 @@
}
}
-.form__wrapper {
-}
-
.form__label {
position: relative;
display: flex;
+ flex-direction: column;
align-items: start;
&--checkbox {
@@ -67,6 +63,10 @@
border: 1px solid $form-bg;
border-radius: 4px;
+ transition:
+ border-color 0.3s ease-in-out,
+ box-shadow 0.3s ease-in-out;
+
content: '';
}
@@ -80,7 +80,7 @@
opacity: 0;
- transition: all 0.2s ease;
+ transition: all 0.3s ease-in-out;
content: '✓';
}
@@ -92,6 +92,38 @@
&:has(input[type='checkbox']:checked)::after {
opacity: 1;
}
+
+ @media (hover: hover) {
+ &:hover::before {
+ border-color: $accent-add-bg;
+ }
+ }
+
+ &:has(input[type='checkbox']:focus-visible)::before {
+ border-color: $accent-add-bg;
+ box-shadow: 0 0 0 3px rgba($accent-add-bg, 0.35);
+ outline: none;
+ }
+
+ &:has(input[type='checkbox']:focus-visible) {
+ outline: none;
+ }
+
+ &--error::before {
+ border-color: $error-color;
+ }
+ }
+
+ &--message {
+ grid-column: 2;
+
+ @media (max-width: $desktop-mid-width) {
+ grid-column: 1 / -1;
+ }
+
+ @media (max-width: $tablet-min-width) {
+ grid-column: unset;
+ }
}
}
@@ -114,17 +146,6 @@
border-radius: 8px;
}
-.visually-hidden {
- position: absolute !important;
- overflow: hidden;
- clip: rect(1px, 1px, 1px, 1px);
-
- width: 1px;
- height: 1px;
-
- white-space: nowrap;
-}
-
.form__radio-option {
z-index: 2;
@@ -140,6 +161,11 @@
cursor: pointer;
user-select: none;
+ &:has(input[type='radio']:focus-visible) {
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+
@media (max-width: $tablet-min-width) {
font-size: 14px;
}
@@ -159,9 +185,9 @@
border-radius: 6px;
transition:
- transform 200ms ease,
- left 200ms ease,
- width 200ms ease;
+ transform 0.3s ease-in-out,
+ left 0.3s ease-in-out,
+ width 0.3s ease-in-out;
}
.form__radio-label {
@@ -212,9 +238,6 @@
content: '';
}
-.form__label-text {
-}
-
.form__label-link {
color: $accent-add-bg;
@@ -222,6 +245,12 @@
transition: 0.4s ease-in-out;
+ &:focus-visible {
+ border-radius: 2px;
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+
@media (hover: hover) {
&:hover,
&:active {
@@ -240,15 +269,17 @@
border: none;
border-bottom: 1px solid $accent-dark-bg;
- transition: 0.4s ease-in-out;
+ transition: border-bottom-color 0.3s ease-in-out;
&:focus {
- border-bottom: 1px solid $accent-add-bg;
+ border-bottom-color: $accent-add-bg;
outline: none;
}
- &:invalid:not(:placeholder-shown) {
- border-bottom: 1px solid $error-color;
+ &:focus-visible {
+ border-bottom-color: $accent-add-bg;
+ outline: none;
+ box-shadow: 0 2px 0 0 rgba($accent-add-bg, 0.4);
}
&--error {
@@ -257,8 +288,25 @@
}
.form__button {
+ flex-shrink: 0;
align-self: end;
margin: 0;
+
+ @media (max-width: $tablet-min-width) {
+ align-self: center;
+ }
+}
+
+.form__footer {
+ display: flex;
+ grid-column: 1 / -1;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+
+ @media (max-width: $tablet-min-width) {
+ flex-direction: column;
+ }
}
.form__select {
@@ -286,6 +334,13 @@
border-bottom: 1px solid $accent-dark-bg;
cursor: pointer;
+ transition: border-bottom-color 0.3s ease-in-out;
+
+ &:focus-visible {
+ border-bottom-color: $accent-add-bg;
+ outline: none;
+ box-shadow: 0 2px 0 0 rgba($accent-add-bg, 0.4);
+ }
}
.form__select__current[aria-expanded='true'] .form__select__icon {
@@ -297,7 +352,7 @@
width: 10px;
height: 10px;
- transition: transform 0.2s ease-in-out;
+ transition: transform 0.3s ease-in-out;
}
.form__select__list {
@@ -325,17 +380,24 @@
}
.form__select__option:hover,
-.form__select__option:active,
+.form__select__option:active {
+ background: $add-bg;
+ outline: none;
+ box-shadow: inset 3px 0 0 $accent-add-bg;
+}
+
.form__select__option:focus {
background: $add-bg;
outline: none;
+ box-shadow: inset 3px 0 0 $accent-add-bg;
}
.form__select__option--selected {
font-weight: 700;
- color: $bg-text-color;
- background-color: $accent-bg;
+ background: $add-bg;
+ outline: none;
+ box-shadow: inset 3px 0 0 $accent-add-bg;
pointer-events: none;
}
@@ -385,8 +447,95 @@
.form__error {
display: block;
- margin-top: 4px;
+ min-height: 20px;
+ margin-top: 5px;
font-size: 12px;
color: $error-color;
}
+
+.form__stars {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+
+ &--error {
+ .form__star svg {
+ fill: rgba($error-color, 0.3);
+ }
+ }
+}
+
+.form__star {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 5px;
+
+ background: none;
+ border: none;
+ border-radius: 4px;
+
+ cursor: pointer;
+ transition: transform 0.3s ease-in-out;
+
+ &:focus-visible {
+ outline: 2px solid $accent-add-bg;
+ outline-offset: 2px;
+ }
+
+ svg {
+ width: 48px;
+ height: 48px;
+ opacity: 0.25;
+ transition: opacity 0.3s ease-in-out;
+ fill: $accent-add-bg;
+ }
+
+ &--active svg {
+ opacity: 1;
+ }
+
+ &--hovered {
+ transform: scale(1.15);
+ }
+}
+
+.form__group {
+ display: flex;
+ flex-direction: column;
+ grid-column: 1 / -1;
+ gap: 24px;
+}
+
+.form__group-title {
+ width: max-content;
+ padding-bottom: 10px;
+
+ font-family: $font-decor;
+ font-size: 28px;
+ font-weight: 600;
+
+ border-bottom: 1px solid $accent-add-bg;
+
+ @media (max-width: $tablet-min-width) {
+ font-size: 22px;
+ }
+}
+
+.form__group-fields {
+ display: grid;
+ gap: 50px;
+ grid-template-columns: 1fr 1fr 1fr;
+
+ @media (max-width: $desktop-min-width) {
+ gap: 30px;
+ }
+ @media (max-width: $desktop-mid-width) {
+ grid-template-columns: 1fr 1fr;
+ }
+ @media (max-width: $tablet-min-width) {
+ display: flex;
+ flex-direction: column;
+ }
+}
diff --git a/src/types/types.ts b/src/types/types.ts
index 4e203512..c04cb237 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -22,12 +22,12 @@ export type CardItem = {
alt: string
}
-export type ProcessItem = {
+export type ProcessFromServer = {
_id: string
title: string
description: string
image: ImageItem
- alt: string
+ alt?: string
}
export type ReviewItem = {
@@ -36,12 +36,10 @@ export type ReviewItem = {
stars: number
name: string
review: string
-}
-
-export type FaqItem = {
- id: number
- question: string
- answer: string
+ photos?: {
+ thumb: string
+ full: string
+ }[]
}
export type SlideItem = {
@@ -64,7 +62,16 @@ export type ImageItem = {
height: string
}
-export type MainInfo = {
+export type AssetItem = {
+ _id: string
+ path: string
+ title?: string
+ mime?: string
+ type?: string
+ size?: number
+}
+
+export type MainInfoFromServer = {
title: string
description: string
logo: ImageItem
@@ -77,10 +84,12 @@ export type MainInfo = {
whatsapp: string
vk: string
max: string
+ agreement: AssetItem
+ policy: AssetItem
}
-export type AdvantageItem = {
- _id: number
+export type AdvantageFromServer = {
+ _id: string
title: string
description: string
icon: ImageItem
@@ -131,6 +140,7 @@ export type ReviewFromServer = {
review: string
stars: number
date: string
+ photos?: ImageItem[]
}
export type MasterFromServer = {
@@ -192,3 +202,9 @@ export type MainSliderFromServer = {
link?: string
image: ImageItem
}
+
+export type FormState = {
+ success: boolean
+ message: string
+ errors?: Record
+}