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)
|
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)
|
||||||
|
|||||||
@@ -170,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],
|
||||||
)
|
)
|
||||||
@@ -204,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,
|
||||||
@@ -302,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],
|
||||||
)
|
)
|
||||||
@@ -318,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1997,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">
|
<div
|
||||||
|
className="relative flex items-center justify-center py-7"
|
||||||
{/* Logo */}
|
style={config?.headerImageUrl
|
||||||
|
? { backgroundImage: `url(${config.headerImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
|
: { backgroundColor: color + '22' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{config?.headerImageUrl && <div className="absolute inset-0 bg-black/35" />}
|
||||||
<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={cn(
|
||||||
style={{ backgroundColor: color }}
|
'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?.logoLetter ?? 'О'}
|
{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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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="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">
|
<div
|
||||||
|
className="relative flex items-center justify-center py-7"
|
||||||
{/* Logo */}
|
style={config?.headerImageUrl
|
||||||
|
? { backgroundImage: `url(${config.headerImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
|
: { backgroundColor: color + '22' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{config?.headerImageUrl && <div className="absolute inset-0 bg-black/35" />}
|
||||||
<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={cn(
|
||||||
style={{ backgroundColor: color }}
|
'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?.logoLetter ?? 'О'}
|
{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>
|
||||||
@@ -144,7 +161,7 @@ export function GuestReviewQrPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rating > 0 && (
|
{rating > 0 && config?.showEmail !== false && (
|
||||||
<input
|
<input
|
||||||
type="email"
|
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"
|
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,
|
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'
|
||||||
@@ -116,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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -144,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[])
|
||||||
@@ -179,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,
|
||||||
@@ -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="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
|
||||||
|
className="relative flex items-center justify-center py-5"
|
||||||
|
style={previewHeaderImageUrl
|
||||||
|
? { backgroundImage: `url(${previewHeaderImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
|
: { backgroundColor: previewColor + '22' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{previewHeaderImageUrl && <div className="absolute inset-0 bg-black/30" />}
|
||||||
<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={cn(
|
||||||
style={{ backgroundColor: previewColor }}
|
'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 }}
|
||||||
>
|
>
|
||||||
{previewHotelName[0]}
|
{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>
|
||||||
@@ -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="Свой цвет" />
|
<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>
|
||||||
@@ -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')} />
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user