diff --git a/backend/pb_migrations/1776610409_created_consultations.js b/backend/pb_migrations/1776610409_created_consultations.js new file mode 100644 index 0000000..d1137cf --- /dev/null +++ b/backend/pb_migrations/1776610409_created_consultations.js @@ -0,0 +1,113 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3785202386", + "max": 0, + "min": 0, + "name": "service", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3441013282", + "indexes": [], + "listRule": null, + "name": "consultations", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3441013282"); + + return app.delete(collection); +}) diff --git a/backend/pb_migrations/1776610679_updated_consultations.js b/backend/pb_migrations/1776610679_updated_consultations.js new file mode 100644 index 0000000..7feb0bb --- /dev/null +++ b/backend/pb_migrations/1776610679_updated_consultations.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3441013282") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3441013282") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "updateRule": null + }, collection) + + return app.save(collection) +}) diff --git a/frontend/src/components/base/ConsultationModal.astro b/frontend/src/components/base/ConsultationModal.astro index 93e3785..9108900 100644 --- a/frontend/src/components/base/ConsultationModal.astro +++ b/frontend/src/components/base/ConsultationModal.astro @@ -69,6 +69,7 @@ const title = 'Бесплатная консультация'; modal.classList.add('active'); modal.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; + startAutoCloseTimer(); } function closeModal() { @@ -76,6 +77,24 @@ const title = 'Бесплатная консультация'; modal.classList.remove('active'); modal.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; + resetAutoCloseTimer(); + } + + // Таймер для автозакрытия + let autoCloseTimer: ReturnType | null = null; + + function startAutoCloseTimer() { + if (autoCloseTimer) clearTimeout(autoCloseTimer); + autoCloseTimer = setTimeout(() => { + closeModal(); + }, 5000); + } + + function resetAutoCloseTimer() { + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + autoCloseTimer = null; + } } // Открытие по кастомному событию @@ -119,7 +138,7 @@ const title = 'Бесплатная консультация'; const phone = formData.get('phone'); if (!name || !phone) { - alert('Пожалуйста, заполните имя и телефон'); + (window as any).toast?.show('Пожалуйста, заполните имя и телефон', 'error', 3000); return; } @@ -133,18 +152,22 @@ const title = 'Бесплатная консультация'; const result = await response.json(); if (result.success) { - alert('Спасибо! Мы свяжемся с вами в течение 15 минут.'); + (window as any).toast?.show('Спасибо! Мы свяжемся с вами в течение 15 минут.', 'success', 4000); form.reset(); closeModal(); } else { - alert('Ошибка: ' + result.error); + (window as any).toast?.show(result.error || 'Ошибка отправки', 'error', 4000); } } catch (error) { console.error('Error:', error); - alert('Ошибка отправки. Попробуйте позже.'); + (window as any).toast?.show('Ошибка отправки. Попробуйте позже.', 'error', 4000); } }); + // Сброс таймера при активности пользователя + modal?.addEventListener('mousemove', resetAutoCloseTimer); + modal?.addEventListener('click', resetAutoCloseTimer); + // Для Astro View Transitions document.addEventListener('astro:page-load', () => { // Переинициализация при навигации diff --git a/frontend/src/pages/api/consultation.ts b/frontend/src/pages/api/consultation.ts index 961178d..1d604ab 100644 --- a/frontend/src/pages/api/consultation.ts +++ b/frontend/src/pages/api/consultation.ts @@ -1,5 +1,17 @@ import type { APIRoute } from 'astro'; -import { pb } from '../../lib/pb'; +import nodemailer from 'nodemailer'; + +const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://localhost:8090'; +const SMTP_HOST = import.meta.env.SMTP_HOST || 'localhost'; +const SMTP_PORT = import.meta.env.SMTP_PORT || '1025'; +const NOTIFY_EMAIL = import.meta.env.NOTIFY_EMAIL || 'info@avtourist.ru'; + +const transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: parseInt(SMTP_PORT), + secure: false, + ignoreTLS: true, +}); const RATE_LIMIT_WINDOW = 60 * 1000; const MAX_REQUESTS = 3; @@ -37,55 +49,113 @@ export const POST: APIRoute = async ({ request }) => { return new Response(JSON.stringify({ success: false, error: 'Слишком много запросов. Попробуйте позже.' - }), { status: 429 }); + }), { status: 429, headers: { 'Content-Type': 'application/json' } }); } try { const data = await request.json(); - const { name, phone, service, website } = data; + const { name, phone, website } = data; if (website) { return new Response(JSON.stringify({ success: false, error: 'Спам обнаружен' - }), { status: 400 }); + }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } if (!name || !phone) { return new Response(JSON.stringify({ success: false, error: 'Имя и телефон обязательны' - }), { status: 400 }); + }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } if (name.length < 2 || name.length > 100) { return new Response(JSON.stringify({ success: false, error: 'Некорректное имя' - }), { status: 400 }); + }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } if (!validatePhone(phone)) { return new Response(JSON.stringify({ success: false, error: 'Некорректный номер телефона' - }), { status: 400 }); + }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } - const record = await pb.collection('consultations').create({ - name: name.trim(), - phone: phone.replace(/\D/g, ''), - service: service || '', - status: 'new', - created_at: new Date().toISOString(), - }); + const cleanPhone = phone.replace(/\D/g, ''); + + const response = await fetch( + `${POCKETBASE_URL}/api/collections/consultations/records`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name.trim(), + phone: cleanPhone, + status: 'new', + }), + } + ); + + const result = await response.json(); + + if (!response.ok) { + return new Response(JSON.stringify({ + success: false, + error: result.message || 'Ошибка при отправке заявки' + }), { status: response.status, headers: { 'Content-Type': 'application/json' } }); + } + + const emailHtml = ` + + + + + + + + Новая заявка на консультацию! + + + Имя: + ${name} + + + Телефон: + ${phone} + + + Дата: + ${new Date().toLocaleString('ru-RU')} + + + + + + `.trim(); + + try { + await transporter.sendMail({ + from: 'avtourist@surgut.ru', + to: NOTIFY_EMAIL, + subject: `Новая заявка от ${name}`, + html: emailHtml, + }); + console.log('Email notification sent'); + } catch (emailError) { + console.error('Email send error:', emailError); + } return new Response(JSON.stringify({ success: true, message: 'Заявка отправлена! Мы свяжемся с вами в течение 15 минут.', - id: record.id - }), { status: 201 }); + id: result.id + }), { status: 201, headers: { 'Content-Type': 'application/json' } }); } catch (error: any) { console.error('Consultation error:', error); @@ -93,6 +163,6 @@ export const POST: APIRoute = async ({ request }) => { return new Response(JSON.stringify({ success: false, error: error.message || 'Ошибка при отправке заявки' - }), { status: 400 }); + }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } }; \ No newline at end of file