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 [showReviewMode, setShowReviewMode] = useState(false);
|
||||
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
||||
const [bottomSheetSnap, setBottomSheetSnap] = useState<string | number | null>("148px");
|
||||
|
||||
// Decision state (like/dislike)
|
||||
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
||||
|
|
@ -487,7 +488,12 @@ function App() {
|
|||
</div>
|
||||
|
||||
{/* 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
|
||||
size="lg"
|
||||
variant="outline"
|
||||
|
|
@ -541,6 +547,7 @@ function App() {
|
|||
highlightedPropertyUrl={highlightedProperty}
|
||||
onActiveListingChange={handleActiveListingChange}
|
||||
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" />
|
||||
Apply Filters
|
||||
Show Matching Listings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -565,8 +565,9 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
|
|||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh Data
|
||||
Scrape New from Rightmove
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">Triggers a live crawl — may take several minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as d3 from "d3";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
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 { createRoot } from 'react-dom/client';
|
||||
import "../assets/Map.css";
|
||||
|
|
@ -32,6 +32,10 @@ interface MapProps {
|
|||
export function Map(props: MapProps) {
|
||||
const data = props.listingData;
|
||||
|
||||
const [showMapHint, setShowMapHint] = useState(() => {
|
||||
return localStorage.getItem('map-hint-dismissed') !== 'true';
|
||||
});
|
||||
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const heatmapRef = useRef<HexgridHeatmapClient | null>(null);
|
||||
|
|
@ -421,6 +425,20 @@ export function Map(props: MapProps) {
|
|||
</button>
|
||||
</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">
|
||||
<svg id="svg"></svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Drawer } from 'vaul';
|
||||
import { MapPin, PoundSterling } from 'lucide-react';
|
||||
import { SwipeableCardRow } from './SwipeableCardRow';
|
||||
|
|
@ -11,6 +11,7 @@ interface MobileBottomSheetProps {
|
|||
highlightedPropertyUrl?: string | null;
|
||||
onActiveListingChange?: (feature: PropertyFeature | null) => void;
|
||||
poiMetricSelection?: { poiId: number; travelMode: string } | null;
|
||||
onSnapChange?: (snap: string | number | null) => void;
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
|
|
@ -24,10 +25,16 @@ export function MobileBottomSheet({
|
|||
highlightedPropertyUrl,
|
||||
onActiveListingChange,
|
||||
poiMetricSelection,
|
||||
onSnapChange,
|
||||
}: MobileBottomSheetProps) {
|
||||
const [snap, setSnap] = useState<string | number | null>("148px");
|
||||
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
||||
|
||||
// Notify parent when snap changes
|
||||
useEffect(() => {
|
||||
onSnapChange?.(snap);
|
||||
}, [snap, onSnapChange]);
|
||||
|
||||
const features = listingData?.features ?? [];
|
||||
|
||||
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 [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
|
|||
return (
|
||||
<img
|
||||
src={photos[0]}
|
||||
alt="Property"
|
||||
alt={altText || "Property"}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
|
@ -120,7 +120,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
|
|||
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
||||
<img
|
||||
src={url}
|
||||
alt={`Photo ${i + 1}`}
|
||||
alt={`Property photo ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
|
@ -187,7 +187,10 @@ export function PropertyCard({
|
|||
{/* Photo carousel */}
|
||||
<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]} />
|
||||
<CardCarousel
|
||||
photos={property.photos?.length ? property.photos : [property.photo_thumbnail]}
|
||||
altText={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -248,7 +251,7 @@ export function PropertyCard({
|
|||
{property.photo_thumbnail && (
|
||||
<img
|
||||
src={property.photo_thumbnail}
|
||||
alt="Property"
|
||||
alt={`${property.rooms}-bed, ${property.qm}m², £${property.total_price.toLocaleString()}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function PropertyCardCompact({
|
|||
{property.photo_thumbnail && (
|
||||
<img
|
||||
src={property.photo_thumbnail}
|
||||
alt="Property"
|
||||
alt={`${property.rooms}-bed, £${property.total_price.toLocaleString()}`}
|
||||
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 { useDrag } from '@use-gesture/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 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 [selectedPhoto, setSelectedPhoto] = useState(0);
|
||||
|
||||
|
|
@ -41,6 +46,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
scale: 1 - stackIndex * 0.05,
|
||||
opacity: stackIndex <= 2 ? 1 : 0,
|
||||
config: { tension: 300, friction: 25 },
|
||||
immediate: prefersReducedMotion,
|
||||
}));
|
||||
|
||||
const bind = useDrag(
|
||||
|
|
@ -118,7 +124,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
<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" />
|
||||
<img src={url} alt={`Property photo ${i + 1}`} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -144,7 +150,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
</div>
|
||||
</>
|
||||
) : 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}
|
||||
<button
|
||||
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 [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [showOnboarding, setShowOnboarding] = useState(() => {
|
||||
return localStorage.getItem('swipe-review-onboarded') !== 'true';
|
||||
});
|
||||
|
||||
const handleSwipe = useCallback(
|
||||
(direction: 'left' | 'right' | 'up') => {
|
||||
|
|
@ -142,41 +145,85 @@ export function SwipeReviewMode({
|
|||
{/* Action buttons */}
|
||||
{!isFinished && (
|
||||
<div className="flex items-center justify-center gap-6 py-6 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="rounded-full h-14 w-14 border-red-300 text-red-500 hover:bg-red-50"
|
||||
onClick={() => handleSwipe('left')}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="rounded-full h-14 w-14 border-red-300 text-red-500 hover:bg-red-50"
|
||||
onClick={() => handleSwipe('left')}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">Dislike</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full h-10 w-10"
|
||||
onClick={handleUndo}
|
||||
disabled={history.length === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full h-10 w-10"
|
||||
onClick={handleUndo}
|
||||
disabled={history.length === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">Undo</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full h-10 w-10"
|
||||
onClick={() => handleSwipe('up')}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full h-10 w-10"
|
||||
onClick={() => handleSwipe('up')}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">Skip</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="rounded-full h-14 w-14 border-green-300 text-green-500 hover:bg-green-50"
|
||||
onClick={() => handleSwipe('right')}
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
</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="outline"
|
||||
size="lg"
|
||||
className="rounded-full h-14 w-14 border-green-300 text-green-500 hover:bg-green-50"
|
||||
onClick={() => handleSwipe('right')}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowOnboarding(false);
|
||||
localStorage.setItem('swipe-review-onboarded', 'true');
|
||||
}}
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -153,3 +153,12 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue