feat: reply email to guest, QR code reviews per room

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 10:23:54 +03:00
parent 67c47a6d01
commit 89e919bd8d
5 changed files with 503 additions and 10 deletions

View File

@@ -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<void> {
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 = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:${brandColor};padding:28px 40px;text-align:center;">
<h1 style="color:#ffffff;margin:0;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${hotelName}</h1>
</div>
<div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 16px;font-weight:600;">Ответ на ваш отзыв</h2>
<p style="color:#334155;line-height:1.7;margin:0 0 20px;font-size:15px;">${greeting}</p>
<p style="color:#334155;line-height:1.7;margin:0 0 20px;font-size:15px;">
Спасибо, что поделились своим мнением о <strong>${hotelName}</strong>. Мы получили ваш отзыв ${stars} и хотим ответить.
</p>
<div style="background:#f8fafc;border-left:4px solid ${brandColor};border-radius:0 10px 10px 0;padding:16px 20px;margin:0 0 28px;">
<p style="color:#334155;font-size:14px;line-height:1.7;margin:0;font-style:italic;">${replyText}</p>
</div>
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
Будем рады видеть вас снова!
</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
<p style="color:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
© 2026 ${hotelName} · <a href="${siteUrl}" style="color:#6366f1;text-decoration:none;">hotelsync.ru</a>
</p>
</div>
</div>
</body>
</html>`
const text = `${hotelName} — ответ на ваш отзыв\n\n${greeting}\n\nСпасибо за ваш отзыв. Ответ от ${hotelName}:\n\n"${replyText}"\n\удем рады видеть вас снова!\n\n© 2026 ${hotelName}`
await transporter.sendMail({
from: `"${hotelName}" <${fromAddr()}>`,
to,
subject: `${hotelName} — ответ на ваш отзыв`,
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
})
}

View File

@@ -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<SlugRoomParam>(
'/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<string, unknown> = {}
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<SlugRoomParam & { Body: { rating: number; text?: string } }>(
'/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 15' })
}
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