From 15f038342b9f0ea7e123d4482e4e99c7101683a8 Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Tue, 12 May 2026 13:59:20 +0300 Subject: [PATCH 1/3] Update forms, add pagination, fix code style --- package-lock.json | 12 +- package.json | 3 +- src/actions/forms.ts | 143 ++++----- .../content/collection/[model]/count/route.ts | 50 +++ .../api/content/collection/[model]/route.ts | 9 +- src/app/categories/page.tsx | 39 ++- src/app/in-stock/page.tsx | 35 ++- src/app/news/page.tsx | 39 ++- src/app/reviews/page.tsx | 65 +++- src/app/works/page.tsx | 31 +- .../AnimationObserver/AnimationObserver.tsx | 6 +- src/components/Categories/Categories.tsx | 30 +- src/components/DropZone/DropZone.module.scss | 228 ++++++++++++++ src/components/DropZone/DropZone.tsx | 297 ++++++++++++++++++ .../FormCalculation/FormCalculationClient.tsx | 87 +++-- .../Forms/FormContacts/FormContacts.tsx | 129 ++++++-- .../Forms/FormReviews/FormReviews.tsx | 242 ++++++++++++-- src/components/Main/Reviews/Reviews.tsx | 32 +- src/components/Menu/Menu.tsx | 4 +- src/components/News/News.tsx | 25 +- .../Pagination/Pagination.module.scss | 100 ++++++ src/components/Pagination/Pagination.tsx | 104 ++++++ src/components/Reviews/Reviews.tsx | 33 +- src/components/ReviewsList/ReviewPhoto.tsx | 90 ++++++ .../ReviewsList/ReviewsList.module.scss | 89 +++++- src/components/ReviewsList/ReviewsList.tsx | 46 ++- src/components/ScrollButton/ScrollButton.tsx | 6 +- src/components/Works/Works.tsx | 7 +- src/components/YandexMap/YandexMap.tsx | 4 +- src/const/const.ts | 5 +- src/lib/CockpitAPI.ts | 77 +++-- src/lib/api-client.ts | 65 +++- src/lib/schemas.ts | 63 ++++ src/styles/blocks/button.scss | 12 +- src/styles/globals.scss | 4 + src/styles/modules/form.module.scss | 143 +++++++-- src/types/types.ts | 5 + 37 files changed, 1992 insertions(+), 367 deletions(-) create mode 100644 src/app/api/content/collection/[model]/count/route.ts create mode 100644 src/components/DropZone/DropZone.module.scss create mode 100644 src/components/DropZone/DropZone.tsx create mode 100644 src/components/Pagination/Pagination.module.scss create mode 100644 src/components/Pagination/Pagination.tsx create mode 100644 src/components/ReviewsList/ReviewPhoto.tsx create mode 100644 src/lib/schemas.ts 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..e3db240c 100644 --- a/src/actions/forms.ts +++ b/src/actions/forms.ts @@ -5,6 +5,13 @@ import { revalidatePath } from 'next/cache' import cockpit from '@/lib/CockpitAPI' import { verifyCaptcha } from '@/lib/captcha' import { formRateLimiter } from '@/lib/rate-limiter' +import { + reviewFormSchema, + applicationFormSchema, + zodErrors, + ReviewFormData, + ApplicationFormData, +} from '@/lib/schemas' export type FormState = { success: boolean @@ -27,22 +34,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 +45,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 +103,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, }) @@ -142,48 +154,34 @@ 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 errors: Record = {} - - if (!name || name.length < 2) { - errors.name = 'Имя должно содержать минимум 2 символа' - } - - if (phone && !isValidPhone(phone)) { - errors.phone = 'Некорректный формат телефона' - } - - if (!email) { - errors.email = 'Email обязателен для заполнения' - } else if (!isValidEmail(email)) { - errors.email = 'Некорректный формат email' - } - - if (!agreed) { - errors.agreement = 'Необходимо согласие на обработку данных' - } + 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() ?? '', + agreement: formData.get('agreement') === 'on', + }) - if (Object.keys(errors).length > 0) { + if (!parsed.success) { return { success: false, - message: 'Пожалуйста, исправьте ошибки в форме', - errors, + message: 'Пожалуйста, заполните форму корректными значениями', + errors: zodErrors(parsed.error), } } + const { name, phone, email, message }: ApplicationFormData = parsed.data + + const captchaToken = formData.get('smart-token')?.toString() || '' const captchaValid = await verifyCaptcha(captchaToken, clientIp) + if (!captchaValid) { return { success: false, @@ -191,11 +189,14 @@ export async function submitApplication( } } + const date = new Date().toISOString().slice(0, 10) + const result = await cockpit.createItem('applications', { name, phone: phone || null, email, message: message || null, + date, _state: false, ip: clientIp, }) 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/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/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/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/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..6529df1d --- /dev/null +++ b/src/components/DropZone/DropZone.tsx @@ -0,0 +1,297 @@ +'use client' + +import { + JSX, + ChangeEvent, + DragEvent, + forwardRef, + useImperativeHandle, + useRef, + useState, +} from 'react' +import clsx from 'clsx' +import dropZoneStyles from './DropZone.module.scss' + +const DEFAULT_MAX_FILES = 5 +const DEFAULT_MAX_SIZE_MB = 5 + +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/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 || '—' : 'Размер/категория'} -
handleBlur('name', evt.target.value)} /> - {state?.errors?.name && ( - {state.errors.name} - )} + + {getError('name')} + -
+ + + + + {getError('stars')} + + -