feat: widget category cards — full-width photo gallery with carousel

- Cards now show wide photo (h-44) at top instead of small thumbnail
- Gallery navigation: left/right arrows on hover, dot indicators, counter
- Gradient overlay with available badge (top-left) and photo count
- Select button at bottom of each card
- Graceful fallback to 🛏️ placeholder when no photos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:56:59 +03:00
parent bbea04ce2e
commit c8ff5ad0ed

View File

@@ -210,6 +210,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, realR
const [selected, setSelected] = useState<string | null>(null)
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
const [catPhotoIndex, setCatPhotoIndex] = useState<Record<string, number>>({})
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
const [submitting, setSubmitting] = useState(false)
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
@@ -822,55 +823,108 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, realR
)}
{displayCategories.map(cat => {
const isSelected = selected === cat.id
const curIdx = catPhotoIndex[cat.id] ?? 0
const photos = cat.photos ?? []
return (
<div
key={cat.id}
className={cn('rounded-xl border-2 overflow-hidden transition-all cursor-pointer', isSelected ? 'border-2' : 'border-slate-200')}
className={cn('rounded-2xl border-2 overflow-hidden transition-all shadow-sm hover:shadow-md', isSelected ? 'border-2' : 'border-slate-200')}
style={isSelected ? { borderColor: settings.primaryColor } : {}}
onClick={() => setSelected(cat.id === selected ? null : cat.id)}
>
<div
className={cn('p-3 transition-colors', isSelected ? '' : 'hover:bg-slate-50')}
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
>
<div className="flex items-start gap-3">
{/* Photo or icon */}
{cat.photos?.[0] ? (
{/* Photo gallery */}
<div className="relative w-full h-44 bg-slate-100 overflow-hidden group">
{photos.length > 0 ? (
<>
<img
src={cat.photos[0]}
src={photos[curIdx]}
alt={cat.name}
className="w-14 h-14 rounded-xl object-cover shrink-0"
className="w-full h-full object-cover transition-transform duration-300"
/>
) : (
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center text-2xl shrink-0">
🛏
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
{/* Nav arrows */}
{photos.length > 1 && (
<>
<button
onClick={e => { e.stopPropagation(); setCatPhotoIndex(p => ({ ...p, [cat.id]: Math.max(0, (p[cat.id] ?? 0) - 1) })) }}
disabled={curIdx === 0}
className="absolute left-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-white/80 flex items-center justify-center shadow opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
>
<ChevronLeft size={14} />
</button>
<button
onClick={e => { e.stopPropagation(); setCatPhotoIndex(p => ({ ...p, [cat.id]: Math.min(photos.length - 1, (p[cat.id] ?? 0) + 1) })) }}
disabled={curIdx === photos.length - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-white/80 flex items-center justify-center shadow opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
>
<ChevronRight size={14} />
</button>
{/* Dots */}
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
{photos.map((_, i) => (
<button
key={i}
onClick={e => { e.stopPropagation(); setCatPhotoIndex(p => ({ ...p, [cat.id]: i })) }}
className={cn('w-1.5 h-1.5 rounded-full transition-all', i === curIdx ? 'bg-white scale-125' : 'bg-white/50')}
/>
))}
</div>
{/* Counter */}
<span className="absolute top-2 right-2 text-[10px] text-white bg-black/40 rounded-full px-2 py-0.5">
{curIdx + 1}/{photos.length}
</span>
</>
)}
{/* Available badge */}
{cat.availableCount > 0 && (
<span className="absolute top-2 left-2 text-[10px] font-semibold text-white bg-emerald-500/90 rounded-full px-2 py-0.5">
{cat.availableCount} своб.
</span>
)}
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<span className="text-5xl">🛏</span>
<span className="text-xs text-slate-400">Нет фото</span>
</div>
)}
</div>
{/* Card content */}
<div
className={cn('p-3 cursor-pointer transition-colors', isSelected ? '' : 'hover:bg-slate-50')}
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
onClick={() => setSelected(cat.id === selected ? null : cat.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm">{cat.name}</p>
<p className="font-semibold text-slate-900 text-sm leading-tight">{cat.name}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs text-slate-500 flex items-center gap-0.5">
<Users size={10} /> до {cat.maxGuests}
</span>
{cat.availableCount > 0 && (
<span className="text-xs text-emerald-600">· {cat.availableCount} свободно</span>
{photos.length > 0 && (
<span className="text-xs text-slate-400 flex items-center gap-0.5">
<Image size={10} /> {photos.length} фото
</span>
)}
</div>
{cat.description && (
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{cat.description}</p>
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">{cat.description}</p>
)}
</div>
<div className="text-right shrink-0">
<p className="font-bold text-slate-900 text-sm">
<p className="font-bold text-slate-900 text-base">
{nights > 0 ? (cat.minPrice * nights).toLocaleString('ru-RU') : cat.minPrice.toLocaleString('ru-RU')}
</p>
<p className="text-[10px] text-slate-400">{nights > 0 ? `за ${nights} ноч.` : 'от/ночь'}</p>
</div>
</div>
{/* Amenities */}
{cat.amenities.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cat.amenities.slice(0, 4).map(a => {
{cat.amenities.slice(0, 5).map(a => {
const Icon = AMENITY_ICONS[a]
return (
<span key={a} className="flex items-center gap-0.5 text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
@@ -878,11 +932,22 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, realR
</span>
)
})}
{cat.amenities.length > 4 && (
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 4}</span>
{cat.amenities.length > 5 && (
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 5}</span>
)}
</div>
)}
{/* Select button */}
<button
onClick={e => { e.stopPropagation(); setSelected(cat.id === selected ? null : cat.id) }}
className={cn('w-full mt-3 py-2 rounded-xl text-sm font-semibold transition-colors')}
style={isSelected
? { background: settings.primaryColor, color: '#fff' }
: { border: `2px solid ${settings.primaryColor}`, color: settings.primaryColor, background: 'transparent' }}
>
{isSelected ? '✓ Выбрана' : 'Выбрать категорию'}
</button>
</div>
</div>
)