feat: review form logo/header image customizer, fix unread msg job startup
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user