Expand swipe card to 50/50 photo/details split with all info
- Card now fills available height with photo carousel in top half and property details in bottom half - Details section shows: price, beds/sqm/price-per-sqm, agency, available date, all POI distances, and price history summary - Fix DialogTitle accessibility warning in ListingDetailSheet and MobileBottomSheet (add sr-only Drawer.Title)
This commit is contained in:
parent
611449d328
commit
e2c22f025f
3 changed files with 50 additions and 11 deletions
|
|
@ -46,6 +46,7 @@ export function ListingDetailSheet({
|
||||||
<Drawer.Portal>
|
<Drawer.Portal>
|
||||||
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
|
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
|
||||||
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh]">
|
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh]">
|
||||||
|
<Drawer.Title className="sr-only">Listing Details</Drawer.Title>
|
||||||
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" />
|
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" />
|
||||||
<div className="overflow-y-auto flex-1">
|
<div className="overflow-y-auto flex-1">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export function MobileBottomSheet({
|
||||||
className="fixed inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl bg-background border-t shadow-lg"
|
className="fixed inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl bg-background border-t shadow-lg"
|
||||||
style={{ maxHeight: '85vh' }}
|
style={{ maxHeight: '85vh' }}
|
||||||
>
|
>
|
||||||
|
<Drawer.Title className="sr-only">Property Listings</Drawer.Title>
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
<div className="flex justify-center pt-2 pb-1">
|
<div className="flex justify-center pt-2 pb-1">
|
||||||
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
|
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
import { useDrag } from '@use-gesture/react';
|
import { useDrag } from '@use-gesture/react';
|
||||||
import useEmblaCarousel from 'embla-carousel-react';
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
import { Bed, Maximize2, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Bed, Maximize2, ExternalLink, ChevronLeft, ChevronRight, Building2, Calendar } from 'lucide-react';
|
||||||
import type { PropertyFeature } from '@/types';
|
import type { PropertyFeature } from '@/types';
|
||||||
|
|
||||||
interface SwipeCardProps {
|
interface SwipeCardProps {
|
||||||
|
|
@ -98,12 +98,13 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
zIndex: 10 - stackIndex,
|
zIndex: 10 - stackIndex,
|
||||||
top: stackIndex * 8,
|
top: stackIndex * 8,
|
||||||
}}
|
}}
|
||||||
className="cursor-grab active:cursor-grabbing"
|
className="cursor-grab active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<div className="relative bg-background rounded-2xl border shadow-lg overflow-hidden mx-4">
|
<div className="relative bg-background rounded-2xl border shadow-lg overflow-hidden mx-4 h-full flex flex-col">
|
||||||
{/* Color overlay */}
|
{/* Color overlay */}
|
||||||
{isTop && (
|
{isTop && (
|
||||||
<animated.div
|
<animated.div
|
||||||
|
|
@ -112,8 +113,8 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Photo carousel */}
|
{/* Photo carousel — top half */}
|
||||||
<div className="h-48 bg-muted relative" style={{ touchAction: 'pan-x' }}>
|
<div className="flex-1 min-h-0 bg-muted relative" style={{ touchAction: 'pan-x' }}>
|
||||||
{photos.length > 1 ? (
|
{photos.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||||
|
|
@ -159,16 +160,18 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details — bottom half */}
|
||||||
<div className="p-4">
|
<div className="flex-1 min-h-0 p-4 flex flex-col gap-3 overflow-y-auto">
|
||||||
<div className="text-xl font-semibold">
|
{/* Price */}
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
£{p.total_price.toLocaleString()}
|
£{p.total_price.toLocaleString()}
|
||||||
{p.listing_type !== 'BUY' && (
|
{p.listing_type !== 'BUY' && (
|
||||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
<span className="text-muted-foreground font-normal text-base">/mo</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
{/* Key stats */}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Bed className="h-4 w-4" /> {p.rooms} bed
|
<Bed className="h-4 w-4" /> {p.rooms} bed
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -178,10 +181,24 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
<span>£{p.qmprice}/m²</span>
|
<span>£{p.qmprice}/m²</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Agency & availability */}
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{p.agency && (
|
||||||
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Building2 className="h-3.5 w-3.5 flex-shrink-0" /> {p.agency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.available_from && (
|
||||||
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Calendar className="h-3.5 w-3.5 flex-shrink-0" /> Available {p.available_from}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* POI distances */}
|
{/* POI distances */}
|
||||||
{p.poi_distances && p.poi_distances.length > 0 && (
|
{p.poi_distances && p.poi_distances.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{p.poi_distances.slice(0, 4).map((d) => (
|
{p.poi_distances.map((d) => (
|
||||||
<span
|
<span
|
||||||
key={`${d.poi_id}_${d.travel_mode}`}
|
key={`${d.poi_id}_${d.travel_mode}`}
|
||||||
className="text-xs bg-muted px-2 py-0.5 rounded"
|
className="text-xs bg-muted px-2 py-0.5 rounded"
|
||||||
|
|
@ -191,6 +208,26 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Price history */}
|
||||||
|
{p.price_history && p.price_history.length > 1 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{p.price_history.length} price changes
|
||||||
|
{(() => {
|
||||||
|
const first = p.price_history[0]?.price;
|
||||||
|
const last = p.price_history[p.price_history.length - 1]?.price;
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
const diff = last - first;
|
||||||
|
return (
|
||||||
|
<span className={diff < 0 ? 'text-green-600 ml-1' : 'text-red-500 ml-1'}>
|
||||||
|
({diff < 0 ? '' : '+'}£{diff.toLocaleString()})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue