334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
|
import React, { useState } from 'react';
|
|
import { DateTime } from 'luxon';
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card } from '@/components/ui/card';
|
|
import {
|
|
MapPin, Star, ExternalLink, Navigation, Globe, Phone, ChevronDown, ChevronUp,
|
|
Clock
|
|
} from 'lucide-react';
|
|
|
|
interface Location {
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
interface Photo {
|
|
thumbnail: string;
|
|
small: string;
|
|
medium: string;
|
|
large: string;
|
|
original: string;
|
|
caption?: string;
|
|
}
|
|
|
|
interface Place {
|
|
name: string;
|
|
location: Location;
|
|
place_id: string;
|
|
vicinity: string;
|
|
rating?: number;
|
|
reviews_count?: number;
|
|
price_level?: string;
|
|
description?: string;
|
|
photos?: Photo[];
|
|
is_closed?: boolean;
|
|
next_open_close?: string;
|
|
type?: string;
|
|
cuisine?: string;
|
|
source?: string;
|
|
phone?: string;
|
|
website?: string;
|
|
hours?: string[];
|
|
distance?: number;
|
|
bearing?: string;
|
|
timezone?: string;
|
|
}
|
|
|
|
interface PlaceCardProps {
|
|
place: Place;
|
|
onClick: () => void;
|
|
isSelected?: boolean;
|
|
variant?: 'overlay' | 'list';
|
|
}
|
|
|
|
|
|
const HoursSection: React.FC<{ hours: string[]; timezone?: string }> = ({ hours, timezone }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const now = timezone ?
|
|
DateTime.now().setZone(timezone) :
|
|
DateTime.now();
|
|
const currentDay = now.weekdayLong;
|
|
|
|
if (!hours?.length) return null;
|
|
|
|
// Find today's hours
|
|
const todayHours = hours.find(h => h.startsWith(currentDay!))?.split(': ')[1] || 'Closed';
|
|
|
|
return (
|
|
<div className="mt-4 border-t dark:border-neutral-800">
|
|
<div
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsOpen(!isOpen);
|
|
}}
|
|
className={cn(
|
|
"mt-4 flex items-center gap-2 cursor-pointer transition-colors",
|
|
"text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Clock className="h-4 w-4 flex-shrink-0" />
|
|
<span>Today: <span className="font-medium text-neutral-900 dark:text-neutral-100">{todayHours}</span></span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsOpen(!isOpen);
|
|
}}
|
|
className="ml-auto p-0 h-8 w-8 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
|
>
|
|
{isOpen ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className={cn(
|
|
"grid transition-all duration-200 overflow-hidden",
|
|
isOpen ? "grid-rows-[1fr] mt-2" : "grid-rows-[0fr]"
|
|
)}>
|
|
<div className="overflow-hidden">
|
|
<div className="rounded-md border dark:border-neutral-800 divide-y divide-neutral-100 dark:divide-neutral-800 bg-neutral-50 dark:bg-neutral-900">
|
|
{hours.map((timeSlot, idx) => {
|
|
const [day, hours] = timeSlot.split(': ');
|
|
const isToday = day === currentDay;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
"flex items-center justify-between py-2 px-3 text-sm rounded-md",
|
|
isToday && "bg-white dark:bg-neutral-800"
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
"font-medium",
|
|
isToday ? "text-primary" : "text-neutral-600 dark:text-neutral-400"
|
|
)}>
|
|
{day}
|
|
</span>
|
|
<span className={cn(
|
|
isToday ? "font-medium" : "text-neutral-600 dark:text-neutral-400"
|
|
)}>
|
|
{hours}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
const PlaceCard: React.FC<PlaceCardProps> = ({
|
|
place,
|
|
onClick,
|
|
isSelected = false,
|
|
variant = 'list'
|
|
}) => {
|
|
const [showHours, setShowHours] = useState(false);
|
|
const isOverlay = variant === 'overlay';
|
|
|
|
const formatTime = (timeStr: string | undefined, timezone: string | undefined): string => {
|
|
if (!timeStr || !timezone) return '';
|
|
const hours = Math.floor(parseInt(timeStr) / 100);
|
|
const minutes = parseInt(timeStr) % 100;
|
|
return DateTime.now()
|
|
.setZone(timezone)
|
|
.set({ hour: hours, minute: minutes })
|
|
.toFormat('h:mm a');
|
|
};
|
|
|
|
const getStatusDisplay = (): { text: string; color: string } | null => {
|
|
if (!place.timezone || place.is_closed === undefined || !place.next_open_close) {
|
|
return null;
|
|
}
|
|
|
|
const timeStr = formatTime(place.next_open_close, place.timezone);
|
|
if (place.is_closed) {
|
|
return {
|
|
text: `Closed · Opens ${timeStr}`,
|
|
color: 'red-600 dark:text-red-400'
|
|
};
|
|
}
|
|
return {
|
|
text: `Open · Closes ${timeStr}`,
|
|
color: 'green-600 dark:text-green-400'
|
|
};
|
|
};
|
|
|
|
const statusDisplay = getStatusDisplay();
|
|
|
|
const cardContent = (
|
|
<>
|
|
<div className="flex gap-3">
|
|
{/* Image with Price Badge */}
|
|
{place.photos?.[0]?.medium && (
|
|
<div className="relative w-20 h-20 rounded-md overflow-hidden flex-shrink-0">
|
|
<img
|
|
src={place.photos[0].medium}
|
|
alt={place.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
{place.price_level && (
|
|
<div className="absolute top-0 left-0 bg-black/80 text-white px-2 py-0.5 text-xs font-medium">
|
|
{place.price_level}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold truncate pr-6">
|
|
{place.name}
|
|
</h3>
|
|
|
|
{/* Rating & Reviews */}
|
|
{place.rating && (
|
|
<div className="flex items-center gap-1 mt-1">
|
|
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
|
<span className="font-medium">{place.rating.toFixed(1)}</span>
|
|
{place.reviews_count && (
|
|
<span className="text-neutral-500">({place.reviews_count})</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status */}
|
|
{statusDisplay && (
|
|
<div className={`text-sm text-${statusDisplay.color} mt-1`}>
|
|
{statusDisplay.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Address */}
|
|
{place.vicinity && (
|
|
<div className="flex items-center text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
|
<MapPin className="w-4 h-4 mr-1 flex-shrink-0" />
|
|
<span className="truncate">{place.vicinity}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex flex-wrap gap-2 mt-3">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
window.open(
|
|
`https://www.google.com/maps/dir/?api=1&destination=${place.location.lat},${place.location.lng}`,
|
|
'_blank'
|
|
);
|
|
}}
|
|
>
|
|
<Navigation className="w-4 h-4 mr-2" />
|
|
Directions
|
|
</Button>
|
|
|
|
{place.phone && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
window.open(`tel:${place.phone}`, '_blank');
|
|
}}
|
|
>
|
|
<Phone className="w-4 h-4 mr-2" />
|
|
Call
|
|
</Button>
|
|
)}
|
|
|
|
{place.website && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
window.open(place.website, '_blank');
|
|
}}
|
|
>
|
|
<Globe className="w-4 h-4 mr-2" />
|
|
Website
|
|
</Button>
|
|
)}
|
|
|
|
{place.place_id && !isOverlay && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
window.open(`https://www.tripadvisor.com/${place.place_id}`, '_blank');
|
|
}}
|
|
>
|
|
<ExternalLink className="w-4 h-4 mr-2" />
|
|
More Info
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hours Section - Only show if has hours */}
|
|
{place.hours && place.hours.length > 0 && (
|
|
<HoursSection hours={place.hours} timezone={place.timezone} />
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (isOverlay) {
|
|
return (
|
|
<div
|
|
className="bg-white/95 dark:bg-black/95 backdrop-blur-sm p-4 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800"
|
|
onClick={onClick}
|
|
>
|
|
{cardContent}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
onClick={onClick}
|
|
className={cn(
|
|
"w-full transition-all duration-200 cursor-pointer p-4",
|
|
"hover:bg-neutral-50 dark:hover:bg-neutral-800",
|
|
isSelected && "ring-2 ring-primary"
|
|
)}
|
|
>
|
|
{cardContent}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default PlaceCard; |