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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user