From 8b76d24ade0d6f40700b95627450f21406aa17c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Alves=20R=C3=B6sel?= <60182044+velocitatem@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:00:31 +0100 Subject: [PATCH] 6 catalog data and mode mappers (#25) * supabase product proxy and rendering * minor pipeline refactor * refactoring and demand estimation * trackion of date index searching * fixing changes of imports * data seeding * chore: airline basic refactor * feat: huge push of product changes and item review with cart * refactored design * chore: moving route elsewhere and align * fix: build of web/ * chore: fixing paper build * fixing chars --- backend/server/app.py | 138 +++ backend/server/requirements.txt | 1 + docker-compose.yml | 3 + experiments/data_export.ipynb | 957 ------------------ experiments/procesing/__init__.py | 19 + experiments/procesing/demand.py | 39 + experiments/procesing/extract.py | 184 ++-- experiments/procesing/pipeline.py | 27 +- experiments/seed_products.py | 125 +++ paper/concat_code.sh | 5 +- web/src/app/airline/products/[id]/page.tsx | 106 ++ web/src/app/airline/products/page.tsx | 102 +- web/src/app/api/products/[id]/route.ts | 35 + web/src/app/api/products/route.ts | 40 + web/src/app/cart/page.tsx | 110 ++ web/src/app/hotel/products/[id]/page.tsx | 106 ++ web/src/app/hotel/products/page.tsx | 113 +-- web/src/app/layout.tsx | 5 +- .../components/feats/airline/AirlineCard.tsx | 22 +- .../feats/airline/AirlineDetails.tsx | 75 ++ .../components/feats/airline/AirlineHero.tsx | 21 +- web/src/components/feats/hotel/HotelCard.tsx | 19 +- .../components/feats/hotel/HotelDetails.tsx | 74 ++ web/src/components/feats/hotel/HotelHero.tsx | 53 +- web/src/contexts/CartContext.tsx | 76 ++ web/src/lib/airline-utils.ts | 75 ++ web/src/lib/hotel-utils.ts | 71 ++ web/src/lib/product-utils.ts | 25 + web/src/proxy.ts | 1 + 29 files changed, 1390 insertions(+), 1237 deletions(-) delete mode 100644 experiments/data_export.ipynb create mode 100644 experiments/procesing/__init__.py create mode 100644 experiments/procesing/demand.py create mode 100644 experiments/seed_products.py create mode 100644 web/src/app/airline/products/[id]/page.tsx create mode 100644 web/src/app/api/products/[id]/route.ts create mode 100644 web/src/app/api/products/route.ts create mode 100644 web/src/app/cart/page.tsx create mode 100644 web/src/app/hotel/products/[id]/page.tsx create mode 100644 web/src/components/feats/airline/AirlineDetails.tsx create mode 100644 web/src/components/feats/hotel/HotelDetails.tsx create mode 100644 web/src/contexts/CartContext.tsx create mode 100644 web/src/lib/airline-utils.ts create mode 100644 web/src/lib/hotel-utils.ts create mode 100644 web/src/lib/product-utils.ts diff --git a/backend/server/app.py b/backend/server/app.py index 4093c7d..2a6e44f 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -11,6 +11,7 @@ from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer from kafka.admin import NewTopic from kafka.errors import TopicAlreadyExistsError from dotenv import load_dotenv +from supabase import create_client, Client load_dotenv() app = FastAPI() @@ -18,6 +19,19 @@ app = FastAPI() # kafka producer - lazy init _producer: Optional[KafkaProducer] = None +# supabase client - lazy init +_supabase: Optional[Client] = None + +def get_supabase() -> Client: + global _supabase + if _supabase is None: + url = os.getenv('NEXT_PUBLIC_SUPABASE_URL') + key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY') + if not url or not key: + raise ValueError("Supabase credentials not configured") + _supabase = create_client(url, key) + return _supabase + def get_producer() -> KafkaProducer: global _producer if _producer is None: @@ -183,6 +197,130 @@ def dump_logs( print(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/products/{product_id}") +async def get_product_by_id(product_id: str): + """fetch single product by id from either hotel_products or airline_products""" + try: + supabase = get_supabase() + + # try hotel_products first + response = supabase.table('hotel_products').select('*').eq('id', product_id).execute() + if response.data and len(response.data) > 0: + return {"success": True, "data": response.data[0]} + + # try airline_products + response = supabase.table('airline_products').select('*').eq('id', product_id).execute() + if response.data and len(response.data) > 0: + return {"success": True, "data": response.data[0]} + + raise HTTPException(status_code=404, detail="Product not found") + + except HTTPException: + raise + except Exception as e: + import traceback + print(f"[PRODUCT_BY_ID_ERROR] {e}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/products/type/{product_type}") +async def get_products( + product_type: str, + dateIndex: Optional[int] = None, + origin: Optional[str] = None, + destination: Optional[str] = None, + tripType: Optional[str] = None, + adults: Optional[int] = None, + children: Optional[int] = None, + infants: Optional[int] = None, + rooms: Optional[int] = None +): + """fetch products from supabase based on type (hotel or airline) + + params: + product_type: either 'hotel' or 'airline' + dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday) + origin: (airline) departure airport code + destination: (airline/hotel) arrival airport or hotel location + tripType: (airline) roundtrip, oneway, multicity + adults, children, infants: passenger counts + rooms: (hotel) number of rooms + """ + if product_type not in ['hotel', 'airline']: + raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'") + + try: + supabase = get_supabase() + table = f'{product_type}_products' + + query = supabase.table(table).select('*') + + # filter by exact date_index if provided + if dateIndex is not None: + query = query.eq('date_index', dateIndex) + + response = query.execute() + results = response.data + + # apply in-memory filters based on metadata for airline products + if product_type == 'airline' and results: + filtered = [] + for product in results: + metadata = product.get('metadata', {}) + + # filter by origin airport + if origin: + dep = metadata.get('departure', {}) + if dep.get('airport') != origin: + continue + + # filter by destination airport + if destination: + arr = metadata.get('arrival', {}) + if arr.get('airport') != destination: + continue + + # passenger count validation (ensure total capacity) + if adults is not None or children is not None or infants is not None: + total_pax = (adults or 0) + (children or 0) + (infants or 0) + avail = product.get('availability', 0) + if avail < total_pax: + continue + + filtered.append(product) + + results = filtered + + # apply in-memory filters for hotel products + elif product_type == 'hotel' and results: + filtered = [] + for product in results: + metadata = product.get('metadata', {}) + + # filter by occupancy capacity + if adults is not None: + max_occ = metadata.get('max_occupancy', 2) + if max_occ < adults: + continue + + # filter by room availability + if rooms is not None: + avail = product.get('availability', 0) + if avail < rooms: + continue + + filtered.append(product) + + results = filtered + + return {"success": True, "count": len(results), "data": results} + + except Exception as e: + import traceback + print(f"[PRODUCTS_ERROR] {e}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + if __name__ == "__main__": diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index d9113ed..6a49ae4 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.24.0 kafka-python==2.0.2 pydantic==2.5.0 python-dotenv==1.0.0 +supabase==2.9.1 diff --git a/docker-compose.yml b/docker-compose.yml index 49223a2..01da852 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: environment: - KAFKA_HOST=kafka - KAFKA_PORT=29092 + - BACKEND_PORT=5000 + - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} + - NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} depends_on: - kafka restart: unless-stopped diff --git a/experiments/data_export.ipynb b/experiments/data_export.ipynb deleted file mode 100644 index 7cd9366..0000000 --- a/experiments/data_export.ipynb +++ /dev/null @@ -1,957 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 10, - "id": "62eafcd9-5462-4063-8873-0e7fb9add907", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from kafka import KafkaConsumer\n", - "import pandas as pd\n", - "import json\n", - "import numpy as np\n", - "import os\n", - "from dotenv import load_dotenv\n", - "import matplotlib.pyplot as plt\n", - "from IPython.display import display, SVG, Image\n", - "load_dotenv()" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "RangeIndex: 73 entries, 0 to 72\n", - "Data columns (total 13 columns):\n", - " # Column Non-Null Count Dtype \n", - "--- ------ -------------- ----- \n", - " 0 sessionId 73 non-null object \n", - " 1 eventName 73 non-null object \n", - " 2 page 73 non-null object \n", - " 3 productId 67 non-null object \n", - " 4 storeMode 73 non-null object \n", - " 5 userAgent 73 non-null object \n", - " 6 ts 73 non-null object \n", - " 7 metadata_referrer 6 non-null object \n", - " 8 metadata_roomType 45 non-null object \n", - " 9 metadata_price 45 non-null float64\n", - " 10 metadata_nights 45 non-null float64\n", - " 11 metadata_elementText 22 non-null object \n", - " 12 metadata_dwellTime 22 non-null float64\n", - "dtypes: float64(3), object(10)\n", - "memory usage: 7.5+ KB\n" - ] - } - ], - "source": [ - "KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n", - "topic = \"user-interactions\"\n", - "consumer = KafkaConsumer(\n", - " topic, \n", - " enable_auto_commit=True,\n", - " value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n", - " auto_offset_reset='earliest', \n", - " bootstrap_servers=['localhost:9092'])\n", - "messages=consumer.poll(timeout_ms=1000,max_records=10000)\n", - "df = []\n", - "for m in messages.values():\n", - " for i in m:\n", - " df.append(i.value)\n", - "df = pd.DataFrame(df)\n", - "# explode metadata col json\n", - "df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n", - "df.info()" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "f6819a1c-32ab-49c7-845b-5df7bf60f561", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
sessionIdeventNamepageproductIdstoreModeuserAgenttsmetadata_referrermetadata_roomTypemetadata_pricemetadata_nightsmetadata_elementTextmetadata_dwellTime
0d176d7c9-4027-4702-9e31-2a71395cdda0page_view/productsNonehotelMozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...2025-11-14T13:23:46.270ZNaNNaNNaNNaNNaNNaNNaNNaNNaNNaN
1f0317a5d-e424-44e9-b784-c8f7291ffe31page_view/NonehotelMozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...2025-11-14T13:26:00.291ZNaNNaNNaNNaNNaN
2f0317a5d-e424-44e9-b784-c8f7291ffe31page_view/productsNonehotelMozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...2025-11-14T13:26:07.769ZNaNNaNNaNNaNNaN
3f0317a5d-e424-44e9-b784-c8f7291ffe31view_item_page/productshtl-0hotelMozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...2025-11-14T13:26:15.010ZNaNPremium Room269.01.0NaNNaN
4238dc588-a7ab-4c0e-bccd-6abca5076c66page_view/productsNonehotelMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...2025-11-14T13:27:15.457ZNaNNaNNaNNaNNaN
5238dc588-a7ab-4c0e-bccd-6abca5076c66view_item_page/productshtl-0hotelMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...2025-11-14T13:27:15.591ZNaNPremium Room264.02.0NaNNaNNaNNaNNaNNaNNaN
432214d9fad-9b00-40c3-bd0e-7739b6acd654click1762448192425DIVNaNNaNNaNNaNNaN/NaN1623.0493.0NaNNaNNaNNaNNaNNaN
6238dc588-a7ab-4c0e-bccd-6abca5076c66view_item_page/productshtl-0hotelMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...2025-11-14T13:27:21.483ZNaNPremium Room264.02.0NaNNaN
7238dc588-a7ab-4c0e-bccd-6abca5076c66hover_over_title/productshtl-0hotelMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...2025-11-14T13:27:22.646ZNaNNaNNaNNaNGrand Plaza Hotel1200.0
8238dc588-a7ab-4c0e-bccd-6abca5076c66view_item_page/productshtl-0hotelMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...2025-11-14T13:27:25.889ZNaNPremium Room264.02.0NaNNaN
35013fc334-4045-4d5a-8739-dd0a8766a63bpage_view/productsNonehotelMozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...2025-11-14T13:53:59.993ZNaNNaNNaNNaNNaN
36013fc334-4045-4d5a-8739-dd0a8766a63bview_item_page/productshtl-0hotelMozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...2025-11-14T13:54:10.705ZNaNPremium Room223.03.0NaNNaN
37013fc334-4045-4d5a-8739-dd0a8766a63bhover_over_title/productshtl-0hotelMozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...2025-11-14T13:54:11.771ZNaNNaN416.0397.0NaNNaNNaNNaNNaNNaNGrand Plaza Hotel1200.0
38013fc334-4045-4d5a-8739-dd0a8766a63bview_item_page/productshtl-1hotelMozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...2025-11-14T13:54:29.772ZNaNStandard Room267.05.0NaNNaN
39013fc334-4045-4d5a-8739-dd0a8766a63bhover_over_title/productshtl-1hotelMozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...2025-11-14T13:54:30.833ZNaNNaNNaNNaNSeaside Resort1200.0
\n", - "
" - ], - "text/plain": [ - " sessionId eventName page \\\n", - "0 d176d7c9-4027-4702-9e31-2a71395cdda0 page_view /products \n", - "1 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view / \n", - "2 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view /products \n", - "3 f0317a5d-e424-44e9-b784-c8f7291ffe31 view_item_page /products \n", - "4 238dc588-a7ab-4c0e-bccd-6abca5076c66 page_view /products \n", - "5 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n", - "6 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n", - "7 238dc588-a7ab-4c0e-bccd-6abca5076c66 hover_over_title /products \n", - "8 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n", - "35 013fc334-4045-4d5a-8739-dd0a8766a63b page_view /products \n", - "36 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n", - "37 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n", - "38 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n", - "39 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n", - "\n", - " productId storeMode userAgent \\\n", - "0 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n", - "1 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n", - "2 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n", - "3 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n", - "4 None hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n", - "5 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n", - "6 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n", - "7 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n", - "8 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n", - "35 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n", - "36 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n", - "37 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n", - "38 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n", - "39 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n", - "\n", - " ts metadata_referrer metadata_roomType \\\n", - "0 2025-11-14T13:23:46.270Z NaN \n", - "1 2025-11-14T13:26:00.291Z NaN \n", - "2 2025-11-14T13:26:07.769Z NaN \n", - "3 2025-11-14T13:26:15.010Z NaN Premium Room \n", - "4 2025-11-14T13:27:15.457Z NaN \n", - "5 2025-11-14T13:27:15.591Z NaN Premium Room \n", - "6 2025-11-14T13:27:21.483Z NaN Premium Room \n", - "7 2025-11-14T13:27:22.646Z NaN NaN \n", - "8 2025-11-14T13:27:25.889Z NaN Premium Room \n", - "35 2025-11-14T13:53:59.993Z NaN \n", - "36 2025-11-14T13:54:10.705Z NaN Premium Room \n", - "37 2025-11-14T13:54:11.771Z NaN NaN \n", - "38 2025-11-14T13:54:29.772Z NaN Standard Room \n", - "39 2025-11-14T13:54:30.833Z NaN NaN \n", - "\n", - " metadata_price metadata_nights metadata_elementText metadata_dwellTime \n", - "0 NaN NaN NaN NaN \n", - "1 NaN NaN NaN NaN \n", - "2 NaN NaN NaN NaN \n", - "3 269.0 1.0 NaN NaN \n", - "4 NaN NaN NaN NaN \n", - "5 264.0 2.0 NaN NaN \n", - "6 264.0 2.0 NaN NaN \n", - "7 NaN NaN Grand Plaza Hotel 1200.0 \n", - "8 264.0 2.0 NaN NaN \n", - "35 NaN NaN NaN NaN \n", - "36 223.0 3.0 NaN NaN \n", - "37 NaN NaN Grand Plaza Hotel 1200.0 \n", - "38 267.0 5.0 NaN NaN \n", - "39 NaN NaN Seaside Resort 1200.0 " - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.groupby('sessionId').head()" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "380eca5f-8304-4fb2-be32-e8bcfd312085", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['013fc334-4045-4d5a-8739-dd0a8766a63b',\n", - " '238dc588-a7ab-4c0e-bccd-6abca5076c66',\n", - " 'd176d7c9-4027-4702-9e31-2a71395cdda0',\n", - " 'f0317a5d-e424-44e9-b784-c8f7291ffe31']" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "sessions = list(set(df['sessionId'])); sessions # 238dc588-a7ab-4c0e-bccd-6abca5076c66" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1", - "metadata": {}, - "outputs": [], - "source": [ - "# map sessions to experiments" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "050d90a4-20a9-47f5-b998-c31178a54cb3", - "metadata": {}, - "outputs": [], - "source": [ - "def build_transition_prob_matrix(df: pd.DataFrame):\n", - " df = df.dropna(subset=['eventName'])\n", - " events = df['eventName'].tolist()\n", - " labels = pd.Index(events).unique().tolist()\n", - " idx = {e:i for i,e in enumerate(labels)}\n", - " M = np.zeros((len(labels), len(labels)), dtype=float)\n", - " for a, b in zip(events, events[1:]):\n", - " M[idx[a], idx[b]] += 1\n", - " row_sums = M.sum(axis=1, keepdims=True)\n", - " with np.errstate(divide='ignore', invalid='ignore'):\n", - " P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n", - " return P, labels" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "e68f9004-82f5-4826-aece-e3dc6e15a18f", - "metadata": {}, - "outputs": [], - "source": [ - "# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n", - "from graphviz import Digraph\n", - "import numpy as np\n", - "import pandas as pd\n", - "\n", - "def _as_prob_df(matrix, labels=None):\n", - " \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n", - " if isinstance(matrix, pd.DataFrame):\n", - " # Ensure square and aligned\n", - " assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n", - " return matrix\n", - " matrix = np.asarray(matrix, dtype=float)\n", - " assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n", - " if labels is None:\n", - " raise ValueError(\"labels are required when matrix is not a DataFrame\")\n", - " assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n", - " return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n", - "\n", - "def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n", - " \"\"\"Build weighted edges > threshold.\"\"\"\n", - " edges = []\n", - " for src in P.index:\n", - " for dst in P.columns:\n", - " w = float(P.loc[src, dst])\n", - " if w > threshold:\n", - " edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n", - " return edges\n", - "\n", - "def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n", - " \"\"\"\n", - " fname: output file stem (no extension)\n", - " matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n", - " ls_index: ordered labels (required if matrix is not a DataFrame)\n", - " threshold: hide edges with weight <= threshold\n", - " fmt: 'svg'|'png'|'pdf' etc.\n", - " view: open after rendering\n", - " \"\"\"\n", - " P = _as_prob_df(matrix, labels=ls_index)\n", - " edges = _df_to_edgelist(P, threshold=threshold)\n", - "\n", - " g = Digraph(format=fmt)\n", - " g.attr(rankdir=\"LR\", size=\"30\")\n", - " g.attr(\"node\", shape=\"circle\")\n", - "\n", - " # ensure isolated nodes appear\n", - " for node in P.index:\n", - " g.node(str(node), width=\"1\", height=\"1\")\n", - "\n", - " for src, dst, label in edges:\n", - " g.edge(src, dst, label=label)\n", - "\n", - " g.render(fname, view=view, cleanup=True)\n", - " return g\n" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "013fc334-4045-4d5a-8739-dd0a8766a63b\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "page_view\n", - "\n", - "page_view\n", - "\n", - "\n", - "\n", - "view_item_page\n", - "\n", - "view_item_page\n", - "\n", - "\n", - "\n", - "page_view->view_item_page\n", - "\n", - "\n", - "1.00\n", - "\n", - "\n", - "\n", - "view_item_page->view_item_page\n", - "\n", - "\n", - "0.68\n", - "\n", - "\n", - "\n", - "hover_over_title\n", - "\n", - "hover_over_title\n", - "\n", - "\n", - "\n", - "view_item_page->hover_over_title\n", - "\n", - "\n", - "0.29\n", - "\n", - "\n", - "\n", - "hover_over_paragraph\n", - "\n", - "hover_over_paragraph\n", - "\n", - "\n", - "\n", - "view_item_page->hover_over_paragraph\n", - "\n", - "\n", - "0.04\n", - "\n", - "\n", - "\n", - "hover_over_title->view_item_page\n", - "\n", - "\n", - "1.00\n", - "\n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[]\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n", - " [0.00000000e+000 6.78571429e-001 2.85714286e-001 3.57142857e-002]\n", - " [0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n", - " [2.05833592e-312 2.29175545e-312 4.94065646e-324 6.92110218e-310]]\n", - "238dc588-a7ab-4c0e-bccd-6abca5076c66\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "page_view\n", - "\n", - "page_view\n", - "\n", - "\n", - "\n", - "view_item_page\n", - "\n", - "view_item_page\n", - "\n", - "\n", - "\n", - "page_view->view_item_page\n", - "\n", - "\n", - "1.00\n", - "\n", - "\n", - "\n", - "view_item_page->view_item_page\n", - "\n", - "\n", - "0.19\n", - "\n", - "\n", - "\n", - "hover_over_title\n", - "\n", - "hover_over_title\n", - "\n", - "\n", - "\n", - "view_item_page->hover_over_title\n", - "\n", - "\n", - "0.38\n", - "\n", - "\n", - "\n", - "hover_over_paragraph\n", - "\n", - "hover_over_paragraph\n", - "\n", - "\n", - "\n", - "view_item_page->hover_over_paragraph\n", - "\n", - "\n", - "0.44\n", - "\n", - "\n", - "\n", - "hover_over_title->view_item_page\n", - "\n", - "\n", - "1.00\n", - "\n", - "\n", - "\n", - "hover_over_paragraph->page_view\n", - "\n", - "\n", - "0.14\n", - "\n", - "\n", - "\n", - "hover_over_paragraph->view_item_page\n", - "\n", - "\n", - "0.86\n", - "\n", - "\n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[0. 1. 0. 0. ]\n", - " [0. 0.1875 0.375 0.4375 ]\n", - " [0. 1. 0. 0. ]\n", - " [0.14285714 0.85714286 0. 0. ]]\n", - "d176d7c9-4027-4702-9e31-2a71395cdda0\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "page_view\n", - "\n", - "page_view\n", - "\n", - "\n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[0.]]\n", - "f0317a5d-e424-44e9-b784-c8f7291ffe31\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "page_view\n", - "\n", - "page_view\n", - "\n", - "\n", - "\n", - "page_view->page_view\n", - "\n", - "\n", - "0.50\n", - "\n", - "\n", - "\n", - "view_item_page\n", - "\n", - "view_item_page\n", - "\n", - "\n", - "\n", - "page_view->view_item_page\n", - "\n", - "\n", - "0.50\n", - "\n", - "\n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[5.0e-001 5.0e-001]\n", - " [9.9e-324 1.5e-323]]\n" - ] - } - ], - "source": [ - "def explore_session(session_id: str):\n", - " subset = df[df['sessionId'] == session_id]\n", - " print(session_id)\n", - " P, labels = build_transition_prob_matrix(subset)\n", - " g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n", - " display(g)\n", - " return P\n", - "for session in sessions:\n", - " print(explore_session(session))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python (PHANTOM)", - "language": "python", - "name": "phantom" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.7" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/experiments/procesing/__init__.py b/experiments/procesing/__init__.py new file mode 100644 index 0000000..48b91bf --- /dev/null +++ b/experiments/procesing/__init__.py @@ -0,0 +1,19 @@ +from .extract import ( + KafkaDataFetcher, + ExperimentJoiner, + EventTitleAugmenter, +) +from .demand import DemandEstimator +from .mapping import SessionTransitionProbMatrixTransformer, render_graph +from .pipeline import etl_pipeline, pricing_pipeline + +__all__ = [ + 'KafkaDataFetcher', + 'ExperimentJoiner', + 'EventTitleAugmenter', + 'DemandEstimator', + 'SessionTransitionProbMatrixTransformer', + 'render_graph', + 'etl_pipeline', + 'pricing_pipeline', +] diff --git a/experiments/procesing/demand.py b/experiments/procesing/demand.py new file mode 100644 index 0000000..d1924ec --- /dev/null +++ b/experiments/procesing/demand.py @@ -0,0 +1,39 @@ +from sklearn.base import BaseEstimator, TransformerMixin +import numpy as np +import pandas as pd +from supabase import create_client, Client +import pandas as pd +import os + +SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL") +SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY") + +supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) + +class DemandEstimator(BaseEstimator, TransformerMixin): + def __init__(self, + store_mode:str='hotel', + session_filter:str="", + experiment_filter:str=""): + self.store=store_mode + self.session_filter=session_filter if len(session_filter)>0 else None + self.experiment_filter=experiment_filter if len(experiment_filter)>0 else None + def fit(self, X): + return self + + def transform(self, interactions : pd.DataFrame): + if interactions.empty: + return pd.DataFrame(columns=["productId", "demand_score"]) + if self.session_filter: + interactions = interactions[interactions['sessionId'] == self.session_filter] + if self.experiment_filter: + interactions = interactions[interactions['experimentId'] == self.experiment_filter] + products=supabase.table(f'{self.store}_products').select("id, room_type, date_index, metadata, availability").execute() + products = pd.DataFrame(products.data) + unique_products = products['id'].unique() + # TODO: improve demand score calculation rather than just counting interactions (use weights..) + # while maintaining simplicity of a simple cross tab approach + product_demand = pd.crosstab(interactions['productId'], "no_of_interactions") + product_demand = product_demand.reindex(unique_products, fill_value=0).reset_index() + product_demand.columns = ['productId', 'demand_score'] + return product_demand diff --git a/experiments/procesing/extract.py b/experiments/procesing/extract.py index 7fbb88c..bd77204 100644 --- a/experiments/procesing/extract.py +++ b/experiments/procesing/extract.py @@ -15,106 +15,98 @@ N_PRICE_BUCKETS = 5 supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) -def get_data_from_kafka() -> pd.DataFrame: - """fetch all events from backend dump endpoint""" - resp = requests.get(f"{BACKEND_URL}/api/kafka/dump") - resp.raise_for_status() - data = resp.json() - if not data.get('success') or not data.get('data'): - return pd.DataFrame() - - df = pd.DataFrame(data['data']) - # explode metadata col json - if 'metadata' in df.columns: - df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_")) - df = df.dropna(subset=['eventName']) - return df - - -def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame: - if df.empty or 'experimentId' not in df.columns: - return df - - unique_exp_ids = df['experimentId'].dropna().unique() - if len(unique_exp_ids) == 0: - return df - - resp = supabase.table('experiments').select( - 'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)' - ).in_('id', unique_exp_ids.tolist()).execute() - - if not resp.data: - return df - - exp_df = pd.DataFrame(resp.data) - - # flatten task nested object if present - if 'task' in exp_df.columns and exp_df['task'].notnull().any(): - task_normalized = pd.json_normalize(exp_df['task'].dropna()) - task_normalized.index = exp_df[exp_df['task'].notnull()].index - exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task') - - # rename experiment columns for clarity - exp_df = exp_df.rename(columns={ - 'id': 'experimentId', - 'subject_name': 'exp_subject', - 'xp_human_only': 'exp_human_only', - 'xp_market_mode': 'exp_market_mode', - 'xp_task_id': 'exp_task_id' - }) - - df = df.merge(exp_df, on='experimentId', how='left') - return df - - -def augment_event_titles(df: pd.DataFrame) -> pd.DataFrame: - # from taking standard view_item_page in eventName to view_item_page_{metadata_schema} - # we want metadata schema to create product specific event names - - # only create price buckets if we have enough unique prices - if df["metadata_price"].notnull().sum() > 0: - try: - price_buckets = pd.qcut( - df["metadata_price"], - q=N_PRICE_BUCKETS, - labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)], - duplicates='drop' # handle duplicate bin edges - ) - except ValueError: - # fallback: if still not enough unique values, use cut with fixed ranges or just use raw price - price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "") - else: - price_buckets = pd.Series([""] * len(df), index=df.index) - - # metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name - # TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page - df["metadata_schema"] = np.where( - df["productId"].notnull() & df["metadata_price"].notnull(), - "_" + df["productId"].astype(str) + "@" + price_buckets.astype(str), - "" - ) - df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str) - return df - - -def extract() -> pd.DataFrame: - df = get_data_from_kafka() - df = join_with_experiments(df) - df = augment_event_titles(df) - return df - - -class DataExtractor(BaseEstimator, TransformerMixin): +class KafkaDataFetcher(BaseEstimator, TransformerMixin): def fit(self, X=None, y=None): return self def transform(self, X=None): - return extract() + resp = requests.get(f"{BACKEND_URL}/api/kafka/dump") + resp.raise_for_status() + data = resp.json() + + if not data.get('success') or not data.get('data'): + return pd.DataFrame() + + df = pd.DataFrame(data['data']) + # explode metadata col json + if 'metadata' in df.columns: + df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_")) + df = df.dropna(subset=['eventName']) + # remape dateIndex + df['dateIndex'] = df['metadata_dateIndex'].astype('Int64') + return df -if __name__ == "__main__": - df = extract() - print(df.head()) - print(df.tail()) - print(df.info()) +class ExperimentJoiner(BaseEstimator, TransformerMixin): + def fit(self, X=None, y=None): + return self + + def transform(self, df): + if df.empty or 'experimentId' not in df.columns: + return df + + unique_exp_ids = df['experimentId'].dropna().unique() + if len(unique_exp_ids) == 0: + return df + + resp = supabase.table('experiments').select( + 'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)' + ).in_('id', unique_exp_ids.tolist()).execute() + + if not resp.data: + return df + + exp_df = pd.DataFrame(resp.data) + + # flatten task nested object if present + if 'task' in exp_df.columns and exp_df['task'].notnull().any(): + task_normalized = pd.json_normalize(exp_df['task'].dropna()) + task_normalized.index = exp_df[exp_df['task'].notnull()].index + exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task') + + # rename experiment columns for clarity + exp_df = exp_df.rename(columns={ + 'id': 'experimentId', + 'subject_name': 'exp_subject', + 'xp_human_only': 'exp_human_only', + 'xp_market_mode': 'exp_market_mode', + 'xp_task_id': 'exp_task_id' + }) + + df = df.merge(exp_df, on='experimentId', how='left') + return df + + +class EventTitleAugmenter(BaseEstimator, TransformerMixin): + def fit(self, X=None, y=None): + return self + + def transform(self, df): + # from taking standard view_item_page in eventName to view_item_page_{metadata_schema} + # we want metadata schema to create product specific event names + + # only create price buckets if we have enough unique prices + if df["metadata_price"].notnull().sum() > 0: + try: + price_buckets = pd.qcut( + df["metadata_price"], + q=N_PRICE_BUCKETS, + labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)], + duplicates='drop' # handle duplicate bin edges + ) + except ValueError: + # fallback: if still not enough unique values, use cut with fixed ranges or just use raw price + price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "") + else: + price_buckets = pd.Series([""] * len(df), index=df.index) + + # metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name + # TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page + df["metadata_schema"] = np.where( + df["productId"].notnull() & df["metadata_price"].notnull(), + "_" + df["productId"].astype(str) + "@" + price_buckets.astype(str), + "" + ) + df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str) + return df diff --git a/experiments/procesing/pipeline.py b/experiments/procesing/pipeline.py index 54aae61..2d93c05 100644 --- a/experiments/procesing/pipeline.py +++ b/experiments/procesing/pipeline.py @@ -1,15 +1,22 @@ from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler -from extract import DataExtractor -from mapping import SessionTransitionProbMatrixTransformer, render_graph +from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter +from mapping import SessionTransitionProbMatrixTransformer, render_graph +from demand import DemandEstimator + + +# exposable pipelines +etl_pipeline = Pipeline([ + ('kafka_fetch', KafkaDataFetcher()), + ('experiment_join', ExperimentJoiner()), + ('event_augment', EventTitleAugmenter()), +]) +pricing_pipeline = Pipeline([ + ('demand_estimation', DemandEstimator()), +]) if __name__ == "__main__": - steps = [ - ('data_extraction', DataExtractor()), - #('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)), - ] - pipeline = Pipeline(steps) - result = pipeline.fit_transform(None) - print(result) - print(result.info()) + processed_data = etl_pipeline.fit_transform(None) + pricing = pricing_pipeline.fit_transform(processed_data) + print(pricing) diff --git a/experiments/seed_products.py b/experiments/seed_products.py new file mode 100644 index 0000000..9d90602 --- /dev/null +++ b/experiments/seed_products.py @@ -0,0 +1,125 @@ +import random +import json +import os +import logging +from dotenv import load_dotenv +from supabase import create_client, Client +from tqdm import tqdm + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') +log = logging.getLogger(__name__) + +SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL") +SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY") + +if not SUPABASE_SERVICE_KEY: + log.error("SUPABASE_SERVICE_ROLE_KEY not found in environment") + raise ValueError("Missing SUPABASE_SERVICE_ROLE_KEY - required for admin operations") + +supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) + +DAYS = 14 + +# hotel room configurations +ROOMS = { + "Presidential Suite": {'amenities': ['ocean_view', 'balcony', 'jacuzzi', 'butler_service', 'premium_minibar'], 'total': 1, 'image_url': "", "base_price": 450, 'name': 'Presidential Suite', 'refundable': True, 'max_occupancy': 4}, + "Executive Suite": {'amenities': ['city_view', 'balcony', 'workspace', 'lounge_access'], 'total': 2, 'image_url': "", "base_price": 280, 'name': 'Executive Suite', 'refundable': True, 'max_occupancy': 3}, + "Junior Suite": {'amenities': ['garden_view', 'mini_fridge', 'coffee_maker'], 'total': 5, 'image_url': "", "base_price": 180, 'name': 'Junior Suite', 'refundable': True, 'max_occupancy': 2}, + "Deluxe Room": {'amenities': ['city_view', 'work_desk', 'coffee_maker'], 'total': 8, 'image_url': "", "base_price": 140, 'name': 'Deluxe Room', 'refundable': False, 'max_occupancy': 2}, + "Superior Room": {'amenities': ['wifi', 'tv', 'safe'], 'total': 12, 'image_url': "", "base_price": 110, 'name': 'Superior Room', 'refundable': False, 'max_occupancy': 2}, + "Standard Room": {'amenities': ['wifi', 'tv'], 'total': 20, 'image_url': "", "base_price": 85, 'name': 'Standard Room', 'refundable': False, 'max_occupancy': 2}, +} + +# flight configurations +FLIGHTS = { + "JFK-LAX-Economy": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 180, 'base_price': 250}, + "JFK-LAX-Business": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 30, 'base_price': 850}, + "ORD-MIA-Economy": {'departure': {'time': '14:15', 'airport': 'ORD'}, 'arrival': {'time': '18:45', 'airport': 'MIA'}, 'duration': '3h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 200, 'base_price': 180}, + "SFO-SEA-Premium": {'departure': {'time': '06:30', 'airport': 'SFO'}, 'arrival': {'time': '08:45', 'airport': 'SEA'}, 'duration': '2h 15m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': False, 'total': 60, 'base_price': 420}, + "ATL-DFW-First": {'departure': {'time': '16:00', 'airport': 'ATL'}, 'arrival': {'time': '17:30', 'airport': 'DFW'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 12, 'base_price': 1600}, + "LAX-SFO-Economy": {'departure': {'time': '10:00', 'airport': 'LAX'}, 'arrival': {'time': '11:30', 'airport': 'SFO'}, 'duration': '1h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 150, 'base_price': 120}, + "MIA-ATL-Premium": {'departure': {'time': '19:00', 'airport': 'MIA'}, 'arrival': {'time': '20:45', 'airport': 'ATL'}, 'duration': '1h 45m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': True, 'total': 50, 'base_price': 380}, + "DFW-ORD-Economy": {'departure': {'time': '07:30', 'airport': 'DFW'}, 'arrival': {'time': '10:15', 'airport': 'ORD'}, 'duration': '2h 45m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 190, 'base_price': 160}, + "SEA-LAX-Business": {'departure': {'time': '13:00', 'airport': 'SEA'}, 'arrival': {'time': '15:30', 'airport': 'LAX'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 40, 'base_price': 720}, + "LAX-JFK-First": {'departure': {'time': '18:00', 'airport': 'LAX'}, 'arrival': {'time': '02:15', 'airport': 'JFK'}, 'duration': '5h 15m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 16, 'base_price': 1850}, +} + +def gen_hotel_products(): + """generate hotel room products for next DAYS days""" + data = [] + for day in range(DAYS): + for room_type, rdata in ROOMS.items(): + data.append({ + 'room_type': room_type, + 'date_index': day + 1, + 'metadata': rdata, + 'availability': random.randint(0, rdata['total']) + }) + return data + +def gen_airline_products(): + """generate flight products for next DAYS days""" + data = [] + for day in range(DAYS): + for flight_type, fdata in FLIGHTS.items(): + data.append({ + 'flight_type': flight_type, + 'date_index': day + 1, + 'metadata': fdata, + 'availability': random.randint(0, fdata['total']) + }) + return data + +def clear_table(table_name: str): + """clear all records from a table""" + try: + resp = supabase.table(table_name).select('id').execute() + if resp.data: + ids = [row['id'] for row in resp.data] + chunk_size = 100 + for i in tqdm(range(0, len(ids), chunk_size), desc=f"Clearing {table_name}", unit="chunk"): + chunk = ids[i:i+chunk_size] + supabase.table(table_name).delete().in_('id', chunk).execute() + log.info(f"Deleted {len(ids)} records from {table_name}") + else: + log.info(f"{table_name} already empty") + except Exception as e: + log.error(f"Failed to clear {table_name}: {e}") + raise + +def seed_table(table_name: str, data: list[dict]): + """insert records into a table""" + try: + chunk_size = 100 + total = len(data) + for i in tqdm(range(0, total, chunk_size), desc=f"Seeding {table_name}", unit="chunk"): + chunk = data[i:i+chunk_size] + supabase.table(table_name).insert(chunk).execute() + log.info(f"Inserted {total} records into {table_name}") + except Exception as e: + log.error(f"Failed to seed {table_name}: {e}") + raise + +def main(): + + log.info("Generating hotel products...") + hotel_products = gen_hotel_products() + log.info(f"Generated {len(hotel_products)} hotel products") + + log.info("Generating airline products...") + airline_products = gen_airline_products() + log.info(f"Generated {len(airline_products)} airline products\n") + + log.info("Clearing existing products...") + clear_table('hotel_products') + clear_table('airline_products') + + log.info("Seeding products...") + seed_table('hotel_products', hotel_products) + seed_table('airline_products', airline_products) + + +if __name__ == "__main__": + main() diff --git a/paper/concat_code.sh b/paper/concat_code.sh index abbd676..3ff905d 100755 --- a/paper/concat_code.sh +++ b/paper/concat_code.sh @@ -21,7 +21,10 @@ add_file() { # Add section header and code listing (no language-specific highlighting) echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE" echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE" - cat "$filepath" >> "$OUTPUT_FILE" + # Convert to ASCII: transliterate what's possible, drop the rest + # LC_ALL=C forces ASCII locale for consistent behavior across environments + LC_ALL=C iconv -f UTF-8 -t ASCII//TRANSLIT//IGNORE "$filepath" 2>/dev/null >> "$OUTPUT_FILE" || \ + LC_ALL=C tr -cd '\11\12\15\40-\176' < "$filepath" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" echo "\\end{lstlisting}" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" diff --git a/web/src/app/airline/products/[id]/page.tsx b/web/src/app/airline/products/[id]/page.tsx new file mode 100644 index 0000000..c8470ed --- /dev/null +++ b/web/src/app/airline/products/[id]/page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Navigation } from '@/components/ui'; +import { useCart } from '@/contexts/CartContext'; +import AirlineDetails from '@/components/feats/airline/AirlineDetails'; +import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils'; +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); +}; + +export default function AirlineProductPage() { + const params = useParams(); + const router = useRouter(); + const { addItem } = useCart(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [added, setAdded] = useState(false); + + const productId = params.id as string; + + useEffect(() => { + const fetchProduct = async () => { + try { + const res = await fetch(`/api/products/${productId}`); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = transformProduct(json.data as AirlineProduct); + setProduct(transformed); + + // fire learn_more_about_item event when product loads + dispatchInteraction('learn_more_about_item', productId, { + type: 'airline', + dateIndex: transformed.dateIndex, + flightType: transformed.flightType, + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load product'); + console.error('[FETCH_FLIGHT_ERROR]', e); + } finally { + setLoading(false); + } + }; + fetchProduct(); + }, [productId]); + + const handleAddToCart = () => { + if (!product) return; + + addItem({ + id: productId, + type: 'airline', + name: product.flightType, + price: product.basePrice, + metadata: { + departure: product.departure, + arrival: product.arrival, + duration: product.duration, + cabinClass: product.cabinClass, + }, + dateIndex: product.dateIndex, + }); + + dispatchInteraction('add_item_to_cart', productId, { + type: 'airline', + price: product.basePrice, + }); + + setAdded(true); + setTimeout(() => setAdded(false), 2000); + }; + + return ( + <> + +
+ {loading &&
Loading flight details...
} + {error &&
{error}
} + + {!loading && !error && product && ( + <> + + + + + )} +
+ + ); +} diff --git a/web/src/app/airline/products/page.tsx b/web/src/app/airline/products/page.tsx index c62e1d1..769ddf0 100644 --- a/web/src/app/airline/products/page.tsx +++ b/web/src/app/airline/products/page.tsx @@ -1,73 +1,69 @@ 'use client'; +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Navigation } from '@/components/ui'; import AirlineCard from '@/components/feats/airline/AirlineCard'; +import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils'; -type CabinClass = 'economy' | 'premium' | 'business' | 'first'; -type FareRule = 'flexible' | 'standard' | 'basic'; +function FlightsList() { + const searchParams = useSearchParams(); + const [flights, setFlights] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); -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; -} + useEffect(() => { + const fetchFlights = async () => { + try { + const url = new URL('/api/products', window.location.origin); + url.searchParams.set('type', 'airline'); -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']; + // forward all relevant search params to the API + const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants']; + params.forEach(param => { + const val = searchParams.get(param); + if (val) url.searchParams.set(param, val); + }); - 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, + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = json.data.map((p: AirlineProduct) => transformProduct(p)); + setFlights(transformed); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load products'); + console.error('[FETCH_ERROR]', e); + } finally { + setLoading(false); + } }; - }); -}; - -export default function AirlineProducts() { - const flights = genRandomFlights(); + fetchFlights(); + }, [searchParams]); return ( <> - -
-

Available Flights

+

Available Flights

+ {loading &&
Loading...
} + {error &&
{error}
} + {!loading && !error && (
{flights.map((f) => ( ))}
+ )} + + ); +} + +export default function AirlineProducts() { + return ( + <> + +
+ Loading...}> + +
); diff --git a/web/src/app/api/products/[id]/route.ts b/web/src/app/api/products/[id]/route.ts new file mode 100644 index 0000000..f942424 --- /dev/null +++ b/web/src/app/api/products/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + if (!id) { + return NextResponse.json( + { error: 'product id is required' }, + { status: 400 } + ); + } + + try { + const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000'; + const url = new URL(`${backendUrl}/api/products/${id}`); + + const res = await fetch(url.toString()); + + if (!res.ok) { + throw new Error(`Backend returned ${res.status}`); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[PRODUCT_DETAIL_ERROR]', error); + return NextResponse.json( + { error: 'Failed to fetch product details' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/products/route.ts b/web/src/app/api/products/route.ts new file mode 100644 index 0000000..c51fdf2 --- /dev/null +++ b/web/src/app/api/products/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const type = searchParams.get('type'); + + if (!type || !['hotel', 'airline'].includes(type)) { + return NextResponse.json( + { error: 'type parameter must be "hotel" or "airline"' }, + { status: 400 } + ); + } + + try { + const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000'; + const url = new URL(`${backendUrl}/api/products/type/${type}`); + + // forward all query params to backend (excluding 'type') + searchParams.forEach((value, key) => { + if (key !== 'type') { + url.searchParams.set(key, value); + } + }); + + const res = await fetch(url.toString()); + + if (!res.ok) { + throw new Error(`Backend returned ${res.status}`); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[PRODUCTS_PROXY_ERROR]', error); + return NextResponse.json( + { error: 'Failed to fetch products' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/cart/page.tsx b/web/src/app/cart/page.tsx new file mode 100644 index 0000000..d487d40 --- /dev/null +++ b/web/src/app/cart/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Navigation } from '@/components/ui'; +import { useCart } from '@/contexts/CartContext'; +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); +}; + +export default function CartPage() { + const { items, removeItem, clearCart, itemCount } = useCart(); + + const handleRemove = (id: string, type: string) => { + removeItem(id); + dispatchInteraction('remove_item', id, { type }); + }; + let itemTypes = Array.from(new Set(items.map(item => item.type)))[0] || 'items'; + + + const total = items.reduce((sum, item) => sum + item.price, 0); + + return ( + <> + +
+
+

Shopping Cart

+ {itemCount > 0 && ( + + )} +
+ + {itemCount === 0 ? ( +
+

Your cart is empty

+ Browse our selection +
+ ) : ( + <> +
+ {items.map(item => ( +
+
+
+ + {item.type} + +

{item.name}

+
+ + {item.type === 'hotel' && ( +
+

{String(item.metadata.roomType)}

+

{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}

+

{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}

+
+ )} + + {item.type === 'airline' && ( +
+

{String(item.metadata.cabinClass)} Class

+

{String((item.metadata.departure as any)?.airport)} → {String((item.metadata.arrival as any)?.airport)}

+

Duration: {String(item.metadata.duration)}

+
+ )} +
+ +
+

${item.price}

+ +
+
+ ))} +
+ +
+
+ Total + ${total.toFixed(2)} +
+ +
+ + )} +
+ + ); +} diff --git a/web/src/app/hotel/products/[id]/page.tsx b/web/src/app/hotel/products/[id]/page.tsx new file mode 100644 index 0000000..c166ba5 --- /dev/null +++ b/web/src/app/hotel/products/[id]/page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Navigation } from '@/components/ui'; +import { useCart } from '@/contexts/CartContext'; +import HotelDetails from '@/components/feats/hotel/HotelDetails'; +import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils'; +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); +}; + +export default function HotelProductPage() { + const params = useParams(); + const router = useRouter(); + const { addItem } = useCart(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [added, setAdded] = useState(false); + + const productId = params.id as string; + + useEffect(() => { + const fetchProduct = async () => { + try { + const res = await fetch(`/api/products/${productId}`); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = transformProduct(json.data as HotelProduct); + setProduct(transformed); + + // fire learn_more_about_item event when product loads + dispatchInteraction('learn_more_about_item', productId, { + type: 'hotel', + dateIndex: transformed.dateIndex, + roomType: transformed.roomType, + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load product'); + console.error('[FETCH_HOTEL_ERROR]', e); + } finally { + setLoading(false); + } + }; + fetchProduct(); + }, [productId]); + + const handleAddToCart = () => { + if (!product) return; + + addItem({ + id: productId, + type: 'hotel', + name: product.name, + price: product.pricePerNight, + metadata: { + roomType: product.roomType, + nights: product.nights, + checkIn: product.checkIn, + checkOut: product.checkOut, + }, + dateIndex: product.dateIndex, + }); + + dispatchInteraction('add_item_to_cart', productId, { + type: 'hotel', + price: product.pricePerNight, + }); + + setAdded(true); + setTimeout(() => setAdded(false), 2000); + }; + + return ( + <> + +
+ {loading &&
Loading hotel details...
} + {error &&
{error}
} + + {!loading && !error && product && ( + <> + + + + + )} +
+ + ); +} diff --git a/web/src/app/hotel/products/page.tsx b/web/src/app/hotel/products/page.tsx index ece120b..a11976c 100644 --- a/web/src/app/hotel/products/page.tsx +++ b/web/src/app/hotel/products/page.tsx @@ -1,74 +1,69 @@ 'use client'; +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Navigation } from '@/components/ui'; import HotelCard from '@/components/feats/hotel/HotelCard'; +import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils'; -interface Hotel { - id: string; - name: string; - roomType: string; - checkIn: string; - checkOut: string; - amenities: string[]; - refundable: boolean; - pricePerNight: number; - nights: number; +function RoomsList() { + const searchParams = useSearchParams(); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRooms = async () => { + try { + const url = new URL('/api/products', window.location.origin); + url.searchParams.set('type', 'hotel'); + + // forward all relevant search params to the API + const params = ['dateIndex', 'destination', 'adults', 'rooms']; + params.forEach(param => { + const val = searchParams.get(param); + if (val) url.searchParams.set(param, val); + }); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const json = await res.json(); + const transformed = json.data.map((p: HotelProduct) => transformProduct(p)); + setRooms(transformed); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load products'); + console.error('[FETCH_ERROR]', e); + } finally { + setLoading(false); + } + }; + fetchRooms(); + }, [searchParams]); + + return ( + <> +

Available Rooms

+ {loading &&
Loading...
} + {error &&
{error}
} + {!loading && !error && ( +
+ {rooms.map((r) => ( + + ))} +
+ )} + + ); } -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) => ( - - ))} -
+ Loading...}> + +
); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 2cef36e..e9f9b63 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { TrackingProvider } from "@/components/TrackingProvider"; +import { CartProvider } from "@/contexts/CartContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,7 +29,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/web/src/components/feats/airline/AirlineCard.tsx b/web/src/components/feats/airline/AirlineCard.tsx index b08827d..a651312 100644 --- a/web/src/components/feats/airline/AirlineCard.tsx +++ b/web/src/components/feats/airline/AirlineCard.tsx @@ -1,6 +1,7 @@ 'use client'; import type { EventName } from '@/lib/events'; +import type { Flight } from '@/lib/airline-utils'; import { useHoverTracking } from '@/hooks/useHoverTracking'; import PriceDisplay from '@/components/ui/PriceDisplay'; @@ -11,32 +12,17 @@ const dispatchInteraction = (eventName: EventName, productId?: string, 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; -} - export default function AirlineCard({ flight }: { flight: Flight }) { const durationRef = useHoverTracking({ eventName: 'hover_over_title', productId: flight.id, - metadata: { elementText: flight.duration }, + metadata: { elementText: flight.duration, dateIndex: flight.dateIndex }, }); const priceRef = useHoverTracking({ eventName: 'hover_over_paragraph', productId: flight.id, - metadata: { elementText: 'price' }, + metadata: { elementText: 'price', dateIndex: flight.dateIndex }, }); const handleCardClick = () => { @@ -44,7 +30,9 @@ export default function AirlineCard({ flight }: { flight: Flight }) { cabinClass: flight.cabinClass, fareRule: flight.fareRule, price: flight.basePrice, + dateIndex: flight.dateIndex, }); + window.location.href = `/airline/products/${flight.id}`; }; return ( diff --git a/web/src/components/feats/airline/AirlineDetails.tsx b/web/src/components/feats/airline/AirlineDetails.tsx new file mode 100644 index 0000000..d68ea6f --- /dev/null +++ b/web/src/components/feats/airline/AirlineDetails.tsx @@ -0,0 +1,75 @@ +'use client'; + +import type { Flight } from '@/lib/airline-utils'; + +interface AirlineDetailsProps { + product: Flight; + onAddToCart: () => void; + addedToCart: boolean; +} + +export default function AirlineDetails({ product, onAddToCart, addedToCart }: AirlineDetailsProps) { + return ( +
+ {/* Image Section */} +
+ Flight Image +
+ + {/* Details Section */} +
+
+
+

{product.flightType}

+

{product.cabinClass} Class

+
+
+

${product.basePrice}

+ {product.refundable && ( + + Refundable + + )} +
+
+ +
+
+

{product.departure.time}

+

{product.departure.airport}

+
+ +
+

{product.duration}

+
+
+
+

+ {product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`} +

+
+ +
+

{product.arrival.time}

+

{product.arrival.airport}

+
+
+ +
+
+ {product.availability} seats remaining + + {product.fareRule} +
+ +
+
+
+ ); +} diff --git a/web/src/components/feats/airline/AirlineHero.tsx b/web/src/components/feats/airline/AirlineHero.tsx index cca2e45..ec2d47e 100644 --- a/web/src/components/feats/airline/AirlineHero.tsx +++ b/web/src/components/feats/airline/AirlineHero.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui'; +import { dateToDaysFromToday } from '@/lib/airline-utils'; type TripType = 'roundtrip' | 'oneway' | 'multicity'; @@ -19,6 +21,7 @@ const LocationIcon = () => ( ); export default function AirlineHero() { + const router = useRouter(); const [tripType, setTripType] = useState('roundtrip'); const [origin, setOrigin] = useState(''); const [destination, setDestination] = useState(''); @@ -28,7 +31,23 @@ export default function AirlineHero() { const handleSearch = (e: FormEvent) => { e.preventDefault(); - console.log({ tripType, origin, destination, departDate, returnDate, passengers }); + const params = new URLSearchParams(); + + if (departDate) { + const daysOffset = dateToDaysFromToday(departDate); + params.set('dateIndex', daysOffset.toString()); + } + + if (origin) params.set('origin', origin); + if (destination) params.set('destination', destination); + if (tripType !== 'roundtrip') params.set('tripType', tripType); + if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate); + + params.set('adults', passengers.adults.toString()); + params.set('children', passengers.children.toString()); + params.set('infants', passengers.infants.toString()); + + router.push(`/airline/products?${params.toString()}`); }; const totalPax = passengers.adults + passengers.children + passengers.infants; diff --git a/web/src/components/feats/hotel/HotelCard.tsx b/web/src/components/feats/hotel/HotelCard.tsx index 8c68801..fc961a1 100644 --- a/web/src/components/feats/hotel/HotelCard.tsx +++ b/web/src/components/feats/hotel/HotelCard.tsx @@ -1,6 +1,7 @@ 'use client'; import type { EventName } from '@/lib/events'; +import type { Hotel } from '@/lib/hotel-utils'; import { useHoverTracking } from '@/hooks/useHoverTracking'; import PriceDisplay from '@/components/ui/PriceDisplay'; @@ -11,18 +12,6 @@ const dispatchInteraction = (eventName: EventName, productId?: string, 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 AmenityIcon = ({ name }: { name: string }) => { const iconMap: Record = { wifi: 'Wi-Fi', @@ -39,13 +28,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) { const titleRef = useHoverTracking({ eventName: 'hover_over_title', productId: hotel.id, - metadata: { elementText: hotel.name }, + metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex }, }); const priceRef = useHoverTracking({ eventName: 'hover_over_paragraph', productId: hotel.id, - metadata: { elementText: 'price' }, + metadata: { elementText: 'price', dateIndex: hotel.dateIndex }, }); const handleCardClick = () => { @@ -53,7 +42,9 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) { roomType: hotel.roomType, price: hotel.pricePerNight, nights: hotel.nights, + dateIndex: hotel.dateIndex, }); + window.location.href = `/hotel/products/${hotel.id}`; }; return ( diff --git a/web/src/components/feats/hotel/HotelDetails.tsx b/web/src/components/feats/hotel/HotelDetails.tsx new file mode 100644 index 0000000..498a1f9 --- /dev/null +++ b/web/src/components/feats/hotel/HotelDetails.tsx @@ -0,0 +1,74 @@ +'use client'; + +import type { Hotel } from '@/lib/hotel-utils'; + +interface HotelDetailsProps { + product: Hotel; + onAddToCart: () => void; + addedToCart: boolean; +} + +export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) { + return ( +
+ {/* Image Section - Larger and cleaner */} +
+ Hotel Image +
+ + {/* Details Section - Full height/width usage */} +
+
+

{product.name}

+

{product.roomType}

+
+ +
+
+

Check-in

+

{product.checkIn}

+
+
+

Check-out

+

{product.checkOut}

+
+
+ +
+

Amenities

+
+ {product.amenities.map(a => ( + + {a} + + ))} +
+
+ + {product.refundable && ( +
+ Free cancellation available +
+ )} + +
+
+

Total for {product.nights} night{product.nights > 1 ? 's' : ''}

+
+ ${product.pricePerNight * product.nights} + / {product.nights} nights +
+
+ + +
+
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelHero.tsx b/web/src/components/feats/hotel/HotelHero.tsx index 70bd595..19b58ef 100644 --- a/web/src/components/feats/hotel/HotelHero.tsx +++ b/web/src/components/feats/hotel/HotelHero.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui'; +import { dateToDaysFromToday } from '@/lib/hotel-utils'; const LocationIcon = () => ( @@ -11,14 +13,25 @@ const LocationIcon = () => ( ); export default function HotelHero() { + const router = useRouter(); const [destination, setDestination] = useState(''); const [checkIn, setCheckIn] = useState(''); - const [checkOut, setCheckOut] = useState(''); const [guests, setGuests] = useState({ adults: 2, rooms: 1 }); const handleSearch = (e: FormEvent) => { e.preventDefault(); - console.log({ destination, checkIn, checkOut, guests }); + const params = new URLSearchParams(); + + if (checkIn) { + const daysOffset = dateToDaysFromToday(checkIn); + params.set('dateIndex', daysOffset.toString()); + } + + if (destination) params.set('destination', destination); + params.set('adults', guests.adults.toString()); + params.set('rooms', guests.rooms.toString()); + + router.push(`/hotel/products?${params.toString()}`); }; return ( @@ -26,16 +39,16 @@ export default function HotelHero() {

- Find your perfect stay + Find your perfect room

- Search hotels, compare prices, and book with confidence + Search rooms, compare prices, and book with confidence

-
-
+
+
- +
- - setCheckOut(e.target.value)} - required - /> -
- -
- - + + setGuests({ ...guests, adults: v })} /> - setGuests({ ...guests, rooms: v })} - />
-
+
-

Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings

+

Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings

diff --git a/web/src/contexts/CartContext.tsx b/web/src/contexts/CartContext.tsx new file mode 100644 index 0000000..3830c8b --- /dev/null +++ b/web/src/contexts/CartContext.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +export interface CartItem { + id: string; + type: 'hotel' | 'airline'; + name: string; + price: number; + metadata: Record; + dateIndex: number; +} + +interface CartContextType { + items: CartItem[]; + addItem: (item: CartItem) => void; + removeItem: (id: string) => void; + clearCart: () => void; + itemCount: number; +} + +const CartContext = createContext(undefined); + +const CART_KEY = 'phantom_cart'; + +export const CartProvider = ({ children }: { children: ReactNode }) => { + const [items, setItems] = useState([]); + const [loaded, setLoaded] = useState(false); + + // load cart from sessionStorage on mount + useEffect(() => { + const stored = sessionStorage.getItem(CART_KEY); + if (stored) { + try { + setItems(JSON.parse(stored)); + } catch (e) { + console.error('[CART_LOAD]', e); + } + } + setLoaded(true); + }, []); + + // persist to sessionStorage whenever cart changes + useEffect(() => { + if (!loaded) return; + sessionStorage.setItem(CART_KEY, JSON.stringify(items)); + }, [items, loaded]); + + const addItem = (item: CartItem) => { + setItems(prev => { + // prevent duplicates + if (prev.find(i => i.id === item.id)) return prev; + return [...prev, item]; + }); + }; + + const removeItem = (id: string) => { + setItems(prev => prev.filter(i => i.id !== id)); + }; + + const clearCart = () => { + setItems([]); + }; + + return ( + + {children} + + ); +}; + +export const useCart = () => { + const ctx = useContext(CartContext); + if (!ctx) throw new Error('useCart must be used within CartProvider'); + return ctx; +}; diff --git a/web/src/lib/airline-utils.ts b/web/src/lib/airline-utils.ts new file mode 100644 index 0000000..74a1916 --- /dev/null +++ b/web/src/lib/airline-utils.ts @@ -0,0 +1,75 @@ +export interface AirlineProduct { + id: string; + flight_type: string; + date_index: number; + metadata: { + departure: { time: string; airport: string }; + arrival: { time: string; airport: string }; + duration: string; + stops: number; + cabin_class: string; + fare_rule: string; + refundable: boolean; + total?: number; + base_price: number; + }; + availability: number; +} + +export interface Flight { + id: string; + flightType: string; + departure: { time: string; airport: string }; + arrival: { time: string; airport: string }; + duration: string; + stops: number; + cabinClass: string; + fareRule: string; + refundable: boolean; + basePrice: number; + dateIndex: number; + availability: number; +} + +const EPOCH = new Date(0); + +export const transformProduct = (p: AirlineProduct): Flight => { + const { id, flight_type, date_index, metadata, availability } = p; + + return { + id, + flightType: flight_type, + departure: metadata.departure, + arrival: metadata.arrival, + duration: metadata.duration, + stops: metadata.stops, + cabinClass: metadata.cabin_class, + fareRule: metadata.fare_rule, + refundable: metadata.refundable, + basePrice: metadata.base_price, + dateIndex: date_index, + availability, + }; +}; + +// convert date string to days from today +export const dateToDaysFromToday = (dateStr: string): number => { + const target = new Date(dateStr); + target.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / 86400000); +}; + +// convert date string to date_index (days since epoch) +export const dateToIndex = (dateStr: string): number => { + const d = new Date(dateStr); + return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000); +}; + +// get current date_index +export const todayIndex = (): number => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000); +}; diff --git a/web/src/lib/hotel-utils.ts b/web/src/lib/hotel-utils.ts new file mode 100644 index 0000000..e660f26 --- /dev/null +++ b/web/src/lib/hotel-utils.ts @@ -0,0 +1,71 @@ +export interface HotelProduct { + id: string; + room_type: string; + date_index: number; + metadata: { + amenities?: string[]; + total?: number; + image_url?: string; + base_price?: number; + name?: string; + refundable?: boolean; + }; + availability: number; +} + +export interface Hotel { + id: string; + name: string; + roomType: string; + checkIn: string; + checkOut: string; + dateIndex: number; + amenities: string[]; + refundable: boolean; + pricePerNight: number; + nights: number; +} + +const EPOCH = new Date(0); + +export const transformProduct = (p: HotelProduct): Hotel => { + const { id, room_type, date_index, metadata } = p; + const checkIn = new Date(EPOCH.getTime() + date_index * 86400000); + const nights = 1; + const checkOut = new Date(checkIn.getTime() + nights * 86400000); + + return { + id, + name: metadata?.name || room_type, + roomType: room_type, + checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + dateIndex: date_index, + amenities: metadata?.amenities || [], + refundable: metadata?.refundable || false, + pricePerNight: metadata?.base_price || 100, + nights, + }; +}; + +// convert date string to days from today +export const dateToDaysFromToday = (dateStr: string): number => { + const target = new Date(dateStr); + target.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / 86400000); +}; + +// convert date string to date_index (days since epoch) +export const dateToIndex = (dateStr: string): number => { + const d = new Date(dateStr); + return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000); +}; + +// get current date_index +export const todayIndex = (): number => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000); +}; diff --git a/web/src/lib/product-utils.ts b/web/src/lib/product-utils.ts new file mode 100644 index 0000000..2b33001 --- /dev/null +++ b/web/src/lib/product-utils.ts @@ -0,0 +1,25 @@ +import { HotelProduct, Hotel, transformProduct as transformHotel } from './hotel-utils'; +import { AirlineProduct, Flight, transformProduct as transformFlight } from './airline-utils'; + +export type Product = Hotel | Flight; +export type ProductRaw = HotelProduct | AirlineProduct; + +export const isHotelProduct = (p: ProductRaw): p is HotelProduct => { + return 'room_type' in p; +}; + +export const isAirlineProduct = (p: ProductRaw): p is AirlineProduct => { + return 'flight_type' in p; +}; + +export const transformProduct = (p: ProductRaw): Product => { + if (isHotelProduct(p)) { + return transformHotel(p); + } + return transformFlight(p); +}; + +export const getProductType = (p: Product): 'hotel' | 'airline' => { + if ('roomType' in p) return 'hotel'; + return 'airline'; +}; diff --git a/web/src/proxy.ts b/web/src/proxy.ts index e602618..7e47089 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -11,6 +11,7 @@ export function proxy(req: NextRequest) { pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.startsWith('/start-task') || + pathname.startsWith('/cart') || pathname.includes('.') // TODO: add robots.txt and sitemap.xml if needed here ) {