From 89e919bd8d2bb17eb3e3d4bf6d3db668b8755ee1 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 21 Apr 2026 10:23:54 +0300 Subject: [PATCH] feat: reply email to guest, QR code reviews per room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH reviews/:id now sends email to guest when manager adds reply (sendReviewReplyEmail: hotel-branded, shows original rating, manager's text) - New public endpoints: GET/POST /api/public/review-qr/:slug/:room (anonymous QR review, no token/booking required, source='qr') - New page GuestReviewQrPage at /review-qr/:slug/:room - ReviewsPage: new QR-коды tab — grid of QR codes per room with download SVG + print Co-Authored-By: Claude Sonnet 4.6 --- backend/src/email.ts | 60 +++++++++ backend/src/routes/reviews.ts | 130 +++++++++++++++++++- src/App.tsx | 2 + src/pages/GuestReviewQrPage.tsx | 209 ++++++++++++++++++++++++++++++++ src/pages/ReviewsPage.tsx | 112 +++++++++++++++-- 5 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 src/pages/GuestReviewQrPage.tsx diff --git a/backend/src/email.ts b/backend/src/email.ts index e70af38..4334fbc 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -304,3 +304,63 @@ export async function sendReviewRequestEmail(params: { }, }) } + +export async function sendReviewReplyEmail(params: { + to: string + guestFirstName: string + hotelName: string + brandColor: string + replyText: string + originalRating: number +}): Promise { + const { to, guestFirstName, hotelName, brandColor, replyText, originalRating } = params + const siteUrl = process.env.SITE_URL ?? 'https://hotelsync.ru' + const greeting = guestFirstName ? `Здравствуйте, ${guestFirstName}!` : 'Здравствуйте!' + const stars = '⭐'.repeat(originalRating) + + const html = ` + + + + + + +
+
+

${hotelName}

+
+
+

Ответ на ваш отзыв

+

${greeting}

+

+ Спасибо, что поделились своим мнением о ${hotelName}. Мы получили ваш отзыв ${stars} и хотим ответить. +

+
+

${replyText}

+
+

+ Будем рады видеть вас снова! +

+
+

+ © 2026 ${hotelName} · hotelsync.ru +

+
+
+ +` + + const text = `${hotelName} — ответ на ваш отзыв\n\n${greeting}\n\nСпасибо за ваш отзыв. Ответ от ${hotelName}:\n\n"${replyText}"\n\nБудем рады видеть вас снова!\n\n© 2026 ${hotelName}` + + await transporter.sendMail({ + from: `"${hotelName}" <${fromAddr()}>`, + to, + subject: `${hotelName} — ответ на ваш отзыв`, + html, + text, + headers: { + 'List-Unsubscribe': unsubscribeHeader, + 'Content-Language': 'ru', + }, + }) +} diff --git a/backend/src/routes/reviews.ts b/backend/src/routes/reviews.ts index a7322a5..2241fa3 100644 --- a/backend/src/routes/reviews.ts +++ b/backend/src/routes/reviews.ts @@ -1,9 +1,17 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { sendReviewReplyEmail } from '../email' -type SlugParam = { Params: { slug: string } } -type SlugIdParam = { Params: { slug: string; id: string } } -type TokenParam = { Params: { token: string } } +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } +type TokenParam = { Params: { token: string } } +type SlugRoomParam = { Params: { slug: string; room: string } } + +function extractFirstName(fullName: string | null): string { + if (!fullName) return '' + const parts = fullName.trim().split(/\s+/) + return parts.length >= 2 ? parts[1] : '' +} const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug @@ -78,6 +86,35 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => { vals, ) if (!rows[0]) return reply.code(404).send({ error: 'Review not found' }) + + // Send email to guest when manager adds a reply + if (replyText && rows[0].booking_id) { + const { rows: bRows } = await db.query<{ + guest_email: string | null; guest_name: string | null + }>( + `SELECT guest_email, guest_name FROM bookings WHERE id = $1`, + [rows[0].booking_id], + ) + const guestEmail = bRows[0]?.guest_email + if (guestEmail) { + const { rows: hRows } = await db.query<{ name: string }>( + `SELECT h.name, + COALESCE((SELECT value #>> '{}' FROM hotel_settings + WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb') AS color + FROM hotels h WHERE h.id = $1`, + [hotelId], + ) + sendReviewReplyEmail({ + to: guestEmail, + guestFirstName: extractFirstName(bRows[0]?.guest_name ?? null), + hotelName: (hRows[0] as unknown as { name: string; color: string })?.name ?? '', + brandColor: (hRows[0] as unknown as { name: string; color: string })?.color ?? '#2563eb', + replyText, + originalRating: rows[0].rating, + }).catch(err => console.error('[reviews] reply email error:', err)) + } + } + return rows[0] }, ) @@ -218,6 +255,93 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => { return { ok: true } }, ) + + // ── GET /api/public/review-qr/:slug/:room ───────────────────────────────── + // Anonymous QR review — returns hotel config, no booking required + fastify.get( + '/api/public/review-qr/:slug/:room', + async (request, reply) => { + const { slug, room } = request.params + + const { rows } = await db.query<{ id: string; name: string }>( + `SELECT id, name FROM hotels WHERE slug = $1 AND is_active = true`, + [slug], + ) + const hotel = rows[0] + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows: settings } = await db.query( + `SELECT key, value FROM hotel_settings + WHERE hotel_id = $1 + AND key IN ( + 'review_redirect_threshold','review_platforms', + 'review_show_text_field','review_welcome_text', + 'review_brand_color' + )`, + [hotel.id], + ) + const s: Record = {} + for (const r of settings) s[r.key] = r.value + + const allPlatforms = Array.isArray(s.review_platforms) + ? (s.review_platforms as { name: string; url: string; enabled: boolean }[]) + .filter(p => p.enabled) + .map(p => ({ name: p.name, url: p.url })) + : [] + + return { + hotelName: hotel.name, + logoLetter: hotel.name[0] ?? 'О', + welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?', + color: (s.review_brand_color as string | undefined) ?? '#2563eb', + redirectThreshold: Number(s.review_redirect_threshold ?? 4), + showTextField: s.review_show_text_field !== false, + platforms: allPlatforms, + roomNumber: room, + } + }, + ) + + // ── POST /api/public/review-qr/:slug/:room ──────────────────────────────── + fastify.post( + '/api/public/review-qr/:slug/:room', + async (request, reply) => { + const { slug, room } = request.params + const { rating, text } = request.body + + if (!rating || rating < 1 || rating > 5) { + return reply.code(400).send({ error: 'Rating must be 1–5' }) + } + + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM hotels WHERE slug = $1`, + [slug], + ) + const hotel = rows[0] + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + // Find room_id by number for attribution + const { rows: roomRows } = await db.query<{ id: string }>( + `SELECT id FROM rooms WHERE hotel_id = $1 AND number = $2 LIMIT 1`, + [hotel.id, room], + ) + + await db.query( + `INSERT INTO reviews + (hotel_id, booking_id, guest_id, guest_name, source, rating, text, is_public) + VALUES ($1, NULL, NULL, $2, 'qr', $3, $4, $5)`, + [ + hotel.id, + roomRows[0] ? `Номер ${room}` : `QR (номер ${room})`, + rating, + text ?? null, + rating >= 4, + ], + ) + + return { ok: true } + }, + ) } export default reviewsRoutes diff --git a/src/App.tsx b/src/App.tsx index 0248582..ee00f13 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { LoyaltyPage } from './pages/LoyaltyPage' import { MaintenancePage } from './pages/MaintenancePage' import { DiscountsPage } from './pages/DiscountsPage' import { GuestReviewPage } from './pages/GuestReviewPage' +import { GuestReviewQrPage } from './pages/GuestReviewQrPage' import { GuestRoomServicePage } from './pages/GuestRoomServicePage' import { ResetPasswordPage } from './pages/ResetPasswordPage' import { AcceptInvitePage } from './pages/AcceptInvitePage' @@ -89,6 +90,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/GuestReviewQrPage.tsx b/src/pages/GuestReviewQrPage.tsx new file mode 100644 index 0000000..13841c2 --- /dev/null +++ b/src/pages/GuestReviewQrPage.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { Star, ExternalLink, CheckCircle2, Heart, Loader2 } from 'lucide-react' +import { cn } from '../lib/utils' + +const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru' + +interface QrConfig { + hotelName: string + logoLetter: string + welcomeText: string + color: string + redirectThreshold: number + showTextField: boolean + platforms: { name: string; url: string }[] + roomNumber: string +} + +export function GuestReviewQrPage() { + const { slug, room } = useParams<{ slug: string; room: string }>() + + const [config, setConfig] = useState(null) + const [status, setStatus] = useState<'loading' | 'ready' | 'invalid'>('loading') + const [rating, setRating] = useState(0) + const [hover, setHover] = useState(0) + const [text, setText] = useState('') + const [submitted, setSubmitted] = useState(false) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!slug || !room) { setStatus('invalid'); return } + fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`) + .then(async res => { + if (!res.ok) { setStatus('invalid'); return } + setConfig(await res.json() as QrConfig) + setStatus('ready') + }) + .catch(() => setStatus('invalid')) + }, [slug, room]) + + const handleSubmit = async () => { + if (rating === 0 || !slug || !room) return + setSubmitting(true) + try { + await fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating, text: text.trim() || undefined }), + }) + setSubmitted(true) + } catch { + setSubmitted(true) + } finally { + setSubmitting(false) + } + } + + const color = config?.color ?? '#2563eb' + const threshold = config?.redirectThreshold ?? 4 + const isPositive = rating >= threshold + + if (status === 'loading') { + return ( +
+ +
+ ) + } + + if (status === 'invalid') { + return ( +
+
+
🔗
+

Страница не найдена

+

Проверьте QR-код.

+
+
+ ) + } + + return ( +
+
+
+
+
+ + {/* Logo */} +
+ {config?.logoLetter ?? 'О'} +
+ +
+

{config?.hotelName}

+ {!submitted && ( +

{config?.welcomeText ?? 'Как вам у нас?'}

+ )} +
+ + {/* ── NOT SUBMITTED ── */} + {!submitted && ( + <> +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ + {(hover || rating) > 0 && ( +

+ {['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]} +

+ )} + + {rating > 0 && config?.showTextField && ( +