Compare commits

..

7 Commits

Author SHA1 Message Date
31de5be873 feat: review form logo/header image customizer, fix unread msg job startup
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
- ReviewsPage: logo upload (circle/square), header bg image, show-email toggle
- GuestReviewQrPage/GuestReviewPage: render logo image, header bg image, logo shape
- Backend: return review_logo_url, review_logo_shape, review_header_image_url, show_email from both public review endpoints
- jobs.ts: run unread messages job 20s after startup (was only on interval)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:09:43 +03:00
9bda48c020 feat: guest email field on QR review form for reply
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:43:49 +03:00
8ab71b1e45 feat: email field for QR review replies, internal note if no email
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:37:22 +03:00
d83ffcb3c7 fix: notification links use app routes without slug prefix
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:31:20 +03:00
bb401095dc fix: add qr to reviews source constraint + notif type fix
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:26:20 +03:00
6165e4c67c fix: stabilize star rating layout — reserve label height, use inline scale
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:21:52 +03:00
d1948f95b9 fix: add missing notif types to frontend (new_booking, room_service, new_review, unread_messages)
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:16:12 +03:00
11 changed files with 296 additions and 81 deletions

View File

@@ -0,0 +1,7 @@
-- Migration 090 — Add 'qr' to reviews source constraint
ALTER TABLE reviews DROP CONSTRAINT IF EXISTS reviews_source_check;
ALTER TABLE reviews
ADD CONSTRAINT reviews_source_check
CHECK (source IN ('direct', 'booking_com', 'airbnb', 'google', 'tripadvisor', 'qr'));

View 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);

View File

@@ -109,7 +109,7 @@ async function runAutoCancelNoShows(): Promise<void> {
title: 'Гость не заехал — бронь отменена',
body: `${b.guest_name ?? 'Гость'} не заехал вовремя. Бронирование автоматически отмечено как неявка.`,
bookingId: b.id,
link: `/${hotel.slug}/bookings`,
link: '/bookings',
}).catch(() => {})
}
}
@@ -192,7 +192,7 @@ async function runAutoCheckouts(): Promise<void> {
title: 'Автоматическое выселение',
body: `${b.guest_name ?? 'Гость'} выселен автоматически по истечении времени проживания.`,
bookingId: b.id,
link: `/${hotel.slug}/calendar`,
link: '/calendar',
}).catch(() => {})
}
}
@@ -348,7 +348,7 @@ async function runUnreadMessagesJob(): Promise<void> {
type: 'unread_messages',
title: `Непрочитанные сообщения — ${row.room_name}`,
body,
link: `/${row.hotel_slug}/chat`,
link: '/chat',
}).catch(() => {})
void sendPushForNotification(row.hotel_id, 'unread_messages', {
@@ -399,6 +399,10 @@ export function startJobs(): void {
runPaymentExpiryJob().catch(console.error)
}, PAYMENT_EXPIRY_INTERVAL)
setTimeout(() => {
runUnreadMessagesJob().catch(console.error)
}, 20_000)
setInterval(() => {
runUnreadMessagesJob().catch(console.error)
}, UNREAD_MSG_INTERVAL)

View File

@@ -475,7 +475,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
title: `Новая бронь через сайт — ${guestName}`,
body: `${checkIn}${checkOut}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
bookingId,
link: `/${hotel.slug}/bookings`,
link: '/bookings',
})
void sendPushForNotification(hotel.id, 'new_booking', {
title: `Новая бронь через сайт — ${guestName}`,

View File

@@ -38,7 +38,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
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.created_at, r.updated_at,
b.room_id,
@@ -56,7 +56,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
)
// ── 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',
{ 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) {
if (replyText) {
// Determine email: manager override → review's own guest_email (QR) → booking email
let emailTo: string | null = guestEmail?.trim() || (rows[0].guest_email as string | null) || 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],
)
const guestEmail = bRows[0]?.guest_email
if (guestEmail) {
const { rows: hRows } = await db.query<{ name: string }>(
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))
@@ -162,7 +170,9 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
'review_redirect_threshold','review_platforms',
'review_show_text_field','review_welcome_text',
'review_brand_color','review_redirect_source',
'review_source_map'
'review_source_map','review_logo_url',
'review_logo_shape','review_header_image_url',
'review_show_email'
)`,
[row.hotel_id],
)
@@ -196,10 +206,14 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
return {
hotelName: row.hotel_name,
logoLetter: (row.hotel_name as string)[0] ?? 'О',
logoUrl: (s.review_logo_url as string | undefined) ?? null,
logoShape: (s.review_logo_shape as string | undefined) ?? 'circle',
headerImageUrl: (s.review_header_image_url as string | undefined) ?? null,
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,
showEmail: s.review_show_email !== false,
platforms,
guestName: row.guest_name ?? '',
bookingSource: bookingSrc,
@@ -261,7 +275,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
type: 'new_review',
title: `Новый отзыв — ${rating}`,
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
link: `/${hotelSlug}/reviews`,
link: '/reviews',
})
void sendPushForNotification(booking.hotel_id, 'new_review', {
title: `Новый отзыв — ${rating}`,
@@ -294,7 +308,9 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
AND key IN (
'review_redirect_threshold','review_platforms',
'review_show_text_field','review_welcome_text',
'review_brand_color'
'review_brand_color','review_logo_url',
'review_logo_shape','review_header_image_url',
'review_show_email'
)`,
[hotel.id],
)
@@ -310,10 +326,14 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
return {
hotelName: hotel.name,
logoLetter: hotel.name[0] ?? 'О',
logoUrl: (s.review_logo_url as string | undefined) ?? null,
logoShape: (s.review_logo_shape as string | undefined) ?? 'circle',
headerImageUrl: (s.review_header_image_url as string | undefined) ?? null,
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,
showEmail: s.review_show_email !== false,
platforms: allPlatforms,
roomNumber: room,
}
@@ -321,11 +341,11 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
)
// ── 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',
async (request, reply) => {
const { slug, room } = request.params
const { rating, text } = request.body
const { rating, text, email } = request.body
if (!rating || rating < 1 || rating > 5) {
return reply.code(400).send({ error: 'Rating must be 15' })
@@ -346,11 +366,12 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
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, booking_id, guest_id, guest_name, guest_email, source, rating, text, is_public)
VALUES ($1, NULL, NULL, $2, $3, 'qr', $4, $5, $6)`,
[
hotel.id,
roomRows[0] ? `Номер ${room}` : `QR (номер ${room})`,
email?.trim() || null,
rating,
text ?? null,
rating >= 4,
@@ -361,7 +382,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
type: 'new_review',
title: `Новый QR-отзыв — №${room}, ${rating}`,
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
link: `/${slug}/reviews`,
link: '/reviews',
})
void sendPushForNotification(hotel.id, 'new_review', {
title: `Новый QR-отзыв — №${room}, ${rating}`,

View File

@@ -1,7 +1,7 @@
import { Sun, Moon, Monitor, Bell, LogOut, ChevronDown, Menu,
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
Sparkles, AlertTriangle, Star, CreditCard, Info,
ArrowRight, Wrench,
ArrowRight, Wrench, MessageSquare, UtensilsCrossed,
} from 'lucide-react'
import { useTheme } from '../../contexts/ThemeContext'
import { useAuth } from '../../contexts/AuthContext'
@@ -19,6 +19,7 @@ const NOTIF_META: Record<NotifType, {
iconColor: string
}> = {
booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
new_booking: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
booking_checkin: { icon: CalendarCheck2, iconBg: 'bg-emerald-100 dark:bg-emerald-900/30', iconColor: 'text-emerald-600 dark:text-emerald-400' },
booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' },
@@ -26,10 +27,15 @@ const NOTIF_META: Record<NotifType, {
channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' },
review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' },
new_review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' },
system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' },
maintenance: { icon: Wrench, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
room_service: { icon: UtensilsCrossed, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
unread_messages: { icon: MessageSquare, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
}
const NOTIF_META_FALLBACK = NOTIF_META.system
function relativeTime(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 60) return 'только что'
@@ -131,7 +137,7 @@ function NotificationsPanel({ onClose }: { onClose: () => void }) {
) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700/60">
{shown.map(n => {
const meta = NOTIF_META[n.type]
const meta = NOTIF_META[n.type] ?? NOTIF_META_FALLBACK
const Icon = meta.icon
return (
<div

View File

@@ -4,6 +4,7 @@ import { api } from '../lib/api'
export type NotifType =
| 'booking_new'
| 'new_booking'
| 'booking_cancelled'
| 'booking_checkin'
| 'booking_checkout'
@@ -11,8 +12,11 @@ export type NotifType =
| 'channel_error'
| 'payment'
| 'review'
| 'new_review'
| 'system'
| 'maintenance'
| 'room_service'
| 'unread_messages'
export interface Notification {
id: string

View File

@@ -482,7 +482,7 @@ export const api = {
list: (slug: string) =>
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),
},
@@ -1980,6 +1980,7 @@ export interface ReviewApi {
bookingId: string | null
guestId: string | null
guestName: string
guestEmail: string | null
source: string
rating: number
text: string | null
@@ -1996,10 +1997,14 @@ export interface ReviewApi {
export interface PublicReviewConfig {
hotelName: string
logoLetter: string
logoUrl: string | null
logoShape: 'circle' | 'square'
headerImageUrl: string | null
welcomeText: string
color: string
redirectThreshold: number
showTextField: boolean
showEmail: boolean
platforms: { name: string; url: string }[]
guestName: string
bookingSource: string

View File

@@ -88,16 +88,29 @@ export function GuestReviewPage() {
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div className="h-2" style={{ backgroundColor: color }} />
<div className="px-8 py-8 text-center space-y-6">
{/* Logo */}
{/* Header area */}
<div
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
style={{ backgroundColor: color }}
className="relative flex items-center justify-center py-7"
style={config?.headerImageUrl
? { backgroundImage: `url(${config.headerImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: color + '22' }
}
>
{config?.logoLetter ?? 'О'}
{config?.headerImageUrl && <div className="absolute inset-0 bg-black/35" />}
<div
className={cn(
'relative w-20 h-20 flex items-center justify-center text-3xl font-bold text-white shadow-lg overflow-hidden',
config?.logoShape === 'square' ? 'rounded-2xl' : 'rounded-full',
)}
style={config?.logoUrl ? {} : { backgroundColor: color }}
>
{config?.logoUrl
? <img src={config.logoUrl} alt="" className="w-full h-full object-cover" />
: (config?.logoLetter ?? 'О')
}
</div>
</div>
<div className="px-8 py-6 text-center space-y-6">
<div>
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
@@ -119,7 +132,8 @@ export function GuestReviewPage() {
onMouseEnter={() => setHover(i + 1)}
onMouseLeave={() => setHover(0)}
onClick={() => setRating(i + 1)}
className="transition-transform hover:scale-110 active:scale-95"
className="transition-transform active:scale-95"
style={{ transform: (hover || rating) > i ? 'scale(1.12)' : 'scale(1)' }}
>
<Star
size={40}
@@ -134,11 +148,11 @@ export function GuestReviewPage() {
))}
</div>
{(hover || rating) > 0 && (
<p className="text-sm font-medium text-slate-600 -mt-2">
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
<p className="text-sm font-medium text-slate-600 -mt-2 h-5 text-center">
{(hover || rating) > 0
? ['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]
: ''}
</p>
)}
{rating > 0 && config?.showTextField && (
<textarea

View File

@@ -8,10 +8,14 @@ const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://ap
interface QrConfig {
hotelName: string
logoLetter: string
logoUrl: string | null
logoShape: 'circle' | 'square'
headerImageUrl: string | null
welcomeText: string
color: string
redirectThreshold: number
showTextField: boolean
showEmail: boolean
platforms: { name: string; url: string }[]
roomNumber: string
}
@@ -24,6 +28,7 @@ export function GuestReviewQrPage() {
const [rating, setRating] = useState(0)
const [hover, setHover] = useState(0)
const [text, setText] = useState('')
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
@@ -45,7 +50,7 @@ export function GuestReviewQrPage() {
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 }),
body: JSON.stringify({ rating, text: text.trim() || undefined, email: email.trim() || undefined }),
})
setSubmitted(true)
} catch {
@@ -83,16 +88,29 @@ export function GuestReviewQrPage() {
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div className="h-2" style={{ backgroundColor: color }} />
<div className="px-8 py-8 text-center space-y-6">
{/* Logo */}
{/* Header area */}
<div
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
style={{ backgroundColor: color }}
className="relative flex items-center justify-center py-7"
style={config?.headerImageUrl
? { backgroundImage: `url(${config.headerImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: color + '22' }
}
>
{config?.logoLetter ?? 'О'}
{config?.headerImageUrl && <div className="absolute inset-0 bg-black/35" />}
<div
className={cn(
'relative w-20 h-20 flex items-center justify-center text-3xl font-bold text-white shadow-lg overflow-hidden',
config?.logoShape === 'square' ? 'rounded-2xl' : 'rounded-full',
)}
style={config?.logoUrl ? {} : { backgroundColor: color }}
>
{config?.logoUrl
? <img src={config.logoUrl} alt="" className="w-full h-full object-cover" />
: (config?.logoLetter ?? 'О')
}
</div>
</div>
<div className="px-8 py-6 text-center space-y-6">
<div>
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
@@ -111,7 +129,8 @@ export function GuestReviewQrPage() {
onMouseEnter={() => setHover(i + 1)}
onMouseLeave={() => setHover(0)}
onClick={() => setRating(i + 1)}
className="transition-transform hover:scale-110 active:scale-95"
className="transition-transform active:scale-95"
style={{ transform: (hover || rating) > i ? 'scale(1.12)' : 'scale(1)' }}
>
<Star
size={40}
@@ -126,11 +145,11 @@ export function GuestReviewQrPage() {
))}
</div>
{(hover || rating) > 0 && (
<p className="text-sm font-medium text-slate-600 -mt-2">
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
<p className="text-sm font-medium text-slate-600 -mt-2 h-5 text-center">
{(hover || rating) > 0
? ['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]
: ''}
</p>
)}
{rating > 0 && config?.showTextField && (
<textarea
@@ -142,6 +161,16 @@ export function GuestReviewQrPage() {
/>
)}
{rating > 0 && config?.showEmail !== false && (
<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
onClick={handleSubmit}
disabled={rating === 0 || submitting}

View File

@@ -4,6 +4,7 @@ import {
Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode,
ArrowUpRight, Plus, Trash2, ExternalLink, Settings2,
Mail, Clock, Eye, Palette, Globe, Save, Loader2, Download, Printer,
Upload, Image as ImageIcon,
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
@@ -74,6 +75,7 @@ export function ReviewsPage() {
const [tab, setTab] = useState<Tab>('pending')
const [replyId, setReplyId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [replyEmail, setReplyEmail] = useState('')
const [copied, setCopied] = useState(false)
// Settings
@@ -115,6 +117,12 @@ export function ReviewsPage() {
const [previewColor, setPreviewColor] = useState(PREVIEW_COLORS[0])
const [previewWelcome, setPreviewWelcome] = useState('Как вам у нас?')
const [previewShowText, setPreviewShowText] = useState(true)
const [previewLogoUrl, setPreviewLogoUrl] = useState('')
const [previewLogoShape, setPreviewLogoShape] = useState<'circle' | 'square'>('circle')
const [previewHeaderImageUrl, setPreviewHeaderImageUrl] = useState('')
const [previewShowEmail, setPreviewShowEmail] = useState(true)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [uploadingHeader, setUploadingHeader] = useState(false)
// ── Load data ───────────────────────────────────────────────────────────────
@@ -143,6 +151,10 @@ export function ReviewsPage() {
if (s.review_welcome_text !== undefined) setPreviewWelcome(String(s.review_welcome_text))
if (s.review_brand_color !== undefined) setPreviewColor(String(s.review_brand_color))
if (s.review_show_text_field !== undefined) setPreviewShowText(Boolean(s.review_show_text_field))
if (s.review_logo_url !== undefined) setPreviewLogoUrl(String(s.review_logo_url ?? ''))
if (s.review_logo_shape !== undefined) setPreviewLogoShape(s.review_logo_shape as 'circle' | 'square')
if (s.review_header_image_url !== undefined) setPreviewHeaderImageUrl(String(s.review_header_image_url ?? ''))
if (s.review_show_email !== undefined) setPreviewShowEmail(s.review_show_email !== false)
if (Array.isArray(s.review_platforms)) setPlatforms(s.review_platforms as ReviewPlatform[])
if (Array.isArray(s.review_source_map)) setSourceMap(s.review_source_map as SourceMapEntry[])
if (Array.isArray(s.review_custom_locations)) setCustomLocations(s.review_custom_locations as string[])
@@ -178,6 +190,10 @@ export function ReviewsPage() {
review_welcome_text: previewWelcome,
review_brand_color: previewColor,
review_show_text_field: previewShowText,
review_logo_url: previewLogoUrl || null,
review_logo_shape: previewLogoShape,
review_header_image_url: previewHeaderImageUrl || null,
review_show_email: previewShowEmail,
review_platforms: platforms,
review_source_map: sourceMap,
review_custom_locations: customLocations,
@@ -189,10 +205,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('')
@@ -328,13 +345,29 @@ export function ReviewsPage() {
<div className="bg-slate-800 dark:bg-slate-950 h-5 flex items-center justify-center">
<div className="w-16 h-1.5 rounded-full bg-slate-600" />
</div>
<div className="p-5 text-center space-y-4">
{/* Header with optional background image */}
<div
className="w-16 h-16 rounded-full mx-auto flex items-center justify-center text-2xl font-bold text-white shadow-lg"
style={{ backgroundColor: previewColor }}
className="relative flex items-center justify-center py-5"
style={previewHeaderImageUrl
? { backgroundImage: `url(${previewHeaderImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: previewColor + '22' }
}
>
{previewHotelName[0]}
{previewHeaderImageUrl && <div className="absolute inset-0 bg-black/30" />}
<div
className={cn(
'relative w-14 h-14 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden',
previewLogoShape === 'square' ? 'rounded-2xl' : 'rounded-full',
)}
style={previewLogoUrl ? {} : { backgroundColor: previewColor }}
>
{previewLogoUrl
? <img src={previewLogoUrl} alt="" className="w-full h-full object-cover" />
: previewHotelName[0]
}
</div>
</div>
<div className="p-5 text-center space-y-4">
<div>
<p className="font-bold text-slate-900 dark:text-slate-100 text-base">{previewHotelName}</p>
<p className="text-lg font-semibold text-slate-700 dark:text-slate-300 mt-1">{previewWelcome}</p>
@@ -440,6 +473,63 @@ export function ReviewsPage() {
<input type="color" className="w-8 h-8 rounded-full cursor-pointer border-0 bg-transparent" value={previewColor} onChange={e => setPreviewColor(e.target.value)} title="Свой цвет" />
</div>
</div>
{/* Logo upload */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Логотип</p>
<div className="flex items-center gap-3 flex-wrap">
{previewLogoUrl && (
<img src={previewLogoUrl} alt="" className="w-12 h-12 object-cover rounded-xl border border-slate-200 dark:border-slate-600 shrink-0" />
)}
<label className={cn('btn-secondary gap-1.5 cursor-pointer text-sm', uploadingLogo && 'opacity-50 pointer-events-none')}>
{uploadingLogo ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />}
{previewLogoUrl ? 'Заменить' : 'Загрузить'}
<input type="file" accept="image/*" className="hidden" onChange={async e => {
const file = e.target.files?.[0]; if (!file) return
setUploadingLogo(true)
try { setPreviewLogoUrl(await api.upload.photo(file, 'hotels')) }
finally { setUploadingLogo(false); e.target.value = '' }
}} />
</label>
{previewLogoUrl && (
<button onClick={() => setPreviewLogoUrl('')} className="text-xs text-red-500 hover:text-red-700 dark:text-red-400">Убрать</button>
)}
</div>
<div className="flex gap-2 mt-2">
{(['circle', 'square'] as const).map(shape => (
<button
key={shape}
onClick={() => setPreviewLogoShape(shape)}
className={cn('px-3 py-1.5 text-xs rounded-lg border transition-colors', previewLogoShape === shape ? 'border-brand-600 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-400' : 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-slate-300')}
>
{shape === 'circle' ? '● Круг' : '■ Квадрат'}
</button>
))}
</div>
</div>
{/* Header background image */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Фон за логотипом</p>
{previewHeaderImageUrl && (
<img src={previewHeaderImageUrl} alt="" className="w-full h-16 object-cover rounded-xl border border-slate-200 dark:border-slate-600 mb-2" />
)}
<div className="flex items-center gap-3 flex-wrap">
<label className={cn('btn-secondary gap-1.5 cursor-pointer text-sm', uploadingHeader && 'opacity-50 pointer-events-none')}>
{uploadingHeader ? <Loader2 size={13} className="animate-spin" /> : <ImageIcon size={13} />}
{previewHeaderImageUrl ? 'Заменить фото' : 'Фото отеля'}
<input type="file" accept="image/*" className="hidden" onChange={async e => {
const file = e.target.files?.[0]; if (!file) return
setUploadingHeader(true)
try { setPreviewHeaderImageUrl(await api.upload.photo(file, 'hotels')) }
finally { setUploadingHeader(false); e.target.value = '' }
}} />
</label>
{previewHeaderImageUrl && (
<button onClick={() => setPreviewHeaderImageUrl('')} className="text-xs text-red-500 hover:text-red-700 dark:text-red-400">Убрать</button>
)}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">Показывать поле для текста</p>
@@ -452,6 +542,18 @@ export function ReviewsPage() {
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', previewShowText ? 'left-[22px]' : 'left-1')} />
</button>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">Поле Email для QR-отзывов</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Гость может указать email для получения ответа</p>
</div>
<button
onClick={() => setPreviewShowEmail(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', previewShowEmail ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', previewShowEmail ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
<div className="card p-4 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200 dark:border-amber-700/50">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-1">Текущий порог: {threshold} из 5 звёзд</p>
@@ -921,16 +1023,35 @@ export function ReviewsPage() {
)}
{replyId === review.id && (
<div className="mt-3 space-y-2">
{review.source === 'qr' && !review.guestEmail && (
<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">
Гость не указал email ответ сохранится как внутренняя заметка
</p>
)}
{review.source === 'qr' && review.guestEmail && (
<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">
Ответ отправится на: {review.guestEmail}
</p>
)}
<textarea
className="input resize-none text-sm w-full" rows={2}
placeholder="Личный ответ гостю (отправится по email)..."
placeholder={review.source === 'qr' && !review.guestEmail
? 'Внутренняя заметка (гость не оставил email)...'
: 'Личный ответ гостю (отправится по email)...'}
value={replyText}
onChange={e => setReplyText(e.target.value)}
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => sendReply(review.id)} className="btn-primary text-xs py-1.5">Отправить гостю</button>
<button onClick={() => setReplyId(null)} className="btn-secondary text-xs py-1.5">Отмена</button>
<button
onClick={() => sendReply(review.id, false)}
className="btn-primary text-xs py-1.5"
>
{review.source === 'qr' && !review.guestEmail
? 'Сохранить заметку'
: 'Отправить гостю'}
</button>
<button onClick={() => { setReplyId(null); setReplyEmail('') }} className="btn-secondary text-xs py-1.5">Отмена</button>
</div>
</div>
)}
@@ -939,10 +1060,10 @@ export function ReviewsPage() {
{!review.isPublic && !review.reply && !review.rejectedAt && replyId !== review.id && (
<>
<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"
>
<MessageSquare size={12} />Ответить гостю
<MessageSquare size={12} />{review.source === 'qr' ? 'Добавить заметку / ответить' : 'Ответить гостю'}
</button>
<button
onClick={() => reject(review.id)}