Improve frontend UI/UX: accessibility, discoverability, and cleanup
- Add prefers-reduced-motion support (global CSS + SwipeCard spring config) - Add dismissible map click hint overlay with localStorage persistence - Replace generic image alt text with descriptive property info across 4 components - Rename filter buttons to clarify intent (Show Matching Listings / Scrape New from Rightmove) - Fix mobile FAB overlap with bottom sheet via dynamic snap-aware positioning - Add swipe review onboarding overlay with gesture explanations and button labels - Delete unused components: AppSidebar, ActiveQuery, SavedView
This commit is contained in:
parent
268f4fd272
commit
339e2cf2ab
13 changed files with 143 additions and 354 deletions
|
|
@ -58,6 +58,7 @@ function App() {
|
||||||
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||||
const [showReviewMode, setShowReviewMode] = useState(false);
|
const [showReviewMode, setShowReviewMode] = useState(false);
|
||||||
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
||||||
|
const [bottomSheetSnap, setBottomSheetSnap] = useState<string | number | null>("148px");
|
||||||
|
|
||||||
// Decision state (like/dislike)
|
// Decision state (like/dislike)
|
||||||
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
||||||
|
|
@ -487,7 +488,12 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter & Review FABs */}
|
{/* Filter & Review FABs */}
|
||||||
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
<div
|
||||||
|
className={`fixed right-4 z-50 flex flex-col gap-2 transition-all duration-200 ${
|
||||||
|
bottomSheetSnap === 0.85 ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
style={{ bottom: bottomSheetSnap === '148px' ? '11rem' : '7rem' }}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -541,6 +547,7 @@ function App() {
|
||||||
highlightedPropertyUrl={highlightedProperty}
|
highlightedPropertyUrl={highlightedProperty}
|
||||||
onActiveListingChange={handleActiveListingChange}
|
onActiveListingChange={handleActiveListingChange}
|
||||||
poiMetricSelection={poiMetricSelection}
|
poiMetricSelection={poiMetricSelection}
|
||||||
|
onSnapChange={setBottomSheetSnap}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarRail,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "Property Explorer",
|
|
||||||
url: "#",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Map View",
|
|
||||||
url: "#",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "List View",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Data Management",
|
|
||||||
url: "#",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Refresh Listings",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Active Tasks",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
url: "#",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Preferences",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Account",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|
||||||
return (
|
|
||||||
<Sidebar {...props}>
|
|
||||||
<SidebarHeader>
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
{data.navMain.map((item) => (
|
|
||||||
<SidebarGroup key={item.title}>
|
|
||||||
<SidebarGroupLabel>{item.title}</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<SidebarMenuItem key={subItem.title}>
|
|
||||||
<SidebarMenuButton asChild isActive={subItem.isActive}>
|
|
||||||
<a href={subItem.url}>{subItem.title}</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
))}
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import { getUser } from '@/auth/authService';
|
|
||||||
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
|
||||||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
|
||||||
import { POLLING_INTERVALS } from '@/constants';
|
|
||||||
import { fetchTaskStatus, cancelTask } from '@/services';
|
|
||||||
import { TaskStatus, type TaskResult } from '@/types';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import AlertError from './AlertError';
|
|
||||||
import { Spinner } from './Spinner';
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
|
||||||
import { Progress } from './ui/progress';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ActiveQueryProps {
|
|
||||||
taskID: string | null;
|
|
||||||
onTaskCancelled?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
|
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const passkeyUser = getStoredPasskeyUser();
|
|
||||||
if (passkeyUser) {
|
|
||||||
setUser(passkeyUser);
|
|
||||||
} else {
|
|
||||||
getUser().then((oidcUser) => {
|
|
||||||
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
|
||||||
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.PENDING);
|
|
||||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
|
|
||||||
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
|
|
||||||
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
|
||||||
const [isCancelling, setIsCancelling] = useState(false);
|
|
||||||
|
|
||||||
const handleCancelTask = async () => {
|
|
||||||
if (!user || !taskID || isCancelling) return;
|
|
||||||
|
|
||||||
setIsCancelling(true);
|
|
||||||
try {
|
|
||||||
const result = await cancelTask(user, taskID);
|
|
||||||
if (result.success) {
|
|
||||||
setTaskStatus(TaskStatus.REVOKED);
|
|
||||||
onTaskCancelled?.();
|
|
||||||
} else {
|
|
||||||
setFetchStatusError(result.message);
|
|
||||||
setAlertDialogIsOpen(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setFetchStatusError(error instanceof Error ? error.message : 'Failed to cancel task');
|
|
||||||
setAlertDialogIsOpen(true);
|
|
||||||
} finally {
|
|
||||||
setIsCancelling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pollTaskStatus = async (interval: NodeJS.Timeout) => {
|
|
||||||
if (!user || !taskID) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetchTaskStatus(user, taskID);
|
|
||||||
setLastUpdateTime(new Date());
|
|
||||||
const status = data.status as TaskStatus;
|
|
||||||
setTaskStatus(status);
|
|
||||||
|
|
||||||
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setFetchStatusError('Task failed with status: ' + status);
|
|
||||||
setAlertDialogIsOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === TaskStatus.SUCCESS) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setProgressPercentage(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only parse result for in-progress tasks
|
|
||||||
if (data.result) {
|
|
||||||
try {
|
|
||||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
|
||||||
setProgressPercentage(parsedResult.progress * 100);
|
|
||||||
} catch {
|
|
||||||
// Result parsing failed, but task is still running - ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setTaskStatus(TaskStatus.FAILURE);
|
|
||||||
setAlertDialogIsOpen(true);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
setFetchStatusError(error.message);
|
|
||||||
} else {
|
|
||||||
setFetchStatusError('Failed to update task status: ' + String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(
|
|
||||||
() => pollTaskStatus(interval),
|
|
||||||
POLLING_INTERVALS.TASK_STATUS_MS
|
|
||||||
);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [taskID, user]);
|
|
||||||
|
|
||||||
if (!taskID) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInProgress = taskStatus &&
|
|
||||||
taskStatus !== TaskStatus.SUCCESS &&
|
|
||||||
taskStatus !== TaskStatus.FAILURE &&
|
|
||||||
taskStatus !== TaskStatus.REVOKED;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 p-2 border-t bg-muted/50">
|
|
||||||
<HoverCard>
|
|
||||||
<HoverCardTrigger className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{taskStatus && <span className="text-sm">Task: {taskStatus}</span>}
|
|
||||||
{isInProgress && <Spinner />}
|
|
||||||
</div>
|
|
||||||
<Progress value={progressPercentage} className="mt-1" />
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent>
|
|
||||||
Task ID: {taskID}
|
|
||||||
<br />
|
|
||||||
Last updated: {lastUpdateTime.toLocaleString()}
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
{isInProgress && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancelTask}
|
|
||||||
disabled={isCancelling}
|
|
||||||
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="ml-1 hidden sm:inline">Cancel</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActiveQuery;
|
|
||||||
|
|
@ -554,7 +554,7 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Filter className="mr-2 h-4 w-4" />
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
Apply Filters
|
Show Matching Listings
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -565,8 +565,9 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh Data
|
Scrape New from Rightmove
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">Triggers a live crawl — may take several minutes</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
import mapboxgl from "mapbox-gl";
|
import mapboxgl from "mapbox-gl";
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
import { useEffect, useRef, useMemo, useCallback } from "react";
|
import { useEffect, useRef, useMemo, useCallback, useState } from "react";
|
||||||
import { Crosshair } from "lucide-react";
|
import { Crosshair } from "lucide-react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import "../assets/Map.css";
|
import "../assets/Map.css";
|
||||||
|
|
@ -32,6 +32,10 @@ interface MapProps {
|
||||||
export function Map(props: MapProps) {
|
export function Map(props: MapProps) {
|
||||||
const data = props.listingData;
|
const data = props.listingData;
|
||||||
|
|
||||||
|
const [showMapHint, setShowMapHint] = useState(() => {
|
||||||
|
return localStorage.getItem('map-hint-dismissed') !== 'true';
|
||||||
|
});
|
||||||
|
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const heatmapRef = useRef<HexgridHeatmapClient | null>(null);
|
const heatmapRef = useRef<HexgridHeatmapClient | null>(null);
|
||||||
|
|
@ -421,6 +425,20 @@ export function Map(props: MapProps) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!props.isPickingPOI && showMapHint && (
|
||||||
|
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-10 bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium">
|
||||||
|
Click on colored areas to view properties
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowMapHint(false);
|
||||||
|
localStorage.setItem('map-hint-dismissed', 'true');
|
||||||
|
}}
|
||||||
|
className="ml-1 px-2 py-0.5 bg-primary-foreground/20 hover:bg-primary-foreground/30 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div id="legend">
|
<div id="legend">
|
||||||
<svg id="svg"></svg>
|
<svg id="svg"></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { Drawer } from 'vaul';
|
import { Drawer } from 'vaul';
|
||||||
import { MapPin, PoundSterling } from 'lucide-react';
|
import { MapPin, PoundSterling } from 'lucide-react';
|
||||||
import { SwipeableCardRow } from './SwipeableCardRow';
|
import { SwipeableCardRow } from './SwipeableCardRow';
|
||||||
|
|
@ -11,6 +11,7 @@ interface MobileBottomSheetProps {
|
||||||
highlightedPropertyUrl?: string | null;
|
highlightedPropertyUrl?: string | null;
|
||||||
onActiveListingChange?: (feature: PropertyFeature | null) => void;
|
onActiveListingChange?: (feature: PropertyFeature | null) => void;
|
||||||
poiMetricSelection?: { poiId: number; travelMode: string } | null;
|
poiMetricSelection?: { poiId: number; travelMode: string } | null;
|
||||||
|
onSnapChange?: (snap: string | number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
function formatCurrency(value: number): string {
|
||||||
|
|
@ -24,10 +25,16 @@ export function MobileBottomSheet({
|
||||||
highlightedPropertyUrl,
|
highlightedPropertyUrl,
|
||||||
onActiveListingChange,
|
onActiveListingChange,
|
||||||
poiMetricSelection,
|
poiMetricSelection,
|
||||||
|
onSnapChange,
|
||||||
}: MobileBottomSheetProps) {
|
}: MobileBottomSheetProps) {
|
||||||
const [snap, setSnap] = useState<string | number | null>("148px");
|
const [snap, setSnap] = useState<string | number | null>("148px");
|
||||||
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
||||||
|
|
||||||
|
// Notify parent when snap changes
|
||||||
|
useEffect(() => {
|
||||||
|
onSnapChange?.(snap);
|
||||||
|
}, [snap, onSnapChange]);
|
||||||
|
|
||||||
const features = listingData?.features ?? [];
|
const features = listingData?.features ?? [];
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardCarousel({ photos }: { photos: string[] }) {
|
function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) {
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={photos[0]}
|
src={photos[0]}
|
||||||
alt="Property"
|
alt={altText || "Property"}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
@ -120,7 +120,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
|
||||||
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Photo ${i + 1}`}
|
alt={`Property photo ${i + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
@ -187,7 +187,10 @@ export function PropertyCard({
|
||||||
{/* Photo carousel */}
|
{/* Photo carousel */}
|
||||||
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
||||||
{(property.photos?.length || property.photo_thumbnail) ? (
|
{(property.photos?.length || property.photo_thumbnail) ? (
|
||||||
<CardCarousel photos={property.photos?.length ? property.photos : [property.photo_thumbnail]} />
|
<CardCarousel
|
||||||
|
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
|
||||||
|
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -248,7 +251,7 @@ export function PropertyCard({
|
||||||
{property.photo_thumbnail && (
|
{property.photo_thumbnail && (
|
||||||
<img
|
<img
|
||||||
src={property.photo_thumbnail}
|
src={property.photo_thumbnail}
|
||||||
alt="Property"
|
alt={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function PropertyCardCompact({
|
||||||
{property.photo_thumbnail && (
|
{property.photo_thumbnail && (
|
||||||
<img
|
<img
|
||||||
src={property.photo_thumbnail}
|
src={property.photo_thumbnail}
|
||||||
alt="Property"
|
alt={`${property.rooms}-bed, £${property.total_price.toLocaleString()}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
|
||||||
import { Heart } from 'lucide-react';
|
|
||||||
import { PropertyCard } from './PropertyCard';
|
|
||||||
import type { GeoJSONFeatureCollection, PropertyFeature, DecisionType } from '@/types';
|
|
||||||
|
|
||||||
interface SavedViewProps {
|
|
||||||
listingData: GeoJSONFeatureCollection;
|
|
||||||
getDecision: (listingId: number, listingType?: string) => DecisionType | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getListingId(feature: PropertyFeature): number {
|
|
||||||
const parts = feature.properties.url.split('/');
|
|
||||||
return parseInt(parts[parts.length - 1], 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SavedView({ listingData, getDecision }: SavedViewProps) {
|
|
||||||
const savedFeatures = useMemo(() => {
|
|
||||||
return listingData.features.filter((f) => {
|
|
||||||
const id = getListingId(f);
|
|
||||||
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
|
||||||
return getDecision(id, type) === 'liked';
|
|
||||||
});
|
|
||||||
}, [listingData, getDecision]);
|
|
||||||
|
|
||||||
if (savedFeatures.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="text-center p-8">
|
|
||||||
<Heart className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
|
||||||
<p className="text-xl font-semibold mb-2">No saved properties yet</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Use the Review mode to swipe through properties and save ones you like.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col bg-background">
|
|
||||||
<div className="px-3 py-2 text-sm text-muted-foreground border-b">
|
|
||||||
{savedFeatures.length} saved {savedFeatures.length === 1 ? 'property' : 'properties'}
|
|
||||||
</div>
|
|
||||||
<Virtuoso
|
|
||||||
className="flex-1"
|
|
||||||
data={savedFeatures}
|
|
||||||
overscan={200}
|
|
||||||
itemContent={(_index, feature) => (
|
|
||||||
<div className="px-3 pb-2 first:pt-3">
|
|
||||||
<PropertyCard
|
|
||||||
key={feature.properties.url}
|
|
||||||
property={feature.properties}
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
import { useRef, useState, useCallback, useEffect, useMemo } 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';
|
||||||
|
|
@ -20,6 +20,11 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
const p = feature.properties;
|
const p = feature.properties;
|
||||||
const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : [];
|
const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : [];
|
||||||
|
|
||||||
|
const prefersReducedMotion = useMemo(
|
||||||
|
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState(0);
|
const [selectedPhoto, setSelectedPhoto] = useState(0);
|
||||||
|
|
||||||
|
|
@ -41,6 +46,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
scale: 1 - stackIndex * 0.05,
|
scale: 1 - stackIndex * 0.05,
|
||||||
opacity: stackIndex <= 2 ? 1 : 0,
|
opacity: stackIndex <= 2 ? 1 : 0,
|
||||||
config: { tension: 300, friction: 25 },
|
config: { tension: 300, friction: 25 },
|
||||||
|
immediate: prefersReducedMotion,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const bind = useDrag(
|
const bind = useDrag(
|
||||||
|
|
@ -118,7 +124,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{photos.map((url, i) => (
|
{photos.map((url, i) => (
|
||||||
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
<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" />
|
<img src={url} alt={`Property photo ${i + 1}`} className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,7 +150,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : photos.length === 1 ? (
|
) : photos.length === 1 ? (
|
||||||
<img src={photos[0]} alt="Property" className="w-full h-full object-cover" />
|
<img src={photos[0]} alt={`${p.rooms}-bed, ${p.qm}m², £${p.total_price.toLocaleString()}`} className="w-full h-full object-cover" />
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="absolute top-3 right-3 bg-background/80 backdrop-blur-sm rounded-full p-2 z-20"
|
className="absolute top-3 right-3 bg-background/80 backdrop-blur-sm rounded-full p-2 z-20"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ export function SwipeReviewMode({
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
|
const [showOnboarding, setShowOnboarding] = useState(() => {
|
||||||
|
return localStorage.getItem('swipe-review-onboarded') !== 'true';
|
||||||
|
});
|
||||||
|
|
||||||
const handleSwipe = useCallback(
|
const handleSwipe = useCallback(
|
||||||
(direction: 'left' | 'right' | 'up') => {
|
(direction: 'left' | 'right' | 'up') => {
|
||||||
|
|
@ -142,6 +145,7 @@ export function SwipeReviewMode({
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
{!isFinished && (
|
{!isFinished && (
|
||||||
<div className="flex items-center justify-center gap-6 py-6 px-4">
|
<div className="flex items-center justify-center gap-6 py-6 px-4">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -150,7 +154,10 @@ export function SwipeReviewMode({
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">Dislike</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -160,7 +167,10 @@ export function SwipeReviewMode({
|
||||||
>
|
>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">Undo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -169,7 +179,10 @@ export function SwipeReviewMode({
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">Skip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -178,6 +191,40 @@ export function SwipeReviewMode({
|
||||||
>
|
>
|
||||||
<Heart className="h-6 w-6" />
|
<Heart className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">Like</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Onboarding overlay */}
|
||||||
|
{showOnboarding && (
|
||||||
|
<div className="absolute inset-0 z-50 bg-black/60 flex flex-col items-center justify-center gap-8 p-8">
|
||||||
|
<div className="flex items-center gap-12 text-white">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<X className="h-10 w-10 text-red-400" />
|
||||||
|
<span className="text-sm font-medium">Swipe left</span>
|
||||||
|
<span className="text-xs text-white/70">Dislike</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<ArrowUp className="h-10 w-10 text-white/80" />
|
||||||
|
<span className="text-sm font-medium">Swipe up</span>
|
||||||
|
<span className="text-xs text-white/70">Skip</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Heart className="h-10 w-10 text-green-400" />
|
||||||
|
<span className="text-sm font-medium">Swipe right</span>
|
||||||
|
<span className="text-xs text-white/70">Like</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowOnboarding(false);
|
||||||
|
localStorage.setItem('swipe-review-onboarded', 'true');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -153,3 +153,12 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue