wrongmove/docs/plans/2026-02-21-card-carousel-tap-detail-plan.md

7.7 KiB

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:

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:

# 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:

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

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):

  photos?: string[];

Step 2: Commit

git add frontend/src/types/index.ts
git commit -m "feat: add photos array to PropertyProperties type"

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:

import { useState, useCallback, useEffect } from 'react';
import useEmblaCarousel from 'embla-carousel-react';

Step 2: Fix handleClick — remove window.open

Replace lines 117-120:

const handleClick = () => {
    window.open(property.url, '_blank', 'noopener,noreferrer');
    onClick?.();
};

With:

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:

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):

<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:

<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

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

git push origin master