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 [selected, setSelected] = useState<string | null>(null)
|
||||||
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
|
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
|
||||||
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
|
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
|
||||||
|
const [catPhotoIndex, setCatPhotoIndex] = useState<Record<string, number>>({})
|
||||||
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
|
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
|
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
|
||||||
@@ -822,55 +823,108 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, realR
|
|||||||
)}
|
)}
|
||||||
{displayCategories.map(cat => {
|
{displayCategories.map(cat => {
|
||||||
const isSelected = selected === cat.id
|
const isSelected = selected === cat.id
|
||||||
|
const curIdx = catPhotoIndex[cat.id] ?? 0
|
||||||
|
const photos = cat.photos ?? []
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cat.id}
|
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 } : {}}
|
style={isSelected ? { borderColor: settings.primaryColor } : {}}
|
||||||
onClick={() => setSelected(cat.id === selected ? null : cat.id)}
|
|
||||||
>
|
>
|
||||||
<div
|
{/* Photo gallery */}
|
||||||
className={cn('p-3 transition-colors', isSelected ? '' : 'hover:bg-slate-50')}
|
<div className="relative w-full h-44 bg-slate-100 overflow-hidden group">
|
||||||
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
|
{photos.length > 0 ? (
|
||||||
>
|
<>
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* Photo or icon */}
|
|
||||||
{cat.photos?.[0] ? (
|
|
||||||
<img
|
<img
|
||||||
src={cat.photos[0]}
|
src={photos[curIdx]}
|
||||||
alt={cat.name}
|
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"
|
||||||
/>
|
/>
|
||||||
) : (
|
{/* Gradient overlay */}
|
||||||
<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 className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||||
🛏️
|
{/* Nav arrows */}
|
||||||
</div>
|
{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">
|
<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">
|
<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">
|
<span className="text-xs text-slate-500 flex items-center gap-0.5">
|
||||||
<Users size={10} /> до {cat.maxGuests}
|
<Users size={10} /> до {cat.maxGuests}
|
||||||
</span>
|
</span>
|
||||||
{cat.availableCount > 0 && (
|
{photos.length > 0 && (
|
||||||
<span className="text-xs text-emerald-600">· {cat.availableCount} свободно</span>
|
<span className="text-xs text-slate-400 flex items-center gap-0.5">
|
||||||
|
<Image size={10} /> {photos.length} фото
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{cat.description && (
|
{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>
|
||||||
<div className="text-right shrink-0">
|
<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')} ₽
|
{nights > 0 ? (cat.minPrice * nights).toLocaleString('ru-RU') : cat.minPrice.toLocaleString('ru-RU')} ₽
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-slate-400">{nights > 0 ? `за ${nights} ноч.` : 'от/ночь'}</p>
|
<p className="text-[10px] text-slate-400">{nights > 0 ? `за ${nights} ноч.` : 'от/ночь'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amenities */}
|
{/* Amenities */}
|
||||||
{cat.amenities.length > 0 && (
|
{cat.amenities.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<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]
|
const Icon = AMENITY_ICONS[a]
|
||||||
return (
|
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">
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{cat.amenities.length > 4 && (
|
{cat.amenities.length > 5 && (
|
||||||
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 4}</span>
|
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 5}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user