implement a pricing display api with session passing

This commit is contained in:
2025-11-12 11:14:11 +01:00
parent 18d935270f
commit 4f9e3a7ed0
7 changed files with 254 additions and 18 deletions

View 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);
}

View File

@@ -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<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
@@ -25,10 +26,6 @@ interface Flight {
basePrice: number;
}
const PriceDisplay = ({ price }: { price: number }) => (
<div className="fare-price">${price}</div>
);
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 }) {
<div className="badge-value text-xs mb-2">Refundable</div>
)}
<div ref={priceRef}>
<PriceDisplay price={flight.basePrice} />
<PriceDisplay
productId={flight.id}
className="fare-price"
/>
</div>
</div>
</div>

View File

@@ -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<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
@@ -22,14 +23,6 @@ interface Hotel {
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',
@@ -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 }) {
<div className="hotel-pricing">
<div ref={priceRef}>
<PriceDisplay price={hotel.pricePerNight} perNight />
<PriceDisplay
productId={hotel.id}
className="price-wrapper"
perNight
/>
</div>
<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>

View 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>
);
}

View 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]);
};

View File

@@ -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'