From 8ab71b1e4566a80f6ae457b05da39113dfa296a5 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 21 Apr 2026 17:37:22 +0300 Subject: [PATCH] feat: email field for QR review replies, internal note if no email Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/reviews.ts | 40 +++++++++++++++++++++-------------- src/lib/api.ts | 2 +- src/pages/ReviewsPage.tsx | 37 ++++++++++++++++++++++++-------- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/backend/src/routes/reviews.ts b/backend/src/routes/reviews.ts index d76038f..77d5230 100644 --- a/backend/src/routes/reviews.ts +++ b/backend/src/routes/reviews.ts @@ -56,7 +56,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => { ) // ── PATCH /api/hotels/:slug/reviews/:id ─────────────────────────────────── - fastify.patch( + fastify.patch( '/api/hotels/:slug/reviews/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -67,7 +67,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { reply: replyText, isPublic, rejected } = request.body + const { reply: replyText, isPublic, rejected, guestEmail } = request.body const updates: string[] = [] const vals: unknown[] = [id, hotelId] @@ -93,16 +93,24 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => { 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 }>( + if (replyText) { + // Determine email: from booking (direct reviews) or manually entered (QR reviews) + let emailTo: string | null = guestEmail?.trim() || null + let guestFirstName = '' + + if (!emailTo && 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], + ) + emailTo = bRows[0]?.guest_email ?? null + guestFirstName = extractFirstName(bRows[0]?.guest_name ?? null) + } + + if (emailTo) { + const { rows: hRows } = await db.query<{ name: string; color: string }>( `SELECT h.name, COALESCE((SELECT value #>> '{}' FROM hotel_settings WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb') AS color @@ -110,10 +118,10 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => { [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', + to: emailTo, + guestFirstName, + hotelName: hRows[0]?.name ?? '', + brandColor: hRows[0]?.color ?? '#2563eb', replyText, originalRating: rows[0].rating, }).catch(err => console.error('[reviews] reply email error:', err)) diff --git a/src/lib/api.ts b/src/lib/api.ts index 9c3d242..afa8498 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -482,7 +482,7 @@ export const api = { list: (slug: string) => req('GET', `/api/hotels/${slug}/reviews`), - update: (slug: string, id: string, data: { reply?: string; isPublic?: boolean; rejected?: boolean }) => + update: (slug: string, id: string, data: { reply?: string; isPublic?: boolean; rejected?: boolean; guestEmail?: string }) => req('PATCH', `/api/hotels/${slug}/reviews/${id}`, data), }, diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx index b1611c8..b594417 100644 --- a/src/pages/ReviewsPage.tsx +++ b/src/pages/ReviewsPage.tsx @@ -74,6 +74,7 @@ export function ReviewsPage() { const [tab, setTab] = useState('pending') const [replyId, setReplyId] = useState(null) const [replyText, setReplyText] = useState('') + const [replyEmail, setReplyEmail] = useState('') const [copied, setCopied] = useState(false) // Settings @@ -189,10 +190,11 @@ export function ReviewsPage() { // ── Review actions ────────────────────────────────────────────────────────── - const sendReply = async (id: string) => { + const sendReply = async (id: string, isQr: boolean) => { if (!replyText.trim() || !slug) return - // Negative review stays private (isPublic=false), just saves the reply - const updated = await api.reviews.update(slug, id, { reply: replyText.trim() }) + const data: { reply: string; guestEmail?: string } = { reply: replyText.trim() } + if (isQr && replyEmail.trim()) data.guestEmail = replyEmail.trim() + const updated = await api.reviews.update(slug, id, data) setReviews(prev => prev.map(r => r.id === id ? updated : r)) setReplyId(null) setReplyText('') @@ -921,16 +923,33 @@ export function ReviewsPage() { )} {replyId === review.id && (
+ {review.source === 'qr' && ( + setReplyEmail(e.target.value)} + autoFocus + /> + )}