feat: email field for QR review replies, internal note if no email
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── PATCH /api/hotels/:slug/reviews/:id ───────────────────────────────────
|
// ── PATCH /api/hotels/:slug/reviews/:id ───────────────────────────────────
|
||||||
fastify.patch<SlugIdParam & { Body: { reply?: string; isPublic?: boolean; rejected?: boolean } }>(
|
fastify.patch<SlugIdParam & { Body: { reply?: string; isPublic?: boolean; rejected?: boolean; guestEmail?: string } }>(
|
||||||
'/api/hotels/:slug/reviews/:id',
|
'/api/hotels/:slug/reviews/:id',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -67,7 +67,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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 updates: string[] = []
|
||||||
const vals: unknown[] = [id, hotelId]
|
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' })
|
if (!rows[0]) return reply.code(404).send({ error: 'Review not found' })
|
||||||
|
|
||||||
// Send email to guest when manager adds a reply
|
// Send email to guest when manager adds a reply
|
||||||
if (replyText && rows[0].booking_id) {
|
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<{
|
const { rows: bRows } = await db.query<{
|
||||||
guest_email: string | null; guest_name: string | null
|
guest_email: string | null; guest_name: string | null
|
||||||
}>(
|
}>(
|
||||||
`SELECT guest_email, guest_name FROM bookings WHERE id = $1`,
|
`SELECT guest_email, guest_name FROM bookings WHERE id = $1`,
|
||||||
[rows[0].booking_id],
|
[rows[0].booking_id],
|
||||||
)
|
)
|
||||||
const guestEmail = bRows[0]?.guest_email
|
emailTo = bRows[0]?.guest_email ?? null
|
||||||
if (guestEmail) {
|
guestFirstName = extractFirstName(bRows[0]?.guest_name ?? null)
|
||||||
const { rows: hRows } = await db.query<{ name: string }>(
|
}
|
||||||
|
|
||||||
|
if (emailTo) {
|
||||||
|
const { rows: hRows } = await db.query<{ name: string; color: string }>(
|
||||||
`SELECT h.name,
|
`SELECT h.name,
|
||||||
COALESCE((SELECT value #>> '{}' FROM hotel_settings
|
COALESCE((SELECT value #>> '{}' FROM hotel_settings
|
||||||
WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb') AS color
|
WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb') AS color
|
||||||
@@ -110,10 +118,10 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
sendReviewReplyEmail({
|
sendReviewReplyEmail({
|
||||||
to: guestEmail,
|
to: emailTo,
|
||||||
guestFirstName: extractFirstName(bRows[0]?.guest_name ?? null),
|
guestFirstName,
|
||||||
hotelName: (hRows[0] as unknown as { name: string; color: string })?.name ?? '',
|
hotelName: hRows[0]?.name ?? '',
|
||||||
brandColor: (hRows[0] as unknown as { name: string; color: string })?.color ?? '#2563eb',
|
brandColor: hRows[0]?.color ?? '#2563eb',
|
||||||
replyText,
|
replyText,
|
||||||
originalRating: rows[0].rating,
|
originalRating: rows[0].rating,
|
||||||
}).catch(err => console.error('[reviews] reply email error:', err))
|
}).catch(err => console.error('[reviews] reply email error:', err))
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ export const api = {
|
|||||||
list: (slug: string) =>
|
list: (slug: string) =>
|
||||||
req<ReviewApi[]>('GET', `/api/hotels/${slug}/reviews`),
|
req<ReviewApi[]>('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<ReviewApi>('PATCH', `/api/hotels/${slug}/reviews/${id}`, data),
|
req<ReviewApi>('PATCH', `/api/hotels/${slug}/reviews/${id}`, data),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export function ReviewsPage() {
|
|||||||
const [tab, setTab] = useState<Tab>('pending')
|
const [tab, setTab] = useState<Tab>('pending')
|
||||||
const [replyId, setReplyId] = useState<string | null>(null)
|
const [replyId, setReplyId] = useState<string | null>(null)
|
||||||
const [replyText, setReplyText] = useState('')
|
const [replyText, setReplyText] = useState('')
|
||||||
|
const [replyEmail, setReplyEmail] = useState('')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -189,10 +190,11 @@ export function ReviewsPage() {
|
|||||||
|
|
||||||
// ── Review actions ──────────────────────────────────────────────────────────
|
// ── Review actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const sendReply = async (id: string) => {
|
const sendReply = async (id: string, isQr: boolean) => {
|
||||||
if (!replyText.trim() || !slug) return
|
if (!replyText.trim() || !slug) return
|
||||||
// Negative review stays private (isPublic=false), just saves the reply
|
const data: { reply: string; guestEmail?: string } = { reply: replyText.trim() }
|
||||||
const updated = await api.reviews.update(slug, id, { 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))
|
setReviews(prev => prev.map(r => r.id === id ? updated : r))
|
||||||
setReplyId(null)
|
setReplyId(null)
|
||||||
setReplyText('')
|
setReplyText('')
|
||||||
@@ -921,16 +923,33 @@ export function ReviewsPage() {
|
|||||||
)}
|
)}
|
||||||
{replyId === review.id && (
|
{replyId === review.id && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<textarea
|
{review.source === 'qr' && (
|
||||||
className="input resize-none text-sm w-full" rows={2}
|
<input
|
||||||
placeholder="Личный ответ гостю (отправится по email)..."
|
type="email"
|
||||||
value={replyText}
|
className="input text-sm w-full"
|
||||||
onChange={e => setReplyText(e.target.value)}
|
placeholder="Email гостя (необязательно — для отправки ответа)"
|
||||||
|
value={replyEmail}
|
||||||
|
onChange={e => setReplyEmail(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="input resize-none text-sm w-full" rows={2}
|
||||||
|
placeholder={review.source === 'qr'
|
||||||
|
? replyEmail ? 'Ответ отправится на указанный email...' : 'Внутренняя заметка (без email — не отправится гостю)...'
|
||||||
|
: 'Личный ответ гостю (отправится по email)...'}
|
||||||
|
value={replyText}
|
||||||
|
onChange={e => setReplyText(e.target.value)}
|
||||||
|
autoFocus={review.source !== 'qr'}
|
||||||
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => sendReply(review.id)} className="btn-primary text-xs py-1.5">Отправить гостю</button>
|
<button
|
||||||
<button onClick={() => setReplyId(null)} className="btn-secondary text-xs py-1.5">Отмена</button>
|
onClick={() => sendReply(review.id, review.source === 'qr')}
|
||||||
|
className="btn-primary text-xs py-1.5"
|
||||||
|
>
|
||||||
|
{review.source === 'qr' && replyEmail ? 'Отправить на email' : review.source === 'qr' ? 'Сохранить заметку' : 'Отправить гостю'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setReplyId(null); setReplyEmail('') }} className="btn-secondary text-xs py-1.5">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -939,10 +958,10 @@ export function ReviewsPage() {
|
|||||||
{!review.isPublic && !review.reply && !review.rejectedAt && replyId !== review.id && (
|
{!review.isPublic && !review.reply && !review.rejectedAt && replyId !== review.id && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setReplyId(review.id); setReplyText('') }}
|
onClick={() => { setReplyId(review.id); setReplyText(''); setReplyEmail('') }}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
|
||||||
>
|
>
|
||||||
<MessageSquare size={12} />Ответить гостю
|
<MessageSquare size={12} />{review.source === 'qr' ? 'Добавить заметку / ответить' : 'Ответить гостю'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => reject(review.id)}
|
onClick={() => reject(review.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user