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()} + > + + + {item.isImage && item.preview ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ + {item.name} +
+ )} +
+ ))} + + {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 || '—' : 'Размер/категория'} -
- {state.message} -
- )} - - {(!state || !state.success) && ( - <> - - - - - - - - - - - - - )} - -
- + ) } 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) && ( + <> + + + + + + + + +
+ + + +
+ + )} + +
+
+ ) +} 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 && ( +
+
+ + + +
+
+ )} + + {prices.length > 0 && ( +
+
+ + + +
+
+ )} + +
+ Тип золочения: + +
+ {( + [ + { value: 'without_gold', label: 'Без золота' }, + { value: 'all', label: 'Золотой фон и нимб' }, + { value: 'halo', label: 'Только нимб' }, + ] as const + ).map((opt, idx) => ( + + ))} + +
+
+ + +
+
+ +
+

Контактные данные

+ +
+ + + + + +
+
+ + - - - - )} - -
-
+ ) } 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) && ( + <> + + + + + + + + + + + + +
+ + + +
+ + )} + +
+ + ) +} 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 ( + + ) +} 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) => ( + + ))} +
+ ) +} 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 +}