Compare commits
7 Commits
76101e8811
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 31de5be873 | |||
| 9bda48c020 | |||
| 8ab71b1e45 | |||
| d83ffcb3c7 | |||
| bb401095dc | |||
| 6165e4c67c | |||
| d1948f95b9 |
7
backend/migrations/090_reviews_source_qr.sql
Normal file
7
backend/migrations/090_reviews_source_qr.sql
Normal 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'));
|
||||||
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);
|
||||||
@@ -109,7 +109,7 @@ async function runAutoCancelNoShows(): Promise<void> {
|
|||||||
title: 'Гость не заехал — бронь отменена',
|
title: 'Гость не заехал — бронь отменена',
|
||||||
body: `${b.guest_name ?? 'Гость'} не заехал вовремя. Бронирование автоматически отмечено как неявка.`,
|
body: `${b.guest_name ?? 'Гость'} не заехал вовремя. Бронирование автоматически отмечено как неявка.`,
|
||||||
bookingId: b.id,
|
bookingId: b.id,
|
||||||
link: `/${hotel.slug}/bookings`,
|
link: '/bookings',
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@ async function runAutoCheckouts(): Promise<void> {
|
|||||||
title: 'Автоматическое выселение',
|
title: 'Автоматическое выселение',
|
||||||
body: `${b.guest_name ?? 'Гость'} выселен автоматически по истечении времени проживания.`,
|
body: `${b.guest_name ?? 'Гость'} выселен автоматически по истечении времени проживания.`,
|
||||||
bookingId: b.id,
|
bookingId: b.id,
|
||||||
link: `/${hotel.slug}/calendar`,
|
link: '/calendar',
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,7 +348,7 @@ async function runUnreadMessagesJob(): Promise<void> {
|
|||||||
type: 'unread_messages',
|
type: 'unread_messages',
|
||||||
title: `Непрочитанные сообщения — ${row.room_name}`,
|
title: `Непрочитанные сообщения — ${row.room_name}`,
|
||||||
body,
|
body,
|
||||||
link: `/${row.hotel_slug}/chat`,
|
link: '/chat',
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
void sendPushForNotification(row.hotel_id, 'unread_messages', {
|
void sendPushForNotification(row.hotel_id, 'unread_messages', {
|
||||||
@@ -399,6 +399,10 @@ export function startJobs(): void {
|
|||||||
runPaymentExpiryJob().catch(console.error)
|
runPaymentExpiryJob().catch(console.error)
|
||||||
}, PAYMENT_EXPIRY_INTERVAL)
|
}, PAYMENT_EXPIRY_INTERVAL)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
runUnreadMessagesJob().catch(console.error)
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
runUnreadMessagesJob().catch(console.error)
|
runUnreadMessagesJob().catch(console.error)
|
||||||
}, UNREAD_MSG_INTERVAL)
|
}, UNREAD_MSG_INTERVAL)
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
title: `Новая бронь через сайт — ${guestName}`,
|
title: `Новая бронь через сайт — ${guestName}`,
|
||||||
body: `${checkIn} — ${checkOut}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
|
body: `${checkIn} — ${checkOut}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
|
||||||
bookingId,
|
bookingId,
|
||||||
link: `/${hotel.slug}/bookings`,
|
link: '/bookings',
|
||||||
})
|
})
|
||||||
void sendPushForNotification(hotel.id, 'new_booking', {
|
void sendPushForNotification(hotel.id, 'new_booking', {
|
||||||
title: `Новая бронь через сайт — ${guestName}`,
|
title: `Новая бронь через сайт — ${guestName}`,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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: 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<{
|
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))
|
||||||
@@ -162,7 +170,9 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
'review_redirect_threshold','review_platforms',
|
'review_redirect_threshold','review_platforms',
|
||||||
'review_show_text_field','review_welcome_text',
|
'review_show_text_field','review_welcome_text',
|
||||||
'review_brand_color','review_redirect_source',
|
'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],
|
[row.hotel_id],
|
||||||
)
|
)
|
||||||
@@ -196,10 +206,14 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return {
|
return {
|
||||||
hotelName: row.hotel_name,
|
hotelName: row.hotel_name,
|
||||||
logoLetter: (row.hotel_name as string)[0] ?? 'О',
|
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) ?? 'Как вам у нас?',
|
welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?',
|
||||||
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
|
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
|
||||||
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
|
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
|
||||||
showTextField: s.review_show_text_field !== false,
|
showTextField: s.review_show_text_field !== false,
|
||||||
|
showEmail: s.review_show_email !== false,
|
||||||
platforms,
|
platforms,
|
||||||
guestName: row.guest_name ?? '',
|
guestName: row.guest_name ?? '',
|
||||||
bookingSource: bookingSrc,
|
bookingSource: bookingSrc,
|
||||||
@@ -261,7 +275,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
type: 'new_review',
|
type: 'new_review',
|
||||||
title: `Новый отзыв — ${rating}★`,
|
title: `Новый отзыв — ${rating}★`,
|
||||||
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
|
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
|
||||||
link: `/${hotelSlug}/reviews`,
|
link: '/reviews',
|
||||||
})
|
})
|
||||||
void sendPushForNotification(booking.hotel_id, 'new_review', {
|
void sendPushForNotification(booking.hotel_id, 'new_review', {
|
||||||
title: `Новый отзыв — ${rating}★`,
|
title: `Новый отзыв — ${rating}★`,
|
||||||
@@ -294,7 +308,9 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
AND key IN (
|
AND key IN (
|
||||||
'review_redirect_threshold','review_platforms',
|
'review_redirect_threshold','review_platforms',
|
||||||
'review_show_text_field','review_welcome_text',
|
'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],
|
[hotel.id],
|
||||||
)
|
)
|
||||||
@@ -310,10 +326,14 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return {
|
return {
|
||||||
hotelName: hotel.name,
|
hotelName: hotel.name,
|
||||||
logoLetter: hotel.name[0] ?? 'О',
|
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) ?? 'Как вам у нас?',
|
welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?',
|
||||||
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
|
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
|
||||||
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
|
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
|
||||||
showTextField: s.review_show_text_field !== false,
|
showTextField: s.review_show_text_field !== false,
|
||||||
|
showEmail: s.review_show_email !== false,
|
||||||
platforms: allPlatforms,
|
platforms: allPlatforms,
|
||||||
roomNumber: room,
|
roomNumber: room,
|
||||||
}
|
}
|
||||||
@@ -321,11 +341,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' })
|
||||||
@@ -346,11 +366,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,
|
||||||
@@ -361,7 +382,7 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
type: 'new_review',
|
type: 'new_review',
|
||||||
title: `Новый QR-отзыв — №${room}, ${rating}★`,
|
title: `Новый QR-отзыв — №${room}, ${rating}★`,
|
||||||
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
|
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
|
||||||
link: `/${slug}/reviews`,
|
link: '/reviews',
|
||||||
})
|
})
|
||||||
void sendPushForNotification(hotel.id, 'new_review', {
|
void sendPushForNotification(hotel.id, 'new_review', {
|
||||||
title: `Новый QR-отзыв — №${room}, ${rating}★`,
|
title: `Новый QR-отзыв — №${room}, ${rating}★`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Sun, Moon, Monitor, Bell, LogOut, ChevronDown, Menu,
|
import { Sun, Moon, Monitor, Bell, LogOut, ChevronDown, Menu,
|
||||||
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
|
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
|
||||||
Sparkles, AlertTriangle, Star, CreditCard, Info,
|
Sparkles, AlertTriangle, Star, CreditCard, Info,
|
||||||
ArrowRight, Wrench,
|
ArrowRight, Wrench, MessageSquare, UtensilsCrossed,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTheme } from '../../contexts/ThemeContext'
|
import { useTheme } from '../../contexts/ThemeContext'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
@@ -19,6 +19,7 @@ const NOTIF_META: Record<NotifType, {
|
|||||||
iconColor: string
|
iconColor: string
|
||||||
}> = {
|
}> = {
|
||||||
booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
|
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_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_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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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 {
|
function relativeTime(iso: string): string {
|
||||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
if (diff < 60) return 'только что'
|
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">
|
<div className="divide-y divide-slate-100 dark:divide-slate-700/60">
|
||||||
{shown.map(n => {
|
{shown.map(n => {
|
||||||
const meta = NOTIF_META[n.type]
|
const meta = NOTIF_META[n.type] ?? NOTIF_META_FALLBACK
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api'
|
|||||||
|
|
||||||
export type NotifType =
|
export type NotifType =
|
||||||
| 'booking_new'
|
| 'booking_new'
|
||||||
|
| 'new_booking'
|
||||||
| 'booking_cancelled'
|
| 'booking_cancelled'
|
||||||
| 'booking_checkin'
|
| 'booking_checkin'
|
||||||
| 'booking_checkout'
|
| 'booking_checkout'
|
||||||
@@ -11,8 +12,11 @@ export type NotifType =
|
|||||||
| 'channel_error'
|
| 'channel_error'
|
||||||
| 'payment'
|
| 'payment'
|
||||||
| 'review'
|
| 'review'
|
||||||
|
| 'new_review'
|
||||||
| 'system'
|
| 'system'
|
||||||
| 'maintenance'
|
| 'maintenance'
|
||||||
|
| 'room_service'
|
||||||
|
| 'unread_messages'
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -1996,10 +1997,14 @@ export interface ReviewApi {
|
|||||||
export interface PublicReviewConfig {
|
export interface PublicReviewConfig {
|
||||||
hotelName: string
|
hotelName: string
|
||||||
logoLetter: string
|
logoLetter: string
|
||||||
|
logoUrl: string | null
|
||||||
|
logoShape: 'circle' | 'square'
|
||||||
|
headerImageUrl: string | null
|
||||||
welcomeText: string
|
welcomeText: string
|
||||||
color: string
|
color: string
|
||||||
redirectThreshold: number
|
redirectThreshold: number
|
||||||
showTextField: boolean
|
showTextField: boolean
|
||||||
|
showEmail: boolean
|
||||||
platforms: { name: string; url: string }[]
|
platforms: { name: string; url: string }[]
|
||||||
guestName: string
|
guestName: string
|
||||||
bookingSource: string
|
bookingSource: string
|
||||||
|
|||||||
@@ -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="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="w-full max-w-sm">
|
||||||
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
||||||
<div className="h-2" style={{ backgroundColor: color }} />
|
{/* Header area */}
|
||||||
<div className="px-8 py-8 text-center space-y-6">
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<div
|
<div
|
||||||
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
|
className="relative flex items-center justify-center py-7"
|
||||||
style={{ backgroundColor: color }}
|
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>
|
||||||
|
<div className="px-8 py-6 text-center space-y-6">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
|
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
|
||||||
@@ -119,7 +132,8 @@ export function GuestReviewPage() {
|
|||||||
onMouseEnter={() => setHover(i + 1)}
|
onMouseEnter={() => setHover(i + 1)}
|
||||||
onMouseLeave={() => setHover(0)}
|
onMouseLeave={() => setHover(0)}
|
||||||
onClick={() => setRating(i + 1)}
|
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
|
<Star
|
||||||
size={40}
|
size={40}
|
||||||
@@ -134,11 +148,11 @@ export function GuestReviewPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(hover || rating) > 0 && (
|
<p className="text-sm font-medium text-slate-600 -mt-2 h-5 text-center">
|
||||||
<p className="text-sm font-medium text-slate-600 -mt-2">
|
{(hover || rating) > 0
|
||||||
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
|
? ['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]
|
||||||
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{rating > 0 && config?.showTextField && (
|
{rating > 0 && config?.showTextField && (
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://ap
|
|||||||
interface QrConfig {
|
interface QrConfig {
|
||||||
hotelName: string
|
hotelName: string
|
||||||
logoLetter: string
|
logoLetter: string
|
||||||
|
logoUrl: string | null
|
||||||
|
logoShape: 'circle' | 'square'
|
||||||
|
headerImageUrl: string | null
|
||||||
welcomeText: string
|
welcomeText: string
|
||||||
color: string
|
color: string
|
||||||
redirectThreshold: number
|
redirectThreshold: number
|
||||||
showTextField: boolean
|
showTextField: boolean
|
||||||
|
showEmail: boolean
|
||||||
platforms: { name: string; url: string }[]
|
platforms: { name: string; url: string }[]
|
||||||
roomNumber: string
|
roomNumber: string
|
||||||
}
|
}
|
||||||
@@ -24,6 +28,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 +50,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 {
|
||||||
@@ -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="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="w-full max-w-sm">
|
||||||
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
||||||
<div className="h-2" style={{ backgroundColor: color }} />
|
{/* Header area */}
|
||||||
<div className="px-8 py-8 text-center space-y-6">
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<div
|
<div
|
||||||
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
|
className="relative flex items-center justify-center py-7"
|
||||||
style={{ backgroundColor: color }}
|
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>
|
||||||
|
<div className="px-8 py-6 text-center space-y-6">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
|
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
|
||||||
@@ -111,7 +129,8 @@ export function GuestReviewQrPage() {
|
|||||||
onMouseEnter={() => setHover(i + 1)}
|
onMouseEnter={() => setHover(i + 1)}
|
||||||
onMouseLeave={() => setHover(0)}
|
onMouseLeave={() => setHover(0)}
|
||||||
onClick={() => setRating(i + 1)}
|
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
|
<Star
|
||||||
size={40}
|
size={40}
|
||||||
@@ -126,11 +145,11 @@ export function GuestReviewQrPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(hover || rating) > 0 && (
|
<p className="text-sm font-medium text-slate-600 -mt-2 h-5 text-center">
|
||||||
<p className="text-sm font-medium text-slate-600 -mt-2">
|
{(hover || rating) > 0
|
||||||
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
|
? ['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]
|
||||||
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{rating > 0 && config?.showTextField && (
|
{rating > 0 && config?.showTextField && (
|
||||||
<textarea
|
<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
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={rating === 0 || submitting}
|
disabled={rating === 0 || submitting}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode,
|
Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode,
|
||||||
ArrowUpRight, Plus, Trash2, ExternalLink, Settings2,
|
ArrowUpRight, Plus, Trash2, ExternalLink, Settings2,
|
||||||
Mail, Clock, Eye, Palette, Globe, Save, Loader2, Download, Printer,
|
Mail, Clock, Eye, Palette, Globe, Save, Loader2, Download, Printer,
|
||||||
|
Upload, Image as ImageIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
@@ -74,6 +75,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
|
||||||
@@ -115,6 +117,12 @@ export function ReviewsPage() {
|
|||||||
const [previewColor, setPreviewColor] = useState(PREVIEW_COLORS[0])
|
const [previewColor, setPreviewColor] = useState(PREVIEW_COLORS[0])
|
||||||
const [previewWelcome, setPreviewWelcome] = useState('Как вам у нас?')
|
const [previewWelcome, setPreviewWelcome] = useState('Как вам у нас?')
|
||||||
const [previewShowText, setPreviewShowText] = useState(true)
|
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 ───────────────────────────────────────────────────────────────
|
// ── Load data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -143,6 +151,10 @@ export function ReviewsPage() {
|
|||||||
if (s.review_welcome_text !== undefined) setPreviewWelcome(String(s.review_welcome_text))
|
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_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_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_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_source_map)) setSourceMap(s.review_source_map as SourceMapEntry[])
|
||||||
if (Array.isArray(s.review_custom_locations)) setCustomLocations(s.review_custom_locations as string[])
|
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_welcome_text: previewWelcome,
|
||||||
review_brand_color: previewColor,
|
review_brand_color: previewColor,
|
||||||
review_show_text_field: previewShowText,
|
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_platforms: platforms,
|
||||||
review_source_map: sourceMap,
|
review_source_map: sourceMap,
|
||||||
review_custom_locations: customLocations,
|
review_custom_locations: customLocations,
|
||||||
@@ -189,10 +205,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('')
|
||||||
@@ -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="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 className="w-16 h-1.5 rounded-full bg-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 text-center space-y-4">
|
{/* Header with optional background image */}
|
||||||
<div
|
<div
|
||||||
className="w-16 h-16 rounded-full mx-auto flex items-center justify-center text-2xl font-bold text-white shadow-lg"
|
className="relative flex items-center justify-center py-5"
|
||||||
style={{ backgroundColor: previewColor }}
|
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>
|
||||||
|
<div className="p-5 text-center space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-slate-900 dark:text-slate-100 text-base">{previewHotelName}</p>
|
<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>
|
<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="Свой цвет" />
|
<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>
|
||||||
</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 className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">Показывать поле для текста</p>
|
<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')} />
|
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', previewShowText ? 'left-[22px]' : 'left-1')} />
|
||||||
</button>
|
</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">Поле 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>
|
||||||
<div className="card p-4 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200 dark:border-amber-700/50">
|
<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>
|
<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 && (
|
{replyId === review.id && (
|
||||||
<div className="mt-3 space-y-2">
|
<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
|
<textarea
|
||||||
className="input resize-none text-sm w-full" rows={2}
|
className="input resize-none text-sm w-full" rows={2}
|
||||||
placeholder="Личный ответ гостю (отправится по email)..."
|
placeholder={review.source === 'qr' && !review.guestEmail
|
||||||
|
? 'Внутренняя заметка (гость не оставил email)...'
|
||||||
|
: 'Личный ответ гостю (отправится по email)...'}
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={e => setReplyText(e.target.value)}
|
onChange={e => setReplyText(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<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, 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -939,10 +1060,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