mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
implement a pricing display api with session passing
This commit is contained in:
@@ -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
|
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_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_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
|
# Service ports - used by docker-compose and service communication
|
||||||
KAFKA_PORT=9092 # kafka broker port for event streaming
|
KAFKA_PORT=9092 # kafka broker port for event streaming
|
||||||
|
|||||||
45
web/src/app/api/pricing/route.ts
Normal file
45
web/src/app/api/pricing/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||||
const e = new CustomEvent('definedInteraction', {
|
const e = new CustomEvent('definedInteraction', {
|
||||||
@@ -25,10 +26,6 @@ interface Flight {
|
|||||||
basePrice: number;
|
basePrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PriceDisplay = ({ price }: { price: number }) => (
|
|
||||||
<div className="fare-price">${price}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||||
const durationRef = useHoverTracking({
|
const durationRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
@@ -39,7 +36,7 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
|||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: `$${flight.basePrice}` },
|
metadata: { elementText: 'price' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -79,7 +76,10 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
|||||||
<div className="badge-value text-xs mb-2">Refundable</div>
|
<div className="badge-value text-xs mb-2">Refundable</div>
|
||||||
)}
|
)}
|
||||||
<div ref={priceRef}>
|
<div ref={priceRef}>
|
||||||
<PriceDisplay price={flight.basePrice} />
|
<PriceDisplay
|
||||||
|
productId={flight.id}
|
||||||
|
className="fare-price"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
||||||
const e = new CustomEvent('definedInteraction', {
|
const e = new CustomEvent('definedInteraction', {
|
||||||
@@ -22,14 +23,6 @@ interface Hotel {
|
|||||||
nights: 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 AmenityIcon = ({ name }: { name: string }) => {
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
wifi: 'Wi-Fi',
|
wifi: 'Wi-Fi',
|
||||||
@@ -52,7 +45,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: `$${hotel.pricePerNight}` },
|
metadata: { elementText: 'price' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -90,10 +83,14 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
|
|
||||||
<div className="hotel-pricing">
|
<div className="hotel-pricing">
|
||||||
<div ref={priceRef}>
|
<div ref={priceRef}>
|
||||||
<PriceDisplay price={hotel.pricePerNight} perNight />
|
<PriceDisplay
|
||||||
|
productId={hotel.id}
|
||||||
|
className="price-wrapper"
|
||||||
|
perNight
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||||
${hotel.pricePerNight * hotel.nights} total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
|
Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
130
web/src/components/ui/PriceDisplay.tsx
Normal file
130
web/src/components/ui/PriceDisplay.tsx
Normal file
@@ -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<SessionData> => {
|
||||||
|
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<SessionData | null>(null);
|
||||||
|
const [data, setData] = useState<PricingData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className={`price-loading ${className}`}>
|
||||||
|
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className={`price-error ${className}`}>
|
||||||
|
<span className="text-red-500 text-sm">Price unavailable</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStale = isCacheStale(data.cachedAt);
|
||||||
|
const formattedPrice = formatPrice(data.price, data.currency);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`price-display ${className}`}>
|
||||||
|
<div className="price-amount">
|
||||||
|
{formattedPrice}
|
||||||
|
{perNight && <span className="text-xs ml-1">/night</span>}
|
||||||
|
</div>
|
||||||
|
{isStale && (
|
||||||
|
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
|
||||||
|
prices maybe out outdated
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
web/src/hooks/useHoverTracking.ts
Normal file
63
web/src/hooks/useHoverTracking.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
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 UseHoverTrackingOptions {
|
||||||
|
eventName: EventName;
|
||||||
|
productId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
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<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const startRef = useRef<number | undefined>(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]);
|
||||||
|
};
|
||||||
@@ -17,7 +17,7 @@ export type EventName =
|
|||||||
| 'filter_for_amenities'
|
| 'filter_for_amenities'
|
||||||
| 'filter_for_price'
|
| 'filter_for_price'
|
||||||
| 'sort_change'
|
| 'sort_change'
|
||||||
// dwell signals (3s threshold)
|
// dwell signals (Ns threshold)
|
||||||
| 'hover_over_title'
|
| 'hover_over_title'
|
||||||
| 'hover_over_paragraph'
|
| 'hover_over_paragraph'
|
||||||
| 'hover_over_link'
|
| 'hover_over_link'
|
||||||
|
|||||||
Reference in New Issue
Block a user