feat: guest email field on QR review form for reply
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:
4
backend/migrations/091_reviews_guest_email.sql
Normal file
4
backend/migrations/091_reviews_guest_email.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 091 — Add guest_email to reviews (for QR anonymous reviews)
|
||||||
|
|
||||||
|
ALTER TABLE reviews
|
||||||
|
ADD COLUMN IF NOT EXISTS guest_email VARCHAR(255);
|
||||||
@@ -38,7 +38,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT r.id, r.booking_id, r.guest_id, r.guest_name, r.source,
|
`SELECT r.id, r.booking_id, r.guest_id, r.guest_name, r.guest_email, r.source,
|
||||||
r.rating, r.text, r.reply, r.replied_at, r.is_public, r.rejected_at,
|
r.rating, r.text, r.reply, r.replied_at, r.is_public, r.rejected_at,
|
||||||
r.created_at, r.updated_at,
|
r.created_at, r.updated_at,
|
||||||
b.room_id,
|
b.room_id,
|
||||||
@@ -94,8 +94,8 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// Send email to guest when manager adds a reply
|
// Send email to guest when manager adds a reply
|
||||||
if (replyText) {
|
if (replyText) {
|
||||||
// Determine email: from booking (direct reviews) or manually entered (QR reviews)
|
// Determine email: manager override → review's own guest_email (QR) → booking email
|
||||||
let emailTo: string | null = guestEmail?.trim() || null
|
let emailTo: string | null = guestEmail?.trim() || (rows[0].guest_email as string | null) || null
|
||||||
let guestFirstName = ''
|
let guestFirstName = ''
|
||||||
|
|
||||||
if (!emailTo && rows[0].booking_id) {
|
if (!emailTo && rows[0].booking_id) {
|
||||||
@@ -329,11 +329,11 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── POST /api/public/review-qr/:slug/:room ────────────────────────────────
|
// ── POST /api/public/review-qr/:slug/:room ────────────────────────────────
|
||||||
fastify.post<SlugRoomParam & { Body: { rating: number; text?: string } }>(
|
fastify.post<SlugRoomParam & { Body: { rating: number; text?: string; email?: string } }>(
|
||||||
'/api/public/review-qr/:slug/:room',
|
'/api/public/review-qr/:slug/:room',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { slug, room } = request.params
|
const { slug, room } = request.params
|
||||||
const { rating, text } = request.body
|
const { rating, text, email } = request.body
|
||||||
|
|
||||||
if (!rating || rating < 1 || rating > 5) {
|
if (!rating || rating < 1 || rating > 5) {
|
||||||
return reply.code(400).send({ error: 'Rating must be 1–5' })
|
return reply.code(400).send({ error: 'Rating must be 1–5' })
|
||||||
@@ -354,11 +354,12 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO reviews
|
`INSERT INTO reviews
|
||||||
(hotel_id, booking_id, guest_id, guest_name, source, rating, text, is_public)
|
(hotel_id, booking_id, guest_id, guest_name, guest_email, source, rating, text, is_public)
|
||||||
VALUES ($1, NULL, NULL, $2, 'qr', $3, $4, $5)`,
|
VALUES ($1, NULL, NULL, $2, $3, 'qr', $4, $5, $6)`,
|
||||||
[
|
[
|
||||||
hotel.id,
|
hotel.id,
|
||||||
roomRows[0] ? `Номер ${room}` : `QR (номер ${room})`,
|
roomRows[0] ? `Номер ${room}` : `QR (номер ${room})`,
|
||||||
|
email?.trim() || null,
|
||||||
rating,
|
rating,
|
||||||
text ?? null,
|
text ?? null,
|
||||||
rating >= 4,
|
rating >= 4,
|
||||||
|
|||||||
@@ -1980,6 +1980,7 @@ export interface ReviewApi {
|
|||||||
bookingId: string | null
|
bookingId: string | null
|
||||||
guestId: string | null
|
guestId: string | null
|
||||||
guestName: string
|
guestName: string
|
||||||
|
guestEmail: string | null
|
||||||
source: string
|
source: string
|
||||||
rating: number
|
rating: number
|
||||||
text: string | null
|
text: string | null
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function GuestReviewQrPage() {
|
|||||||
const [rating, setRating] = useState(0)
|
const [rating, setRating] = useState(0)
|
||||||
const [hover, setHover] = useState(0)
|
const [hover, setHover] = useState(0)
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export function GuestReviewQrPage() {
|
|||||||
await fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`, {
|
await fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ rating, text: text.trim() || undefined }),
|
body: JSON.stringify({ rating, text: text.trim() || undefined, email: email.trim() || undefined }),
|
||||||
})
|
})
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -143,6 +144,16 @@ export function GuestReviewQrPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rating > 0 && (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="w-full border border-slate-200 rounded-2xl p-3.5 text-sm text-slate-700 bg-slate-50 focus:outline-none focus:border-blue-400 transition-colors placeholder:text-slate-400"
|
||||||
|
placeholder="Ваш email для ответа (необязательно)"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={rating === 0 || submitting}
|
disabled={rating === 0 || submitting}
|
||||||
|
|||||||
@@ -923,31 +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">
|
||||||
{review.source === 'qr' && (
|
{review.source === 'qr' && !review.guestEmail && (
|
||||||
<input
|
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 rounded-lg">
|
||||||
type="email"
|
Гость не указал email — ответ сохранится как внутренняя заметка
|
||||||
className="input text-sm w-full"
|
</p>
|
||||||
placeholder="Email гостя (необязательно — для отправки ответа)"
|
)}
|
||||||
value={replyEmail}
|
{review.source === 'qr' && review.guestEmail && (
|
||||||
onChange={e => setReplyEmail(e.target.value)}
|
<p className="text-xs text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-3 py-2 rounded-lg">
|
||||||
autoFocus
|
Ответ отправится на: {review.guestEmail}
|
||||||
/>
|
</p>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
className="input resize-none text-sm w-full" rows={2}
|
className="input resize-none text-sm w-full" rows={2}
|
||||||
placeholder={review.source === 'qr'
|
placeholder={review.source === 'qr' && !review.guestEmail
|
||||||
? replyEmail ? 'Ответ отправится на указанный email...' : 'Внутренняя заметка (без email — не отправится гостю)...'
|
? 'Внутренняя заметка (гость не оставил email)...'
|
||||||
: 'Личный ответ гостю (отправится по email)...'}
|
: 'Личный ответ гостю (отправится по email)...'}
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={e => setReplyText(e.target.value)}
|
onChange={e => setReplyText(e.target.value)}
|
||||||
autoFocus={review.source !== 'qr'}
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => sendReply(review.id, review.source === 'qr')}
|
onClick={() => sendReply(review.id, false)}
|
||||||
className="btn-primary text-xs py-1.5"
|
className="btn-primary text-xs py-1.5"
|
||||||
>
|
>
|
||||||
{review.source === 'qr' && replyEmail ? 'Отправить на email' : review.source === 'qr' ? 'Сохранить заметку' : 'Отправить гостю'}
|
{review.source === 'qr' && !review.guestEmail
|
||||||
|
? 'Сохранить заметку'
|
||||||
|
: 'Отправить гостю'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setReplyId(null); setReplyEmail('') }} className="btn-secondary text-xs py-1.5">Отмена</button>
|
<button onClick={() => { setReplyId(null); setReplyEmail('') }} className="btn-secondary text-xs py-1.5">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user