diff --git a/.env.example b/.env.example index ae7b99c..95639c9 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ HOSTNAME=localhost # hostname for service discovery across docker network STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling +NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions # Service ports - used by docker-compose and service communication KAFKA_PORT=9092 # kafka broker port for event streaming diff --git a/web/src/app/api/pricing/route.ts b/web/src/app/api/pricing/route.ts new file mode 100644 index 0000000..414d311 --- /dev/null +++ b/web/src/app/api/pricing/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; + +interface PricingResponse { + price: number; + currency: string; + cachedAt: string; +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const productId = searchParams.get('productId'); + const sessionId = searchParams.get('sessionId'); + const experimentId = searchParams.get('experimentId'); + const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop'; + + // log in dev + if (process.env.NODE_ENV === 'development') { + console.log('[pricing-api]', { + productId, + sessionId, + experimentId, + storeMode, + timestamp: new Date().toISOString(), + }); + } + + if (!productId) { + return NextResponse.json( + { error: 'productId is required' }, + { status: 400 } + ); + } + + // stub: call external pricing provider (random for now) + const basePrice = 100 + Math.random() * 900; // 100-1000 range + const price = Math.round(basePrice * 100) / 100; + + const response: PricingResponse = { + price, + currency: 'EUR', + cachedAt: new Date().toISOString(), + }; + + return NextResponse.json(response); +} diff --git a/web/src/components/feats/airline/AirlineCard.tsx b/web/src/components/feats/airline/AirlineCard.tsx index 681bf15..b08827d 100644 --- a/web/src/components/feats/airline/AirlineCard.tsx +++ b/web/src/components/feats/airline/AirlineCard.tsx @@ -2,6 +2,7 @@ import type { EventName } from '@/lib/events'; import { useHoverTracking } from '@/hooks/useHoverTracking'; +import PriceDisplay from '@/components/ui/PriceDisplay'; const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { const e = new CustomEvent('definedInteraction', { @@ -25,10 +26,6 @@ interface Flight { basePrice: number; } -const PriceDisplay = ({ price }: { price: number }) => ( -
${price}
-); - export default function AirlineCard({ flight }: { flight: Flight }) { const durationRef = useHoverTracking({ eventName: 'hover_over_title', @@ -39,7 +36,7 @@ export default function AirlineCard({ flight }: { flight: Flight }) { const priceRef = useHoverTracking({ eventName: 'hover_over_paragraph', productId: flight.id, - metadata: { elementText: `$${flight.basePrice}` }, + metadata: { elementText: 'price' }, }); const handleCardClick = () => { @@ -79,7 +76,10 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
Refundable
)}
- +
diff --git a/web/src/components/feats/hotel/HotelCard.tsx b/web/src/components/feats/hotel/HotelCard.tsx index 9a8c7e1..8c68801 100644 --- a/web/src/components/feats/hotel/HotelCard.tsx +++ b/web/src/components/feats/hotel/HotelCard.tsx @@ -2,6 +2,7 @@ import type { EventName } from '@/lib/events'; import { useHoverTracking } from '@/hooks/useHoverTracking'; +import PriceDisplay from '@/components/ui/PriceDisplay'; const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { const e = new CustomEvent('definedInteraction', { @@ -22,14 +23,6 @@ interface Hotel { 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', @@ -52,7 +45,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) { const priceRef = useHoverTracking({ eventName: 'hover_over_paragraph', productId: hotel.id, - metadata: { elementText: `$${hotel.pricePerNight}` }, + metadata: { elementText: 'price' }, }); const handleCardClick = () => { @@ -90,10 +83,14 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
- +
- ${hotel.pricePerNight * hotel.nights} total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''} + Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
diff --git a/web/src/components/ui/PriceDisplay.tsx b/web/src/components/ui/PriceDisplay.tsx new file mode 100644 index 0000000..bb20619 --- /dev/null +++ b/web/src/components/ui/PriceDisplay.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; + +interface PriceDisplayProps { + productId: string; + className?: string; + perNight?: boolean; +} + +interface PricingData { + price: number; + currency: string; + cachedAt: string; +} + +interface SessionData { + sessionId: string; + experimentId?: string; +} + +const fetchSession = async (): Promise => { + try { + const res = await fetch('/api/session'); + const data = await res.json(); + return { + sessionId: data.sessionId || '', + experimentId: data.experimentId || '', + }; + } catch (err) { + console.error('failed to fetch session:', err); + return { sessionId: '', experimentId: '' }; + } +}; + +const formatPrice = (price: number, currency: string) => { + return new Intl.NumberFormat('en-US', { // like an std localization + style: 'currency', + currency, + }).format(price); +}; + +const isCacheStale = (cachedAt: string, thresholdMs = 60000) => { + const cacheTime = new Date(cachedAt).getTime(); + const now = Date.now(); + return now - cacheTime > thresholdMs; +}; + +export default function PriceDisplay({ + productId, + className = '', + perNight = false, +}: PriceDisplayProps) { + const sessionRef = useRef(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const initAndFetch = async () => { + setLoading(true); + setError(null); + + try { + // fetch session if not already loaded + if (!sessionRef.current) { + sessionRef.current = await fetchSession(); + } + + const { sessionId, experimentId } = sessionRef.current; + + const params = new URLSearchParams({ + productId, + sessionId, + experimentId: experimentId || '', + }); + + const res = await fetch(`/api/pricing?${params.toString()}`); + + if (!res.ok) { + throw new Error(`Failed to fetch price: ${res.status}`); + } + + const pricingData: PricingData = await res.json(); + setData(pricingData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + initAndFetch(); + }, [productId]); + + if (loading) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (error || !data) { + return ( +
+ Price unavailable +
+ ); + } + + const isStale = isCacheStale(data.cachedAt); + const formattedPrice = formatPrice(data.price, data.currency); + + return ( +
+
+ {formattedPrice} + {perNight && /night} +
+ {isStale && ( + + prices maybe out outdated + + )} +
+ ); +} diff --git a/web/src/hooks/useHoverTracking.ts b/web/src/hooks/useHoverTracking.ts new file mode 100644 index 0000000..f0eb4ae --- /dev/null +++ b/web/src/hooks/useHoverTracking.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef } from 'react'; +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 UseHoverTrackingOptions { + eventName: EventName; + productId?: string; + metadata?: Record; + threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD +} + +export const useHoverTracking = (options: UseHoverTrackingOptions) => { + const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD + ? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10) + : 1500; + const { eventName, productId, metadata, threshold = defaultThreshold } = options; + const timerRef = useRef(undefined); + const startRef = useRef(undefined); + + return useCallback((node: HTMLElement | null) => { + if (!node) { + if (timerRef.current) clearTimeout(timerRef.current); + return; + } + + const onEnter = () => { + startRef.current = Date.now(); + timerRef.current = setTimeout(() => { + const dwellTime = Date.now() - startRef.current!; + dispatchInteraction(eventName, productId, { + ...metadata, + dwellTime, + }); + }, threshold); + }; + + const onLeave = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }; + + node.addEventListener('mouseenter', onEnter); + node.addEventListener('mouseleave', onLeave); + + return () => { + node.removeEventListener('mouseenter', onEnter); + node.removeEventListener('mouseleave', onLeave); + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [eventName, productId, metadata, threshold]); +}; diff --git a/web/src/lib/events.ts b/web/src/lib/events.ts index 503621c..52f027f 100644 --- a/web/src/lib/events.ts +++ b/web/src/lib/events.ts @@ -17,7 +17,7 @@ export type EventName = | 'filter_for_amenities' | 'filter_for_price' | 'sort_change' - // dwell signals (3s threshold) + // dwell signals (Ns threshold) | 'hover_over_title' | 'hover_over_paragraph' | 'hover_over_link'