mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
6 catalog data and mode mappers (#25)
* supabase product proxy and rendering * minor pipeline refactor * refactoring and demand estimation * trackion of date index searching * fixing changes of imports * data seeding * chore: airline basic refactor * feat: huge push of product changes and item review with cart * refactored design * chore: moving route elsewhere and align * fix: build of web/ * chore: fixing paper build * fixing chars
This commit is contained in:
committed by
GitHub
parent
894ce87a5d
commit
8b76d24ade
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Flight } from '@/lib/airline-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
@@ -11,32 +12,17 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||
|
||||
interface Flight {
|
||||
id: string;
|
||||
departure: { time: string; airport: string };
|
||||
arrival: { time: string; airport: string };
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabinClass: CabinClass;
|
||||
fareRule: FareRule;
|
||||
refundable: boolean;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
const durationRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: flight.duration },
|
||||
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: flight.id,
|
||||
metadata: { elementText: 'price' },
|
||||
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
@@ -44,7 +30,9 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
cabinClass: flight.cabinClass,
|
||||
fareRule: flight.fareRule,
|
||||
price: flight.basePrice,
|
||||
dateIndex: flight.dateIndex,
|
||||
});
|
||||
window.location.href = `/airline/products/${flight.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
75
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
75
web/src/components/feats/airline/AirlineDetails.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import type { Flight } from '@/lib/airline-utils';
|
||||
|
||||
interface AirlineDetailsProps {
|
||||
product: Flight;
|
||||
onAddToCart: () => void;
|
||||
addedToCart: boolean;
|
||||
}
|
||||
|
||||
export default function AirlineDetails({ product, onAddToCart, addedToCart }: AirlineDetailsProps) {
|
||||
return (
|
||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
{/* Image Section */}
|
||||
<div className="w-full lg:w-1/3 bg-gray-100 rounded-lg aspect-square flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Flight Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-start border-b pb-6 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-1">{product.flightType}</h1>
|
||||
<p className="text-lg text-gray-500">{product.cabinClass} Class</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-4xl font-bold text-gray-900">${product.basePrice}</p>
|
||||
{product.refundable && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-green-50 text-green-700 rounded-full text-xs font-medium">
|
||||
Refundable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="text-center min-w-[100px]">
|
||||
<p className="text-3xl font-bold text-gray-900">{product.departure.time}</p>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.departure.airport}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-8 flex flex-col items-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{product.duration}</p>
|
||||
<div className="w-full h-0.5 bg-gray-200 relative flex items-center justify-center">
|
||||
<div className="absolute w-3 h-3 bg-gray-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center min-w-[100px]">
|
||||
<p className="text-3xl font-bold text-gray-900">{product.arrival.time}</p>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.arrival.airport}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-6 border-t">
|
||||
<div className="text-gray-600">
|
||||
<span className="font-bold text-gray-900">{product.availability}</span> seats remaining
|
||||
<span className="mx-2">•</span>
|
||||
{product.fareRule}
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
||||
>
|
||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
||||
|
||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||
|
||||
@@ -19,6 +21,7 @@ const LocationIcon = () => (
|
||||
);
|
||||
|
||||
export default function AirlineHero() {
|
||||
const router = useRouter();
|
||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
@@ -28,7 +31,23 @@ export default function AirlineHero() {
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (departDate) {
|
||||
const daysOffset = dateToDaysFromToday(departDate);
|
||||
params.set('dateIndex', daysOffset.toString());
|
||||
}
|
||||
|
||||
if (origin) params.set('origin', origin);
|
||||
if (destination) params.set('destination', destination);
|
||||
if (tripType !== 'roundtrip') params.set('tripType', tripType);
|
||||
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
|
||||
|
||||
params.set('adults', passengers.adults.toString());
|
||||
params.set('children', passengers.children.toString());
|
||||
params.set('infants', passengers.infants.toString());
|
||||
|
||||
router.push(`/airline/products?${params.toString()}`);
|
||||
};
|
||||
|
||||
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
@@ -11,18 +12,6 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
||||
document.dispatchEvent(e);
|
||||
};
|
||||
|
||||
interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
roomType: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
|
||||
const AmenityIcon = ({ name }: { name: string }) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
wifi: 'Wi-Fi',
|
||||
@@ -39,13 +28,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
const titleRef = useHoverTracking({
|
||||
eventName: 'hover_over_title',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: hotel.name },
|
||||
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
||||
});
|
||||
|
||||
const priceRef = useHoverTracking({
|
||||
eventName: 'hover_over_paragraph',
|
||||
productId: hotel.id,
|
||||
metadata: { elementText: 'price' },
|
||||
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
||||
});
|
||||
|
||||
const handleCardClick = () => {
|
||||
@@ -53,7 +42,9 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
roomType: hotel.roomType,
|
||||
price: hotel.pricePerNight,
|
||||
nights: hotel.nights,
|
||||
dateIndex: hotel.dateIndex,
|
||||
});
|
||||
window.location.href = `/hotel/products/${hotel.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
74
web/src/components/feats/hotel/HotelDetails.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
|
||||
interface HotelDetailsProps {
|
||||
product: Hotel;
|
||||
onAddToCart: () => void;
|
||||
addedToCart: boolean;
|
||||
}
|
||||
|
||||
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
||||
return (
|
||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
{/* Image Section - Larger and cleaner */}
|
||||
<div className="w-full lg:w-1/2 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section - Full height/width usage */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="border-b pb-6 mb-6">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
|
||||
<p className="text-xl text-gray-500">{product.roomType}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-in</h3>
|
||||
<p className="text-lg text-gray-700">{product.checkIn}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-out</h3>
|
||||
<p className="text-lg text-gray-700">{product.checkOut}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-3">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.amenities.map(a => (
|
||||
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.refundable && (
|
||||
<div className="mb-8 p-4 bg-green-50 text-green-800 rounded-md inline-block">
|
||||
<span className="font-medium">Free cancellation available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6 border-t flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
|
||||
<span className="text-gray-500">/ {product.nights} nights</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddToCart}
|
||||
disabled={addedToCart}
|
||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
||||
>
|
||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
||||
|
||||
const LocationIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -11,14 +13,25 @@ const LocationIcon = () => (
|
||||
);
|
||||
|
||||
export default function HotelHero() {
|
||||
const router = useRouter();
|
||||
const [destination, setDestination] = useState('');
|
||||
const [checkIn, setCheckIn] = useState('');
|
||||
const [checkOut, setCheckOut] = useState('');
|
||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log({ destination, checkIn, checkOut, guests });
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (checkIn) {
|
||||
const daysOffset = dateToDaysFromToday(checkIn);
|
||||
params.set('dateIndex', daysOffset.toString());
|
||||
}
|
||||
|
||||
if (destination) params.set('destination', destination);
|
||||
params.set('adults', guests.adults.toString());
|
||||
params.set('rooms', guests.rooms.toString());
|
||||
|
||||
router.push(`/hotel/products?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -26,16 +39,16 @@ export default function HotelHero() {
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Find your perfect stay
|
||||
Find your perfect room
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Search hotels, compare prices, and book with confidence
|
||||
Search rooms, compare prices, and book with confidence
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="search-form">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="destination">Where to?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -49,7 +62,7 @@ export default function HotelHero() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkIn">Check-in</Label>
|
||||
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
||||
<DateInput
|
||||
id="checkIn"
|
||||
value={checkIn}
|
||||
@@ -59,43 +72,27 @@ export default function HotelHero() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="checkOut">Check-out</Label>
|
||||
<DateInput
|
||||
id="checkOut"
|
||||
value={checkOut}
|
||||
onChange={(e) => setCheckOut(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<Label htmlFor="guests">Guests & Rooms</Label>
|
||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
||||
<Label htmlFor="guests">Guests</Label>
|
||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
||||
<DropdownCounter
|
||||
label="Adults"
|
||||
value={guests.adults}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||
/>
|
||||
<DropdownCounter
|
||||
label="Rooms"
|
||||
value={guests.rooms}
|
||||
min={1}
|
||||
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<Button type="submit" fullWidth>
|
||||
Search Hotels
|
||||
Search Rooms
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user