diff --git a/web/src/app/airline/products/page.tsx b/web/src/app/airline/products/page.tsx new file mode 100644 index 0000000..c62e1d1 --- /dev/null +++ b/web/src/app/airline/products/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Navigation } from '@/components/ui'; +import AirlineCard from '@/components/feats/airline/AirlineCard'; + +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; +} + +const genRandomFlights = (): Flight[] => { + const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA']; + const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first']; + const fareRules: FareRule[] = ['flexible', 'standard', 'basic']; + + return Array.from({ length: 12 }, (_, i) => { + const depHour = Math.floor(Math.random() * 24); + const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24; + const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1; + const cabin = cabins[Math.floor(Math.random() * cabins.length)]; + const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)]; + + const basePrice = Math.floor( + (cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) + + Math.random() * 300 + ); + + return { + id: `flt-${i}`, + departure: { + time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`, + airport: airports[Math.floor(Math.random() * airports.length)], + }, + arrival: { + time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`, + airport: airports[Math.floor(Math.random() * airports.length)], + }, + duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`, + stops, + cabinClass: cabin, + fareRule, + refundable: Math.random() > 0.7, + basePrice, + }; + }); +}; + +export default function AirlineProducts() { + const flights = genRandomFlights(); + + return ( + <> + +
+

Available Flights

+
+ {flights.map((f) => ( + + ))} +
+
+ + ); +} diff --git a/web/src/app/hotel/products/page.tsx b/web/src/app/hotel/products/page.tsx new file mode 100644 index 0000000..ece120b --- /dev/null +++ b/web/src/app/hotel/products/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Navigation } from '@/components/ui'; +import HotelCard from '@/components/feats/hotel/HotelCard'; + +interface Hotel { + id: string; + name: string; + roomType: string; + checkIn: string; + checkOut: string; + amenities: string[]; + refundable: boolean; + pricePerNight: number; + nights: number; +} + +const genRandomHotels = (): Hotel[] => { + const names = [ + 'Grand Plaza Hotel', + 'Seaside Resort', + 'Downtown Suites', + 'Mountain View Lodge', + 'City Center Inn', + 'Luxury Beach Resort', + 'Urban Boutique Hotel', + 'Garden View Hotel', + ]; + const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room']; + const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa']; + + return Array.from({ length: 10 }, (_, i) => { + const nights = Math.floor(Math.random() * 5) + 1; + const basePrice = Math.floor(80 + Math.random() * 220); + const selectedAmenities = amenities + .sort(() => Math.random() - 0.5) + .slice(0, Math.floor(Math.random() * 3) + 2); + + const today = new Date(); + const checkInDate = new Date(today); + checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10)); + const checkOutDate = new Date(checkInDate); + checkOutDate.setDate(checkInDate.getDate() + nights); + + return { + id: `htl-${i}`, + name: names[i % names.length], + roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)], + checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + amenities: selectedAmenities, + refundable: Math.random() > 0.5, + pricePerNight: basePrice, + nights, + }; + }); +}; + +export default function HotelProducts() { + const hotels = genRandomHotels(); + + return ( + <> + +
+

Available Hotels

+
+ {hotels.map((h) => ( + + ))} +
+
+ + ); +} diff --git a/web/src/components/feats/airline/AirlineCard.tsx b/web/src/components/feats/airline/AirlineCard.tsx new file mode 100644 index 0000000..eff36d0 --- /dev/null +++ b/web/src/components/feats/airline/AirlineCard.tsx @@ -0,0 +1,73 @@ +'use client'; + +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, 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; +} + +const PriceDisplay = ({ price }: { price: number }) => ( +
${price}
+); + +export default function AirlineCard({ flight }: { flight: Flight }) { + const handleCardClick = () => { + dispatchInteraction('product_view', flight.id, { + cabinClass: flight.cabinClass, + fareRule: flight.fareRule, + price: flight.basePrice, + }); + }; + + return ( +
dispatchInteraction('product_hover', flight.id)} + > +
+
{flight.departure.time}
+
{flight.departure.airport}
+
+ +
+
{flight.duration}
+
+ {flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`} +
+
+ +
+
{flight.arrival.time}
+
{flight.arrival.airport}
+
+ +
+
{flight.cabinClass}
+
{flight.fareRule}
+ {flight.refundable && ( +
Refundable
+ )} + +
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelCard.tsx b/web/src/components/feats/hotel/HotelCard.tsx new file mode 100644 index 0000000..2e635a8 --- /dev/null +++ b/web/src/components/feats/hotel/HotelCard.tsx @@ -0,0 +1,87 @@ +'use client'; + +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, 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 PriceDisplay = ({ price, perNight }: { price: number; perNight: boolean }) => ( +
+
{perNight ? 'Per night' : 'Total'}
+
${price}
+ {perNight &&
/night
} +
+); + +const AmenityIcon = ({ name }: { name: string }) => { + const iconMap: Record = { + wifi: 'Wi-Fi', + pool: 'Pool', + gym: 'Gym', + parking: 'Parking', + breakfast: 'Breakfast', + spa: 'Spa', + }; + return {iconMap[name.toLowerCase()] || name}; +}; + +export default function HotelCard({ hotel }: { hotel: Hotel }) { + const handleCardClick = () => { + dispatchInteraction('product_view', hotel.id, { + roomType: hotel.roomType, + price: hotel.pricePerNight, + nights: hotel.nights, + }); + }; + + return ( +
dispatchInteraction('product_hover', hotel.id)} + > +
+ Image +
+ +
+

{hotel.name}

+
{hotel.roomType}
+
+ {hotel.checkIn} - {hotel.checkOut} +
+
+ {hotel.amenities.map((a) => ( + + ))} +
+ {hotel.refundable && ( +
Free cancellation
+ )} +
+ +
+ +
+ ${hotel.pricePerNight * hotel.nights} total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''} +
+
+
+ ); +} diff --git a/web/src/components/ui/Navigation.tsx b/web/src/components/ui/Navigation.tsx new file mode 100644 index 0000000..47753d6 --- /dev/null +++ b/web/src/components/ui/Navigation.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, metadata }, + }); + document.dispatchEvent(e); +}; + +const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => { + const path = usePathname(); + const isActive = path === href; + + return ( + + {children} + + ); +}; + +export default function Navigation() { + return ( + + ); +} diff --git a/web/src/lib/events.ts b/web/src/lib/events.ts new file mode 100644 index 0000000..09292e6 --- /dev/null +++ b/web/src/lib/events.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +// canonical events for tracking user interactions +export type EventName = + | 'page_view' + | 'click' + | 'product_view' + | 'product_hover' + | 'search' + | 'filter_apply' + | 'sort_change' + | 'add_to_cart' + | 'remove_from_cart' + | 'checkout_start' + | 'purchase_complete' + | 'session_start'; + +export const eventNames: readonly EventName[] = [ + 'page_view', + 'click', + 'product_view', + 'product_hover', + 'search', + 'filter_apply', + 'sort_change', + 'add_to_cart', + 'remove_from_cart', + 'checkout_start', + 'purchase_complete', + 'session_start', +] as const; + +export interface EventBase { + sessionId: string; + experimentId?: string; + storeMode: 'hotel' | 'airline'; + ts: string; // ISO8601 + page: string; + eventName: EventName; + productId?: string; + metadata?: Record; + userAgent?: string; +} + +// zod schema for runtime validation +export const eventBaseSchema = z.object({ + sessionId: z.string().min(1), + experimentId: z.string().optional(), + storeMode: z.enum(['hotel', 'airline']), + ts: z.string().datetime(), // validates ISO8601 + page: z.string().min(1), + eventName: z.enum([ + 'page_view', + 'product_view', + 'product_hover', + 'search', + 'filter_apply', + 'sort_change', + 'add_to_cart', + 'remove_from_cart', + 'checkout_start', + 'purchase_complete', + 'session_start', + ]), + productId: z.string().optional(), + metadata: z.record(z.unknown()).optional(), + userAgent: z.string().optional(), +}); + +export type EventBaseValidated = z.infer;