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>
This commit is contained in:
2026-04-21 23:09:43 +03:00
parent 9bda48c020
commit 31de5be873
6 changed files with 171 additions and 21 deletions

View File

@@ -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

@@ -170,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],
)
@@ -204,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,
@@ -302,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],
)
@@ -318,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,
}

View File

@@ -1997,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>

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
}
@@ -84,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>
@@ -144,7 +161,7 @@ export function GuestReviewQrPage() {
/>
)}
{rating > 0 && (
{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"

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'
@@ -116,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 ───────────────────────────────────────────────────────────────
@@ -144,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[])
@@ -179,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,
@@ -330,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>
@@ -442,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>
@@ -454,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>