From e677170fd202c5e9f0c64224dbd8d6d7dd60fe61 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 6 Nov 2025 14:03:10 +0100 Subject: [PATCH 01/10] inital structure of docs --- web/README.md | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/web/README.md b/web/README.md index e215bc4..e9ed160 100644 --- a/web/README.md +++ b/web/README.md @@ -1,36 +1,10 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). -## Getting Started +# Phantom Air/Hotels -First, run the development server: +Design Discovery Documentation: https://github.com/velocitatem/PHANTOM/wiki/Design-Discovery -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +> This webapp serves two modes `{HOTEL,AIRLINE}` which are given by an env variable -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The webapp should serve under the / route the landing page which for both platforms is very similar. We define a set of components like Hero, Card, Button, Link ... This we can then pass to specific components each mode might demand that makes it behave differently, hotel cards showing hotel rooms from database and airline cards showing flights from database and each fetching prices from the pricing provider with a different HTTP parameter. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. From 5777437540e5ef09c23db0840ea970861c6a2a1c Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 6 Nov 2025 14:12:18 +0100 Subject: [PATCH 02/10] init styles and docs --- web/README.md | 4 ++++ web/src/styles/airline.css | 0 web/src/styles/hotel.css | 0 3 files changed, 4 insertions(+) create mode 100644 web/src/styles/airline.css create mode 100644 web/src/styles/hotel.css diff --git a/web/README.md b/web/README.md index e9ed160..12fff92 100644 --- a/web/README.md +++ b/web/README.md @@ -8,3 +8,7 @@ Design Discovery Documentation: https://github.com/velocitatem/PHANTOM/wiki/Desi The webapp should serve under the / route the landing page which for both platforms is very similar. We define a set of components like Hero, Card, Button, Link ... This we can then pass to specific components each mode might demand that makes it behave differently, hotel cards showing hotel rooms from database and airline cards showing flights from database and each fetching prices from the pricing provider with a different HTTP parameter. +- globally we define a middleware.ts which is our switcher for modes. +- /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition. +- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard. +- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each. diff --git a/web/src/styles/airline.css b/web/src/styles/airline.css new file mode 100644 index 0000000..e69de29 diff --git a/web/src/styles/hotel.css b/web/src/styles/hotel.css new file mode 100644 index 0000000..e69de29 From aa98b2d1691bae27f52c328fa57a6d9f80121b2a Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 6 Nov 2025 14:15:50 +0100 Subject: [PATCH 03/10] basic style implementation --- web/src/app/globals.css | 74 ++++++- web/src/styles/airline.css | 302 ++++++++++++++++++++++++++++ web/src/styles/hotel.css | 400 +++++++++++++++++++++++++++++++++++++ 3 files changed, 775 insertions(+), 1 deletion(-) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a2dc41e..eba0bfc 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -3,6 +3,15 @@ :root { --background: #ffffff; --foreground: #171717; + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --text-primary: #333333; + --text-secondary: #666666; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 32px; + --border-radius: 8px; + --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1); } @theme inline { @@ -19,8 +28,71 @@ } } +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +h1 { font-size: 2.5rem; } +h2 { font-size: 2rem; } +h3 { font-size: 1.5rem; } + +button { + cursor: pointer; + border: none; + outline: none; + font-family: inherit; + transition: all 0.2s ease; +} + +input, select, textarea { + font-family: inherit; + font-size: 1rem; + outline: none; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--spacing-md); +} + +.card { + background: var(--bg-primary); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.btn-primary { + padding: 12px 24px; + font-weight: 600; + font-size: 1rem; + border-radius: var(--border-radius); + transition: all 0.2s ease; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.section-spacing { + margin-bottom: var(--spacing-lg); } diff --git a/web/src/styles/airline.css b/web/src/styles/airline.css index e69de29..33f5cb9 100644 --- a/web/src/styles/airline.css +++ b/web/src/styles/airline.css @@ -0,0 +1,302 @@ +/* Airline Platform - Sky Blue Theme */ + +:root[data-mode="airline"] { + --accent-primary: #007aff; + --accent-secondary: #4caf50; + --accent-warning: #ff3b30; + --accent-primary-hover: #0051d5; + --accent-primary-light: #e6f2ff; + --text-accent: #007aff; +} + +[data-mode="airline"] { + --primary-color: var(--accent-primary); +} + +[data-mode="airline"] .btn-primary { + background-color: var(--accent-primary); + color: #ffffff; +} + +[data-mode="airline"] .btn-primary:hover { + background-color: var(--accent-primary-hover); +} + +[data-mode="airline"] .btn-secondary { + background-color: transparent; + color: var(--accent-primary); + border: 2px solid var(--accent-primary); +} + +[data-mode="airline"] .btn-secondary:hover { + background-color: var(--accent-primary-light); +} + +[data-mode="airline"] .badge-value { + background-color: var(--accent-secondary); + color: #ffffff; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +} + +[data-mode="airline"] .badge-warning { + background-color: var(--accent-warning); + color: #ffffff; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +} + +[data-mode="airline"] .search-form { + background: var(--bg-primary); + padding: var(--spacing-lg); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); +} + +[data-mode="airline"] .flight-card { + display: grid; + grid-template-columns: 2fr 3fr 2fr; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--bg-primary); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + margin-bottom: var(--spacing-md); + transition: box-shadow 0.2s ease; +} + +[data-mode="airline"] .flight-card:hover { + box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15); +} + +[data-mode="airline"] .flight-timing { + display: flex; + flex-direction: column; + justify-content: center; +} + +[data-mode="airline"] .flight-time { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); +} + +[data-mode="airline"] .flight-airport { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +[data-mode="airline"] .flight-route { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +[data-mode="airline"] .flight-duration { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 4px; +} + +[data-mode="airline"] .flight-stops { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 600; +} + +[data-mode="airline"] .flight-pricing { + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--spacing-sm); +} + +[data-mode="airline"] .fare-option { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm); + border: 1px solid #e0e0e0; + border-radius: 6px; + transition: all 0.2s ease; +} + +[data-mode="airline"] .fare-option:hover { + border-color: var(--accent-primary); + background-color: var(--accent-primary-light); +} + +[data-mode="airline"] .fare-class { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} + +[data-mode="airline"] .fare-price { + font-size: 1.125rem; + font-weight: 700; + color: var(--accent-primary); +} + +[data-mode="airline"] .date-price-bar { + display: flex; + overflow-x: auto; + gap: var(--spacing-sm); + padding: var(--spacing-md) 0; + margin-bottom: var(--spacing-lg); +} + +[data-mode="airline"] .date-option { + min-width: 100px; + padding: var(--spacing-sm); + text-align: center; + border: 2px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +[data-mode="airline"] .date-option:hover { + border-color: var(--accent-primary); +} + +[data-mode="airline"] .date-option.active { + border-color: var(--accent-primary); + background-color: var(--accent-primary-light); +} + +[data-mode="airline"] .date-label { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 4px; +} + +[data-mode="airline"] .date-price { + font-size: 1rem; + font-weight: 700; + color: var(--accent-primary); +} + +[data-mode="airline"] .progress-wizard { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 800px; + margin: var(--spacing-lg) auto; + padding: 0 var(--spacing-md); +} + +[data-mode="airline"] .wizard-step { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + position: relative; +} + +[data-mode="airline"] .wizard-step::after { + content: ''; + position: absolute; + top: 20px; + left: 50%; + width: 100%; + height: 2px; + background: #e0e0e0; + z-index: -1; +} + +[data-mode="airline"] .wizard-step:last-child::after { + display: none; +} + +[data-mode="airline"] .wizard-number { + width: 40px; + height: 40px; + border-radius: 50%; + background: #e0e0e0; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + margin-bottom: var(--spacing-sm); +} + +[data-mode="airline"] .wizard-step.active .wizard-number { + background: var(--accent-primary); + color: #ffffff; +} + +[data-mode="airline"] .wizard-step.completed .wizard-number { + background: var(--accent-secondary); + color: #ffffff; +} + +[data-mode="airline"] .wizard-label { + font-size: 0.875rem; + color: var(--text-secondary); + text-align: center; +} + +[data-mode="airline"] .wizard-step.active .wizard-label { + color: var(--accent-primary); + font-weight: 600; +} + +[data-mode="airline"] a { + color: var(--accent-primary); + text-decoration: none; +} + +[data-mode="airline"] a:hover { + text-decoration: underline; +} + +[data-mode="airline"] .input-field { + border: 2px solid #e0e0e0; + border-radius: 6px; + padding: 12px; + transition: border-color 0.2s ease; +} + +[data-mode="airline"] .input-field:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-light); +} + +[data-mode="airline"] .filter-sidebar { + background: var(--bg-primary); + padding: var(--spacing-md); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); +} + +[data-mode="airline"] .filter-section { + margin-bottom: var(--spacing-lg); +} + +[data-mode="airline"] .filter-title { + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +[data-mode="airline"] .checkbox-label { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) 0; + cursor: pointer; +} + +[data-mode="airline"] .checkbox-label:hover { + color: var(--accent-primary); +} diff --git a/web/src/styles/hotel.css b/web/src/styles/hotel.css index e69de29..8c67285 100644 --- a/web/src/styles/hotel.css +++ b/web/src/styles/hotel.css @@ -0,0 +1,400 @@ +/* Hotel Platform - Action Blue Theme */ + +:root[data-mode="hotel"] { + --accent-primary: #007aff; + --accent-secondary: #4caf50; + --accent-warning: #d9534f; + --accent-primary-hover: #0051d5; + --accent-primary-light: #e6f2ff; + --text-accent: #007aff; + --bg-tertiary: #f5f5f7; +} + +[data-mode="hotel"] { + --primary-color: var(--accent-primary); +} + +[data-mode="hotel"] .btn-primary { + background-color: var(--accent-primary); + color: #ffffff; +} + +[data-mode="hotel"] .btn-primary:hover { + background-color: var(--accent-primary-hover); +} + +[data-mode="hotel"] .btn-secondary { + background-color: transparent; + color: var(--accent-primary); + border: 2px solid var(--accent-primary); +} + +[data-mode="hotel"] .btn-secondary:hover { + background-color: var(--accent-primary-light); +} + +[data-mode="hotel"] .badge-value { + background-color: var(--accent-secondary); + color: #ffffff; + padding: 4px 10px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +} + +[data-mode="hotel"] .badge-warning { + background-color: var(--accent-warning); + color: #ffffff; + padding: 4px 10px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +} + +[data-mode="hotel"] .badge-rating { + background-color: var(--accent-primary); + color: #ffffff; + padding: 6px 10px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 700; +} + +[data-mode="hotel"] .search-form { + background: var(--bg-primary); + padding: var(--spacing-lg); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + max-width: 900px; + margin: 0 auto; +} + +[data-mode="hotel"] .hotel-card { + display: grid; + grid-template-columns: 300px 1fr auto; + gap: var(--spacing-md); + background: var(--bg-primary); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + margin-bottom: var(--spacing-md); + overflow: hidden; + transition: box-shadow 0.2s ease; +} + +[data-mode="hotel"] .hotel-card:hover { + box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15); +} + +[data-mode="hotel"] .hotel-image { + position: relative; + width: 100%; + height: 100%; + min-height: 220px; + overflow: hidden; +} + +[data-mode="hotel"] .hotel-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +[data-mode="hotel"] .image-carousel { + position: relative; +} + +[data-mode="hotel"] .carousel-nav { + position: absolute; + bottom: var(--spacing-sm); + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 6px; +} + +[data-mode="hotel"] .carousel-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + cursor: pointer; +} + +[data-mode="hotel"] .carousel-dot.active { + background: #ffffff; +} + +[data-mode="hotel"] .hotel-info { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +[data-mode="hotel"] .hotel-name { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +[data-mode="hotel"] .hotel-location { + font-size: 0.875rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +[data-mode="hotel"] .hotel-rating { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +[data-mode="hotel"] .rating-text { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} + +[data-mode="hotel"] .hotel-features { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +[data-mode="hotel"] .feature-tag { + padding: 4px 8px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 0.75rem; + border-radius: 4px; +} + +[data-mode="hotel"] .hotel-pricing { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + min-width: 200px; +} + +[data-mode="hotel"] .price-wrapper { + text-align: right; +} + +[data-mode="hotel"] .price-label { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 4px; +} + +[data-mode="hotel"] .price-amount { + font-size: 1.75rem; + font-weight: 700; + color: var(--accent-primary); +} + +[data-mode="hotel"] .price-unit { + font-size: 0.875rem; + color: var(--text-secondary); +} + +[data-mode="hotel"] .price-original { + text-decoration: line-through; + color: var(--text-secondary); + font-size: 1rem; + margin-right: var(--spacing-sm); +} + +[data-mode="hotel"] .urgency-message { + font-size: 0.75rem; + color: var(--accent-warning); + font-weight: 600; + margin-top: 4px; +} + +[data-mode="hotel"] .free-cancellation { + font-size: 0.75rem; + color: var(--accent-secondary); + font-weight: 600; + margin-top: 4px; +} + +[data-mode="hotel"] .filter-sidebar { + background: var(--bg-primary); + padding: var(--spacing-md); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); +} + +[data-mode="hotel"] .filter-section { + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid #e0e0e0; +} + +[data-mode="hotel"] .filter-section:last-child { + border-bottom: none; +} + +[data-mode="hotel"] .filter-title { + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-md); +} + +[data-mode="hotel"] .checkbox-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) 0; + cursor: pointer; +} + +[data-mode="hotel"] .checkbox-label:hover { + color: var(--accent-primary); +} + +[data-mode="hotel"] .checkbox-count { + font-size: 0.875rem; + color: var(--text-secondary); +} + +[data-mode="hotel"] .price-slider { + margin-top: var(--spacing-md); +} + +[data-mode="hotel"] .slider-track { + width: 100%; + height: 6px; + background: #e0e0e0; + border-radius: 3px; + position: relative; +} + +[data-mode="hotel"] .slider-range { + height: 100%; + background: var(--accent-primary); + border-radius: 3px; +} + +[data-mode="hotel"] .slider-values { + display: flex; + justify-content: space-between; + margin-top: var(--spacing-sm); + font-size: 0.875rem; + color: var(--text-secondary); +} + +[data-mode="hotel"] .map-toggle { + background: var(--bg-primary); + border: 2px solid var(--accent-primary); + color: var(--accent-primary); + padding: 12px 24px; + border-radius: var(--border-radius); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +[data-mode="hotel"] .map-toggle:hover { + background: var(--accent-primary); + color: #ffffff; +} + +[data-mode="hotel"] .results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-lg); + padding: var(--spacing-md); + background: var(--bg-primary); + border-radius: var(--border-radius); +} + +[data-mode="hotel"] .sort-dropdown { + padding: 8px 12px; + border: 2px solid #e0e0e0; + border-radius: 6px; + background: var(--bg-primary); + cursor: pointer; + font-size: 0.875rem; +} + +[data-mode="hotel"] .sort-dropdown:focus { + border-color: var(--accent-primary); +} + +[data-mode="hotel"] .view-toggle { + display: flex; + gap: var(--spacing-sm); +} + +[data-mode="hotel"] .view-button { + padding: 8px 12px; + background: transparent; + border: 2px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +[data-mode="hotel"] .view-button.active { + background: var(--accent-primary); + color: #ffffff; + border-color: var(--accent-primary); +} + +[data-mode="hotel"] a { + color: var(--accent-primary); + text-decoration: none; +} + +[data-mode="hotel"] a:hover { + text-decoration: underline; +} + +[data-mode="hotel"] .input-field { + border: 2px solid #e0e0e0; + border-radius: 6px; + padding: 12px; + width: 100%; + transition: border-color 0.2s ease; +} + +[data-mode="hotel"] .input-field:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-light); +} + +[data-mode="hotel"] .tab-navigation { + display: flex; + gap: 0; + margin-bottom: var(--spacing-lg); + border-bottom: 2px solid #e0e0e0; +} + +[data-mode="hotel"] .tab-item { + padding: 12px 24px; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +[data-mode="hotel"] .tab-item:hover { + color: var(--accent-primary); +} + +[data-mode="hotel"] .tab-item.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} From f6e780fdf145330811848797e673a1fec0e5edee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Alves=20R=C3=B6sel?= <60182044+velocitatem@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:39:59 +0100 Subject: [PATCH 04/10] 13 create outline for research paper draft (#18) * updated outline for paper from issue * extra paper sections and some formalization of series data * algorithms and acknowledgements --- paper/src/auto/main.el | 8 ++- paper/src/chapters/01-intro.tex | 10 ++- paper/src/chapters/02-literature-review.tex | 17 ++++++ paper/src/chapters/03-methodology.tex | 68 +++++++++++++++++++++ paper/src/chapters/04-results.tex | 16 +++++ paper/src/chapters/05-discussion.tex | 9 +++ paper/src/chapters/06-conclusion.tex | 8 +++ paper/src/chapters/acknowledgements.tex | 3 + paper/src/main.tex | 6 ++ paper/src/preamble.tex | 4 +- 10 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 paper/src/chapters/02-literature-review.tex create mode 100644 paper/src/chapters/03-methodology.tex create mode 100644 paper/src/chapters/04-results.tex create mode 100644 paper/src/chapters/05-discussion.tex create mode 100644 paper/src/chapters/06-conclusion.tex create mode 100644 paper/src/chapters/acknowledgements.tex diff --git a/paper/src/auto/main.el b/paper/src/auto/main.el index 6207362..3e186b1 100644 --- a/paper/src/auto/main.el +++ b/paper/src/auto/main.el @@ -6,11 +6,17 @@ (setq TeX-command-extra-options "-file-line-error -interaction=nonstopmode") (TeX-add-to-alist 'LaTeX-provided-class-options - '(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm"))) + '(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm" "natbib=false"))) (TeX-run-style-hooks "latex2e" "preamble" "chapters/01-intro" + "chapters/02-literature-review" + "chapters/03-methodology" + "chapters/04-results" + "chapters/05-discussion" + "chapters/06-conclusion" + "../build/concatenated_code" "acmart" "acmart10") (TeX-add-symbols diff --git a/paper/src/chapters/01-intro.tex b/paper/src/chapters/01-intro.tex index 2133d8c..23fa1a6 100644 --- a/paper/src/chapters/01-intro.tex +++ b/paper/src/chapters/01-intro.tex @@ -6,5 +6,11 @@ %% \label{fig:example} %% \end{figure} -\section{Know They Enemy} -To know how to overcome we need to +\section{Introduction} + +Research Objectives and Contribution: What are we making, why and who should care? + +\subsection{Motivation and Market Context} +Current market dynamics and trends of dynamic pricing and AI agents. Future projections of AI agents. Key stakeholders that are discussing this and reporting on it (Thales). Who is most affected +\subsection{Solution Space Overview} +Different approaches and perspectives, here also add a preview of what will be developed and explored in the lit review. diff --git a/paper/src/chapters/02-literature-review.tex b/paper/src/chapters/02-literature-review.tex new file mode 100644 index 0000000..6395206 --- /dev/null +++ b/paper/src/chapters/02-literature-review.tex @@ -0,0 +1,17 @@ +\section{Literature Review} + +\subsection{Foundational Concepts} + +What is the taxonomy and definition of an agent and an actor in this case, a bit more about interaction models in sessions and about dynamic pricing algorithms. + +\subsection{Problem Evidence and Market Impact} +Documented instances of agent-driven market disruptions - Quantitative evidence of pricing manipulation - Case studies from affected industries + +\subsection{Theoretical Foundations: Economic Prallels} + +Economic foundations: relating the problem to options pricing theory. Cost of Information (COI) concept and its relevance + +\subsection{Landscape of Existing Work} + +Previous efforts in adversarial computer use LLM agents, show how multi-faceted the whole problem is +Here we can show a market visualization (venn-like-diagram) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex new file mode 100644 index 0000000..dd21186 --- /dev/null +++ b/paper/src/chapters/03-methodology.tex @@ -0,0 +1,68 @@ +\section{Methodology} + + +\subsection{Problem Formalization} + +Mathematical formalization of agent-induced pricing distortions. Formal definition of potential loss mechanisms $\alpha D$ + +We consider a business across time during which we have an evolving vector $p_t \in \Re^N$ where $N$ is the number of products in our catalogue. our price vector is directly dependent on a demand function $q_t$ which we define as a linear method of a price elasticity matrix $B_t$. This is the same setup that Microsoft created in their research. + +We gether interaction data from users interacting with a sample platform simulating a hotel/airline which generates interaction distributions $I_t = \{(p_t, q_t^\text{obs}, \pi_t)\}_{t=1}^T$ + + +\subsection{Cost of Information Framework} + +Mathematical demonstration and validation of the COI and citation backed evidence, and framework overview + show harm to user via other cost distortions. Maybe split into 3.2.1 (COI Theory) and 3.2.2 (Framework Design) + +\subsection{System Architecture} +\begin{figure}[ht] +\centering +\begin{tikzpicture}[ + node distance=1.5cm and 2.5cm, + box/.style={rectangle, draw, thick, minimum height=1cm, minimum width=3cm, align=center, fill=blue!10}, + kafka/.style={rectangle, draw=orange, thick, minimum height=1cm, minimum width=3cm, align=center, fill=orange!15}, + arrow/.style={thick,->,>=Stealth} +] + +% Nodes +\node[box] (webapp) {Web Application \\ (Producer \& Consumer)}; +\node[kafka, below=of webapp] (kafka) {Apache Kafka \\ Cluster}; +\node[box, below=of kafka] (backend) {Backend Services / Microservices \\ (Producers and Consumers)}; + +% Connections +\draw[arrow] (webapp) to[out=210,in=150] node[above]{Publish} (kafka); +\draw[arrow] (kafka) to[out=50,in=330] node[below]{Consume} (webapp); +\draw[arrow] (backend) -- node[above]{Publish/Consume} (kafka); + +% Optional: Kafka internal components +%\node[below=0.7cm of kafka, align=center] (topics) {Topics \\ Partitions}; + +% Optional background +\begin{scope}[on background layer] + \node[draw, rounded corners, fill=orange!5, fit=(kafka), inner sep=0.3cm] {}; +\end{scope} +\end{tikzpicture} +\caption{Technical Diagram} +\end{figure} + +High level overview of how it works +\subsection{Experimental Design} +Study methodology and approach. Data acquisition strategy. Defined objectives and success criteria. Observable metrics and KPIs + +\subsection{Dynamic Pricing Algorithm Analysis} +Deep dive into how the algorithm works, different kinds and justification for chosen appraoches + agent impact modeling and quantification. +\subsection{Reinforcement Learning Formulation} +How do we define the state space, action space and reward function breakdown and algorithm benchmarking. +POSSIBLY: Expand into full subsections: 3.6.1 (State-Action Space), 3.6.2 (Reward Design), 3.6.3 (Benchmarking) + + +\begin{algorithm}[t] +\DontPrintSemicolon +\KwIn{stepsize $\eta$, smoothing $\delta$, rank $d$} +\For{$t=1$ \KwTo $T$}{ + Sample $u_t$ on unit sphere; set $x_t^\prime=x_t+\delta u_t$\; + Set $p_t \gets U x_t^\prime$ and observe $q_t, R_t(p_t)$\; + $x_{t+1} \gets \Pi\_{\mathcal{X}}(x_t-\eta R_t(p_t) u_t)$\; +} +\caption{Online Pricing Optimization (template)} +\end{algorithm} diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex new file mode 100644 index 0000000..ca57292 --- /dev/null +++ b/paper/src/chapters/04-results.tex @@ -0,0 +1,16 @@ +\section{Results} + +\subsection{Behavioral Analysis} + +Include markov chains of transition matrices, compare distributions (look at Divergence metrics) + + +\subsection{Experimental Outcomes} + +Align with defined objectives, show results and statistical significance (or not). + + +\subsection{Interpretation and Insights} +Inference from given patterns and show key findings. + +\subsection{Anomalies} diff --git a/paper/src/chapters/05-discussion.tex b/paper/src/chapters/05-discussion.tex new file mode 100644 index 0000000..a2052a1 --- /dev/null +++ b/paper/src/chapters/05-discussion.tex @@ -0,0 +1,9 @@ +\section{Discussion} + +\subsection{Risk Assessment and Limitations} + +Acknowledge risks and constraints and data sizes. + +\subsection{Implications of Findings} + +Interpretation of results and altenrative scenarios with broader market implications. diff --git a/paper/src/chapters/06-conclusion.tex b/paper/src/chapters/06-conclusion.tex new file mode 100644 index 0000000..f923a49 --- /dev/null +++ b/paper/src/chapters/06-conclusion.tex @@ -0,0 +1,8 @@ +\section{Conclusion} + +\subsection{Summary of contributions } +Restate the thesis and key findings with validation of research objectives. + +\subsection{Future Works and Next Steps} + +Identify the research gaps here and potential business implications and setup of business + Proposed extensions and a long term agenda. diff --git a/paper/src/chapters/acknowledgements.tex b/paper/src/chapters/acknowledgements.tex new file mode 100644 index 0000000..160bad2 --- /dev/null +++ b/paper/src/chapters/acknowledgements.tex @@ -0,0 +1,3 @@ +\section{Acknowledgements} + +Eugene Bykovets, PhD - ETH diff --git a/paper/src/main.tex b/paper/src/main.tex index 4c7340d..80699a2 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -35,6 +35,12 @@ The primary objective of this thesis is to develop and validate pricing heuristi \maketitle \input{chapters/01-intro} +\input{chapters/02-literature-review} +\input{chapters/03-methodology} +\input{chapters/04-results} +\input{chapters/05-discussion} +\input{chapters/06-conclusion} + \printbibliography diff --git a/paper/src/preamble.tex b/paper/src/preamble.tex index 31f3639..0acd7c7 100644 --- a/paper/src/preamble.tex +++ b/paper/src/preamble.tex @@ -4,10 +4,12 @@ \usepackage{csquotes} \usepackage{subcaption} \usepackage{siunitx} - +\usepackage{tikz} \usepackage{listings} \usepackage{xcolor} +\usepackage[ruled,vlined]{algorithm2e} +\usetikzlibrary{positioning, shapes, arrows.meta, fit, backgrounds} \lstset{ basicstyle=\ttfamily\footnotesize, breaklines=true, From 6b7060450ce1ebd3c015934ffaa01263dce95656 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 4 Nov 2025 20:05:17 +0100 Subject: [PATCH 05/10] updated outline for paper from issue --- paper/src/auto/main.el | 1 - 1 file changed, 1 deletion(-) diff --git a/paper/src/auto/main.el b/paper/src/auto/main.el index 3e186b1..86386e4 100644 --- a/paper/src/auto/main.el +++ b/paper/src/auto/main.el @@ -22,4 +22,3 @@ (TeX-add-symbols '("footnotetextcopyrightpermission" 1))) :latex) - From 7ece6e82cbd85b5e15d65432acf68f983f1166e5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:11:50 +0100 Subject: [PATCH 06/10] Refactor docker-compose services to use individual Dockerfiles (#20) * Initial plan * Refactor services into individual Dockerfiles Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * Add EXPOSE directives to all Dockerfiles with port documentation Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> --- docker-compose.yml | 16 ++++++++++++---- docker/Kafka.dockerfile | 7 +++++++ docker/Redis.dockerfile | 4 ++++ docker/RedpandaConsole.dockerfile | 4 ++++ docker/Zookeeper.dockerfile | 4 ++++ 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 docker/Kafka.dockerfile create mode 100644 docker/Redis.dockerfile create mode 100644 docker/RedpandaConsole.dockerfile create mode 100644 docker/Zookeeper.dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index abfb77d..b0a6521 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,9 @@ services: redis: container_name: "PHANTOM-redis" - image: redis:7-alpine + build: + context: ./docker + dockerfile: Redis.dockerfile ports: - "${REDIS_PORT:-6378}:6379" volumes: @@ -9,7 +11,9 @@ services: restart: unless-stopped zookeeper: container_name: "PHANTOM-zookeeper" - image: confluentinc/cp-zookeeper:latest + build: + context: ./docker + dockerfile: Zookeeper.dockerfile environment: ZOOKEEPER_CLIENT_PORT: 2181 ports: @@ -17,7 +21,9 @@ services: kafka: container_name: "PHANTOM-kafka" - image: confluentinc/cp-kafka:7.5.0 + build: + context: ./docker + dockerfile: Kafka.dockerfile depends_on: - zookeeper environment: @@ -36,7 +42,9 @@ services: redpanda-console: container_name: "PHANTOM-redpanda-console" - image: docker.redpanda.com/redpandadata/console:latest + build: + context: ./docker + dockerfile: RedpandaConsole.dockerfile depends_on: - kafka environment: diff --git a/docker/Kafka.dockerfile b/docker/Kafka.dockerfile new file mode 100644 index 0000000..33c6ee8 --- /dev/null +++ b/docker/Kafka.dockerfile @@ -0,0 +1,7 @@ +FROM confluentinc/cp-kafka:7.5.0 + +# Expose Kafka ports +# 9092: External client connections +# 29092: Internal broker communication +# 9999: JMX monitoring port +EXPOSE 9092 29092 9999 diff --git a/docker/Redis.dockerfile b/docker/Redis.dockerfile new file mode 100644 index 0000000..183ee10 --- /dev/null +++ b/docker/Redis.dockerfile @@ -0,0 +1,4 @@ +FROM redis:7-alpine + +# Expose Redis port +EXPOSE 6379 diff --git a/docker/RedpandaConsole.dockerfile b/docker/RedpandaConsole.dockerfile new file mode 100644 index 0000000..d90ff48 --- /dev/null +++ b/docker/RedpandaConsole.dockerfile @@ -0,0 +1,4 @@ +FROM docker.redpanda.com/redpandadata/console:latest + +# Expose Redpanda Console web UI port +EXPOSE 8080 diff --git a/docker/Zookeeper.dockerfile b/docker/Zookeeper.dockerfile new file mode 100644 index 0000000..87df5d2 --- /dev/null +++ b/docker/Zookeeper.dockerfile @@ -0,0 +1,4 @@ +FROM confluentinc/cp-zookeeper:latest + +# Expose Zookeeper client port +EXPOSE 2181 From 37b2099ee000b62c8a9950c3233ae88022e1cad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Alves=20R=C3=B6sel?= <60182044+velocitatem@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:07:27 +0100 Subject: [PATCH 07/10] 2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17) * chore: cleaning gitignore * formating and env documentation * feat: context switching of hotel/airline depndent on env var via middleware * fixed alignment and building * wrong file * prods * fixed applying style * better session cookie management * tentative session storage with maybe using airtable * migrated api of ingestion * events and products apge * fixing build * 13 create outline for research paper draft (#18) * updated outline for paper from issue * extra paper sections and some formalization of series data * algorithms and acknowledgements * updated outline for paper from issue * upadted text formating * event unification * refactor tracking to ues callbacks instead of refs * implement a pricing display api with session passing * moved middleware to proxy according to new changes in Nextjs * refactoed kafka ingestion to go via backend not web-db * Refactor docker-compose services to use individual Dockerfiles (#20) * Initial plan * Refactor services into individual Dockerfiles Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * Add EXPOSE directives to all Dockerfiles with port documentation Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> * fixing small bugs and adding exepriments to tracking * added some doc --- .env.example | 21 +- .gitignore | 6 +- README.md | 4 + backend/server/app.py | 99 ++ backend/server/requirements.txt | 5 + docker-compose.yml | 15 + docker/backend.Dockerfile | 12 + experiments/data_export.ipynb | 979 +++++++++++++----- paper/src/preamble.tex | 5 +- web/README.md | 83 ++ web/package-lock.json | 22 +- web/package.json | 4 +- web/src/app/admin/experiments/page.tsx | 199 ++++ web/src/app/airline/layout.tsx | 6 + web/src/app/airline/page.tsx | 9 + web/src/app/airline/products/page.tsx | 74 ++ web/src/app/api/admin/experiments/route.ts | 15 + .../app/api/admin/experiments/start/route.ts | 43 + .../app/api/admin/experiments/stop/route.ts | 39 + web/src/app/api/ingest/route.ts | 42 + web/src/app/api/pricing/route.ts | 45 + web/src/app/api/session/route.ts | 46 + web/src/app/api/track/route.ts | 33 - web/src/app/globals.css | 11 + web/src/app/hotel/layout.tsx | 6 + web/src/app/hotel/page.tsx | 9 + web/src/app/hotel/products/page.tsx | 75 ++ .../components/feats/airline/AirlineCard.tsx | 87 ++ .../components/feats/airline/AirlineHero.tsx | 156 +++ web/src/components/feats/hotel/HotelCard.tsx | 98 ++ web/src/components/feats/hotel/HotelHero.tsx | 103 ++ web/src/components/ui/Button.tsx | 20 + web/src/components/ui/DateInput.tsx | 7 + web/src/components/ui/Dropdown.tsx | 83 ++ web/src/components/ui/Input.tsx | 29 + web/src/components/ui/Label.tsx | 13 + web/src/components/ui/Navigation.tsx | 48 + web/src/components/ui/PriceDisplay.tsx | 136 +++ web/src/components/ui/RadioGroup.tsx | 33 + web/src/components/ui/index.ts | 7 + web/src/hooks/useHoverTracking.ts | 63 ++ web/src/hooks/useInteractionTracking.ts | 137 +-- web/src/hooks/useSession.ts | 38 + web/src/lib/config.ts | 30 + web/src/lib/events.ts | 91 ++ web/src/lib/kafka.ts | 42 - web/src/lib/sessionStore.ts | 102 ++ web/src/proxy.ts | 36 + web/src/styles/airline.css | 25 +- web/src/styles/hotel.css | 20 +- 50 files changed, 2865 insertions(+), 446 deletions(-) create mode 100644 backend/server/app.py create mode 100644 backend/server/requirements.txt create mode 100644 docker/backend.Dockerfile create mode 100755 web/src/app/admin/experiments/page.tsx create mode 100644 web/src/app/airline/layout.tsx create mode 100644 web/src/app/airline/page.tsx create mode 100644 web/src/app/airline/products/page.tsx create mode 100644 web/src/app/api/admin/experiments/route.ts create mode 100644 web/src/app/api/admin/experiments/start/route.ts create mode 100644 web/src/app/api/admin/experiments/stop/route.ts create mode 100644 web/src/app/api/ingest/route.ts create mode 100644 web/src/app/api/pricing/route.ts create mode 100644 web/src/app/api/session/route.ts delete mode 100644 web/src/app/api/track/route.ts create mode 100644 web/src/app/hotel/layout.tsx create mode 100644 web/src/app/hotel/page.tsx create mode 100644 web/src/app/hotel/products/page.tsx create mode 100644 web/src/components/feats/airline/AirlineCard.tsx create mode 100644 web/src/components/feats/airline/AirlineHero.tsx create mode 100644 web/src/components/feats/hotel/HotelCard.tsx create mode 100644 web/src/components/feats/hotel/HotelHero.tsx create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/DateInput.tsx create mode 100644 web/src/components/ui/Dropdown.tsx create mode 100644 web/src/components/ui/Input.tsx create mode 100644 web/src/components/ui/Label.tsx create mode 100644 web/src/components/ui/Navigation.tsx create mode 100644 web/src/components/ui/PriceDisplay.tsx create mode 100644 web/src/components/ui/RadioGroup.tsx create mode 100644 web/src/components/ui/index.ts create mode 100644 web/src/hooks/useHoverTracking.ts create mode 100644 web/src/hooks/useSession.ts create mode 100644 web/src/lib/config.ts create mode 100644 web/src/lib/events.ts delete mode 100644 web/src/lib/kafka.ts create mode 100644 web/src/lib/sessionStore.ts create mode 100644 web/src/proxy.ts diff --git a/.env.example b/.env.example index 0cba496..c011fd8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,18 @@ -HOSTNAME=localhost +# Network configuration +HOSTNAME=localhost # hostname for service discovery across docker network -# PORTS -KAFKA_PORT=9092 -REDIS_PORT=6377 +# Application configuration +STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme +NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format +NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling +NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions + +# Backend service +BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod) + +# Service ports - used by docker-compose and service communication +BACKEND_PORT=5000 # backend server port for kafka ingestion API +KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com) +KAFKA_PORT=9092 # kafka broker port for event streaming +REDIS_PORT=6377 # redis port for worker queue and caching +REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring diff --git a/.gitignore b/.gitignore index f4056c4..7cdbf14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ **/.env -**/.venv \ No newline at end of file +**/.venv +PHANTOM.wiki/ +**/.virtual_documents/ +**/__pycache__/ +**/.ipynb_checkpoints/ \ No newline at end of file diff --git a/README.md b/README.md index ac0a597..76c5e14 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ [![Build PDF](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml/badge.svg)](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml) + +- https://phantom-hotel.vercel.app/ +- https://phantom-airline.vercel.app/ + diff --git a/backend/server/app.py b/backend/server/app.py new file mode 100644 index 0000000..8544689 --- /dev/null +++ b/backend/server/app.py @@ -0,0 +1,99 @@ +# boilerplate code +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, Any +import uvicorn +import os +import json +from datetime import datetime +from kafka import KafkaProducer +from dotenv import load_dotenv +load_dotenv() + +app = FastAPI() + +# kafka producer - lazy init +_producer: Optional[KafkaProducer] = None + +def get_producer() -> KafkaProducer: + global _producer + if _producer is None: + host = os.getenv('KAFKA_HOST', 'localhost') + port = os.getenv('KAFKA_PORT', '9092') + broker = f'{host}:{port}' if port else host + _producer = KafkaProducer( + bootstrap_servers=[broker], + value_serializer=lambda v: json.dumps(v).encode('utf-8'), + key_serializer=lambda k: k.encode('utf-8') if k else None, + ) + return _producer + +class EventPayload(BaseModel): + sessionId: str + eventName: str + page: str + productId: Optional[str] = None + metadata: Optional[dict[str, Any]] = None + storeMode: str + userAgent: Optional[str] = None + ts: Optional[str] = None + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/health") +async def health(): + kafka_status = "unknown" + try: + producer = get_producer() + # attempt to get cluster metadata to verify connection + producer.bootstrap_connected() + kafka_status = "connected" + except Exception as e: + kafka_status = f"error: {str(e)}" + + return { + "status": "healthy", + "kafka": kafka_status, + "kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}" + } + + +@app.post("/api/kafka/ingest") +async def ingest_logs(event: EventPayload): + try: + if not event.ts: + event.ts = datetime.utcnow().isoformat() + 'Z' + + producer = get_producer() + producer.send( + 'user-interactions', + key=event.sessionId, + value=event.model_dump() + ) + producer.flush(timeout=5) + + return {"success": True} + except Exception as e: + import traceback + print(f"[ERROR] {e}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/kafka/dump") +def dump_logs(): + # TODO: implement a dump of logs of time period t_start to t_end (params of get) + # OR: allow for params of last_n logs as a param - creating two modes of the dumping + pass + + + +if __name__ == "__main__": + PORT=int(os.getenv("BACKEND_PORT", 5000)) + uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True) diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt new file mode 100644 index 0000000..d9113ed --- /dev/null +++ b/backend/server/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +kafka-python==2.0.2 +pydantic==2.5.0 +python-dotenv==1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml index b0a6521..49223a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,18 @@ services: + backend: + container_name: "PHANTOM-backend" + build: + context: . + dockerfile: docker/backend.Dockerfile + ports: + - "${BACKEND_PORT:-5000}:5000" + environment: + - KAFKA_HOST=kafka + - KAFKA_PORT=29092 + depends_on: + - kafka + restart: unless-stopped + redis: container_name: "PHANTOM-redis" build: @@ -9,6 +23,7 @@ services: volumes: - phantom_redis_data:/data restart: unless-stopped + zookeeper: container_name: "PHANTOM-zookeeper" build: diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 0000000..32eb28b --- /dev/null +++ b/docker/backend.Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY backend/server/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/server/app.py . + +EXPOSE 5000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/experiments/data_export.ipynb b/experiments/data_export.ipynb index c0620ff..4ba73fb 100644 --- a/experiments/data_export.ipynb +++ b/experiments/data_export.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 98, + "execution_count": 9, "id": "62eafcd9-5462-4063-8873-0e7fb9add907", "metadata": {}, "outputs": [ @@ -12,7 +12,7 @@ "True" ] }, - "execution_count": 98, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -31,7 +31,7 @@ }, { "cell_type": "code", - "execution_count": 86, + "execution_count": 10, "id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f", "metadata": {}, "outputs": [ @@ -40,22 +40,31 @@ "output_type": "stream", "text": [ "\n", - "RangeIndex: 141 entries, 0 to 140\n", - "Data columns (total 10 columns):\n", - " # Column Non-Null Count Dtype \n", - "--- ------ -------------- ----- \n", - " 0 sessionId 141 non-null object \n", - " 1 eventType 141 non-null object \n", - " 2 ts 141 non-null int64 \n", - " 3 targetEl 14 non-null object \n", - " 4 targetUrl 1 non-null object \n", - " 5 metadata_path 141 non-null object \n", - " 6 metadata_referrer 6 non-null object \n", - " 7 metadata_x 14 non-null float64\n", - " 8 metadata_y 14 non-null float64\n", - " 9 metadata_scrollY 121 non-null float64\n", - "dtypes: float64(3), int64(1), object(6)\n", - "memory usage: 11.1+ KB\n" + "RangeIndex: 528 entries, 0 to 527\n", + "Data columns (total 19 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 sessionId 528 non-null object \n", + " 1 eventType 467 non-null object \n", + " 2 ts 528 non-null object \n", + " 3 targetEl 401 non-null object \n", + " 4 eventName 61 non-null object \n", + " 5 page 61 non-null object \n", + " 6 storeMode 61 non-null object \n", + " 7 userAgent 61 non-null object \n", + " 8 productId 21 non-null object \n", + " 9 metadata_path 467 non-null object \n", + " 10 metadata_referrer 82 non-null object \n", + " 11 metadata_x 425 non-null float64\n", + " 12 metadata_y 425 non-null float64\n", + " 13 metadata_event 7 non-null object \n", + " 14 metadata_targetEl 24 non-null object \n", + " 15 metadata_roomType 5 non-null object \n", + " 16 metadata_price 5 non-null float64\n", + " 17 metadata_nights 5 non-null float64\n", + " 18 metadata_targetUrl 4 non-null object \n", + "dtypes: float64(4), object(15)\n", + "memory usage: 78.5+ KB\n" ] } ], @@ -81,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": 87, + "execution_count": 11, "id": "f6819a1c-32ab-49c7-845b-5df7bf60f561", "metadata": {}, "outputs": [ @@ -110,20 +119,33 @@ " eventType\n", " ts\n", " targetEl\n", - " targetUrl\n", + " eventName\n", + " page\n", + " storeMode\n", + " userAgent\n", + " productId\n", " metadata_path\n", " metadata_referrer\n", " metadata_x\n", " metadata_y\n", - " metadata_scrollY\n", + " metadata_event\n", + " metadata_targetEl\n", + " metadata_roomType\n", + " metadata_price\n", + " metadata_nights\n", + " metadata_targetUrl\n", " \n", " \n", " \n", " \n", " 0\n", - " 1761225843899-qaiwwwyj2o\n", + " 1762434923440-66hdhq8qicd\n", " pageview\n", - " 1761226211163\n", + " 1762434924107\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", @@ -131,64 +153,87 @@ " NaN\n", " NaN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " \n", " \n", " 1\n", - " 1761225843899-qaiwwwyj2o\n", + " 1762434923440-66hdhq8qicd\n", " click\n", - " 1761226218090\n", - " MAIN\n", + " 1762434925198\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " /\n", " NaN\n", - " 815.0\n", - " 331.0\n", + " 1098.0\n", + " 663.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", " 2\n", - " 1761225843899-qaiwwwyj2o\n", + " 1762434923440-66hdhq8qicd\n", " click\n", - " 1761226220890\n", + " 1762434925371\n", " MAIN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " /\n", " NaN\n", - " 1129.0\n", - " 605.0\n", + " 1098.0\n", + " 663.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", " 3\n", - " 1761225843899-qaiwwwyj2o\n", - " click\n", - " 1761226225801\n", - " DIV\n", + " 1762434923440-66hdhq8qicd\n", + " pageview\n", + " 1762437192910\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", - " 532.0\n", - " 545.0\n", " NaN\n", " \n", " \n", " 4\n", - " 1761225843899-qaiwwwyj2o\n", - " click\n", - " 1761226229364\n", - " DIV\n", - " NaN\n", - " /\n", - " NaN\n", - " 481.0\n", - " 399.0\n", - " NaN\n", - " \n", - " \n", - " 5\n", - " 1761227236286-e7mphcvw6t\n", + " 1762434923440-66hdhq8qicd\n", " pageview\n", - " 1761227236426\n", + " 1762437198539\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", @@ -196,38 +241,131 @@ " NaN\n", " NaN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " \n", " \n", - " 6\n", - " 1761227236286-e7mphcvw6t\n", + " 390\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", " click\n", - " 1761227239328\n", + " 1762443115648\n", " DIV\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " /\n", " NaN\n", - " 202.0\n", - " 351.0\n", + " 245.0\n", + " 595.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", - " 7\n", - " 1761227236286-e7mphcvw6t\n", + " 391\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", " click\n", - " 1761227244783\n", - " A\n", - " https://vercel.com/new?utm_source=create-next-...\n", + " 1762443174606\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " /\n", " NaN\n", - " 377.0\n", - " 723.0\n", + " 475.0\n", + " 428.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", - " 8\n", - " 1761828056433-0gz7aboz86h\n", + " 392\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", + " click\n", + " 1762443183406\n", + " INPUT\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 832.0\n", + " 219.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 393\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", + " click\n", + " 1762443208588\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 485.0\n", + " 155.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 394\n", + " d423ce8a-77aa-4c9a-94d4-d1adddcc3472\n", + " click\n", + " 1762443225474\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 281.0\n", + " 281.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 407\n", + " 1762444018243-0120z6z5u42f\n", " pageview\n", - " 1761828261783\n", + " 1762444018256\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", @@ -235,111 +373,381 @@ " NaN\n", " NaN\n", " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " \n", " \n", - " 9\n", - " 1761828056433-0gz7aboz86h\n", + " 408\n", + " 1762444018243-0120z6z5u42f\n", " click\n", - " 1761828266484\n", - " H1\n", + " 1762445774344\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " /\n", " NaN\n", - " 527.0\n", - " 169.0\n", + " 299.0\n", + " 214.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " \n", " \n", - " 10\n", - " 1761828056433-0gz7aboz86h\n", - " scroll\n", - " 1761828270314\n", + " 431\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " pageview\n", + " 1762448190973\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " NaN\n", - " 51.666668\n", " \n", " \n", - " 11\n", - " 1761828056433-0gz7aboz86h\n", - " scroll\n", - " 1761828270328\n", + " 432\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " click\n", + " 1762448192425\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", " NaN\n", + " 1623.0\n", + " 493.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", - " 50.000000\n", " \n", " \n", - " 12\n", - " 1761828056433-0gz7aboz86h\n", - " scroll\n", - " 1761828270336\n", + " 433\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " click\n", + " 1762448192645\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", " /\n", " NaN\n", + " 1623.0\n", + " 493.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 434\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " pageview\n", + " 1762448205850\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 435\n", + " 214d9fad-9b00-40c3-bd0e-7739b6acd654\n", + " click\n", + " 1762448207922\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 421.0\n", + " 216.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 438\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " pageview\n", + " 1762448283244\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 439\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " click\n", + " 1762448295524\n", + " HTML\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 614.0\n", + " 720.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 440\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " click\n", + " 1762448342763\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 416.0\n", + " 397.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 441\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " pageview\n", + " 1762448343396\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " \n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " \n", + " \n", + " 442\n", + " f0d40ca6-c1d3-4ecd-beb3-796adc74349d\n", + " click\n", + " 1762448829631\n", + " DIV\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", + " /\n", + " NaN\n", + " 45.0\n", + " 44.0\n", + " NaN\n", + " NaN\n", + " NaN\n", + " NaN\n", " NaN\n", " NaN\n", - " 49.166668\n", " \n", " \n", "\n", "" ], "text/plain": [ - " sessionId eventType ts targetEl \\\n", - "0 1761225843899-qaiwwwyj2o pageview 1761226211163 NaN \n", - "1 1761225843899-qaiwwwyj2o click 1761226218090 MAIN \n", - "2 1761225843899-qaiwwwyj2o click 1761226220890 MAIN \n", - "3 1761225843899-qaiwwwyj2o click 1761226225801 DIV \n", - "4 1761225843899-qaiwwwyj2o click 1761226229364 DIV \n", - "5 1761227236286-e7mphcvw6t pageview 1761227236426 NaN \n", - "6 1761227236286-e7mphcvw6t click 1761227239328 DIV \n", - "7 1761227236286-e7mphcvw6t click 1761227244783 A \n", - "8 1761828056433-0gz7aboz86h pageview 1761828261783 NaN \n", - "9 1761828056433-0gz7aboz86h click 1761828266484 H1 \n", - "10 1761828056433-0gz7aboz86h scroll 1761828270314 NaN \n", - "11 1761828056433-0gz7aboz86h scroll 1761828270328 NaN \n", - "12 1761828056433-0gz7aboz86h scroll 1761828270336 NaN \n", + " sessionId eventType ts targetEl \\\n", + "0 1762434923440-66hdhq8qicd pageview 1762434924107 NaN \n", + "1 1762434923440-66hdhq8qicd click 1762434925198 DIV \n", + "2 1762434923440-66hdhq8qicd click 1762434925371 MAIN \n", + "3 1762434923440-66hdhq8qicd pageview 1762437192910 NaN \n", + "4 1762434923440-66hdhq8qicd pageview 1762437198539 NaN \n", + "390 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443115648 DIV \n", + "391 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443174606 DIV \n", + "392 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443183406 INPUT \n", + "393 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443208588 DIV \n", + "394 d423ce8a-77aa-4c9a-94d4-d1adddcc3472 click 1762443225474 DIV \n", + "407 1762444018243-0120z6z5u42f pageview 1762444018256 NaN \n", + "408 1762444018243-0120z6z5u42f click 1762445774344 DIV \n", + "431 214d9fad-9b00-40c3-bd0e-7739b6acd654 pageview 1762448190973 NaN \n", + "432 214d9fad-9b00-40c3-bd0e-7739b6acd654 click 1762448192425 DIV \n", + "433 214d9fad-9b00-40c3-bd0e-7739b6acd654 click 1762448192645 DIV \n", + "434 214d9fad-9b00-40c3-bd0e-7739b6acd654 pageview 1762448205850 NaN \n", + "435 214d9fad-9b00-40c3-bd0e-7739b6acd654 click 1762448207922 DIV \n", + "438 f0d40ca6-c1d3-4ecd-beb3-796adc74349d pageview 1762448283244 NaN \n", + "439 f0d40ca6-c1d3-4ecd-beb3-796adc74349d click 1762448295524 HTML \n", + "440 f0d40ca6-c1d3-4ecd-beb3-796adc74349d click 1762448342763 DIV \n", + "441 f0d40ca6-c1d3-4ecd-beb3-796adc74349d pageview 1762448343396 NaN \n", + "442 f0d40ca6-c1d3-4ecd-beb3-796adc74349d click 1762448829631 DIV \n", "\n", - " targetUrl metadata_path \\\n", - "0 NaN / \n", - "1 NaN / \n", - "2 NaN / \n", - "3 NaN / \n", - "4 NaN / \n", - "5 NaN / \n", - "6 NaN / \n", - "7 https://vercel.com/new?utm_source=create-next-... / \n", - "8 NaN / \n", - "9 NaN / \n", - "10 NaN / \n", - "11 NaN / \n", - "12 NaN / \n", + " eventName page storeMode userAgent productId metadata_path \\\n", + "0 NaN NaN NaN NaN NaN / \n", + "1 NaN NaN NaN NaN NaN / \n", + "2 NaN NaN NaN NaN NaN / \n", + "3 NaN NaN NaN NaN NaN / \n", + "4 NaN NaN NaN NaN NaN / \n", + "390 NaN NaN NaN NaN NaN / \n", + "391 NaN NaN NaN NaN NaN / \n", + "392 NaN NaN NaN NaN NaN / \n", + "393 NaN NaN NaN NaN NaN / \n", + "394 NaN NaN NaN NaN NaN / \n", + "407 NaN NaN NaN NaN NaN / \n", + "408 NaN NaN NaN NaN NaN / \n", + "431 NaN NaN NaN NaN NaN / \n", + "432 NaN NaN NaN NaN NaN / \n", + "433 NaN NaN NaN NaN NaN / \n", + "434 NaN NaN NaN NaN NaN / \n", + "435 NaN NaN NaN NaN NaN / \n", + "438 NaN NaN NaN NaN NaN / \n", + "439 NaN NaN NaN NaN NaN / \n", + "440 NaN NaN NaN NaN NaN / \n", + "441 NaN NaN NaN NaN NaN / \n", + "442 NaN NaN NaN NaN NaN / \n", "\n", - " metadata_referrer metadata_x metadata_y metadata_scrollY \n", - "0 NaN NaN NaN \n", - "1 NaN 815.0 331.0 NaN \n", - "2 NaN 1129.0 605.0 NaN \n", - "3 NaN 532.0 545.0 NaN \n", - "4 NaN 481.0 399.0 NaN \n", - "5 NaN NaN NaN \n", - "6 NaN 202.0 351.0 NaN \n", - "7 NaN 377.0 723.0 NaN \n", - "8 NaN NaN NaN \n", - "9 NaN 527.0 169.0 NaN \n", - "10 NaN NaN NaN 51.666668 \n", - "11 NaN NaN NaN 50.000000 \n", - "12 NaN NaN NaN 49.166668 " + " metadata_referrer metadata_x metadata_y metadata_event \\\n", + "0 NaN NaN NaN \n", + "1 NaN 1098.0 663.0 NaN \n", + "2 NaN 1098.0 663.0 NaN \n", + "3 NaN NaN NaN \n", + "4 NaN NaN NaN \n", + "390 NaN 245.0 595.0 NaN \n", + "391 NaN 475.0 428.0 NaN \n", + "392 NaN 832.0 219.0 NaN \n", + "393 NaN 485.0 155.0 NaN \n", + "394 NaN 281.0 281.0 NaN \n", + "407 NaN NaN NaN \n", + "408 NaN 299.0 214.0 NaN \n", + "431 NaN NaN NaN \n", + "432 NaN 1623.0 493.0 NaN \n", + "433 NaN 1623.0 493.0 NaN \n", + "434 NaN NaN NaN \n", + "435 NaN 421.0 216.0 NaN \n", + "438 NaN NaN NaN \n", + "439 NaN 614.0 720.0 NaN \n", + "440 NaN 416.0 397.0 NaN \n", + "441 NaN NaN NaN \n", + "442 NaN 45.0 44.0 NaN \n", + "\n", + " metadata_targetEl metadata_roomType metadata_price metadata_nights \\\n", + "0 NaN NaN NaN NaN \n", + "1 NaN NaN NaN NaN \n", + "2 NaN NaN NaN NaN \n", + "3 NaN NaN NaN NaN \n", + "4 NaN NaN NaN NaN \n", + "390 NaN NaN NaN NaN \n", + "391 NaN NaN NaN NaN \n", + "392 NaN NaN NaN NaN \n", + "393 NaN NaN NaN NaN \n", + "394 NaN NaN NaN NaN \n", + "407 NaN NaN NaN NaN \n", + "408 NaN NaN NaN NaN \n", + "431 NaN NaN NaN NaN \n", + "432 NaN NaN NaN NaN \n", + "433 NaN NaN NaN NaN \n", + "434 NaN NaN NaN NaN \n", + "435 NaN NaN NaN NaN \n", + "438 NaN NaN NaN NaN \n", + "439 NaN NaN NaN NaN \n", + "440 NaN NaN NaN NaN \n", + "441 NaN NaN NaN NaN \n", + "442 NaN NaN NaN NaN \n", + "\n", + " metadata_targetUrl \n", + "0 NaN \n", + "1 NaN \n", + "2 NaN \n", + "3 NaN \n", + "4 NaN \n", + "390 NaN \n", + "391 NaN \n", + "392 NaN \n", + "393 NaN \n", + "394 NaN \n", + "407 NaN \n", + "408 NaN \n", + "431 NaN \n", + "432 NaN \n", + "433 NaN \n", + "434 NaN \n", + "435 NaN \n", + "438 NaN \n", + "439 NaN \n", + "440 NaN \n", + "441 NaN \n", + "442 NaN " ] }, - "execution_count": 87, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -350,19 +758,21 @@ }, { "cell_type": "code", - "execution_count": 88, + "execution_count": 12, "id": "380eca5f-8304-4fb2-be32-e8bcfd312085", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "['1761225843899-qaiwwwyj2o',\n", - " '1761828056433-0gz7aboz86h',\n", - " '1761227236286-e7mphcvw6t']" + "['214d9fad-9b00-40c3-bd0e-7739b6acd654',\n", + " '1762444018243-0120z6z5u42f',\n", + " 'f0d40ca6-c1d3-4ecd-beb3-796adc74349d',\n", + " 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',\n", + " '1762434923440-66hdhq8qicd']" ] }, - "execution_count": 88, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -373,7 +783,7 @@ }, { "cell_type": "code", - "execution_count": 89, + "execution_count": 13, "id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1", "metadata": {}, "outputs": [], @@ -383,14 +793,14 @@ }, { "cell_type": "code", - "execution_count": 101, + "execution_count": 17, "id": "050d90a4-20a9-47f5-b998-c31178a54cb3", "metadata": {}, "outputs": [], "source": [ "def build_transition_prob_matrix(df: pd.DataFrame):\n", - " df = df.dropna(subset=['eventType'])\n", - " events = df['eventType'].tolist()\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", @@ -404,7 +814,7 @@ }, { "cell_type": "code", - "execution_count": 107, + "execution_count": 18, "id": "e68f9004-82f5-4826-aece-e3dc6e15a18f", "metadata": {}, "outputs": [], @@ -466,7 +876,7 @@ }, { "cell_type": "code", - "execution_count": 108, + "execution_count": 19, "id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc", "metadata": {}, "outputs": [ @@ -479,41 +889,15 @@ "\n", "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "pageview\n", - "\n", - "pageview\n", - "\n", - "\n", - "\n", - "click\n", - "\n", - "click\n", - "\n", - "\n", - "\n", - "pageview->click\n", - "\n", - "\n", - "1.0\n", - "\n", - "\n", - "\n", - "click->click\n", - "\n", - "\n", - "1.0\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "metadata": {}, @@ -523,8 +907,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "[[0. 1.]\n", - " [0. 1.]]\n" + "[]\n" ] }, { @@ -536,75 +919,169 @@ "\n", "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "pageview\n", - "\n", - "pageview\n", + "\n", + "\n", + "\n", "\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": [ + "[]\n" + ] + }, + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "page_view\n", + "\n", + "page_view\n", + "\n", + "\n", "\n", - "pageview->pageview\n", - "\n", - "\n", - "0.2\n", + "page_view->page_view\n", + "\n", + "\n", + "0.70\n", "\n", "\n", "\n", "click\n", - "\n", - "click\n", + "\n", + "click\n", "\n", - "\n", + "\n", "\n", - "pageview->click\n", - "\n", - "\n", - "0.8\n", + "page_view->click\n", + "\n", + "\n", + "0.17\n", "\n", - "\n", - "\n", - "click->pageview\n", - "\n", - "\n", - "0.3\n", - "\n", - "\n", - "\n", - "click->click\n", - "\n", - "\n", - "0.6\n", - "\n", - "\n", + "\n", "\n", - "scroll\n", - "\n", - "scroll\n", + "product_hover\n", + "\n", + "product_hover\n", "\n", - "\n", + "\n", + "\n", + "page_view->product_hover\n", + "\n", + "\n", + "0.13\n", + "\n", + "\n", + "\n", + "click->page_view\n", + "\n", + "\n", + "0.35\n", + "\n", + "\n", "\n", - "click->scroll\n", - "\n", - "\n", - "0.1\n", + "click->click\n", + "\n", + "\n", + "0.41\n", "\n", - "\n", + "\n", "\n", - "scroll->scroll\n", - "\n", - "\n", - "1.0\n", + "click->product_hover\n", + "\n", + "\n", + "0.24\n", + "\n", + "\n", + "\n", + "product_hover->click\n", + "\n", + "\n", + "0.07\n", + "\n", + "\n", + "\n", + "product_hover->product_hover\n", + "\n", + "\n", + "0.60\n", + "\n", + "\n", + "\n", + "product_view\n", + "\n", + "product_view\n", + "\n", + "\n", + "\n", + "product_hover->product_view\n", + "\n", + "\n", + "0.33\n", + "\n", + "\n", + "\n", + "product_view->click\n", + "\n", + "\n", + "1.00\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "metadata": {}, @@ -614,9 +1091,10 @@ "name": "stdout", "output_type": "stream", "text": [ - "[[0.25 0.75 0. ]\n", - " [0.28571429 0.57142857 0.14285714]\n", - " [0. 0.00826446 0.99173554]]\n" + "[[0.69565217 0.17391304 0.13043478 0. ]\n", + " [0.35294118 0.41176471 0.23529412 0. ]\n", + " [0. 0.06666667 0.6 0.33333333]\n", + " [0. 1. 0. 0. ]]\n" ] }, { @@ -628,41 +1106,15 @@ "\n", "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "pageview\n", - "\n", - "pageview\n", - "\n", - "\n", - "\n", - "click\n", - "\n", - "click\n", - "\n", - "\n", - "\n", - "pageview->click\n", - "\n", - "\n", - "1.0\n", - "\n", - "\n", - "\n", - "click->click\n", - "\n", - "\n", - "1.0\n", - "\n", + "\n", + "\n", + "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "metadata": {}, @@ -672,14 +1124,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "[[0. 1.]\n", - " [0. 1.]]\n" + "[]\n" ] } ], "source": [ "def explore_session(session_id: str):\n", - " subset = df[df['sessionId'] == session_id] # not .where(...)\n", + " subset = df[df['sessionId'] == 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", diff --git a/paper/src/preamble.tex b/paper/src/preamble.tex index 0acd7c7..79b2857 100644 --- a/paper/src/preamble.tex +++ b/paper/src/preamble.tex @@ -20,7 +20,10 @@ commentstyle=\color{green!60!black}, stringstyle=\color{red}, showstringspaces=false, - captionpos=b + captionpos=b, + inputencoding=utf8, + extendedchars=true, + literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1 } % Use biblatex instead of natbib (acmart default) diff --git a/web/README.md b/web/README.md index 12fff92..ae759cb 100644 --- a/web/README.md +++ b/web/README.md @@ -12,3 +12,86 @@ The webapp should serve under the / route the landing page which for both platfo - /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition. - /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard. - in /styles/ we define airline.css and hotel.css to tailor accents and styling for each. + +## How to Run + +```sh +# install deps +npm install + +# set store mode (hotel or airline) +export STORE_MODE=hotel + +# run dev server +npm run dev +``` + +Server runs on `http://localhost:3000` + +## Environment Variables + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `HOSTNAME` | Server hostname | `localhost` | `localhost` | +| `STORE_MODE` | Mode switch for platform | `hotel` | `hotel` or `airline` | +| `NEXT_PUBLIC_API_BASE` | Public API base URL | `http://localhost:3000` | `http://localhost:3000` | +| `NEXT_PUBLIC_APP_ENV` | Application environment | `dev` | `dev`, `prod` | +| `NEXT_PUBLIC_HOVER_THRESHOLD` | Hover dwell threshold (ms) | `1200` | `1200` | +| `BACKEND_URL` | Backend service URL | `http://localhost:5000` | `http://localhost:5000` | + +## Routes + +### Public Pages +- `/` — Landing page (mode-aware root) +- `/hotel` — Hotel mode landing +- `/hotel/products` — Hotel catalog +- `/airline` — Airline mode landing +- `/airline/products` — Flight catalog +- `/admin/experiments` — Experiment management UI + +### API Routes +- `GET /api/session` — Fetch or create session, sets httpOnly cookie +- `GET /api/pricing?productId=X&sessionId=Y&experimentId=Z` — Get product price from provider +- `POST /api/ingest` — Ingest event to Kafka via backend +- `GET /api/admin/experiments` — List all experiments +- `POST /api/admin/experiments/start` — Start new experiment for session +- `POST /api/admin/experiments/stop` — Stop experiment by ID + +## Event Catalog + +All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events: + +| Event Name | Category | Payload Example | +|------------|----------|-----------------| +| `session_start` | Session | `{ sessionId, experimentId?, storeMode, ts, page, eventName, userAgent? }` | +| `page_view` | Navigation | `{ sessionId, experimentId?, storeMode, ts, page: "/hotel", eventName: "page_view" }` | +| `view_item_page` | Discovery | `{ sessionId, storeMode, ts, page: "/hotel/products", productId: "H001", eventName: "view_item_page" }` | +| `learn_more_about_item` | Discovery | `{ sessionId, storeMode, ts, page, productId, eventName: "learn_more_about_item" }` | +| `add_item_to_cart` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "add_item_to_cart" }` | +| `remove_item` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "remove_item" }` | +| `checkout_start` | Cart | `{ sessionId, storeMode, ts, page, eventName: "checkout_start" }` | +| `purchase_complete` | Cart | `{ sessionId, storeMode, ts, page, eventName: "purchase_complete", metadata?: { total: 500 } }` | +| `search` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "search", metadata: { query: "paris" } }` | +| `filter_for_date` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_date", metadata: { from: "2025-01-15", to: "2025-01-20" } }` | +| `filter_for_amenities` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_amenities", metadata: { amenities: ["wifi", "pool"] } }` | +| `filter_for_price` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_price", metadata: { min: 100, max: 500 } }` | +| `sort_change` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "sort_change", metadata: { sort: "price_asc" } }` | +| `hover_over_title` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_title", metadata: { duration: 1500 } }` | +| `hover_over_paragraph` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_paragraph", metadata: { duration: 2000 } }` | +| `hover_over_link` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_link", metadata: { href: "/hotel/products" } }` | +| `hover_over_button` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_button", metadata: { label: "Book Now" } }` | + +## Architecture + +### Route Groups +- `(hotel)` — Hotel mode pages +- `(airline)` — Airline mode pages +- `api/*` — API routes (session, pricing, ingest, admin) + +### Middleware Flow +1. Request arrives at Next.js +2. Session middleware checks for `phantom_session_id` cookie +3. If missing, `/api/session` mints new session + sets cookie +4. Store mode (`STORE_MODE` env) determines rendered page variant +5. Client-side components fetch pricing via `/api/pricing` +6. User interactions emit events to `/api/ingest` → Kafka diff --git a/web/package-lock.json b/web/package-lock.json index 33bebd6..e773ffb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,10 +8,10 @@ "name": "web", "version": "0.1.0", "dependencies": { - "kafkajs": "^2.2.4", "next": "16.0.0", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "zod": "^4.1.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1041,15 +1041,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/kafkajs": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", - "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -1616,6 +1607,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/web/package.json b/web/package.json index 9b83e2d..0a32603 100644 --- a/web/package.json +++ b/web/package.json @@ -8,10 +8,10 @@ "start": "next start" }, "dependencies": { - "kafkajs": "^2.2.4", "next": "16.0.0", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "zod": "^4.1.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/web/src/app/admin/experiments/page.tsx b/web/src/app/admin/experiments/page.tsx new file mode 100755 index 0000000..ef8f89e --- /dev/null +++ b/web/src/app/admin/experiments/page.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSession } from '@/hooks/useSession'; + +type Experiment = { + id: string; + status: 'active' | 'stopped'; + sessionIds: string[]; + createdAt: number; +}; + +export default function ExperimentsAdmin() { + const { sessionId, isLoading: sessionLoading } = useSession(); + const [exps, setExps] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchExps = async () => { + try { + const res = await fetch('/api/admin/experiments'); + if (!res.ok) throw new Error(`fetch failed: ${res.status}`); + const data = await res.json(); + setExps(data.experiments || []); + } catch (err: any) { + setError(err.message); + } + }; + + useEffect(() => { + fetchExps(); + }, []); + + const handleStart = async () => { + if (!sessionId) { + setError('no session available'); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/admin/experiments/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'start failed'); + } + + await fetchExps(); // refresh list + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleStop = async (expId: string) => { + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/admin/experiments/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ experimentId: expId }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'stop failed'); + } + + await fetchExps(); // refresh list + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + if (sessionLoading) { + return ( +
+

loading session...

+
+ ); + } + + return ( +
+
+
+
+

+ Experiments +

+

+ current session: {sessionId || 'none'} +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + {exps.length === 0 ? ( + + + + ) : ( + exps.map((exp) => ( + + + + + + + + )) + )} + +
+ experiment id + + status + + session count + + created + + action +
+ no experiments yet +
+ {exp.id.slice(0, 8)}... + + + {exp.status} + + + {exp.sessionIds.length} + + {new Date(exp.createdAt).toLocaleString()} + + {exp.status === 'active' && ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/app/airline/layout.tsx b/web/src/app/airline/layout.tsx new file mode 100644 index 0000000..a298d7d --- /dev/null +++ b/web/src/app/airline/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from 'react'; +import '@/styles/airline.css'; + +export default function AirlineLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/web/src/app/airline/page.tsx b/web/src/app/airline/page.tsx new file mode 100644 index 0000000..7499f46 --- /dev/null +++ b/web/src/app/airline/page.tsx @@ -0,0 +1,9 @@ +import AirlineHero from '@/components/feats/airline/AirlineHero'; + +export default function AirlineHome() { + return ( +
+ +
+ ); +} diff --git a/web/src/app/airline/products/page.tsx b/web/src/app/airline/products/page.tsx new file mode 100644 index 0000000..c62e1d1 --- /dev/null +++ b/web/src/app/airline/products/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Navigation } from '@/components/ui'; +import AirlineCard from '@/components/feats/airline/AirlineCard'; + +type CabinClass = 'economy' | 'premium' | 'business' | 'first'; +type FareRule = 'flexible' | 'standard' | 'basic'; + +interface Flight { + id: string; + departure: { time: string; airport: string }; + arrival: { time: string; airport: string }; + duration: string; + stops: number; + cabinClass: CabinClass; + fareRule: FareRule; + refundable: boolean; + basePrice: number; +} + +const genRandomFlights = (): Flight[] => { + const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA']; + const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first']; + const fareRules: FareRule[] = ['flexible', 'standard', 'basic']; + + return Array.from({ length: 12 }, (_, i) => { + const depHour = Math.floor(Math.random() * 24); + const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24; + const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1; + const cabin = cabins[Math.floor(Math.random() * cabins.length)]; + const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)]; + + const basePrice = Math.floor( + (cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) + + Math.random() * 300 + ); + + return { + id: `flt-${i}`, + departure: { + time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`, + airport: airports[Math.floor(Math.random() * airports.length)], + }, + arrival: { + time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`, + airport: airports[Math.floor(Math.random() * airports.length)], + }, + duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`, + stops, + cabinClass: cabin, + fareRule, + refundable: Math.random() > 0.7, + basePrice, + }; + }); +}; + +export default function AirlineProducts() { + const flights = genRandomFlights(); + + return ( + <> + +
+

Available Flights

+
+ {flights.map((f) => ( + + ))} +
+
+ + ); +} diff --git a/web/src/app/api/admin/experiments/route.ts b/web/src/app/api/admin/experiments/route.ts new file mode 100644 index 0000000..58fcede --- /dev/null +++ b/web/src/app/api/admin/experiments/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getAllExperiments } from '@/lib/sessionStore'; + +export async function GET() { + try { + const exps = getAllExperiments(); + return NextResponse.json({ experiments: exps }); + } catch (err: any) { + console.error('experiments list error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/admin/experiments/start/route.ts b/web/src/app/api/admin/experiments/start/route.ts new file mode 100644 index 0000000..2fb35e3 --- /dev/null +++ b/web/src/app/api/admin/experiments/start/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { randomUUID } from 'crypto'; +import { createExperiment, getSession } from '@/lib/sessionStore'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { sessionId } = body; + + if (!sessionId) { + return NextResponse.json( + { error: 'sessionId required' }, + { status: 400 } + ); + } + + // verify session exists + const session = getSession(sessionId); + if (!session) { + return NextResponse.json( + { error: 'session not found' }, + { status: 404 } + ); + } + + // generate and create experiment + const experimentId = randomUUID(); + const exp = createExperiment(sessionId, experimentId); + + return NextResponse.json({ + experimentId: exp.id, + sessionId, + status: exp.status, + createdAt: exp.createdAt, + }); + } catch (err: any) { + console.error('experiment start error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/admin/experiments/stop/route.ts b/web/src/app/api/admin/experiments/stop/route.ts new file mode 100644 index 0000000..521219b --- /dev/null +++ b/web/src/app/api/admin/experiments/stop/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { stopExperimentById, getExperiment } from '@/lib/sessionStore'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { experimentId } = body; + + if (!experimentId) { + return NextResponse.json( + { error: 'experimentId required' }, + { status: 400 } + ); + } + + // verify experiment exists + const existing = getExperiment(experimentId); + if (!existing) { + return NextResponse.json( + { error: 'experiment not found' }, + { status: 404 } + ); + } + + // stop the experiment + const exp = stopExperimentById(experimentId); + + return NextResponse.json({ + experimentId: exp!.id, + status: exp!.status, + }); + } catch (err: any) { + console.error('experiment stop error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/ingest/route.ts b/web/src/app/api/ingest/route.ts new file mode 100644 index 0000000..70497ac --- /dev/null +++ b/web/src/app/api/ingest/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { EventBase } from '@/lib/events'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const storeMode = process.env.STORE_MODE || 'hotel'; + const userAgent = req.headers.get('user-agent') || undefined; + + const event: EventBase = { + ...body, + storeMode, + userAgent, + ts: body.ts || new Date().toISOString(), + }; + + const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + + if (!res.ok) { + throw new Error(`Backend returned ${res.status}`); + } + + if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') { + console.log('[ingest]', event); + } + + return NextResponse.json({ success: true }); + } catch (err: any) { + console.error('[ingest error]', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/pricing/route.ts b/web/src/app/api/pricing/route.ts new file mode 100644 index 0000000..414d311 --- /dev/null +++ b/web/src/app/api/pricing/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; + +interface PricingResponse { + price: number; + currency: string; + cachedAt: string; +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const productId = searchParams.get('productId'); + const sessionId = searchParams.get('sessionId'); + const experimentId = searchParams.get('experimentId'); + const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop'; + + // log in dev + if (process.env.NODE_ENV === 'development') { + console.log('[pricing-api]', { + productId, + sessionId, + experimentId, + storeMode, + timestamp: new Date().toISOString(), + }); + } + + if (!productId) { + return NextResponse.json( + { error: 'productId is required' }, + { status: 400 } + ); + } + + // stub: call external pricing provider (random for now) + const basePrice = 100 + Math.random() * 900; // 100-1000 range + const price = Math.round(basePrice * 100) / 100; + + const response: PricingResponse = { + price, + currency: 'EUR', + cachedAt: new Date().toISOString(), + }; + + return NextResponse.json(response); +} diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts new file mode 100644 index 0000000..7f5b4c6 --- /dev/null +++ b/web/src/app/api/session/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { randomUUID } from 'crypto'; +import { getSession, createSession } from '@/lib/sessionStore'; + +const COOKIE_NAME = 'phantom_session_id'; +const isProd = process.env.NODE_ENV === 'production'; + +export async function GET(req: NextRequest) { + try { + // check for existing session cookie + const existingSession = req.cookies.get(COOKIE_NAME)?.value; + + if (existingSession) { + const sessionData = getSession(existingSession); + return NextResponse.json({ + sessionId: existingSession, + experimentId: sessionData?.experimentId, + }); + } + + // mint new session id + const sessionId = randomUUID(); + createSession(sessionId); + + const res = NextResponse.json({ sessionId, experimentId: undefined }); + + // set httpOnly cookie with security flags + res.cookies.set({ + name: COOKIE_NAME, + value: sessionId, + httpOnly: true, + sameSite: 'lax', + secure: isProd, + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 days + }); + + return res; + } catch (err: any) { + console.error('session error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/track/route.ts b/web/src/app/api/track/route.ts deleted file mode 100644 index 1ccd720..0000000 --- a/web/src/app/api/track/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { sendInteractionEvent } from '@/lib/kafka'; - -export async function POST(req: NextRequest) { - try { - const body = await req.json(); - const { sessionId, eventType, targetEl, targetUrl, metadata } = body; - - if (!sessionId || !eventType) { - return NextResponse.json( - { error: 'sessionId and eventType required' }, - { status: 400 } - ); - } - - await sendInteractionEvent({ - sessionId, - eventType, - targetEl, - targetUrl, - metadata, - ts: Date.now(), - }); - - return NextResponse.json({ success: true }); - } catch (err: any) { - console.error('track error:', err); - return NextResponse.json( - { error: err.message || 'unknown error' }, - { status: 500 } - ); - } -} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index eba0bfc..4a5b0c9 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; +@layer base { :root { --background: #ffffff; --foreground: #171717; @@ -13,6 +14,7 @@ --border-radius: 8px; --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1); } +} @theme inline { --color-background: var(--background); @@ -21,6 +23,7 @@ --font-mono: var(--font-geist-mono); } +@layer base { @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; @@ -66,7 +69,9 @@ input, select, textarea { font-size: 1rem; outline: none; } +} +@layer components { .container { max-width: 1200px; margin: 0 auto; @@ -86,13 +91,19 @@ input, select, textarea { font-size: 1rem; border-radius: var(--border-radius); transition: all 0.2s ease; + background-color: #007aff; + color: #ffffff; + border: none; + cursor: pointer; } .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + background-color: #0051d5; } .section-spacing { margin-bottom: var(--spacing-lg); } +} diff --git a/web/src/app/hotel/layout.tsx b/web/src/app/hotel/layout.tsx new file mode 100644 index 0000000..ead9aa1 --- /dev/null +++ b/web/src/app/hotel/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from 'react'; +import '@/styles/hotel.css'; + +export default function HotelLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/web/src/app/hotel/page.tsx b/web/src/app/hotel/page.tsx new file mode 100644 index 0000000..c614ff5 --- /dev/null +++ b/web/src/app/hotel/page.tsx @@ -0,0 +1,9 @@ +import HotelHero from '@/components/feats/hotel/HotelHero'; + +export default function HotelHome() { + return ( +
+ +
+ ); +} diff --git a/web/src/app/hotel/products/page.tsx b/web/src/app/hotel/products/page.tsx new file mode 100644 index 0000000..ece120b --- /dev/null +++ b/web/src/app/hotel/products/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Navigation } from '@/components/ui'; +import HotelCard from '@/components/feats/hotel/HotelCard'; + +interface Hotel { + id: string; + name: string; + roomType: string; + checkIn: string; + checkOut: string; + amenities: string[]; + refundable: boolean; + pricePerNight: number; + nights: number; +} + +const genRandomHotels = (): Hotel[] => { + const names = [ + 'Grand Plaza Hotel', + 'Seaside Resort', + 'Downtown Suites', + 'Mountain View Lodge', + 'City Center Inn', + 'Luxury Beach Resort', + 'Urban Boutique Hotel', + 'Garden View Hotel', + ]; + const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room']; + const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa']; + + return Array.from({ length: 10 }, (_, i) => { + const nights = Math.floor(Math.random() * 5) + 1; + const basePrice = Math.floor(80 + Math.random() * 220); + const selectedAmenities = amenities + .sort(() => Math.random() - 0.5) + .slice(0, Math.floor(Math.random() * 3) + 2); + + const today = new Date(); + const checkInDate = new Date(today); + checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10)); + const checkOutDate = new Date(checkInDate); + checkOutDate.setDate(checkInDate.getDate() + nights); + + return { + id: `htl-${i}`, + name: names[i % names.length], + roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)], + checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + amenities: selectedAmenities, + refundable: Math.random() > 0.5, + pricePerNight: basePrice, + nights, + }; + }); +}; + +export default function HotelProducts() { + const hotels = genRandomHotels(); + + return ( + <> + +
+

Available Hotels

+
+ {hotels.map((h) => ( + + ))} +
+
+ + ); +} diff --git a/web/src/components/feats/airline/AirlineCard.tsx b/web/src/components/feats/airline/AirlineCard.tsx new file mode 100644 index 0000000..b08827d --- /dev/null +++ b/web/src/components/feats/airline/AirlineCard.tsx @@ -0,0 +1,87 @@ +'use client'; + +import type { EventName } from '@/lib/events'; +import { useHoverTracking } from '@/hooks/useHoverTracking'; +import PriceDisplay from '@/components/ui/PriceDisplay'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +type CabinClass = 'economy' | 'premium' | 'business' | 'first'; +type FareRule = 'flexible' | 'standard' | 'basic'; + +interface Flight { + id: string; + departure: { time: string; airport: string }; + arrival: { time: string; airport: string }; + duration: string; + stops: number; + cabinClass: CabinClass; + fareRule: FareRule; + refundable: boolean; + basePrice: number; +} + +export default function AirlineCard({ flight }: { flight: Flight }) { + const durationRef = useHoverTracking({ + eventName: 'hover_over_title', + productId: flight.id, + metadata: { elementText: flight.duration }, + }); + + const priceRef = useHoverTracking({ + eventName: 'hover_over_paragraph', + productId: flight.id, + metadata: { elementText: 'price' }, + }); + + const handleCardClick = () => { + dispatchInteraction('view_item_page', flight.id, { + cabinClass: flight.cabinClass, + fareRule: flight.fareRule, + price: flight.basePrice, + }); + }; + + return ( +
+
+
{flight.departure.time}
+
{flight.departure.airport}
+
+ +
+
{flight.duration}
+
+ {flight.stops === 0 ? 'Direct' : `${flight.stops} stop${flight.stops > 1 ? 's' : ''}`} +
+
+ +
+
{flight.arrival.time}
+
{flight.arrival.airport}
+
+ +
+
{flight.cabinClass}
+
{flight.fareRule}
+ {flight.refundable && ( +
Refundable
+ )} +
+ +
+
+
+ ); +} diff --git a/web/src/components/feats/airline/AirlineHero.tsx b/web/src/components/feats/airline/AirlineHero.tsx new file mode 100644 index 0000000..cca2e45 --- /dev/null +++ b/web/src/components/feats/airline/AirlineHero.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui'; + +type TripType = 'roundtrip' | 'oneway' | 'multicity'; + +const PlaneIcon = () => ( + + + +); + +const LocationIcon = () => ( + + + + +); + +export default function AirlineHero() { + const [tripType, setTripType] = useState('roundtrip'); + const [origin, setOrigin] = useState(''); + const [destination, setDestination] = useState(''); + const [departDate, setDepartDate] = useState(''); + const [returnDate, setReturnDate] = useState(''); + const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 }); + + const handleSearch = (e: FormEvent) => { + e.preventDefault(); + console.log({ tripType, origin, destination, departDate, returnDate, passengers }); + }; + + const totalPax = passengers.adults + passengers.children + passengers.infants; + + return ( +
+
+
+

+ Book flights at the best prices +

+

+ Compare hundreds of airlines and find the perfect flight for your journey +

+
+ +
+
+
+ +
+ +
+
+ + setOrigin(e.target.value)} + placeholder="Airport or city" + icon={} + required + /> +
+ +
+ + setDestination(e.target.value)} + placeholder="Airport or city" + icon={} + required + /> +
+ +
+ + setDepartDate(e.target.value)} + required + /> +
+ +
+ + {tripType === 'roundtrip' ? ( + setReturnDate(e.target.value)} + required + /> + ) : ( + + )} +
+
+ +
+
+ + + setPassengers({ ...passengers, adults: v })} + /> + setPassengers({ ...passengers, children: v })} + /> + setPassengers({ ...passengers, infants: v })} + /> + +
+
+ +
+ +
+
+
+ +
+

Direct flights available · Flexible booking · Compare 500+ airlines worldwide

+
+
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelCard.tsx b/web/src/components/feats/hotel/HotelCard.tsx new file mode 100644 index 0000000..8c68801 --- /dev/null +++ b/web/src/components/feats/hotel/HotelCard.tsx @@ -0,0 +1,98 @@ +'use client'; + +import type { EventName } from '@/lib/events'; +import { useHoverTracking } from '@/hooks/useHoverTracking'; +import PriceDisplay from '@/components/ui/PriceDisplay'; + +const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +interface Hotel { + id: string; + name: string; + roomType: string; + checkIn: string; + checkOut: string; + amenities: string[]; + refundable: boolean; + pricePerNight: number; + nights: number; +} + +const AmenityIcon = ({ name }: { name: string }) => { + const iconMap: Record = { + wifi: 'Wi-Fi', + pool: 'Pool', + gym: 'Gym', + parking: 'Parking', + breakfast: 'Breakfast', + spa: 'Spa', + }; + return {iconMap[name.toLowerCase()] || name}; +}; + +export default function HotelCard({ hotel }: { hotel: Hotel }) { + const titleRef = useHoverTracking({ + eventName: 'hover_over_title', + productId: hotel.id, + metadata: { elementText: hotel.name }, + }); + + const priceRef = useHoverTracking({ + eventName: 'hover_over_paragraph', + productId: hotel.id, + metadata: { elementText: 'price' }, + }); + + const handleCardClick = () => { + dispatchInteraction('view_item_page', hotel.id, { + roomType: hotel.roomType, + price: hotel.pricePerNight, + nights: hotel.nights, + }); + }; + + return ( +
+
+ Image +
+ +
+

{hotel.name}

+
{hotel.roomType}
+
+ {hotel.checkIn} - {hotel.checkOut} +
+
+ {hotel.amenities.map((a) => ( + + ))} +
+ {hotel.refundable && ( +
Free cancellation
+ )} +
+ +
+
+ +
+
+ Total for {hotel.nights} night{hotel.nights > 1 ? 's' : ''} +
+
+
+ ); +} diff --git a/web/src/components/feats/hotel/HotelHero.tsx b/web/src/components/feats/hotel/HotelHero.tsx new file mode 100644 index 0000000..70bd595 --- /dev/null +++ b/web/src/components/feats/hotel/HotelHero.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui'; + +const LocationIcon = () => ( + + + + +); + +export default function HotelHero() { + 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 }); + }; + + return ( +
+
+
+

+ Find your perfect stay +

+

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

+
+ +
+
+
+ + setDestination(e.target.value)} + placeholder="City, hotel, or landmark" + icon={} + required + /> +
+ +
+ + setCheckIn(e.target.value)} + required + /> +
+ +
+ + 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

+
+
+
+ ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..a2302a7 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,20 @@ +import { ReactNode, ButtonHTMLAttributes } from 'react'; + +type BtnVariant = 'primary' | 'secondary'; + +interface BtnProps extends ButtonHTMLAttributes { + variant?: BtnVariant; + children: ReactNode; + fullWidth?: boolean; +} + +export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) { + const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary'; + const widthClass = fullWidth ? 'w-full' : ''; + + return ( + + ); +} diff --git a/web/src/components/ui/DateInput.tsx b/web/src/components/ui/DateInput.tsx new file mode 100644 index 0000000..f6edd94 --- /dev/null +++ b/web/src/components/ui/DateInput.tsx @@ -0,0 +1,7 @@ +import { InputHTMLAttributes } from 'react'; + +interface DateInpProps extends Omit, 'type'> {} + +export default function DateInput({ className = '', ...props }: DateInpProps) { + return ; +} diff --git a/web/src/components/ui/Dropdown.tsx b/web/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..5a70dd4 --- /dev/null +++ b/web/src/components/ui/Dropdown.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { ReactNode, useState, useRef, useEffect } from 'react'; + +interface DropdownProps { + label: string; + children: ReactNode; +} + +export default function Dropdown({ label, children }: DropdownProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +interface CounterProps { + label: string; + sublabel?: string; + value: number; + min?: number; + max?: number; + onChange: (val: number) => void; +} + +export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) { + return ( +
+
+ {label} + {sublabel && {sublabel}} +
+
+ + {value} + +
+
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..f58c86d --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,29 @@ +import { InputHTMLAttributes, ReactNode } from 'react'; + +interface InpProps extends InputHTMLAttributes { + icon?: ReactNode; +} + +export default function Input({ icon, className = '', style, ...props }: InpProps) { + const padClass = icon ? 'pl-10' : ''; + // Fallback if a custom CSS rule still overrides Tailwind + const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style; + + return ( +
+ {icon && ( +
+ {icon} +
+ )} + +
+ ); +} diff --git a/web/src/components/ui/Label.tsx b/web/src/components/ui/Label.tsx new file mode 100644 index 0000000..a404e92 --- /dev/null +++ b/web/src/components/ui/Label.tsx @@ -0,0 +1,13 @@ +import { ReactNode, LabelHTMLAttributes } from 'react'; + +interface LblProps extends LabelHTMLAttributes { + children: ReactNode; +} + +export default function Label({ children, className = '', ...props }: LblProps) { + return ( + + ); +} diff --git a/web/src/components/ui/Navigation.tsx b/web/src/components/ui/Navigation.tsx new file mode 100644 index 0000000..47753d6 --- /dev/null +++ b/web/src/components/ui/Navigation.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = (eventName: EventName, metadata?: Record) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, metadata }, + }); + document.dispatchEvent(e); +}; + +const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => { + const path = usePathname(); + const isActive = path === href; + + return ( + + {children} + + ); +}; + +export default function Navigation() { + return ( + + ); +} diff --git a/web/src/components/ui/PriceDisplay.tsx b/web/src/components/ui/PriceDisplay.tsx new file mode 100644 index 0000000..b340ab5 --- /dev/null +++ b/web/src/components/ui/PriceDisplay.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; + +interface PriceDisplayProps { + productId: string; + className?: string; + perNight?: boolean; +} + +interface PricingData { + price: number; + currency: string; + cachedAt: string; +} + +interface SessionData { + sessionId: string; + experimentId?: string; +} + +const fetchSession = async (): Promise => { + try { + const res = await fetch('/api/session'); + const data = await res.json(); + return { + sessionId: data.sessionId || '', + experimentId: data.experimentId || '', + }; + } catch (err) { + console.error('failed to fetch session:', err); + return { sessionId: '', experimentId: '' }; + } +}; + +const formatPrice = (price: number, currency: string) => { + return new Intl.NumberFormat('en-US', { // like an std localization + style: 'currency', + currency, + }).format(price); +}; + +const isCacheStale = (cachedAt: string, thresholdMs = 60000) => { + const cacheTime = new Date(cachedAt).getTime(); + const now = Date.now(); + return now - cacheTime > thresholdMs; +}; + +export default function PriceDisplay({ + productId, + className = '', + perNight = false, +}: PriceDisplayProps) { + const sessionRef = useRef(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const initAndFetch = async () => { + setLoading(true); + setError(null); + + try { + // fetch session if not already loaded + if (!sessionRef.current) { + sessionRef.current = await fetchSession(); + } + + const { sessionId, experimentId } = sessionRef.current; + + if (!sessionId) { + setError('Invalid session'); + setLoading(false); + return; + } + + const params = new URLSearchParams({ + productId, + sessionId, + experimentId: experimentId || '', + }); + + const res = await fetch(`/api/pricing?${params.toString()}`); + + if (!res.ok) { + throw new Error(`Failed to fetch price: ${res.status}`); + } + + const pricingData: PricingData = await res.json(); + setData(pricingData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + initAndFetch(); + }, [productId]); + + if (loading) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (error || !data) { + return ( +
+ Price unavailable +
+ ); + } + + const isStale = isCacheStale(data.cachedAt); + const formattedPrice = formatPrice(data.price, data.currency); + + return ( +
+
+ {formattedPrice} + {perNight && /night} +
+ {isStale && ( + + prices may be outdated + + )} +
+ ); +} diff --git a/web/src/components/ui/RadioGroup.tsx b/web/src/components/ui/RadioGroup.tsx new file mode 100644 index 0000000..e315565 --- /dev/null +++ b/web/src/components/ui/RadioGroup.tsx @@ -0,0 +1,33 @@ +'use client'; + +interface RadioOpt { + value: T; + label: string; +} + +interface RadioGrpProps { + name: string; + options: RadioOpt[]; + value: T; + onChange: (val: T) => void; +} + +export default function RadioGroup({ name, options, value, onChange }: RadioGrpProps) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..d3734cb --- /dev/null +++ b/web/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export { default as Button } from './Button'; +export { default as Label } from './Label'; +export { default as Input } from './Input'; +export { default as DateInput } from './DateInput'; +export { default as RadioGroup } from './RadioGroup'; +export { default as Dropdown, DropdownCounter } from './Dropdown'; +export { default as Navigation } from './Navigation'; diff --git a/web/src/hooks/useHoverTracking.ts b/web/src/hooks/useHoverTracking.ts new file mode 100644 index 0000000..f0eb4ae --- /dev/null +++ b/web/src/hooks/useHoverTracking.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef } from 'react'; +import type { EventName } from '@/lib/events'; + +const dispatchInteraction = ( + eventName: EventName, + productId?: string, + metadata?: Record +) => { + const e = new CustomEvent('definedInteraction', { + detail: { eventName, productId, metadata }, + }); + document.dispatchEvent(e); +}; + +interface UseHoverTrackingOptions { + eventName: EventName; + productId?: string; + metadata?: Record; + threshold?: number; // ms, default 1500 or NEXT_PUBLIC_HOVER_THRESHOLD +} + +export const useHoverTracking = (options: UseHoverTrackingOptions) => { + const defaultThreshold = process.env.NEXT_PUBLIC_HOVER_THRESHOLD + ? parseInt(process.env.NEXT_PUBLIC_HOVER_THRESHOLD, 10) + : 1500; + const { eventName, productId, metadata, threshold = defaultThreshold } = options; + const timerRef = useRef(undefined); + const startRef = useRef(undefined); + + return useCallback((node: HTMLElement | null) => { + if (!node) { + if (timerRef.current) clearTimeout(timerRef.current); + return; + } + + const onEnter = () => { + startRef.current = Date.now(); + timerRef.current = setTimeout(() => { + const dwellTime = Date.now() - startRef.current!; + dispatchInteraction(eventName, productId, { + ...metadata, + dwellTime, + }); + }, threshold); + }; + + const onLeave = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }; + + node.addEventListener('mouseenter', onEnter); + node.addEventListener('mouseleave', onLeave); + + return () => { + node.removeEventListener('mouseenter', onEnter); + node.removeEventListener('mouseleave', onLeave); + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [eventName, productId, metadata, threshold]); +}; diff --git a/web/src/hooks/useInteractionTracking.ts b/web/src/hooks/useInteractionTracking.ts index 317a2c3..563e9ec 100644 --- a/web/src/hooks/useInteractionTracking.ts +++ b/web/src/hooks/useInteractionTracking.ts @@ -1,117 +1,86 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import '@/lib/experiments' // ensure experiments lib is loaded +import type { EventName } from '@/lib/events'; -const genSessionId = () => { - if (typeof window === 'undefined') return ''; - let sid = sessionStorage.getItem('phantom_session_id'); - if (!sid) { - sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - sessionStorage.setItem('phantom_session_id', sid); - // TODO: when creating new id send to exepriemtn tracking db - // match between sesion-id and experiment-id for this session - // so that we can identify all interactions aligning with a specific experiment goal. - } - return sid; +const fetchSessionId = async (): Promise => { + try { + const res = await fetch('/api/session'); + const data = await res.json(); + return data.sessionId || ''; + } catch (err) { + console.error('failed to fetch session:', err); + return ''; + } }; const track = async (ev: { - sessionId: string; - eventType: string; - targetEl?: string; - targetUrl?: string; - metadata?: Record; + sessionId: string; + eventName: EventName; + page: string; + productId?: string; + metadata?: Record; }) => { - try { - await fetch('/api/track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(ev), - }); - } catch (err) { - console.error('track failed:', err); - } + try { + await fetch('/api/ingest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ev), + }); + } catch (err) { + console.error('track failed:', err); + } }; export const useInteractionTracking = () => { const sidRef = useRef(''); + const [ready, setReady] = useState(false); useEffect(() => { - sidRef.current = genSessionId(); - - const handleClick = (e: MouseEvent) => { - const tgt = e.target as HTMLElement; - track({ - sessionId: sidRef.current, - eventType: 'click', - targetEl: tgt.tagName, - targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined, - metadata: { - x: e.clientX, - y: e.clientY, - path: window.location.pathname, - }, - }); - }; - - const handleScroll = () => { - track({ - sessionId: sidRef.current, - eventType: 'scroll', - metadata: { - scrollY: window.scrollY, - path: window.location.pathname, - }, - }); - }; + // fetch session id from httpOnly cookie via API + fetchSessionId().then((sid) => { + sidRef.current = sid; + setReady(true); + }); const handlePageView = () => { + if (!sidRef.current) return; + const page = window.location.pathname; track({ sessionId: sidRef.current, - eventType: 'pageview', + eventName: 'page_view', + page, metadata: { - path: window.location.pathname, referrer: document.referrer, }, }); }; - enum DefinedInteractions { - ADD_TO_CART = 'add_to_cart', - PURCHASE = 'purchase', - } - - // called when clicking on "Add to Cart" button or "Purchase" button - const handleDefinedInteraction = ( - interactionType: DefinedInteractions, - metadata?: Record - ) => { + // called for canonical events dispatched via custom events + const handleDefinedInteraction = (e: Event) => { + if (!sidRef.current) return; + const customEvent = e as CustomEvent<{ + eventName: EventName; + productId?: string; + metadata?: Record; + }>; + const page = window.location.pathname; track({ sessionId: sidRef.current, - eventType: interactionType, - metadata: { - path: window.location.pathname, - ...metadata, - }, + eventName: customEvent.detail.eventName, + page, + productId: customEvent.detail.productId, + metadata: customEvent.detail.metadata, }); }; + // wait for session to be ready before tracking + if (!ready) return; handlePageView(); - document.addEventListener('click', handleClick); - document.addEventListener('definedInteraction', (e: Event) => { - const customEvent = e as CustomEvent; - handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata); - }); - // TOO NOISY: enable if needed but tbh not worth it - //window.addEventListener('scroll', handleScroll, { passive: true }); + document.addEventListener('definedInteraction', handleDefinedInteraction); return () => { - document.removeEventListener('click', handleClick); - document.removeEventListener('definedInteraction', (e: Event) => { - const customEvent = e as CustomEvent; - handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata); - }); - //window.removeEventListener('scroll', handleScroll); + document.removeEventListener('definedInteraction', handleDefinedInteraction); }; - }, []); + }, [ready]); }; diff --git a/web/src/hooks/useSession.ts b/web/src/hooks/useSession.ts new file mode 100644 index 0000000..d2f48eb --- /dev/null +++ b/web/src/hooks/useSession.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; + +type SessionState = { + sessionId: string | null; + experimentId: string | null; + isLoading: boolean; +}; + +export const useSession = () => { + const [state, setState] = useState({ + sessionId: null, + experimentId: null, + isLoading: true, + }); + + useEffect(() => { + const fetchSession = async () => { + try { + const res = await fetch('/api/session'); + if (!res.ok) throw new Error(`fetch failed: ${res.status}`); + + const data = await res.json(); + setState({ + sessionId: data.sessionId || null, + experimentId: data.experimentId || null, + isLoading: false, + }); + } catch (err) { + console.error('session fetch error:', err); + setState({ sessionId: null, experimentId: null, isLoading: false }); + } + }; + + fetchSession(); + }, []); + + return state; +}; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000..ca9664e --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +type Env = z.infer; +const envSchema = z.object({ + STORE_MODE: z.enum(['hotel', 'airline'], { + message: 'STORE_MODE must be either "hotel" or "airline"' + }), + NEXT_PUBLIC_API_BASE: z.string().url({ + message: 'NEXT_PUBLIC_API_BASE must be a valid URL (e.g., http://localhost:3000)' + }), + NEXT_PUBLIC_APP_ENV: z.enum(['dev', 'prod'], { + message: 'NEXT_PUBLIC_APP_ENV must be either "dev" or "prod"' + }), +}); + +// parse and validate env at module load, fail fast with descriptive errors +const parseEnv = (): Env => { + const result = envSchema.safeParse({ + STORE_MODE: process.env.STORE_MODE, + NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE, + NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV, + }); + if (!result.success) { + const errors = result.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n'); + throw new Error(`Environment validation failed:\n${errors}`); + } + return result.data; +}; + +export const config: Env = parseEnv(); diff --git a/web/src/lib/events.ts b/web/src/lib/events.ts new file mode 100644 index 0000000..52f027f --- /dev/null +++ b/web/src/lib/events.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +// canonical events for tracking user interactions +export type EventName = + // navigation & discovery + | 'page_view' + | 'view_item_page' + | 'learn_more_about_item' + // cart operations + | 'add_item_to_cart' + | 'remove_item' + | 'checkout_start' + | 'purchase_complete' + // filtering & search + | 'search' + | 'filter_for_date' + | 'filter_for_amenities' + | 'filter_for_price' + | 'sort_change' + // dwell signals (Ns threshold) + | 'hover_over_title' + | 'hover_over_paragraph' + | 'hover_over_link' + | 'hover_over_button' + // session + | 'session_start'; + +export const eventNames: readonly EventName[] = [ + 'page_view', + 'view_item_page', + 'learn_more_about_item', + 'add_item_to_cart', + 'remove_item', + 'checkout_start', + 'purchase_complete', + 'search', + 'filter_for_date', + 'filter_for_amenities', + 'filter_for_price', + 'sort_change', + 'hover_over_title', + 'hover_over_paragraph', + 'hover_over_link', + 'hover_over_button', + 'session_start', +] as const; + +export interface EventBase { + sessionId: string; + experimentId?: string; + storeMode: 'hotel' | 'airline'; + ts: string; // ISO8601 + page: string; + eventName: EventName; + productId?: string; + metadata?: Record; + userAgent?: string; +} + +// zod schema for runtime validation +export const eventBaseSchema = z.object({ + sessionId: z.string().min(1), + experimentId: z.string().optional(), + storeMode: z.enum(['hotel', 'airline']), + ts: z.string().datetime(), // validates ISO8601 + page: z.string().min(1), + eventName: z.enum([ + 'page_view', + 'view_item_page', + 'learn_more_about_item', + 'add_item_to_cart', + 'remove_item', + 'checkout_start', + 'purchase_complete', + 'search', + 'filter_for_date', + 'filter_for_amenities', + 'filter_for_price', + 'sort_change', + 'hover_over_title', + 'hover_over_paragraph', + 'hover_over_link', + 'hover_over_button', + 'session_start', + ]), + productId: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + userAgent: z.string().optional(), +}); + +export type EventBaseValidated = z.infer; diff --git a/web/src/lib/kafka.ts b/web/src/lib/kafka.ts deleted file mode 100644 index f6abea9..0000000 --- a/web/src/lib/kafka.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Kafka, Producer } from 'kafkajs'; - -let producer: Producer | null = null; - -const kafka = new Kafka({ - clientId: 'phantom-web', - brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`], -}); - -export const getProducer = async (): Promise => { - if (!producer) { - producer = kafka.producer(); - await producer.connect(); - } - return producer; -}; - -export const sendInteractionEvent = async (ev: { - sessionId: string; - eventType: string; - targetEl?: string; - targetUrl?: string; - metadata?: Record; - ts: number; -}) => { - const p = await getProducer(); - // add to the metadata - await p.send({ - topic: 'user-interactions', - messages: [{ - key: ev.sessionId, - value: JSON.stringify(ev), - }], - }); -}; - -export const disconnect = async () => { - if (producer) { - await producer.disconnect(); - producer = null; - } -}; diff --git a/web/src/lib/sessionStore.ts b/web/src/lib/sessionStore.ts new file mode 100644 index 0000000..769cfd7 --- /dev/null +++ b/web/src/lib/sessionStore.ts @@ -0,0 +1,102 @@ +type SessionData = { + experimentId?: string; + startedAt: number; + status: 'active' | 'stopped'; +}; + +type ExperimentData = { + id: string; + status: 'active' | 'stopped'; + sessionIds: string[]; + createdAt: number; +}; + +const store = new Map(); +const experiments = new Map(); + +const cfg = { + key: process.env.AIRTABLE_API_KEY, + base: process.env.AIRTABLE_BASE_ID, + table: process.env.AIRTABLE_TABLE_NAME || 'Sessions', +}; + +// sync session to airtable if credentials present +const syncToAirtable = async (sid: string, data: SessionData) => { + if (!cfg.key || !cfg.base) return; // skip if not configured + + try { + const url = `https://api.airtable.com/v0/${cfg.base}/${encodeURIComponent(cfg.table)}`; + await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${cfg.key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: { + sessionId: sid, + experimentId: data.experimentId || '', + startedAt: new Date(data.startedAt).toISOString(), + status: data.status, + }, + }), + }); + } catch (err) { + console.error('airtable sync failed:', err); + } +}; + +export const getSession = (sid: string) => store.get(sid); + +export const createSession = (sid: string) => { + const data: SessionData = { startedAt: Date.now(), status: 'active' }; + store.set(sid, data); + syncToAirtable(sid, data); // async fire-and-forget + return data; +}; + +export const setExperiment = (sid: string, expId: string) => { + const data = store.get(sid) || createSession(sid); + data.experimentId = expId; + store.set(sid, data); + syncToAirtable(sid, data); + return data; +}; + +export const stopExperiment = (sid: string) => { + const data = store.get(sid); + if (data) { + data.status = 'stopped'; + store.set(sid, data); + syncToAirtable(sid, data); + } + return data; +}; + +// experiment-level operations +export const createExperiment = (sid: string, expId: string) => { + const exp: ExperimentData = { + id: expId, + status: 'active', + sessionIds: [sid], + createdAt: Date.now(), + }; + experiments.set(expId, exp); + setExperiment(sid, expId); // link session to experiment + console.log(`experiment ${expId} started with session ${sid}`); + return exp; +}; + +export const stopExperimentById = (expId: string) => { + const exp = experiments.get(expId); + if (exp) { + exp.status = 'stopped'; + experiments.set(expId, exp); + console.log(`experiment ${expId} stopped`); + } + return exp; +}; + +export const getExperiment = (expId: string) => experiments.get(expId); + +export const getAllExperiments = () => Array.from(experiments.values()); diff --git a/web/src/proxy.ts b/web/src/proxy.ts new file mode 100644 index 0000000..ec5b281 --- /dev/null +++ b/web/src/proxy.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export function proxy(req: NextRequest) { + const mode = process.env.STORE_MODE; + const { pathname } = req.nextUrl; + + // skip rewrites for api routes, admin routes, static files, and next internals + if ( + pathname.startsWith('/api') || + pathname.startsWith('/admin') || + pathname.startsWith('/_next') || + pathname.startsWith('/static') || + pathname.includes('.') + // TODO: add robots.txt and sitemap.xml if needed here + ) { + return NextResponse.next(); + } + + // already prefixed with mode + if (pathname.startsWith(`/${mode}`)) { + return NextResponse.next(); + } + + // rewrite root and unprefixed paths to mode-specific route group + const url = req.nextUrl.clone(); + url.pathname = `/${mode}${pathname === '/' ? '' : pathname}`; + + return NextResponse.rewrite(url); +} + +export const config = { + matcher: [ + // match all paths except those starting with _next/static, _next/image, favicon.ico + '/((?!_next/static|_next/image|favicon.ico).*)', + ], +}; diff --git a/web/src/styles/airline.css b/web/src/styles/airline.css index 33f5cb9..564e366 100644 --- a/web/src/styles/airline.css +++ b/web/src/styles/airline.css @@ -1,25 +1,38 @@ /* Airline Platform - Sky Blue Theme */ -:root[data-mode="airline"] { +@layer base { +[data-mode="airline"] { --accent-primary: #007aff; --accent-secondary: #4caf50; --accent-warning: #ff3b30; --accent-primary-hover: #0051d5; --accent-primary-light: #e6f2ff; --text-accent: #007aff; + --hero-bg: linear-gradient(to bottom, white, #e6f2ff); +} } +@layer components { [data-mode="airline"] { --primary-color: var(--accent-primary); } [data-mode="airline"] .btn-primary { - background-color: var(--accent-primary); - color: #ffffff; + background-color: var(--accent-primary) !important; + color: #ffffff !important; + padding: 12px 24px; + font-weight: 600; + font-size: 1rem; + border-radius: var(--border-radius); + border: none; + cursor: pointer; + transition: all 0.2s ease; } [data-mode="airline"] .btn-primary:hover { background-color: var(--accent-primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3); } [data-mode="airline"] .btn-secondary { @@ -264,6 +277,7 @@ border-radius: 6px; padding: 12px; transition: border-color 0.2s ease; + width: 100%; } [data-mode="airline"] .input-field:focus { @@ -300,3 +314,8 @@ [data-mode="airline"] .checkbox-label:hover { color: var(--accent-primary); } + +[data-mode="airline"] .hero-section { + background: var(--hero-bg); +} +} diff --git a/web/src/styles/hotel.css b/web/src/styles/hotel.css index 8c67285..b49c6a6 100644 --- a/web/src/styles/hotel.css +++ b/web/src/styles/hotel.css @@ -1,6 +1,7 @@ /* Hotel Platform - Action Blue Theme */ -:root[data-mode="hotel"] { +@layer base { +[data-mode="hotel"] { --accent-primary: #007aff; --accent-secondary: #4caf50; --accent-warning: #d9534f; @@ -8,8 +9,11 @@ --accent-primary-light: #e6f2ff; --text-accent: #007aff; --bg-tertiary: #f5f5f7; + --hero-bg: linear-gradient(to bottom, white, #f5f5f5); +} } +@layer components { [data-mode="hotel"] { --primary-color: var(--accent-primary); } @@ -17,10 +21,19 @@ [data-mode="hotel"] .btn-primary { background-color: var(--accent-primary); color: #ffffff; + padding: 12px 24px; + font-weight: 600; + font-size: 1rem; + border-radius: var(--border-radius); + border: none; + cursor: pointer; + transition: all 0.2s ease; } [data-mode="hotel"] .btn-primary:hover { background-color: var(--accent-primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3); } [data-mode="hotel"] .btn-secondary { @@ -398,3 +411,8 @@ color: var(--accent-primary); border-bottom-color: var(--accent-primary); } + +[data-mode="hotel"] .hero-section { + background: var(--hero-bg); +} +} From 4acfb019f86ade4381fa93346f4aa4d2a7e309c2 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 13 Nov 2025 18:21:07 +0100 Subject: [PATCH 08/10] fixing prod --- backend/server/app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/server/app.py b/backend/server/app.py index 8544689..0443250 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -26,6 +26,11 @@ def get_producer() -> KafkaProducer: bootstrap_servers=[broker], value_serializer=lambda v: json.dumps(v).encode('utf-8'), key_serializer=lambda k: k.encode('utf-8') if k else None, + acks=1, # wait for leader ack only + retries=3, + max_in_flight_requests_per_connection=5, + request_timeout_ms=30000, + api_version_auto_timeout_ms=10000, ) return _producer @@ -72,12 +77,13 @@ async def ingest_logs(event: EventPayload): event.ts = datetime.utcnow().isoformat() + 'Z' producer = get_producer() - producer.send( + future = producer.send( 'user-interactions', key=event.sessionId, value=event.model_dump() ) - producer.flush(timeout=5) + # add callback for error logging but don't block + future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}")) return {"success": True} except Exception as e: From 53a39b07dd216cc1e12b341200a5f7cd5d63a3f6 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 13 Nov 2025 18:27:36 +0100 Subject: [PATCH 09/10] prod kafka server logging --- backend/server/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/server/app.py b/backend/server/app.py index 0443250..c91a88c 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -22,16 +22,18 @@ def get_producer() -> KafkaProducer: host = os.getenv('KAFKA_HOST', 'localhost') port = os.getenv('KAFKA_PORT', '9092') broker = f'{host}:{port}' if port else host + print(f"[KAFKA_INIT] Connecting to broker: {broker}") _producer = KafkaProducer( bootstrap_servers=[broker], value_serializer=lambda v: json.dumps(v).encode('utf-8'), key_serializer=lambda k: k.encode('utf-8') if k else None, - acks=1, # wait for leader ack only + acks=1, retries=3, max_in_flight_requests_per_connection=5, request_timeout_ms=30000, api_version_auto_timeout_ms=10000, ) + print(f"[KAFKA_INIT] Producer created successfully") return _producer class EventPayload(BaseModel): From 9bb6f842f4e5d6272b192a72c8b8b19e8b641fe7 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 13 Nov 2025 18:41:37 +0100 Subject: [PATCH 10/10] topic auto create --- backend/server/app.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/server/app.py b/backend/server/app.py index c91a88c..d57d1de 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -7,7 +7,9 @@ import uvicorn import os import json from datetime import datetime -from kafka import KafkaProducer +from kafka import KafkaProducer, KafkaAdminClient +from kafka.admin import NewTopic +from kafka.errors import TopicAlreadyExistsError from dotenv import load_dotenv load_dotenv() @@ -20,7 +22,7 @@ def get_producer() -> KafkaProducer: global _producer if _producer is None: host = os.getenv('KAFKA_HOST', 'localhost') - port = os.getenv('KAFKA_PORT', '9092') + port = os.getenv('KAFKA_PORT', '29092') # use internal broker port broker = f'{host}:{port}' if port else host print(f"[KAFKA_INIT] Connecting to broker: {broker}") _producer = KafkaProducer( @@ -32,6 +34,7 @@ def get_producer() -> KafkaProducer: max_in_flight_requests_per_connection=5, request_timeout_ms=30000, api_version_auto_timeout_ms=10000, + max_block_ms=5000, # don't block send() for more than 5s ) print(f"[KAFKA_INIT] Producer created successfully") return _producer @@ -54,6 +57,33 @@ app.add_middleware( allow_headers=["*"], ) +@app.on_event("startup") +async def startup_event(): + """create kafka topics on startup""" + host = os.getenv('KAFKA_HOST', 'localhost') + port = os.getenv('KAFKA_PORT', '29092') + broker = f'{host}:{port}' + + try: + print(f"[STARTUP] Creating Kafka topics on {broker}") + admin = KafkaAdminClient( + bootstrap_servers=[broker], + request_timeout_ms=10000, + ) + + topics = [ + NewTopic(name='user-interactions', num_partitions=3, replication_factor=1) + ] + + admin.create_topics(new_topics=topics, validate_only=False) + print(f"[STARTUP] Topics created successfully") + admin.close() + except TopicAlreadyExistsError: + print(f"[STARTUP] Topics already exist, skipping creation") + except Exception as e: + print(f"[STARTUP] Failed to create topics: {e}") + print(f"[STARTUP] Will rely on auto-creation on first message") + @app.get("/health") async def health(): kafka_status = "unknown"