mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
events and products apge
This commit is contained in:
74
web/src/app/airline/products/page.tsx
Normal file
74
web/src/app/airline/products/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
||||
<div className="space-y-4">
|
||||
{flights.map((f) => (
|
||||
<AirlineCard key={f.id} flight={f} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
web/src/app/hotel/products/page.tsx
Normal file
75
web/src/app/hotel/products/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
||||
<div className="space-y-4">
|
||||
{hotels.map((h) => (
|
||||
<HotelCard key={h.id} hotel={h} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
web/src/components/feats/airline/AirlineCard.tsx
Normal file
73
web/src/components/feats/airline/AirlineCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
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 }) => (
|
||||
<div className="fare-price">${price}</div>
|
||||
);
|
||||
|
||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||
const handleCardClick = () => {
|
||||
dispatchInteraction('product_view', flight.id, {
|
||||
cabinClass: flight.cabinClass,
|
||||
fareRule: flight.fareRule,
|
||||
price: flight.basePrice,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flight-card cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => dispatchInteraction('product_hover', flight.id)}
|
||||
>
|
||||
<div className="flight-timing">
|
||||
<div className="flight-time">{flight.departure.time}</div>
|
||||
<div className="flight-airport">{flight.departure.airport}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-route">
|
||||
<div className="flight-duration">{flight.duration}</div>
|
||||
<div className="flight-stops">
|
||||
{flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-timing">
|
||||
<div className="flight-time">{flight.arrival.time}</div>
|
||||
<div className="flight-airport">{flight.arrival.airport}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-pricing">
|
||||
<div className="fare-class capitalize mb-2">{flight.cabinClass}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2 capitalize">{flight.fareRule}</div>
|
||||
{flight.refundable && (
|
||||
<div className="badge-value text-xs mb-2">Refundable</div>
|
||||
)}
|
||||
<PriceDisplay price={flight.basePrice} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
web/src/components/feats/hotel/HotelCard.tsx
Normal file
87
web/src/components/feats/hotel/HotelCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
|
||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||
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 }) => (
|
||||
<div className="price-wrapper">
|
||||
<div className="price-label">{perNight ? 'Per night' : 'Total'}</div>
|
||||
<div className="price-amount">${price}</div>
|
||||
{perNight && <div className="price-unit">/night</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AmenityIcon = ({ name }: { name: string }) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
wifi: 'Wi-Fi',
|
||||
pool: 'Pool',
|
||||
gym: 'Gym',
|
||||
parking: 'Parking',
|
||||
breakfast: 'Breakfast',
|
||||
spa: 'Spa',
|
||||
};
|
||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
|
||||
};
|
||||
|
||||
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
const handleCardClick = () => {
|
||||
dispatchInteraction('product_view', hotel.id, {
|
||||
roomType: hotel.roomType,
|
||||
price: hotel.pricePerNight,
|
||||
nights: hotel.nights,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hotel-card cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => dispatchInteraction('product_hover', hotel.id)}
|
||||
>
|
||||
<div className="hotel-image bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-sm">Image</span>
|
||||
</div>
|
||||
|
||||
<div className="hotel-info">
|
||||
<h3 className="hotel-name">{hotel.name}</h3>
|
||||
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{hotel.checkIn} - {hotel.checkOut}
|
||||
</div>
|
||||
<div className="hotel-features">
|
||||
{hotel.amenities.map((a) => (
|
||||
<AmenityIcon key={a} name={a} />
|
||||
))}
|
||||
</div>
|
||||
{hotel.refundable && (
|
||||
<div className="free-cancellation mt-2">Free cancellation</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hotel-pricing">
|
||||
<PriceDisplay price={hotel.pricePerNight} perNight />
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
${hotel.pricePerNight * hotel.nights} total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
web/src/components/ui/Navigation.tsx
Normal file
48
web/src/components/ui/Navigation.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||
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 (
|
||||
<Link
|
||||
href={href}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Navigation() {
|
||||
return (
|
||||
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center space-x-1">
|
||||
<NavLink href="/">Home</NavLink>
|
||||
<NavLink href="/products">Products</NavLink>
|
||||
<NavLink href="/search">Search</NavLink>
|
||||
<NavLink href="/cart">Cart</NavLink>
|
||||
<NavLink href="/checkout">Checkout</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
70
web/src/lib/events.ts
Normal file
70
web/src/lib/events.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<typeof eventBaseSchema>;
|
||||
Reference in New Issue
Block a user