Skip to content
Merged

Dev #34

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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
248 changes: 177 additions & 71 deletions src/actions/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
}
import {
reviewFormSchema,
applicationFormSchema,
messageFormSchema,
zodErrors,
ReviewFormData,
ApplicationFormData,
MessageFormData,
} from '@/lib/schemas'
import type { FormState } from '@/types/types'

/**
* Получение IP адреса
Expand All @@ -27,22 +31,6 @@ async function getClientIp(): Promise<string> {
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)
}

/**
* Отправка формы отзывов
*/
Expand All @@ -54,64 +42,85 @@ 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<string, string> = {}
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,
message: 'Ошибка проверки капчи. Попробуйте еще раз.',
}
}

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,
})
Expand All @@ -132,7 +141,7 @@ export async function submitReview(
}

/**
* Отправка формы обратной связи
* Отправка формы заказа
*/
export async function submitApplication(
prevState: FormState | null,
Expand All @@ -142,68 +151,165 @@ 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<string, string> = {}
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<FormState> {
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,
message: 'Ошибка проверки капчи. Попробуйте еще раз.',
}
}

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,
})

if (!result) {
return {
success: false,
message: 'Ошибка при отправке заявки. Попробуйте позже.',
message: 'Ошибка при отправке сообщения. Попробуйте позже.',
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/api/content/collection/[model]/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading