docs: add card carousel and tap-to-detail implementation plan [ci skip]
This commit is contained in:
parent
089ee88728
commit
f2e8d7d9f9
1 changed files with 260 additions and 0 deletions
260
docs/plans/2026-02-21-card-carousel-tap-detail-plan.md
Normal file
260
docs/plans/2026-02-21-card-carousel-tap-detail-plan.md
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
# Card Carousel and Tap-to-Detail Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Replace single thumbnail with 5-photo carousel on listing cards and fix tap behavior to open the detail bottom sheet.
|
||||||
|
|
||||||
|
**Architecture:** Backend adds `additional_info` to streaming columns, extracts first 5 photo URLs into a `photos` property in GeoJSON output. Frontend adds a compact embla-carousel to PropertyCard and changes click handler to open the detail sheet instead of navigating to Rightmove.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (backend GeoJSON export), React + TypeScript + embla-carousel-react (frontend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — Add photos to GeoJSON streaming
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `repositories/listing_repository.py:19-25` (STREAMING_COLUMNS)
|
||||||
|
- Modify: `ui_exporter.py:12-85` (convert_row_to_geojson)
|
||||||
|
- Modify: `ui_exporter.py:88-134` (convert_to_geojson_feature)
|
||||||
|
|
||||||
|
**Step 1: Add `additional_info` to STREAMING_COLUMNS**
|
||||||
|
|
||||||
|
In `repositories/listing_repository.py`, add `'additional_info'` to the list:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STREAMING_COLUMNS = [
|
||||||
|
'id', 'price', 'number_of_bedrooms', 'square_meters',
|
||||||
|
'longitude', 'latitude', 'photo_thumbnail', 'last_seen',
|
||||||
|
'agency', 'price_history_json', 'available_from',
|
||||||
|
'service_charge', 'lease_left', 'additional_info',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Extract photos in `convert_row_to_geojson`**
|
||||||
|
|
||||||
|
In `ui_exporter.py`, after the `available_from` handling block (after line 47), add photo extraction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract first 5 photo URLs from additional_info
|
||||||
|
photos: list[str] = []
|
||||||
|
additional_info = row.get('additional_info')
|
||||||
|
if additional_info:
|
||||||
|
if isinstance(additional_info, str):
|
||||||
|
import json as _json
|
||||||
|
additional_info = _json.loads(additional_info)
|
||||||
|
images = additional_info.get('property', {}).get('images', [])
|
||||||
|
photos = [img['url'] for img in images[:5] if isinstance(img, dict) and 'url' in img]
|
||||||
|
if not photos and row.get('photo_thumbnail'):
|
||||||
|
photos = [row['photo_thumbnail']]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `"photos": photos,` to the properties dict (after line 66, the `photo_thumbnail` line).
|
||||||
|
|
||||||
|
**Step 3: Extract photos in `convert_to_geojson_feature`**
|
||||||
|
|
||||||
|
In `ui_exporter.py`, in `convert_to_geojson_feature`, `property_info` is already extracted at line 98. After line 98, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
images = property_info.get('images', [])
|
||||||
|
photos = [img['url'] for img in images[:5] if isinstance(img, dict) and 'url' in img]
|
||||||
|
if not photos and listing.photo_thumbnail:
|
||||||
|
photos = [listing.photo_thumbnail]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `"photos": photos,` to the properties dict (after the `photo_thumbnail` line).
|
||||||
|
|
||||||
|
**Step 4: Run backend tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v --tb=short -k "export or exporter or geojson"`
|
||||||
|
Expected: All pass (no tests directly assert the exact properties shape for photos)
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add repositories/listing_repository.py ui_exporter.py
|
||||||
|
git commit -m "feat: include first 5 photo URLs in GeoJSON listing properties"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Frontend — Add photos to type definition
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/types/index.ts:10-26` (PropertyProperties)
|
||||||
|
|
||||||
|
**Step 1: Add `photos` field**
|
||||||
|
|
||||||
|
In `PropertyProperties` interface, add after line 22 (`photo_thumbnail`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
photos?: string[];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/types/index.ts
|
||||||
|
git commit -m "feat: add photos array to PropertyProperties type"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Frontend — Add compact carousel to PropertyCard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/PropertyCard.tsx:1-3` (imports)
|
||||||
|
- Modify: `frontend/src/components/PropertyCard.tsx:117-120` (handleClick)
|
||||||
|
- Modify: `frontend/src/components/PropertyCard.tsx:130-139` (compact thumbnail)
|
||||||
|
|
||||||
|
**Step 1: Add embla imports**
|
||||||
|
|
||||||
|
Add at top of PropertyCard.tsx, after the existing imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Fix handleClick — remove window.open**
|
||||||
|
|
||||||
|
Replace lines 117-120:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleClick = () => {
|
||||||
|
window.open(property.url, '_blank', 'noopener,noreferrer');
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleClick = () => {
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add CardCarousel component**
|
||||||
|
|
||||||
|
Add a small inline component before the `PropertyCard` function (after the `AllPOIDistances` component, before the `PropertyCardProps` interface). This keeps carousel logic isolated:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function CardCarousel({ photos }: { photos: string[] }) {
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, dragFree: false });
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const onSelect = useCallback(() => {
|
||||||
|
if (!emblaApi) return;
|
||||||
|
setSelectedIndex(emblaApi.selectedScrollSnap());
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emblaApi) return;
|
||||||
|
emblaApi.on('select', onSelect);
|
||||||
|
return () => { emblaApi.off('select', onSelect); };
|
||||||
|
}, [emblaApi, onSelect]);
|
||||||
|
|
||||||
|
if (photos.length <= 1) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photos[0]}
|
||||||
|
alt="Property"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
{photos.map((url, i) => (
|
||||||
|
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`Photo ${i + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Dot indicators */}
|
||||||
|
<div className="absolute bottom-1 left-0 right-0 flex justify-center gap-1">
|
||||||
|
{photos.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-1 h-1 rounded-full ${
|
||||||
|
i === selectedIndex ? 'bg-white' : 'bg-white/40'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `onClick={e => e.stopPropagation()}` prevents carousel swipe from triggering the card's click handler (which opens the detail sheet).
|
||||||
|
|
||||||
|
**Step 4: Replace thumbnail with carousel in compact variant**
|
||||||
|
|
||||||
|
Replace the thumbnail div (lines 130-139):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div className="w-20 h-20 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
||||||
|
{property.photo_thumbnail && (
|
||||||
|
<img
|
||||||
|
src={property.photo_thumbnail}
|
||||||
|
alt="Property"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
||||||
|
{(property.photos?.length || property.photo_thumbnail) ? (
|
||||||
|
<CardCarousel photos={property.photos?.length ? property.photos : [property.photo_thumbnail]} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: slightly larger (w-24 h-24 vs w-20 h-20) to make the carousel more usable.
|
||||||
|
|
||||||
|
**Step 5: Run frontend tests**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run --reporter=verbose`
|
||||||
|
Expected: All pass
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/PropertyCard.tsx
|
||||||
|
git commit -m "feat: replace thumbnail with photo carousel and fix tap-to-detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Verify end-to-end
|
||||||
|
|
||||||
|
**Step 1: Run all backend tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v --tb=short`
|
||||||
|
Expected: All pass
|
||||||
|
|
||||||
|
**Step 2: Run all frontend tests**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run --reporter=verbose`
|
||||||
|
Expected: All pass
|
||||||
|
|
||||||
|
**Step 3: Commit all remaining changes and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue