From 778d6a525cde6dd61d5bae0b82ea94f3358697a3 Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 20:30:50 -0400 Subject: [PATCH 1/8] unify api access --- frontend/lib/api.js | 395 +++++++++--------- frontend/lib/apiClient.js | 95 ----- frontend/lib/authService.js | 13 +- frontend/src/app/account/page.jsx | 29 +- .../app/api/admin/evaluation/export/route.js | 5 +- .../src/app/api/admin/evaluation/route.js | 5 +- frontend/src/app/auth/callback/page.jsx | 6 +- .../src/app/components/InvitationsPanel.jsx | 40 +- .../src/app/components/PreferencesForm.jsx | 32 +- frontend/src/app/discover/page.jsx | 285 +++++++------ frontend/src/app/groups/[id]/edit/page.jsx | 47 +-- frontend/src/app/groups/[id]/page.jsx | 125 ++---- frontend/src/app/groups/create/page.jsx | 42 +- frontend/src/app/groups/page.jsx | 57 +-- frontend/src/app/hooks/usePageTracking.js | 19 +- frontend/src/app/listings/[id]/page.jsx | 109 ++--- frontend/src/app/matches/page.jsx | 158 +++---- frontend/src/app/onboarding/page.jsx | 34 +- frontend/src/app/preferences-setup/page.jsx | 32 +- 19 files changed, 658 insertions(+), 870 deletions(-) delete mode 100644 frontend/lib/apiClient.js diff --git a/frontend/lib/api.js b/frontend/lib/api.js index 8fa3cfe..37b4d44 100644 --- a/frontend/lib/api.js +++ b/frontend/lib/api.js @@ -2,6 +2,76 @@ import { createAppError, parseApiErrorResponse } from './errorHandling'; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +/** Full URL for a path under `/api` (leading slash optional). */ +export function apiUrl(pathAfterApi) { + const p = String(pathAfterApi).startsWith('/') ? pathAfterApi : `/${pathAfterApi}`; + return `${API_BASE_URL}/api${p}`; +} + +export function mergeAuthHeaders(initHeaders, token) { + const headers = new Headers(initHeaders ?? undefined); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + return headers; +} + +/** + * `fetch` to the backend `/api/*` route. + * @param {string} pathAfterApi e.g. `/preferences/abc` or `preferences/abc` + * @param {RequestInit} [init] + * @param {{ token?: string|null }} [extra] + */ +export function apiFetch(pathAfterApi, init = {}, extra = {}) { + const { token } = extra; + const headers = mergeAuthHeaders(init.headers, token); + return fetch(apiUrl(pathAfterApi), { ...init, headers }); +} + +/** + * JSON-oriented request with shared error normalization (throws AppError from errorHandling). + * Pass `json` to stringify body and set Content-Type. Other RequestInit fields (signal, keepalive, etc.) are forwarded. + */ +export async function apiJson(pathAfterApi, options = {}) { + const { + method = 'GET', + headers: hdrs, + body, + json, + token, + fallbackMessage = 'Request failed', + ...fetchRest + } = options; + + const headers = mergeAuthHeaders(hdrs, token); + let finalBody = body; + if (json !== undefined) { + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + finalBody = JSON.stringify(json); + } + + const response = await fetch(apiUrl(pathAfterApi), { + ...fetchRest, + method, + headers, + body: finalBody, + }); + + if (!response.ok) { + await throwApiError(response, fallbackMessage); + } + + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + async function throwApiError(response, fallbackMessage = 'Request failed') { const info = await parseApiErrorResponse(response, fallbackMessage); throw createAppError(info.message, { @@ -11,11 +81,18 @@ async function throwApiError(response, fallbackMessage = 'Request failed') { }); } +async function readJsonOrThrow(response, fallbackMessage) { + if (!response.ok) { + await throwApiError(response, fallbackMessage); + } + return response.json(); +} + export const api = { // Listings endpoints async getListings(filters = {}) { const params = new URLSearchParams(); - + if (filters.status) params.append('status', filters.status); if (filters.city) params.append('city', filters.city); if (filters.property_type) params.append('property_type', filters.property_type); @@ -24,99 +101,62 @@ export const api = { if (filters.min_bedrooms) params.append('min_bedrooms', filters.min_bedrooms); if (filters.limit) params.append('limit', filters.limit); if (filters.offset) params.append('offset', filters.offset); - + const queryString = params.toString(); - const url = `${API_BASE_URL}/api/listings${queryString ? `?${queryString}` : ''}`; - - const response = await fetch(url); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch listings'); - } - const data = await response.json(); - return data; + const response = await apiFetch(`/listings${queryString ? `?${queryString}` : ''}`); + return readJsonOrThrow(response, 'Failed to fetch listings'); }, async getListing(id) { - const response = await fetch(`${API_BASE_URL}/api/listings/${id}`); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch listing'); - } - const data = await response.json(); - return data; + const response = await apiFetch(`/listings/${id}`); + return readJsonOrThrow(response, 'Failed to fetch listing'); }, async getInterestedListings(token) { - const response = await fetch(`${API_BASE_URL}/api/interactions/interested-listings`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch interested listings'); - } - return response.json(); + const response = await apiFetch('/interactions/interested-listings', {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch interested listings'); }, async getInterestedListingIds(token) { - const response = await fetch(`${API_BASE_URL}/api/interactions/interested-listings/ids`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch interested listing ids'); - } - return response.json(); + const response = await apiFetch('/interactions/interested-listings/ids', {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch interested listing ids'); }, async markInterestedListing(token, listingId, source = null) { - const response = await fetch(`${API_BASE_URL}/api/interactions/interested-listings/${listingId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + const response = await apiFetch( + `/interactions/interested-listings/${listingId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source }), }, - body: JSON.stringify({ source }), - }); - if (!response.ok) { - await throwApiError(response, 'Failed to mark listing interested'); - } - return response.json(); + { token } + ); + return readJsonOrThrow(response, 'Failed to mark listing interested'); }, async unmarkInterestedListing(token, listingId) { - const response = await fetch(`${API_BASE_URL}/api/interactions/interested-listings/${listingId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to remove interested listing'); - } - return response.json(); + const response = await apiFetch(`/interactions/interested-listings/${listingId}`, { method: 'DELETE' }, { token }); + return readJsonOrThrow(response, 'Failed to remove interested listing'); }, async createListing(listingData, token) { - const response = await fetch(`${API_BASE_URL}/api/listings`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + const response = await apiFetch( + '/listings', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(listingData), }, - body: JSON.stringify(listingData), - }); - if (!response.ok) { - await throwApiError(response, 'Failed to create listing'); - } - const data = await response.json(); - return data; + { token } + ); + return readJsonOrThrow(response, 'Failed to create listing'); }, // Users endpoints async getUsers(token, limit = 100, offset = 0) { - const response = await fetch(`${API_BASE_URL}/api/users?limit=${limit}&offset=${offset}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch users'); - } - const data = await response.json(); - return data; + const response = await apiFetch(`/users?limit=${limit}&offset=${offset}`, {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch users'); }, async searchUsers(token, options = {}) { @@ -126,35 +166,19 @@ export const api = { const q = (options.q || '').trim(); if (q) params.append('q', q); - const response = await fetch(`${API_BASE_URL}/api/users?${params.toString()}`, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to search users'); - } - return response.json(); + const response = await apiFetch(`/users?${params.toString()}`, {}, { token }); + return readJsonOrThrow(response, 'Failed to search users'); }, async getUser(id, token) { - const response = await fetch(`${API_BASE_URL}/api/users/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch user'); - } - const data = await response.json(); - return data; + const response = await apiFetch(`/users/${id}`, {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch user'); }, /** Authenticated user profile fetch (for inbox name resolution). */ async getUserWithAuth(id, token) { - const response = await fetch(`${API_BASE_URL}/api/users/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch user'); - } - return response.json(); + const response = await apiFetch(`/users/${id}`, {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch user'); }, /** Roommate suggestions; requires target_city on seeker prefs. */ @@ -164,129 +188,94 @@ export const api = { params.append('limit', String(limit)); const mode = options.mode === 'hard_filter' ? 'hard_filter' : 'ml'; params.append('mode', mode); - const response = await fetch( - `${API_BASE_URL}/api/matches/roommate-suggestions?${params.toString()}`, - { headers: { Authorization: `Bearer ${token}` } } - ); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch roommate suggestions'); - } - return response.json(); + const response = await apiFetch(`/matches/roommate-suggestions?${params.toString()}`, {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch roommate suggestions'); }, async expressRoommateInterest(token, toUserId) { - const response = await fetch(`${API_BASE_URL}/api/roommate-intros/express-interest`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + const response = await apiFetch( + '/roommate-intros/express-interest', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to_user_id: toUserId }), }, - body: JSON.stringify({ to_user_id: toUserId }), - }); - if (!response.ok) { - await throwApiError(response, 'Failed to send roommate interest'); - } - return response.json(); + { token } + ); + return readJsonOrThrow(response, 'Failed to send roommate interest'); }, async getRoommateIntroInbox(token) { - const response = await fetch(`${API_BASE_URL}/api/roommate-intros/inbox`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch roommate inbox'); - } - return response.json(); + const response = await apiFetch('/roommate-intros/inbox', {}, { token }); + return readJsonOrThrow(response, 'Failed to fetch roommate inbox'); }, async respondToRoommateIntro(token, introId, action) { - const response = await fetch( - `${API_BASE_URL}/api/roommate-intros/${encodeURIComponent(introId)}/respond`, + const response = await apiFetch( + `/roommate-intros/${encodeURIComponent(introId)}/respond`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }), - } + }, + { token } ); - if (!response.ok) { - await throwApiError(response, 'Failed to respond to roommate intro'); - } - return response.json(); + return readJsonOrThrow(response, 'Failed to respond to roommate intro'); }, async getIntroStatusWith(token, otherUserId) { - const response = await fetch( - `${API_BASE_URL}/api/roommate-intros/status-with/${encodeURIComponent(otherUserId)}`, - { headers: { Authorization: `Bearer ${token}` } } + const response = await apiFetch( + `/roommate-intros/status-with/${encodeURIComponent(otherUserId)}`, + {}, + { token } ); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch intro status'); - } - return response.json(); + return readJsonOrThrow(response, 'Failed to fetch intro status'); }, async createUser(userData, token) { - const response = await fetch(`${API_BASE_URL}/api/users`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token && { 'Authorization': `Bearer ${token}` }), + const headers = { 'Content-Type': 'application/json' }; + const response = await apiFetch( + '/users', + { + method: 'POST', + headers, + body: JSON.stringify(userData), }, - body: JSON.stringify(userData), - }); - if (!response.ok) { - await throwApiError(response, 'Failed to create user'); - } - const data = await response.json(); - return data; + { token } + ); + return readJsonOrThrow(response, 'Failed to create user'); }, // Roommate posts endpoints async getRoommatePosts(filters = {}) { const params = new URLSearchParams(); - + if (filters.status) params.append('status', filters.status); if (filters.city) params.append('city', filters.city); if (filters.limit) params.append('limit', filters.limit); if (filters.offset) params.append('offset', filters.offset); - + const queryString = params.toString(); - const url = `${API_BASE_URL}/api/roommate-posts${queryString ? `?${queryString}` : ''}`; - - const response = await fetch(url); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch roommate posts'); - } - const data = await response.json(); - return data; + const response = await apiFetch(`/roommate-posts${queryString ? `?${queryString}` : ''}`); + return readJsonOrThrow(response, 'Failed to fetch roommate posts'); }, async getRoommatePost(id) { - const response = await fetch(`${API_BASE_URL}/api/roommate-posts/${id}`); - if (!response.ok) { - await throwApiError(response, 'Failed to fetch roommate post'); - } - const data = await response.json(); - return data; + const response = await apiFetch(`/roommate-posts/${id}`); + return readJsonOrThrow(response, 'Failed to fetch roommate post'); }, async createRoommatePost(postData, token) { - const response = await fetch(`${API_BASE_URL}/api/roommate-posts`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + const response = await apiFetch( + '/roommate-posts', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(postData), }, - body: JSON.stringify(postData), - }); - if (!response.ok) { - await throwApiError(response, 'Failed to create roommate post'); - } - const data = await response.json(); - return data; + { token } + ); + return readJsonOrThrow(response, 'Failed to create roommate post'); }, // --------------------------------------------------------------------------- @@ -296,11 +285,15 @@ export const api = { async postSwipeContext(token, payload) { try { - await fetch(`${API_BASE_URL}/api/interactions/swipe-context`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify(payload), - }); + await apiFetch( + '/interactions/swipe-context', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { token } + ); } catch { // Best-effort only. } @@ -308,11 +301,15 @@ export const api = { async postListingView(token, payload) { try { - await fetch(`${API_BASE_URL}/api/interactions/listing-views`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify(payload), - }); + await apiFetch( + '/interactions/listing-views', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { token } + ); } catch { // Best-effort only. } @@ -320,11 +317,15 @@ export const api = { async postPageView(token, payload) { try { - await fetch(`${API_BASE_URL}/api/interactions/page-views`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify(payload), - }); + await apiFetch( + '/interactions/page-views', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { token } + ); } catch { // Best-effort only. } @@ -332,11 +333,15 @@ export const api = { async postSearchQuery(token, payload) { try { - await fetch(`${API_BASE_URL}/api/interactions/search-queries`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify(payload), - }); + await apiFetch( + '/interactions/search-queries', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { token } + ); } catch { // Best-effort only. } diff --git a/frontend/lib/apiClient.js b/frontend/lib/apiClient.js deleted file mode 100644 index 8482477..0000000 --- a/frontend/lib/apiClient.js +++ /dev/null @@ -1,95 +0,0 @@ -// API Client for backend communication -const API_BASE = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api`; - -export const apiClient = { - async get(endpoint, token = null) { - const headers = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${endpoint}`, { - method: 'GET', - headers, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Request failed'); - } - - return response.json(); - }, - - async post(endpoint, data, token = null) { - const headers = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${endpoint}`, { - method: 'POST', - headers, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Request failed'); - } - - return response.json(); - }, - - async put(endpoint, data, token = null) { - const headers = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${endpoint}`, { - method: 'PUT', - headers, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Request failed'); - } - - return response.json(); - }, - - async delete(endpoint, token = null) { - const headers = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${endpoint}`, { - method: 'DELETE', - headers, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Request failed'); - } - - return response.json(); - }, -}; - diff --git a/frontend/lib/authService.js b/frontend/lib/authService.js index 0c573d5..88f21a2 100644 --- a/frontend/lib/authService.js +++ b/frontend/lib/authService.js @@ -4,14 +4,13 @@ import { normalizeAuthErrorMessage, parseApiErrorResponse, } from './errorHandling'; +import { apiUrl } from './api'; import { supabase } from './supabaseClient'; export class AuthService { - static API_BASE = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/auth`; - static async signup(email, password, fullName) { try { - const response = await fetch(`${this.API_BASE}/signup`, { + const response = await fetch(apiUrl('/auth/signup'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -55,7 +54,7 @@ export class AuthService { static async signin(email, password) { try { - const response = await fetch(`${this.API_BASE}/signin`, { + const response = await fetch(apiUrl('/auth/signin'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -86,7 +85,7 @@ export class AuthService { static async signout(token) { try { - const response = await fetch(`${this.API_BASE}/signout`, { + const response = await fetch(apiUrl('/auth/signout'), { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -110,7 +109,7 @@ export class AuthService { static async getCurrentUser(token) { try { - const response = await fetch(`${this.API_BASE}/me`, { + const response = await fetch(apiUrl('/auth/me'), { method: 'GET', headers: { Authorization: `Bearer ${token}`, @@ -142,7 +141,7 @@ export class AuthService { static async refreshToken(refreshToken) { try { - const response = await fetch(`${this.API_BASE}/refresh`, { + const response = await fetch(apiUrl('/auth/refresh'), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/app/account/page.jsx b/frontend/src/app/account/page.jsx index 8b575b9..0a950a6 100644 --- a/frontend/src/app/account/page.jsx +++ b/frontend/src/app/account/page.jsx @@ -1,7 +1,5 @@ 'use client'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - import { useState, useEffect, useLayoutEffect, useRef, Suspense } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'next/navigation'; @@ -47,7 +45,7 @@ import { PreferencesForm } from '../components/PreferencesForm'; import { ImageWithFallback } from '../components/ImageWithFallback'; import { SkeletonAccountProfile, SkeletonListingCard } from '../components/Skeletons'; import { useAuth } from '../contexts/AuthContext'; -import { api } from '../../../lib/api'; +import { api, apiFetch } from '../../../lib/api'; const INTERESTED_LISTING_FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080'; @@ -396,9 +394,7 @@ function ProfilePanel() { queryKey: ['me'], queryFn: async () => { const token = await getValidToken(); - const res = await fetch(`${API_BASE}/api/auth/me`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await apiFetch(`/auth/me`, {}, { token }); return res.json(); }, enabled: !!authState?.accessToken, @@ -436,9 +432,9 @@ function ProfilePanel() { queryKey: ['profile-options'], queryFn: async () => { const [c, s, r] = await Promise.all([ - fetch(`${API_BASE}/api/options/companies?limit=500`).then((res) => res.json()), - fetch(`${API_BASE}/api/options/schools?limit=500`).then((res) => res.json()), - fetch(`${API_BASE}/api/options/roles`).then((res) => res.json()), + apiFetch(`/options/companies?limit=500`).then((res) => res.json()), + apiFetch(`/options/schools?limit=500`).then((res) => res.json()), + apiFetch(`/options/roles`).then((res) => res.json()), ]); return { companies: c.data || [], @@ -484,14 +480,15 @@ function ProfilePanel() { role_title: userData.role_title || null, }; - const response = await fetch(`${API_BASE}/api/users/${userData.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + const response = await apiFetch( + `/users/${userData.id}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), }, - body: JSON.stringify(updateData), - }); + { token } + ); const data = await response.json(); diff --git a/frontend/src/app/api/admin/evaluation/export/route.js b/frontend/src/app/api/admin/evaluation/export/route.js index 5320225..387a425 100644 --- a/frontend/src/app/api/admin/evaluation/export/route.js +++ b/frontend/src/app/api/admin/evaluation/export/route.js @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +import { apiUrl } from '../../../../../../lib/api'; export const dynamic = 'force-dynamic'; @@ -10,7 +9,7 @@ export async function GET(request) { return NextResponse.json({ detail: 'Authorization required' }, { status: 401 }); } - const upstreamUrl = new URL(`${API_BASE}/api/admin/evaluation/export/authenticated`); + const upstreamUrl = new URL(apiUrl('/admin/evaluation/export/authenticated')); const incomingParams = new URL(request.url).searchParams; incomingParams.forEach((value, key) => { upstreamUrl.searchParams.set(key, value); diff --git a/frontend/src/app/api/admin/evaluation/route.js b/frontend/src/app/api/admin/evaluation/route.js index 707ae55..20b3b37 100644 --- a/frontend/src/app/api/admin/evaluation/route.js +++ b/frontend/src/app/api/admin/evaluation/route.js @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +import { apiUrl } from '../../../../../lib/api'; export const dynamic = 'force-dynamic'; @@ -10,7 +9,7 @@ export async function GET(request) { return NextResponse.json({ detail: 'Authorization required' }, { status: 401 }); } - const upstreamUrl = new URL(`${API_BASE}/api/admin/evaluation/summary/authenticated`); + const upstreamUrl = new URL(apiUrl('/admin/evaluation/summary/authenticated')); const incomingParams = new URL(request.url).searchParams; incomingParams.forEach((value, key) => { upstreamUrl.searchParams.set(key, value); diff --git a/frontend/src/app/auth/callback/page.jsx b/frontend/src/app/auth/callback/page.jsx index f558787..2625655 100644 --- a/frontend/src/app/auth/callback/page.jsx +++ b/frontend/src/app/auth/callback/page.jsx @@ -5,8 +5,8 @@ import { useRouter } from 'next/navigation'; import { Center, Loader, Text, Stack, Alert } from '@mantine/core'; import { IconAlertCircle } from '@tabler/icons-react'; import { supabase } from '../../../../lib/supabaseClient'; +import { apiFetch } from '../../../../lib/api'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; const AUTH_STORAGE_KEY = 'padly_auth'; const USER_STORAGE_KEY = 'padly_user'; @@ -53,9 +53,7 @@ export default function AuthCallbackPage() { // Fetch / upsert the user profile from FastAPI. // /me auto-creates public.users + solo group for brand-new Google users. - const meResponse = await fetch(`${API_BASE}/api/auth/me`, { - headers: { Authorization: `Bearer ${access_token}` }, - }); + const meResponse = await apiFetch(`/auth/me`, {}, { token: access_token }); const meRaw = await meResponse.text(); let meData; diff --git a/frontend/src/app/components/InvitationsPanel.jsx b/frontend/src/app/components/InvitationsPanel.jsx index 3bb3cea..694a6ed 100644 --- a/frontend/src/app/components/InvitationsPanel.jsx +++ b/frontend/src/app/components/InvitationsPanel.jsx @@ -1,7 +1,5 @@ 'use client'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { @@ -28,6 +26,7 @@ import { IconCurrencyDollar, IconCalendar, } from '@tabler/icons-react'; +import { apiFetch } from '../../../lib/api'; export function InvitationsPanel({ user, authState, onBrowseGroups }) { const router = useRouter(); @@ -45,15 +44,7 @@ export function InvitationsPanel({ user, authState, onBrowseGroups }) { try { setLoading(true); - const headers = {}; - if (authState?.accessToken) { - headers['Authorization'] = `Bearer ${authState.accessToken}`; - } - - const response = await fetch( - `${API_BASE}/api/roommate-groups?my_groups=true`, - { headers } - ); + const response = await apiFetch(`/roommate-groups?my_groups=true`, {}, { token: authState?.accessToken }); const data = await response.json(); @@ -62,9 +53,10 @@ export function InvitationsPanel({ user, authState, onBrowseGroups }) { const pendingInvites = []; for (const group of allGroups) { - const memberResponse = await fetch( - `${API_BASE}/api/roommate-groups/${group.id}/members`, - { headers } + const memberResponse = await apiFetch( + `/roommate-groups/${group.id}/members`, + {}, + { token: authState?.accessToken } ); const memberData = await memberResponse.json(); @@ -96,15 +88,7 @@ export function InvitationsPanel({ user, authState, onBrowseGroups }) { const handleAccept = async (groupId) => { setProcessingId(groupId); try { - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/join`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${authState.accessToken}`, - }, - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/join`, { method: 'POST' }, { token: authState.accessToken }); const data = await response.json(); @@ -134,15 +118,7 @@ export function InvitationsPanel({ user, authState, onBrowseGroups }) { const handleReject = async (groupId) => { setProcessingId(groupId); try { - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/reject`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${authState.accessToken}`, - }, - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/reject`, { method: 'POST' }, { token: authState.accessToken }); const data = await response.json(); diff --git a/frontend/src/app/components/PreferencesForm.jsx b/frontend/src/app/components/PreferencesForm.jsx index 0566704..7fdd19a 100644 --- a/frontend/src/app/components/PreferencesForm.jsx +++ b/frontend/src/app/components/PreferencesForm.jsx @@ -23,8 +23,8 @@ import { useAuth } from '../contexts/AuthContext'; import { usePadlyTour } from '../contexts/TourContext'; import { parseApiErrorResponse } from '../../../lib/errorHandling'; import { GENDER_IDENTITY_OPTIONS, normalizeGenderIdentity } from '../../../lib/genderIdentity'; +import { apiFetch } from '../../../lib/api'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; const NUM_HISTOGRAM_BINS = 30; const PREFS_SHADOW_TTL_MS = 60 * 1000; const PREFERENCE_PAYLOAD_KEYS = [ @@ -260,9 +260,7 @@ export function PreferencesForm() { const { data: savedPrefs, isLoading: prefsLoading } = useQuery({ queryKey: ['preferences', userId], queryFn: async () => { - const res = await fetch(`${API_BASE}/api/preferences/${userId}`, { - headers: { Authorization: `Bearer ${authState.accessToken}`, 'Content-Type': 'application/json' }, - }); + const res = await apiFetch(`/preferences/${userId}`, {}, { token: authState.accessToken }); if (!res.ok) throw new Error('Failed to load preferences'); return (await res.json()).data || {}; }, @@ -326,7 +324,7 @@ export function PreferencesForm() { // ── Effects ────────────────────────────────────────────────────────────── useEffect(() => { - fetch(`${API_BASE}/api/options/countries`) + apiFetch(`/options/countries`) .then((r) => r.ok ? r.json() : null) .then((d) => d && setCountryOptions(d.data || [])) .catch(() => {}); @@ -343,7 +341,7 @@ export function PreferencesForm() { const controller = new AbortController(); setLoadingStates(true); - fetch(`${API_BASE}/api/options/states?country_code=${encodeURIComponent(country)}`, { signal: controller.signal }) + apiFetch(`/options/states?country_code=${encodeURIComponent(country)}`, { signal: controller.signal }) .then((r) => r.ok ? r.json() : null) .then((d) => { if (!d) return; @@ -378,8 +376,8 @@ export function PreferencesForm() { const controller = new AbortController(); setLoadingCities(true); - fetch( - `${API_BASE}/api/options/cities?country_code=${encodeURIComponent(target_country)}&state_code=${encodeURIComponent(target_state_province)}&q=${encodeURIComponent(citySearch)}&limit=250`, + apiFetch( + `/options/cities?country_code=${encodeURIComponent(target_country)}&state_code=${encodeURIComponent(target_state_province)}&q=${encodeURIComponent(citySearch)}&limit=250`, { signal: controller.signal } ) .then((r) => r.ok ? r.json() : null) @@ -414,8 +412,8 @@ export function PreferencesForm() { return; } setLoadingPrices(true); - fetch( - `${API_BASE}/api/listings/price-histogram?city=${encodeURIComponent(city)}&status=active&bins=${NUM_HISTOGRAM_BINS}` + apiFetch( + `/listings/price-histogram?city=${encodeURIComponent(city)}&status=active&bins=${NUM_HISTOGRAM_BINS}` ) .then((r) => r.ok ? r.json() : null) .then((d) => { @@ -491,11 +489,15 @@ export function PreferencesForm() { : prefs.move_in_date, }; - const response = await fetch(`${API_BASE}/api/preferences/${userId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authState.accessToken}` }, - body: JSON.stringify(payload), - }); + const response = await apiFetch( + `/preferences/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { token: authState.accessToken } + ); if (!response.ok) { const parsedError = await parseApiErrorResponse(response, 'Failed to save preferences'); diff --git a/frontend/src/app/discover/page.jsx b/frontend/src/app/discover/page.jsx index 2a22624..d0d222b 100644 --- a/frontend/src/app/discover/page.jsx +++ b/frontend/src/app/discover/page.jsx @@ -1,6 +1,5 @@ 'use client'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; const DISCOVER_FEEDBACK_SWIPE_THRESHOLD = 10; const DISCOVER_PROGRESS_TTL_MS = 30 * 60 * 1000; @@ -31,6 +30,7 @@ import { createRecommendationClientSessionId, createRecommendationEventId, } from '../../../lib/recommendationFeedback'; +import { apiFetch } from '../../../lib/api'; import { GROUPS_FEATURE_ENABLED } from '../../../lib/featureFlags'; import { getLikedListings, saveLikedListing } from './likedListings'; @@ -163,9 +163,7 @@ function DiscoverContent() { queryFn: async () => { const token = await getValidToken(); if (!token) return null; - const res = await fetch(`${API_BASE}/api/preferences/${userId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await apiFetch(`/preferences/${userId}`, {}, { token }); if (!res.ok) return null; const d = await res.json(); return d.data || d || null; @@ -317,15 +315,16 @@ function DiscoverContent() { if (!authState?.accessToken || !feedbackSessionId) return null; try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-sessions/${feedbackSessionId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-sessions/${feedbackSessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive, }, - body: JSON.stringify(payload), - keepalive, - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to update discover recommendation session'); @@ -357,24 +356,25 @@ function DiscoverContent() { if (!authState?.accessToken || !feedbackSessionId) return null; try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-events`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + client_event_id: createRecommendationEventId(eventType), + surface: 'discover', + event_type: eventType, + listing_id: listingId, + position_in_feed: positionInFeed, + dwell_ms: dwellMs, + metadata, + }), + keepalive, }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - client_event_id: createRecommendationEventId(eventType), - surface: 'discover', - event_type: eventType, - listing_id: listingId, - position_in_feed: positionInFeed, - dwell_ms: dwellMs, - metadata, - }), - keepalive, - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to persist discover recommendation event'); @@ -406,7 +406,7 @@ function DiscoverContent() { offset: 0, }; activeFilterBodyRef.current = body; - const res = await fetch(`${API_BASE}/api/recommendations`, { + const res = await apiFetch(`/recommendations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -430,9 +430,7 @@ function DiscoverContent() { let hasCorePreferences = false; const swipedIds = new Set(); - const prefRes = await fetch(`${API_BASE}/api/preferences/${userId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const prefRes = await apiFetch(`/preferences/${userId}`, {}, { token }); if (prefRes.ok) { const prefData = await prefRes.json(); prefs = prefData.data || prefData || {}; @@ -444,9 +442,7 @@ function DiscoverContent() { } try { - const swipesRes = await fetch(`${API_BASE}/api/interactions/swipes/me?limit=500`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const swipesRes = await apiFetch(`/interactions/swipes/me?limit=500`, {}, { token }); if (swipesRes.ok) { const swipesPayload = await swipesRes.json(); for (const event of swipesPayload?.data || []) { @@ -462,9 +458,7 @@ function DiscoverContent() { let behaviorSampleSize; try { - const behaviorRes = await fetch(`${API_BASE}/api/interactions/behavior/me?days=180`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const behaviorRes = await apiFetch(`/interactions/behavior/me?days=180`, {}, { token }); if (behaviorRes.ok) { const behaviorPayload = await behaviorRes.json(); const behavior = behaviorPayload?.data || {}; @@ -519,14 +513,15 @@ function DiscoverContent() { // Cache the filter body so swipe-context events can reference it. activeFilterBodyRef.current = body; - const res = await fetch(`${API_BASE}/api/recommendations`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + const res = await apiFetch( + `/recommendations`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }); + { token } + ); if (!res.ok) { const apiError = await parseApiErrorResponse(res, 'Failed to fetch recommendations'); @@ -546,16 +541,20 @@ function DiscoverContent() { ); // Fire search query event — best-effort. - void fetch(`${API_BASE}/api/interactions/search-queries`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - session_id: getOrCreateSwipeSessionId(), - filter_snapshot: body, - results_returned: (data.recommendations || []).length, - offset, - }), - }).catch(() => {}); + void apiFetch( + `/interactions/search-queries`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: getOrCreateSwipeSessionId(), + filter_snapshot: body, + results_returned: (data.recommendations || []).length, + offset, + }), + }, + { token } + ).catch(() => {}); return { listings: fresh, @@ -724,14 +723,15 @@ function DiscoverContent() { const ensureRecommendationSession = async () => { try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-sessions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSessionPayload(listings, rankingContext)), }, - body: JSON.stringify(buildSessionPayload(listings, rankingContext)), - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to create discover recommendation session'); @@ -815,26 +815,27 @@ function DiscoverContent() { getToken().then((token) => { if (!token) return; - fetch(`${API_BASE}/api/interactions/recommendation-sessions/${sessionId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + apiFetch( + `/interactions/recommendation-sessions/${sessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mark_ended: true, + surface_dwell_ms: dwellMs, + likes_count: metrics.likesCount, + detail_opens_count: metrics.detailOpensCount, + recommendation_count_shown: recommendations.length, + top_listing_ids_shown: topListingIds, + algorithm_version: context?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, + model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), + experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'discover_ranker_v1' : null), + experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), + }), + keepalive: true, }, - body: JSON.stringify({ - mark_ended: true, - surface_dwell_ms: dwellMs, - likes_count: metrics.likesCount, - detail_opens_count: metrics.detailOpensCount, - recommendation_count_shown: recommendations.length, - top_listing_ids_shown: topListingIds, - algorithm_version: context?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, - model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), - experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'discover_ranker_v1' : null), - experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), - }), - keepalive: true, - }).catch(() => {}); + { token } + ).catch(() => {}); }).catch(() => {}); }; }, []); @@ -867,27 +868,28 @@ function DiscoverContent() { : DISCOVER_ALGORITHM_VERSION; try { - const response = await fetch(`${API_BASE}/api/interactions/swipes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + const response = await apiFetch( + `/interactions/swipes`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listing.listing_id, + action, + surface: 'discover', + session_id: swipeSessionIdRef.current, + position_in_feed: position, + algorithm_version: algorithmVersion, + model_version: listing?.ml_score != null ? 'recommender-v1' : null, + city_filter: listing.city ?? null, + latency_ms: + startedAt != null && typeof performance !== 'undefined' + ? Math.max(0, Math.round(performance.now() - startedAt)) + : undefined, + }), }, - body: JSON.stringify({ - listing_id: listing.listing_id, - action, - surface: 'discover', - session_id: swipeSessionIdRef.current, - position_in_feed: position, - algorithm_version: algorithmVersion, - model_version: listing?.ml_score != null ? 'recommender-v1' : null, - city_filter: listing.city ?? null, - latency_ms: - startedAt != null && typeof performance !== 'undefined' - ? Math.max(0, Math.round(performance.now() - startedAt)) - : undefined, - }), - }); + { token } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to persist swipe interaction'); @@ -903,17 +905,21 @@ function DiscoverContent() { swipeSessionIdRef.current = getOrCreateSwipeSessionId(); } try { - await fetch(`${API_BASE}/api/interactions/swipe-context`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authState.accessToken}` }, - body: JSON.stringify({ - listing_id: listing.listing_id, - action, - session_id: swipeSessionIdRef.current, - active_filters_snapshot: activeFilterBodyRef.current || null, - device_context: collectDeviceContext(), - }), - }); + await apiFetch( + `/interactions/swipe-context`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listing.listing_id, + action, + session_id: swipeSessionIdRef.current, + active_filters_snapshot: activeFilterBodyRef.current || null, + device_context: collectDeviceContext(), + }), + }, + { token: authState.accessToken } + ); } catch { // Best-effort only. } @@ -929,18 +935,22 @@ function DiscoverContent() { ? Math.max(0, Date.now() - cardViewStartRef.current) : null; try { - await fetch(`${API_BASE}/api/interactions/listing-views`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authState.accessToken}` }, - body: JSON.stringify({ - listing_id: listing.listing_id, - surface, - session_id: swipeSessionIdRef.current, - view_duration_ms: duration_ms, - expanded: cardExpandedRef.current, - photos_viewed_count: cardPhotoCountRef.current, - }), - }); + await apiFetch( + `/interactions/listing-views`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listing.listing_id, + surface, + session_id: swipeSessionIdRef.current, + view_duration_ms: duration_ms, + expanded: cardExpandedRef.current, + photos_viewed_count: cardPhotoCountRef.current, + }), + }, + { token: authState.accessToken } + ); } catch { // Best-effort only. } @@ -960,7 +970,7 @@ function DiscoverContent() { try { let localPrefs = {}; try { localPrefs = JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch {} - void fetch(`${API_BASE}/api/interactions/guest-events`, { + void apiFetch(`/interactions/guest-events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1121,18 +1131,19 @@ function DiscoverContent() { experiment_variant: rankingContext?.experiment_variant ?? null, }); - const response = await fetch(`${API_BASE}/api/interactions/recommendation-feedback`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-feedback`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + feedback_label: feedbackLabel, + reason_label: reasonLabel, + }), }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - feedback_label: feedbackLabel, - reason_label: reasonLabel, - }), - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to submit discover recommendation feedback'); diff --git a/frontend/src/app/groups/[id]/edit/page.jsx b/frontend/src/app/groups/[id]/edit/page.jsx index 3ebf16d..39e0e42 100644 --- a/frontend/src/app/groups/[id]/edit/page.jsx +++ b/frontend/src/app/groups/[id]/edit/page.jsx @@ -1,7 +1,5 @@ 'use client'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { @@ -26,6 +24,7 @@ import { IconArrowLeft, IconCheck, IconAlertCircle } from '@tabler/icons-react'; import { useAuth } from '../../../contexts/AuthContext'; import { Navigation } from '../../../components/Navigation'; import { ProtectedRoute } from '../../../components/ProtectedRoute'; +import { apiFetch } from '../../../../../lib/api'; export default function EditGroupPage() { const router = useRouter(); @@ -54,9 +53,7 @@ export default function EditGroupPage() { const loadCities = async () => { try { const query = citySearch.trim(); - const response = await fetch( - `${API_BASE}/api/options/cities-global?q=${encodeURIComponent(query)}&limit=200` - ); + const response = await apiFetch(`/options/cities-global?q=${encodeURIComponent(query)}&limit=200`); if (!response.ok) return; const result = await response.json(); setCityOptions(result.data || []); @@ -73,14 +70,7 @@ export default function EditGroupPage() { if (!groupId) return; try { - const headers = {}; - if (authState?.accessToken) { - headers['Authorization'] = `Bearer ${authState.accessToken}`; - } - - const response = await fetch(`${API_BASE}/api/roommate-groups/${groupId}`, { - headers - }); + const response = await apiFetch(`/roommate-groups/${groupId}`, {}, { token: authState?.accessToken }); const data = await response.json(); @@ -168,22 +158,23 @@ export default function EditGroupPage() { return; } - const response = await fetch(`${API_BASE}/api/roommate-groups/${groupId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + const response = await apiFetch( + `/roommate-groups/${groupId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_name: groupName, + description: description || null, + target_city: targetCity, + budget_per_person_min: budgetMin, + budget_per_person_max: budgetMax, + target_move_in_date: moveInDate ? moveInDate.toISOString().split('T')[0] : null, + target_group_size: isSolo ? groupSize : (hasGroupSizeLimit ? groupSize : null), + }), }, - body: JSON.stringify({ - group_name: groupName, - description: description || null, - target_city: targetCity, - budget_per_person_min: budgetMin, - budget_per_person_max: budgetMax, - target_move_in_date: moveInDate ? moveInDate.toISOString().split('T')[0] : null, - target_group_size: isSolo ? groupSize : (hasGroupSizeLimit ? groupSize : null) - }) - }); + { token } + ); const data = await response.json(); diff --git a/frontend/src/app/groups/[id]/page.jsx b/frontend/src/app/groups/[id]/page.jsx index f0d2ded..3424dca 100644 --- a/frontend/src/app/groups/[id]/page.jsx +++ b/frontend/src/app/groups/[id]/page.jsx @@ -1,7 +1,5 @@ 'use client'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { @@ -63,6 +61,7 @@ import { import { useAuth } from '../../contexts/AuthContext'; import { Navigation } from '../../components/Navigation'; import { ProtectedRoute } from '../../components/ProtectedRoute'; +import { apiFetch } from '../../../../lib/api'; export default function GroupDetailPage() { const router = useRouter(); @@ -100,11 +99,7 @@ export default function GroupDetailPage() { const fetchCurrentUser = async () => { if (!currentUser && authState?.accessToken) { try { - const response = await fetch(`${API_BASE}/api/auth/me`, { - headers: { - 'Authorization': `Bearer ${authState.accessToken}` - } - }); + const response = await apiFetch(`/auth/me`, {}, { token: authState.accessToken }); const data = await response.json(); if (response.ok && data.user) { // Merge auth info with profile data @@ -135,12 +130,12 @@ export default function GroupDetailPage() { setLoading(true); try { const validToken = await getValidToken(); - const headers = validToken ? { 'Authorization': `Bearer ${validToken}` } : {}; - + // Fetch group details with members - const groupResponse = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}?include_members=true`, - { headers } + const groupResponse = await apiFetch( + `/roommate-groups/${groupId}?include_members=true`, + {}, + { token: validToken } ); const groupData = await groupResponse.json(); @@ -152,9 +147,10 @@ export default function GroupDetailPage() { // Fetch group->listing feed (rule-based ranking). let listingsFeed = []; try { - const fallbackResponse = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/ranked-listings?limit=50`, - { headers } + const fallbackResponse = await apiFetch( + `/roommate-groups/${groupId}/ranked-listings?limit=50`, + {}, + { token: validToken } ); const fallbackData = await fallbackResponse.json(); if (fallbackResponse.ok && fallbackData.status === 'success') { @@ -181,9 +177,10 @@ export default function GroupDetailPage() { setLoadingLiked(true); try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/interactions/swipes/groups/${groupId}/liked?action=group_save`, - { headers: { Authorization: `Bearer ${validToken}` } } + const response = await apiFetch( + `/interactions/swipes/groups/${groupId}/liked?action=group_save`, + {}, + { token: validToken } ); const data = await response.json(); if (response.ok && data.status === 'success') { @@ -209,16 +206,14 @@ export default function GroupDetailPage() { setInviting(true); try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/invite`, + const response = await apiFetch( + `/roommate-groups/${groupId}/invite`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${validToken}` - }, - body: JSON.stringify({ user_email: inviteEmail }) - } + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_email: inviteEmail }), + }, + { token: validToken } ); const data = await response.json(); @@ -255,12 +250,7 @@ export default function GroupDetailPage() { if (!validToken) { throw new Error('Please log in to view compatible users'); } - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/compatible-users`, - { - headers: { 'Authorization': `Bearer ${validToken}` } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/compatible-users`, {}, { token: validToken }); const data = await response.json(); if (response.ok && data.status === 'success') { @@ -284,16 +274,14 @@ export default function GroupDetailPage() { setInvitingUserId(userId); try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/invite`, + const response = await apiFetch( + `/roommate-groups/${groupId}/invite`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${validToken}` - }, - body: JSON.stringify({ user_email: userEmail }) - } + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_email: userEmail }), + }, + { token: validToken } ); const data = await response.json(); @@ -338,12 +326,7 @@ export default function GroupDetailPage() { if (!validToken) { throw new Error('Please log in to view join requests'); } - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/pending-requests`, - { - headers: { 'Authorization': `Bearer ${validToken}` } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/pending-requests`, {}, { token: validToken }); const data = await response.json(); if (response.ok && data.status === 'success') { @@ -373,15 +356,7 @@ export default function GroupDetailPage() { setProcessingRequestId(userId); try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/accept-request/${userId}`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${validToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/accept-request/${userId}`, { method: 'POST' }, { token: validToken }); const data = await response.json(); @@ -413,15 +388,7 @@ export default function GroupDetailPage() { setProcessingRequestId(userId); try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/reject-request/${userId}`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${validToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/reject-request/${userId}`, { method: 'POST' }, { token: validToken }); const data = await response.json(); @@ -464,15 +431,7 @@ export default function GroupDetailPage() { try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/leave`, - { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${validToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/leave`, { method: 'DELETE' }, { token: validToken }); const data = await response.json(); @@ -501,15 +460,7 @@ export default function GroupDetailPage() { try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}`, - { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${validToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}`, { method: 'DELETE' }, { token: validToken }); const data = await response.json(); @@ -538,15 +489,7 @@ export default function GroupDetailPage() { try { const validToken = await getValidToken(); - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/members/${memberId}`, - { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${validToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/members/${memberId}`, { method: 'DELETE' }, { token: validToken }); const data = await response.json(); diff --git a/frontend/src/app/groups/create/page.jsx b/frontend/src/app/groups/create/page.jsx index 90e2228..3a01152 100644 --- a/frontend/src/app/groups/create/page.jsx +++ b/frontend/src/app/groups/create/page.jsx @@ -1,6 +1,4 @@ -'use client'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -27,6 +25,7 @@ import { IconArrowLeft, IconCheck } from '@tabler/icons-react'; import { useAuth } from '../../contexts/AuthContext'; import { Navigation } from '../../components/Navigation'; import { ProtectedRoute } from '../../components/ProtectedRoute'; +import { apiFetch } from '../../../../lib/api'; export default function CreateGroupPage() { const router = useRouter(); @@ -50,9 +49,7 @@ export default function CreateGroupPage() { const loadCities = async () => { try { const query = citySearch.trim(); - const response = await fetch( - `${API_BASE}/api/options/cities-global?q=${encodeURIComponent(query)}&limit=200` - ); + const response = await apiFetch(`/options/cities-global?q=${encodeURIComponent(query)}&limit=200`); if (!response.ok) return; const result = await response.json(); setCityOptions(result.data || []); @@ -120,23 +117,24 @@ export default function CreateGroupPage() { return; } - const response = await fetch(`${API_BASE}/api/roommate-groups`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + const response = await apiFetch( + `/roommate-groups`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_name: groupName, + description: description || null, + target_city: targetCity, + budget_per_person_min: budgetMin, + budget_per_person_max: budgetMax, + target_move_in_date: moveInDate ? moveInDate.toISOString().split('T')[0] : null, + target_group_size: hasGroupSizeLimit ? groupSize : null, + status: 'active', + }), }, - body: JSON.stringify({ - group_name: groupName, - description: description || null, - target_city: targetCity, - budget_per_person_min: budgetMin, - budget_per_person_max: budgetMax, - target_move_in_date: moveInDate ? moveInDate.toISOString().split('T')[0] : null, - target_group_size: hasGroupSizeLimit ? groupSize : null, - status: 'active' - }) - }); + { token } + ); const data = await response.json(); diff --git a/frontend/src/app/groups/page.jsx b/frontend/src/app/groups/page.jsx index ddaac7a..985bccd 100644 --- a/frontend/src/app/groups/page.jsx +++ b/frontend/src/app/groups/page.jsx @@ -1,6 +1,4 @@ -'use client'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +'use client'; import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; @@ -39,7 +37,7 @@ import { useAuth } from '../contexts/AuthContext'; import { Navigation } from '../components/Navigation'; import { ProtectedRoute } from '../components/ProtectedRoute'; import { InvitationsPanel } from '../components/InvitationsPanel'; -import { api } from '../../../lib/api'; +import { api, apiFetch } from '../../../lib/api'; // --- People tab helpers (copied from roommates/page.jsx) --- @@ -362,9 +360,10 @@ function GroupsPageContent() { if (!authState?.accessToken) return; try { const city = searchCity || 'San Francisco'; - const response = await fetch( - `${API_BASE}/api/matches/groups?city=${encodeURIComponent(city)}&min_score=30&limit=50`, - { headers: { 'Authorization': `Bearer ${authState.accessToken}` } } + const response = await apiFetch( + `/matches/groups?city=${encodeURIComponent(city)}&min_score=30&limit=50`, + {}, + { token: authState.accessToken } ); const data = await response.json(); if (response.ok && data.status === 'success') { @@ -380,14 +379,7 @@ function GroupsPageContent() { try { // Fetch user's pending join requests - const pendingResponse = await fetch( - `${API_BASE}/api/roommate-groups/my-pending-requests`, - { - headers: { - 'Authorization': `Bearer ${authState.accessToken}` - } - } - ); + const pendingResponse = await apiFetch(`/roommate-groups/my-pending-requests`, {}, { token: authState.accessToken }); const pendingData = await pendingResponse.json(); if (pendingResponse.ok && pendingData.status === 'success') { @@ -396,14 +388,7 @@ function GroupsPageContent() { } // Fetch user's accepted memberships - const response = await fetch( - `${API_BASE}/api/roommate-groups?my_groups=true`, - { - headers: { - 'Authorization': `Bearer ${authState.accessToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups?my_groups=true`, {}, { token: authState.accessToken }); const data = await response.json(); if (data.status === 'success') { @@ -431,25 +416,19 @@ function GroupsPageContent() { const fetchGroups = async () => { try { setLoading(true); - let url = `${API_BASE}/api/roommate-groups?status=${statusFilter}`; + let path = `/roommate-groups?status=${statusFilter}`; if (activeTab === 'my-groups' && user) { - url += '&my_groups=true'; + path += '&my_groups=true'; } if (searchCity) { - url += `&city=${encodeURIComponent(searchCity)}`; + path += `&city=${encodeURIComponent(searchCity)}`; } - // Get token from authState - const headers = {}; - if (authState?.accessToken) { - headers['Authorization'] = `Bearer ${authState.accessToken}`; - } - - console.log('Fetching groups:', { url, hasToken: !!authState?.accessToken, activeTab, user: user?.id }); + console.log('Fetching groups:', { path, hasToken: !!authState?.accessToken, activeTab, user: user?.id }); - const response = await fetch(url, { headers }); + const response = await apiFetch(path, {}, { token: authState?.accessToken }); const data = await response.json(); console.log('Groups response:', { status: data.status, count: data.count, groupsLength: data.data?.length }); @@ -490,15 +469,7 @@ function GroupsPageContent() { setJoiningGroupId(groupId); try { - const response = await fetch( - `${API_BASE}/api/roommate-groups/${groupId}/request-join`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${authState.accessToken}` - } - } - ); + const response = await apiFetch(`/roommate-groups/${groupId}/request-join`, { method: 'POST' }, { token: authState.accessToken }); const data = await response.json(); diff --git a/frontend/src/app/hooks/usePageTracking.js b/frontend/src/app/hooks/usePageTracking.js index 08c37bb..60375da 100644 --- a/frontend/src/app/hooks/usePageTracking.js +++ b/frontend/src/app/hooks/usePageTracking.js @@ -1,8 +1,8 @@ 'use client'; import { useEffect, useRef } from 'react'; +import { apiFetch } from '../../../lib/api'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; const SESSION_KEY = 'padly_swipe_session_id'; function getSessionId() { @@ -54,15 +54,16 @@ export function usePageTracking(pageName, token, referrer = null) { // keepalive: true lets the fetch outlive the component unmount. // We use fetch (not sendBeacon) because sendBeacon can't carry Authorization headers. - fetch(`${API_BASE}/api/interactions/page-views`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + apiFetch( + `/interactions/page-views`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, }, - body: payload, - keepalive: true, - }).catch(() => { + { token } + ).catch(() => { // Best-effort only — swallow silently. }); }; diff --git a/frontend/src/app/listings/[id]/page.jsx b/frontend/src/app/listings/[id]/page.jsx index 01df60b..3b5f193 100644 --- a/frontend/src/app/listings/[id]/page.jsx +++ b/frontend/src/app/listings/[id]/page.jsx @@ -10,7 +10,7 @@ import { Navigation } from '../../components/Navigation'; import { ImageWithFallback } from '../../components/ImageWithFallback'; import { useAuth } from '../../contexts/AuthContext'; import { ProtectedRoute } from '../../components/ProtectedRoute'; -import { api } from '../../../../lib/api'; +import { api, apiFetch } from '../../../../lib/api'; import { getErrorMessage } from '../../../../lib/errorHandling'; import { formatAmenityLabel, getActiveAmenityKeys } from '../../../../lib/formatters'; import { usePageTracking } from '../../hooks/usePageTracking'; @@ -18,8 +18,6 @@ import { createRecommendationEventId } from '../../../../lib/recommendationFeedb import { notifications } from '@mantine/notifications'; import Link from 'next/link'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - /** Format a raw enum/slug value like "entire_place" → "Entire Place". */ function formatEnum(val) { if (!val) return 'N/A'; @@ -89,45 +87,47 @@ export default function ListingDetailPage() { return () => { const dwellMs = Math.max(0, Date.now() - viewStartRef.current); - fetch(`${API_BASE}/api/interactions/listing-views`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + apiFetch( + `/interactions/listing-views`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listingId, + surface: 'listing_detail', + session_id: genericSessionId, + view_duration_ms: dwellMs, + expanded: false, + photos_viewed_count: 0, + }), + keepalive: true, }, - body: JSON.stringify({ - listing_id: listingId, - surface: 'listing_detail', - session_id: genericSessionId, - view_duration_ms: dwellMs, - expanded: false, - photos_viewed_count: 0, - }), - keepalive: true, - }).catch(() => {}); + { token: authState.accessToken } + ).catch(() => {}); if (!shouldTrackPassiveDwell) return; - fetch(`${API_BASE}/api/interactions/recommendation-events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + apiFetch( + `/interactions/recommendation-events`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: recommendationSessionId, + client_event_id: createRecommendationEventId('detail-view'), + surface: 'matches', + event_type: 'detail_view', + listing_id: listingId, + position_in_feed: positionInFeed, + dwell_ms: dwellMs, + metadata: { + source: 'listing_detail_page', + }, + }), + keepalive: true, }, - body: JSON.stringify({ - recommendation_session_id: recommendationSessionId, - client_event_id: createRecommendationEventId('detail-view'), - surface: 'matches', - event_type: 'detail_view', - listing_id: listingId, - position_in_feed: positionInFeed, - dwell_ms: dwellMs, - metadata: { - source: 'listing_detail_page', - }, - }), - keepalive: true, - }).catch(() => {}); + { token: authState.accessToken } + ).catch(() => {}); }; }, [authState?.accessToken, listingId, positionInFeed, recommendationSessionId, recommendationSource]); @@ -212,24 +212,25 @@ export default function ListingDetailPage() { } if (recommendationSessionId && recommendationSurface) { - fetch(`${API_BASE}/api/interactions/recommendation-events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + apiFetch( + `/interactions/recommendation-events`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: recommendationSessionId, + client_event_id: createRecommendationEventId(nextInterested ? 'save' : 'unsave'), + surface: recommendationSurface, + event_type: nextInterested ? 'save' : 'unsave', + listing_id: listingId, + position_in_feed: positionInFeed, + metadata: { + source: 'listing_detail_interest_toggle', + }, + }), }, - body: JSON.stringify({ - recommendation_session_id: recommendationSessionId, - client_event_id: createRecommendationEventId(nextInterested ? 'save' : 'unsave'), - surface: recommendationSurface, - event_type: nextInterested ? 'save' : 'unsave', - listing_id: listingId, - position_in_feed: positionInFeed, - metadata: { - source: 'listing_detail_interest_toggle', - }, - }), - }).catch(() => {}); + { token } + ).catch(() => {}); } await queryClient.invalidateQueries({ queryKey: ['interested-listings'] }); diff --git a/frontend/src/app/matches/page.jsx b/frontend/src/app/matches/page.jsx index 639e650..807af57 100644 --- a/frontend/src/app/matches/page.jsx +++ b/frontend/src/app/matches/page.jsx @@ -1,6 +1,5 @@ 'use client'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; const MATCHES_SCROLL_TRIGGER_PX = 900; const MATCHES_TOP_RETURN_PX = 120; // How long a cached recommendations result stays fresh in localStorage. @@ -34,6 +33,7 @@ import { createRecommendationClientSessionId, createRecommendationEventId, } from '../../../lib/recommendationFeedback'; +import { apiFetch } from '../../../lib/api'; export default function MatchesPage() { return ( @@ -64,9 +64,7 @@ function MatchesPageContent() { queryFn: async () => { const token = await getValidToken(); if (!token || !userId) return null; - const res = await fetch(`${API_BASE}/api/preferences/${userId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await apiFetch(`/preferences/${userId}`, {}, { token }); if (!res.ok) return null; const data = await res.json(); return data?.data || data || null; @@ -153,15 +151,16 @@ function MatchesPageContent() { if (!authState?.accessToken || !feedbackSessionId) return null; try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-sessions/${feedbackSessionId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-sessions/${feedbackSessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive, }, - body: JSON.stringify(payload), - keepalive, - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to update recommendation session'); @@ -193,24 +192,25 @@ function MatchesPageContent() { if (!authState?.accessToken || !feedbackSessionId) return null; try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-events`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + client_event_id: createRecommendationEventId(eventType), + surface: 'matches', + event_type: eventType, + listing_id: listingId, + position_in_feed: positionInFeed, + dwell_ms: dwellMs, + metadata, + }), + keepalive, }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - client_event_id: createRecommendationEventId(eventType), - surface: 'matches', - event_type: eventType, - listing_id: listingId, - position_in_feed: positionInFeed, - dwell_ms: dwellMs, - metadata, - }), - keepalive, - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to persist recommendation engagement event'); @@ -250,9 +250,7 @@ function MatchesPageContent() { const liked = getLikedListings(); const likedExtras = {}; - const prefRes = await fetch(`${API_BASE}/api/preferences/${userId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const prefRes = await apiFetch(`/preferences/${userId}`, {}, { token }); if (prefRes.ok) { const prefData = await prefRes.json(); prefs = prefData.data || prefData || {}; @@ -264,9 +262,7 @@ function MatchesPageContent() { } try { - const behaviorRes = await fetch(`${API_BASE}/api/interactions/behavior/me?days=180`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const behaviorRes = await apiFetch(`/interactions/behavior/me?days=180`, {}, { token }); if (behaviorRes.ok) { const behaviorPayload = await behaviorRes.json(); const behavior = behaviorPayload?.data || {}; @@ -317,14 +313,15 @@ function MatchesPageContent() { ...likedExtras, }; - const res = await fetch(`${API_BASE}/api/recommendations`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + const res = await apiFetch( + `/recommendations`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }); + { token } + ); if (!res.ok) { const apiError = await parseApiErrorResponse(res, 'Failed to fetch recommendations'); @@ -454,14 +451,15 @@ function MatchesPageContent() { const ensureRecommendationSession = async () => { try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-sessions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSessionPayload(listings, rankingContext)), }, - body: JSON.stringify(buildSessionPayload(listings, rankingContext)), - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to create recommendation session'); @@ -564,26 +562,27 @@ function MatchesPageContent() { getToken().then((token) => { if (!token) return; - fetch(`${API_BASE}/api/interactions/recommendation-sessions/${sessionId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + apiFetch( + `/interactions/recommendation-sessions/${sessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mark_ended: true, + surface_dwell_ms: dwellMs, + detail_opens_count: metrics.detailOpensCount, + saves_count: metrics.savesCount, + recommendation_count_shown: recommendations.length, + top_listing_ids_shown: topListingIds, + algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? null, + model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), + experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'matches_ranker_v1' : null), + experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), + }), + keepalive: true, }, - body: JSON.stringify({ - mark_ended: true, - surface_dwell_ms: dwellMs, - detail_opens_count: metrics.detailOpensCount, - saves_count: metrics.savesCount, - recommendation_count_shown: recommendations.length, - top_listing_ids_shown: topListingIds, - algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? null, - model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), - experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'matches_ranker_v1' : null), - experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), - }), - keepalive: true, - }).catch(() => {}); + { token } + ).catch(() => {}); }).catch(() => {}); }; }, []); @@ -593,18 +592,19 @@ function MatchesPageContent() { setFeedbackSubmitting(true); try { - const response = await fetch(`${API_BASE}/api/interactions/recommendation-feedback`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authState.accessToken}`, + const response = await apiFetch( + `/interactions/recommendation-feedback`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + feedback_label: feedbackLabel, + reason_label: reasonLabel, + }), }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - feedback_label: feedbackLabel, - reason_label: reasonLabel, - }), - }); + { token: authState.accessToken } + ); if (!response.ok && response.status !== 503) { console.warn('Failed to submit recommendation feedback'); diff --git a/frontend/src/app/onboarding/page.jsx b/frontend/src/app/onboarding/page.jsx index 0d1e82f..feab9d5 100644 --- a/frontend/src/app/onboarding/page.jsx +++ b/frontend/src/app/onboarding/page.jsx @@ -1,6 +1,4 @@ -'use client'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +'use client'; import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; @@ -25,6 +23,7 @@ import { notifications } from '@mantine/notifications'; import { IconUser, IconBriefcase, IconSchool, IconCheck, IconHome, IconSearch } from '@tabler/icons-react'; import { useAuth } from '../contexts/AuthContext'; import { usePadlyTour } from '../contexts/TourContext'; +import { apiFetch } from '../../../lib/api'; export default function OnboardingPage() { const [isLoading, setIsLoading] = useState(false); @@ -46,9 +45,9 @@ export default function OnboardingPage() { const loadOptions = async () => { try { const [companiesRes, schoolsRes, rolesRes] = await Promise.all([ - fetch(`${API_BASE}/api/options/companies?limit=500`), - fetch(`${API_BASE}/api/options/schools?limit=500`), - fetch(`${API_BASE}/api/options/roles`), + apiFetch(`/options/companies?limit=500`), + apiFetch(`/options/schools?limit=500`), + apiFetch(`/options/roles`), ]); if (companiesRes.ok) { @@ -97,14 +96,7 @@ export default function OnboardingPage() { console.log('Got valid token'); // Get the user profile ID from the users table (not auth_id) - const userResponse = await fetch( - `${API_BASE}/api/auth/me`, - { - headers: { - 'Authorization': `Bearer ${token}` - } - } - ); + const userResponse = await apiFetch(`/auth/me`, {}, { token }); const userData = await userResponse.json(); console.log('User data from /me:', userData); @@ -133,16 +125,14 @@ export default function OnboardingPage() { console.log('Sending update data:', updateData); // Update user profile - const response = await fetch( - `${API_BASE}/api/users/${userId}`, + const response = await apiFetch( + `/users/${userId}`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(updateData) - } + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }, + { token } ); const data = await response.json(); diff --git a/frontend/src/app/preferences-setup/page.jsx b/frontend/src/app/preferences-setup/page.jsx index 791f88a..ee88084 100644 --- a/frontend/src/app/preferences-setup/page.jsx +++ b/frontend/src/app/preferences-setup/page.jsx @@ -31,8 +31,8 @@ import { } from '@tabler/icons-react'; import { useAuth } from '../contexts/AuthContext'; import { GENDER_IDENTITY_OPTIONS, normalizeGenderIdentity } from '../../../lib/genderIdentity'; +import { apiFetch } from '../../../lib/api'; -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; const NUM_HISTOGRAM_BINS = 30; function formatPrice(val) { @@ -160,7 +160,7 @@ export default function PreferencesSetupPage() { // Load countries on mount useEffect(() => { - fetch(`${API_BASE}/api/options/countries`) + apiFetch(`/options/countries`) .then((r) => r.ok ? r.json() : null) .then((d) => d && setCountryOptions(d.data || [])) .catch(() => {}); @@ -175,7 +175,7 @@ export default function PreferencesSetupPage() { setStateSearch(''); if (!targetCountry) return; - fetch(`${API_BASE}/api/options/states?country_code=${encodeURIComponent(targetCountry)}`) + apiFetch(`/options/states?country_code=${encodeURIComponent(targetCountry)}`) .then((r) => r.ok ? r.json() : null) .then((d) => d && setStateOptions(d.data || [])) .catch(() => {}); @@ -190,8 +190,8 @@ export default function PreferencesSetupPage() { // Load city options useEffect(() => { if (!targetCountry || !targetState) return; - fetch( - `${API_BASE}/api/options/cities?country_code=${encodeURIComponent(targetCountry)}&state_code=${encodeURIComponent(targetState)}&q=${encodeURIComponent(citySearch)}&limit=250` + apiFetch( + `/options/cities?country_code=${encodeURIComponent(targetCountry)}&state_code=${encodeURIComponent(targetState)}&q=${encodeURIComponent(citySearch)}&limit=250` ) .then((r) => r.ok ? r.json() : null) .then((d) => d && setCityOptions(d.data || [])) @@ -208,8 +208,8 @@ export default function PreferencesSetupPage() { } setLoadingPrices(true); - fetch( - `${API_BASE}/api/listings/price-histogram?city=${encodeURIComponent(targetCity)}&status=active&bins=${NUM_HISTOGRAM_BINS}` + apiFetch( + `/listings/price-histogram?city=${encodeURIComponent(targetCity)}&status=active&bins=${NUM_HISTOGRAM_BINS}` ) .then((r) => r.ok ? r.json() : null) .then((d) => { @@ -273,9 +273,7 @@ export default function PreferencesSetupPage() { const token = await getValidToken(); if (!token) throw new Error('Not authenticated'); - const meRes = await fetch(`${API_BASE}/api/auth/me`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const meRes = await apiFetch(`/auth/me`, {}, { token }); const meData = await meRes.json(); if (!meRes.ok) throw new Error(meData.detail || 'Failed to get user info'); const userId = meData.user?.profile?.id; @@ -306,11 +304,15 @@ export default function PreferencesSetupPage() { body.furnished_preference = furnishedPref; body.gender_policy = genderPolicy; - const res = await fetch(`${API_BASE}/api/preferences/${userId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - }); + const res = await apiFetch( + `/preferences/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { token } + ); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Failed to save preferences'); From 199a4f8e96bb5389ddc98d90973bfb99dc9ca8e9 Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 20:36:16 -0400 Subject: [PATCH 2/8] added reload matches upon preferences update --- .../src/app/components/PreferencesForm.jsx | 10 ++++ frontend/src/app/discover/page.jsx | 9 +++ frontend/src/app/matches/page.jsx | 60 ++++++++++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/PreferencesForm.jsx b/frontend/src/app/components/PreferencesForm.jsx index 7fdd19a..46f5716 100644 --- a/frontend/src/app/components/PreferencesForm.jsx +++ b/frontend/src/app/components/PreferencesForm.jsx @@ -545,6 +545,16 @@ export function PreferencesForm() { queryClient.invalidateQueries({ queryKey: ['discover-feed', userId], refetchType: 'all' }); queryClient.invalidateQueries({ queryKey: ['matches-feed', userId], refetchType: 'all' }); + // Signal to the Matches page that preferences changed so it can offer a + // "Reload Matches" button without forcing an immediate re-fetch. + if (typeof window !== 'undefined' && userId) { + try { + localStorage.setItem(`padly_matches_stale_at_${userId}`, String(Date.now())); + } catch { + // Ignore storage errors. + } + } + if (locationChanged && typeof window !== 'undefined') { try { sessionStorage.removeItem(`padly_discover_progress:${userId}`); diff --git a/frontend/src/app/discover/page.jsx b/frontend/src/app/discover/page.jsx index d0d222b..fb50897 100644 --- a/frontend/src/app/discover/page.jsx +++ b/frontend/src/app/discover/page.jsx @@ -1053,6 +1053,15 @@ function DiscoverContent() { setSwipesInCycle(sessionMetricsRef.current.swipesCount); if (action === 'like') { sessionMetricsRef.current.likesCount += 1; + // Signal to the Matches page that behaviour has changed so it can offer + // a "Reload Matches" button the next time the user visits. + if (userId && typeof window !== 'undefined') { + try { + localStorage.setItem(`padly_matches_stale_at_${userId}`, String(Date.now())); + } catch { + // Ignore storage errors. + } + } } setCurrentIndex((prev) => prev + 1); diff --git a/frontend/src/app/matches/page.jsx b/frontend/src/app/matches/page.jsx index 807af57..39b8dcf 100644 --- a/frontend/src/app/matches/page.jsx +++ b/frontend/src/app/matches/page.jsx @@ -12,7 +12,7 @@ import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react import { useQuery } from '@tanstack/react-query'; import { Container, Title, Text, Grid, Card, Badge, Button, Stack, Box, ThemeIcon, Group } from '@mantine/core'; import { SkeletonListingCard } from '../components/Skeletons'; -import { IconSparkles, IconMapPin } from '@tabler/icons-react'; +import { IconSparkles, IconMapPin, IconRefresh } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; import { Navigation } from '../components/Navigation'; import { ProtectedRoute } from '../components/ProtectedRoute'; @@ -103,6 +103,8 @@ function MatchesPageContent() { const [feedbackAcknowledged, setFeedbackAcknowledged] = useState(false); // Countdown seconds remaining before the user can manually retry after an error. const [retrySecondsLeft, setRetrySecondsLeft] = useState(0); + // True when preferences or discover likes have changed since the last fetch. + const [hasStaleChanges, setHasStaleChanges] = useState(false); // Tracks the last feedData object seen so we only sync on a genuine new result const prevFeedDataRef = useRef(null); @@ -428,6 +430,51 @@ function MatchesPageContent() { refetchFeed(); }, [refetchFeed]); + // Returns true when preferences or discover likes have changed since the + // last successful recommendations fetch for the current user + location. + const checkStaleChanges = useCallback(() => { + if (typeof window === 'undefined' || !userId || preferenceLocationKey === '||') return false; + try { + const staleAt = Number(localStorage.getItem(`padly_matches_stale_at_${userId}`) ?? 0); + if (!staleAt) return false; + const cacheRaw = localStorage.getItem(`padly_rec_${userId}_${preferenceLocationKey}`); + const lastFetchTs = cacheRaw ? (JSON.parse(cacheRaw)?.ts ?? 0) : 0; + return staleAt > lastFetchTs; + } catch { + return false; + } + }, [userId, preferenceLocationKey]); + + // Check for stale changes on mount and whenever the user returns to the tab. + useEffect(() => { + setHasStaleChanges(checkStaleChanges()); + }, [checkStaleChanges]); + + useEffect(() => { + const onVisibility = () => { + if (document.visibilityState === 'visible') { + setHasStaleChanges(checkStaleChanges()); + } + }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, [checkStaleChanges]); + + const handleReloadMatches = useCallback(() => { + // Clear the stale signal and the localStorage cache so fetchMatchesFeed + // hits the API instead of returning the cached result. + if (typeof window !== 'undefined' && userId) { + try { + localStorage.removeItem(`padly_matches_stale_at_${userId}`); + localStorage.removeItem(`padly_rec_${userId}_${preferenceLocationKey}`); + } catch { + // Ignore storage errors. + } + } + setHasStaleChanges(false); + refetchFeed(); + }, [preferenceLocationKey, refetchFeed, userId]); + useEffect(() => { latestTokenRef.current = getValidToken; }, [getValidToken]); @@ -694,6 +741,17 @@ function MatchesPageContent() { Thanks. Your feedback was saved for recommendation evaluation. )} + {hasStaleChanges && !loading && ( + + )} {!loading && !error && listings.length > 0 && ( {listings.length} listings found From feb7bb53ec747b84896fb065558e68757d977d7f Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 21:07:32 -0400 Subject: [PATCH 3/8] decomposing oversized route pages --- frontend/src/app/discover/page.jsx | 2011 +---------------- frontend/src/app/groups/[id]/page.jsx | 1301 +---------- frontend/src/app/matches/page.jsx | 1029 +-------- .../discover/components/DiscoverPageView.jsx | 854 +++++++ .../discover/hooks/useDiscoverPage.js | 1157 ++++++++++ frontend/src/features/discover/lib/session.js | 110 + .../detail/components/GroupDetailPageView.jsx | 882 ++++++++ .../groups/detail/hooks/useGroupDetailPage.js | 498 ++++ .../matches/components/MatchesPageView.jsx | 359 +++ .../features/matches/hooks/useMatchesPage.js | 718 ++++++ .../src/features/matches/lib/constants.js | 7 + .../features/matches/lib/parseListingTitle.js | 8 + 12 files changed, 4613 insertions(+), 4321 deletions(-) create mode 100644 frontend/src/features/discover/components/DiscoverPageView.jsx create mode 100644 frontend/src/features/discover/hooks/useDiscoverPage.js create mode 100644 frontend/src/features/discover/lib/session.js create mode 100644 frontend/src/features/groups/detail/components/GroupDetailPageView.jsx create mode 100644 frontend/src/features/groups/detail/hooks/useGroupDetailPage.js create mode 100644 frontend/src/features/matches/components/MatchesPageView.jsx create mode 100644 frontend/src/features/matches/hooks/useMatchesPage.js create mode 100644 frontend/src/features/matches/lib/constants.js create mode 100644 frontend/src/features/matches/lib/parseListingTitle.js diff --git a/frontend/src/app/discover/page.jsx b/frontend/src/app/discover/page.jsx index fb50897..c2203f0 100644 --- a/frontend/src/app/discover/page.jsx +++ b/frontend/src/app/discover/page.jsx @@ -1,2014 +1,11 @@ 'use client'; -const DISCOVER_FEEDBACK_SWIPE_THRESHOLD = 10; -const DISCOVER_PROGRESS_TTL_MS = 30 * 60 * 1000; - -import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Container, Box, Text, Title, Button, Stack, ActionIcon, Group, Progress, Modal, Badge, Divider, ThemeIcon } from '@mantine/core'; -import { useHotkeys } from '@mantine/hooks'; -import { IconX, IconHeart, IconRefresh, IconInfoCircle, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; -import { ImageWithFallback } from '../components/ImageWithFallback'; -import { useRouter } from 'next/navigation'; -import { Navigation } from '../components/Navigation'; -import { SkeletonSwipeCard } from '../components/Skeletons'; -import { ProtectedRoute } from '../components/ProtectedRoute'; -import { useAuth } from '../contexts/AuthContext'; -import { usePadlyTour } from '../contexts/TourContext'; -import { usePageTracking } from '../hooks/usePageTracking'; -import { SwipeCard } from '../components/SwipeCard'; -import { - createAppError, - hasCompleteCorePreferences, - normalizeRecommendationsError, - parseApiErrorResponse, -} from '../../../lib/errorHandling'; -import { formatAmenityLabel, formatEnumLabel, getActiveAmenityKeys, parseListingTitle } from '../../../lib/formatters'; -import { - MATCHES_FEEDBACK_CHOICES, - MATCHES_NEGATIVE_REASON_CHOICES, - createRecommendationClientSessionId, - createRecommendationEventId, -} from '../../../lib/recommendationFeedback'; -import { apiFetch } from '../../../lib/api'; -import { GROUPS_FEATURE_ENABLED } from '../../../lib/featureFlags'; - -import { getLikedListings, saveLikedListing } from './likedListings'; - -// ── session helpers ───────────────────────────────────────────────────────── - -const SWIPE_SESSION_KEY = 'padly_swipe_session_id'; - -/** Stable client label for swipe telemetry (backend requires algorithm_version). */ -const DISCOVER_ALGORITHM_VERSION = 'discover-v1'; - -function getDiscoverProgressKey(userId) { - return `padly_discover_progress:${userId}`; -} - -function clearDiscoverProgress(userId) { - if (typeof window === 'undefined' || !userId) return; - - try { - sessionStorage.removeItem(getDiscoverProgressKey(userId)); - } catch { - // Best-effort only. - } -} - -function loadDiscoverProgress(userId) { - if (typeof window === 'undefined' || !userId) return null; - - try { - const raw = sessionStorage.getItem(getDiscoverProgressKey(userId)); - if (!raw) return null; - - const parsed = JSON.parse(raw); - if (!parsed?.savedAt || Date.now() - parsed.savedAt > DISCOVER_PROGRESS_TTL_MS) { - sessionStorage.removeItem(getDiscoverProgressKey(userId)); - return null; - } - - const hasListings = Array.isArray(parsed.listings) && parsed.listings.length > 0; - const hasProgress = hasListings && Number.isFinite(parsed.currentIndex) && parsed.currentIndex > 0; - const isCompletedStack = hasListings && Number.isFinite(parsed.currentIndex) && parsed.currentIndex >= parsed.listings.length; - if (!hasProgress && !isCompletedStack) { - sessionStorage.removeItem(getDiscoverProgressKey(userId)); - return null; - } - - return parsed; - } catch { - return null; - } -} - -function saveDiscoverProgress(userId, payload) { - if (typeof window === 'undefined' || !userId) return; - - try { - sessionStorage.setItem( - getDiscoverProgressKey(userId), - JSON.stringify({ - ...payload, - savedAt: Date.now(), - }) - ); - } catch { - // Best-effort only. - } -} - -function getOrCreateSwipeSessionId() { - if (typeof window === 'undefined') return 'server-session'; - const existing = sessionStorage.getItem(SWIPE_SESSION_KEY); - if (existing) return existing; - - const generated = typeof crypto !== 'undefined' && crypto.randomUUID - ? crypto.randomUUID() - : `session-${Date.now()}-${Math.random().toString(16).slice(2)}`; - sessionStorage.setItem(SWIPE_SESSION_KEY, generated); - return generated; -} - -function collectDeviceContext() { - if (typeof window === 'undefined') return {}; - const ua = navigator.userAgent || ''; - const isTablet = /Tablet|iPad/i.test(ua); - const isMobile = /Mobi|Android/i.test(ua); - const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop'; - - let os = 'unknown'; - if (/Windows/i.test(ua)) os = 'windows'; - else if (/Mac OS X/i.test(ua)) os = 'macos'; - else if (/Android/i.test(ua)) os = 'android'; - else if (/iPhone|iPad|iPod/i.test(ua)) os = 'ios'; - else if (/Linux/i.test(ua)) os = 'linux'; - - let browser = 'unknown'; - if (/Firefox\/\d/i.test(ua)) browser = 'firefox'; - else if (/Edg\/\d/i.test(ua)) browser = 'edge'; - else if (/Chrome\/\d/i.test(ua) && !/Chromium/i.test(ua)) browser = 'chrome'; - else if (/Safari\/\d/i.test(ua) && !/Chrome/i.test(ua)) browser = 'safari'; - - const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; - return { - device_type: deviceType, - os, - browser, - screen_width: window.screen?.width ?? null, - screen_height: window.screen?.height ?? null, - connection_type: conn?.effectiveType ?? conn?.type ?? null, - }; -} - -// ── page ──────────────────────────────────────────────────────────────────── +import { useDiscoverPage } from '../../features/discover/hooks/useDiscoverPage'; +import { DiscoverPageView } from '../../features/discover/components/DiscoverPageView'; export default function DiscoverPage() { // /discover is intentionally public — guests can browse without logging in. // Rate limiting and result capping for unauthenticated requests are enforced server-side. - return ; -} - -function DiscoverContent() { - const { user, getValidToken, authState, isAuthenticated, isLoading: authLoading } = useAuth(); - const { tourPhase } = usePadlyTour(); - const userId = user?.profile?.id; - const router = useRouter(); - - // Lightweight prefs fetch — only used to key the feed query on city so - // changing location always triggers a fresh fetch. - const { data: prefsData } = useQuery({ - queryKey: ['user-prefs', userId], - queryFn: async () => { - const token = await getValidToken(); - if (!token) return null; - const res = await apiFetch(`/preferences/${userId}`, {}, { token }); - if (!res.ok) return null; - const d = await res.json(); - return d.data || d || null; - }, - enabled: !!userId, - staleTime: 0, - }); - const prefCity = prefsData?.target_city ?? null; - usePageTracking('discover', authState?.accessToken); - - // ── guest mode ───────────────────────────────────────────────────────────── - const isGuest = !authLoading && !isAuthenticated; - - // Guest preferences from sessionStorage — read once on mount, never changes - const [guestPrefs] = useState(() => { - if (typeof window === 'undefined') return {}; - try { return JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch { return {}; } - }); - const guestCity = guestPrefs?.target_city ?? null; - - // Stable guest session ID — follows the same ref-init pattern used for - // recommendationClientSessionIdRef elsewhere in this file - const guestSessionIdRef = useRef(null); - if (!guestSessionIdRef.current && typeof window !== 'undefined') { - try { - const _existing = sessionStorage.getItem('padly_guest_session_id'); - if (_existing) { - guestSessionIdRef.current = _existing; - } else { - const _newId = crypto.randomUUID?.() ?? `guest-${Date.now()}-${Math.random().toString(16).slice(2)}`; - sessionStorage.setItem('padly_guest_session_id', _newId); - guestSessionIdRef.current = _newId; - } - } catch { /* best-effort */ } - } - - const guestSwipeCountRef = useRef(0); - const guestNudgeShownRef = useRef(false); - const guestLikeCountRef = useRef(0); // tracks total guest likes to throttle the signup modal - const [guestNudgeShown, setGuestNudgeShown] = useState(false); - const [showGuestSignupModal, setShowGuestSignupModal] = useState(false); - const [pendingGuestLike, setPendingGuestLike] = useState(null); - - /** Bumps React Query cache key so a refetch always reapplies stack state (refetch alone can reuse the same data reference). */ - const [feedReloadNonce, setFeedReloadNonce] = useState(0); - - const [listings, setListings] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [appendLoading, setAppendLoading] = useState(false); - const [appendError, setAppendError] = useState(null); - const [missingCorePreferences, setMissingCorePreferences] = useState(false); - const [emptyResultReason, setEmptyResultReason] = useState(null); - const [expandedListing, setExpandedListing] = useState(null); - const [expandedImageIndex, setExpandedImageIndex] = useState(0); - const [fullscreenOpen, setFullscreenOpen] = useState(false); - const [hasMore, setHasMore] = useState(false); - const swipeSessionIdRef = useRef(null); - const recommendationClientSessionIdRef = useRef(null); - const nextOffsetRef = useRef(0); - // Tracks the last feedData object seen so we only sync on a genuine new result - const prevFeedDataRef = useRef(null); - const restoredProgressRef = useRef(false); - // Stable refs for currentIndex and listings so handleSwipe doesn't need them as deps - const currentIndexRef = useRef(currentIndex); - const listingsRef = useRef(listings); - useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); - useEffect(() => { listingsRef.current = listings; }, [listings]); - - const activeFilterBodyRef = useRef({}); - const cardViewStartRef = useRef(null); - const cardPhotoCountRef = useRef(0); - const cardExpandedRef = useRef(false); - const expandedOpenedAtRef = useRef(null); - const surfaceStartedAtRef = useRef(Date.now()); - const promptShownRef = useRef(false); - const latestSessionIdRef = useRef(null); - const latestTokenRef = useRef(null); - const latestListingsRef = useRef([]); - const latestRankingContextRef = useRef(null); - const sessionMetricsRef = useRef({ likesCount: 0, detailOpensCount: 0, swipesCount: 0 }); - - const [rankingContext, setRankingContext] = useState(null); - const [feedbackSessionId, setFeedbackSessionId] = useState(null); - const [promptAllowed, setPromptAllowed] = useState(false); - const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); - const [feedbackStep, setFeedbackStep] = useState('question'); - const [pendingFeedbackLabel, setPendingFeedbackLabel] = useState(null); - const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); - const [feedbackAcknowledged, setFeedbackAcknowledged] = useState(false); - const [feedbackCycle, setFeedbackCycle] = useState(0); - const [swipesInCycle, setSwipesInCycle] = useState(0); - - if (!recommendationClientSessionIdRef.current && typeof window !== 'undefined') { - recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); - } - - const deriveRankingContext = useCallback((payload) => { - const responseContext = payload?.ranking_context; - if (responseContext) return responseContext; - - const recommendations = payload?.recommendations || []; - const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); - return { - algorithm_version: recommendations[0]?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, - model_version: hasMlScores ? 'recommender-v1' : null, - experiment_name: recommendations.length > 0 ? 'discover_ranker_v1' : null, - experiment_variant: recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null, - }; - }, []); - - const buildSessionPayload = useCallback((recommendations = listings, context = rankingContext) => { - if (!recommendationClientSessionIdRef.current) { - recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); - } - - const topListingIds = recommendations - .map((listing) => listing?.listing_id) - .filter(Boolean) - .slice(0, 20); - const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); - - return { - client_session_id: recommendationClientSessionIdRef.current, - surface: 'discover', - recommendation_count_shown: recommendations.length, - top_listing_ids_shown: topListingIds, - algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, - model_version: context?.model_version ?? (hasMlScores ? 'recommender-v1' : null), - experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'discover_ranker_v1' : null), - experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null), - }; - }, [listings, rankingContext]); - - const startNextFeedbackCycle = useCallback(() => { - recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); - sessionMetricsRef.current = { likesCount: 0, detailOpensCount: 0, swipesCount: 0 }; - setSwipesInCycle(0); - setFeedbackSessionId(null); - setPromptAllowed(false); - setShowFeedbackPrompt(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - promptShownRef.current = false; - surfaceStartedAtRef.current = Date.now(); - setFeedbackCycle((current) => current + 1); - }, []); - - const patchRecommendationSession = useCallback(async (payload, { keepalive = false } = {}) => { - if (!authState?.accessToken || !feedbackSessionId) return null; - - try { - const response = await apiFetch( - `/interactions/recommendation-sessions/${feedbackSessionId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - keepalive, - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to update discover recommendation session'); - return null; - } - - if (response.status === 503) return null; - const result = await response.json(); - return result?.data ?? null; - } catch { - return null; - } - }, [authState?.accessToken, feedbackSessionId]); - - const findListingPosition = useCallback((listingId) => { - if (!listingId) return null; - const index = listings.findIndex((item) => item?.listing_id === listingId); - return index >= 0 ? index : null; - }, [listings]); - - const persistRecommendationEvent = useCallback(async ({ - eventType, - listingId = null, - positionInFeed = null, - dwellMs = null, - metadata = {}, - keepalive = false, - }) => { - if (!authState?.accessToken || !feedbackSessionId) return null; - - try { - const response = await apiFetch( - `/interactions/recommendation-events`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - client_event_id: createRecommendationEventId(eventType), - surface: 'discover', - event_type: eventType, - listing_id: listingId, - position_in_feed: positionInFeed, - dwell_ms: dwellMs, - metadata, - }), - keepalive, - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to persist discover recommendation event'); - } - } catch { - // Best-effort only. - } - }, [authState?.accessToken, feedbackSessionId]); - - // ── shared fetch helper (used by the query AND loadMore) ────────────────── - - const fetchFeedPage = useCallback(async ({ offset = 0 } = {}) => { - // Guest path — no auth token, preferences come from sessionStorage - if (!userId) { - let localGuestPrefs = {}; - try { localGuestPrefs = JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch {} - if (!localGuestPrefs.target_city) { - return { listings: [], prefs: localGuestPrefs, hasCorePreferences: false, hasMore: false, nextOffset: 0 }; - } - const body = { - target_city: localGuestPrefs.target_city, - target_country: localGuestPrefs.target_country || undefined, - target_state_province: localGuestPrefs.target_state_province || undefined, - budget_min: localGuestPrefs.budget_min || undefined, - budget_max: localGuestPrefs.budget_max || undefined, - required_bedrooms: localGuestPrefs.required_bedrooms || undefined, - target_bathrooms: localGuestPrefs.target_bathrooms || undefined, - top_n: 20, - offset: 0, - }; - activeFilterBodyRef.current = body; - const res = await apiFetch(`/recommendations`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error('Failed to fetch recommendations'); - const data = await res.json(); - return { - listings: data.recommendations || [], - prefs: localGuestPrefs, - hasCorePreferences: true, - hasMore: false, - nextOffset: 0, - rankingContext: deriveRankingContext(data), - }; - } - - const token = await getValidToken(); - if (!token) throw new Error('Not authenticated'); - - let prefs = {}; - let hasCorePreferences = false; - const swipedIds = new Set(); - - const prefRes = await apiFetch(`/preferences/${userId}`, {}, { token }); - if (prefRes.ok) { - const prefData = await prefRes.json(); - prefs = prefData.data || prefData || {}; - hasCorePreferences = hasCompleteCorePreferences(prefs); - } - - if (!hasCorePreferences) { - return { listings: [], prefs, hasCorePreferences, hasMore: false, nextOffset: 0 }; - } - - try { - const swipesRes = await apiFetch(`/interactions/swipes/me?limit=500`, {}, { token }); - if (swipesRes.ok) { - const swipesPayload = await swipesRes.json(); - for (const event of swipesPayload?.data || []) { - if (event?.listing_id) swipedIds.add(event.listing_id); - } - } - } catch { - // Swipe history fetch is optional; fall back to local liked cache only. - } - - const liked = getLikedListings(); - const likedExtras = {}; - let behaviorSampleSize; - - try { - const behaviorRes = await apiFetch(`/interactions/behavior/me?days=180`, {}, { token }); - if (behaviorRes.ok) { - const behaviorPayload = await behaviorRes.json(); - const behavior = behaviorPayload?.data || {}; - if (behavior.liked_mean_price != null) likedExtras.liked_mean_price = behavior.liked_mean_price; - if (behavior.liked_mean_beds != null) likedExtras.liked_mean_beds = behavior.liked_mean_beds; - if (behavior.liked_mean_sqfeet != null) likedExtras.liked_mean_sqfeet = behavior.liked_mean_sqfeet; - if (behavior.sample_size != null) behaviorSampleSize = behavior.sample_size; - } - } catch { - // Behavior vector is optional. - } - - if (liked.length > 0) { - const avg = (arr) => arr.filter(Boolean).reduce((a, b) => a + b, 0) / arr.filter(Boolean).length; - if (likedExtras.liked_mean_price == null) likedExtras.liked_mean_price = avg(liked.map((l) => l.price_per_month)); - if (likedExtras.liked_mean_beds == null) likedExtras.liked_mean_beds = avg(liked.map((l) => l.number_of_bedrooms)); - if (likedExtras.liked_mean_sqfeet == null) likedExtras.liked_mean_sqfeet = avg(liked.map((l) => l.area_sqft)); - } - - const body = { - budget_min: prefs.budget_min ?? undefined, - budget_max: prefs.budget_max ?? undefined, - target_country: prefs.target_country ?? undefined, - target_state_province: prefs.target_state_province ?? undefined, - target_city: prefs.target_city ?? undefined, - required_bedrooms: prefs.required_bedrooms ?? undefined, - target_bathrooms: prefs.target_bathrooms ?? undefined, - desired_beds: prefs.required_bedrooms ?? undefined, - desired_baths: prefs.target_bathrooms ?? undefined, - target_deposit_amount: prefs.target_deposit_amount ?? undefined, - furnished_preference: prefs.furnished_preference ?? undefined, - gender_policy: prefs.gender_policy ?? undefined, - target_lease_type: prefs.target_lease_type ?? undefined, - target_lease_duration_months: prefs.target_lease_duration_months ?? undefined, - allow_larger_layouts: prefs?.lifestyle_preferences?.allow_larger_layouts ?? undefined, - move_in_date: prefs.move_in_date ?? undefined, - target_furnished: prefs.target_furnished ?? undefined, - wants_furnished: - prefs.furnished_preference === 'required' || prefs.furnished_preference === 'preferred' - ? 1 - : prefs.target_furnished === true - ? 1 - : undefined, - pref_lat: prefs.target_latitude ?? undefined, - pref_lon: prefs.target_longitude ?? undefined, - top_n: 50, - offset, - behavior_sample_size: behaviorSampleSize, - ...likedExtras, - }; - - // Cache the filter body so swipe-context events can reference it. - activeFilterBodyRef.current = body; - - const res = await apiFetch( - `/recommendations`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - { token } - ); - - if (!res.ok) { - const apiError = await parseApiErrorResponse(res, 'Failed to fetch recommendations'); - throw createAppError(apiError.message, { - status: apiError.status, - payload: apiError.payload, - rawMessage: apiError.message, - }); - } - - const data = await res.json(); - const nextRankingContext = deriveRankingContext(data); - - const likedIds = new Set(getLikedListings().map((l) => l.listing_id)); - const fresh = (data.recommendations || []).filter( - (l) => !likedIds.has(l.listing_id) && !swipedIds.has(l.listing_id) - ); - - // Fire search query event — best-effort. - void apiFetch( - `/interactions/search-queries`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: getOrCreateSwipeSessionId(), - filter_snapshot: body, - results_returned: (data.recommendations || []).length, - offset, - }), - }, - { token } - ).catch(() => {}); - - return { - listings: fresh, - prefs, - hasCorePreferences, - hasMore: Boolean(data.has_more), - nextOffset: (data.offset || 0) + (data.count || 0), - rankingContext: nextRankingContext, - }; - }, [deriveRankingContext, getValidToken, userId]); - - // ── cached initial feed (5-min stale time) ───────────────────────────────── - - const { - data: feedData, - isLoading: feedLoading, - error: feedQueryError, - } = useQuery({ - queryKey: isGuest - ? ['discover-feed-guest', guestCity, feedReloadNonce] - : ['discover-feed', userId, prefCity, feedReloadNonce], - queryFn: () => fetchFeedPage({ offset: 0 }), - enabled: isGuest ? !!guestCity : (!!userId && prefCity !== null), - staleTime: 0, - gcTime: 10 * 60 * 1000, - retry: false, - }); - - const handleDiscoverFeedReload = useCallback(() => { - if (!userId) return; - clearDiscoverProgress(userId); - prevFeedDataRef.current = null; - setFeedReloadNonce((n) => n + 1); - }, [userId]); - - useLayoutEffect(() => { - if (!userId) return; - - const restored = loadDiscoverProgress(userId); - if (!restored) return; - - // If the user changed their city since this progress was saved, discard it. - if (prefCity && restored.targetCity && restored.targetCity !== prefCity) { - clearDiscoverProgress(userId); - return; - } - - restoredProgressRef.current = true; - setListings(Array.isArray(restored.listings) ? restored.listings : []); - setCurrentIndex( - Number.isFinite(restored.currentIndex) - ? Math.max(0, Math.min(restored.currentIndex, restored.listings?.length || 0)) - : 0 - ); - setHasMore(Boolean(restored.hasMore)); - setMissingCorePreferences(Boolean(restored.missingCorePreferences)); - setEmptyResultReason(restored.emptyResultReason ?? null); - setRankingContext(restored.rankingContext ?? null); - nextOffsetRef.current = Number.isFinite(restored.nextOffset) ? restored.nextOffset : 0; - }, [userId]); - - // Sync query data → swipe-stack state. - // useLayoutEffect runs before paint so there is no visible flash of empty state on - // remounts that have a cache hit (feedData is available immediately). - useLayoutEffect(() => { - if (!feedData || feedData === prevFeedDataRef.current) return; - prevFeedDataRef.current = feedData; - if (restoredProgressRef.current) { - restoredProgressRef.current = false; - return; - } - setListings(feedData.listings); - setCurrentIndex(0); - setHasMore(feedData.hasMore); - setMissingCorePreferences(!feedData.hasCorePreferences); - setRankingContext(feedData.rankingContext ?? null); - setEmptyResultReason( - feedData.listings.length === 0 - ? (feedData.hasCorePreferences ? 'strict_constraints' : 'missing_preferences') - : null - ); - nextOffsetRef.current = feedData.nextOffset; - sessionMetricsRef.current = { likesCount: 0, detailOpensCount: 0, swipesCount: 0 }; - setSwipesInCycle(0); - setFeedbackCycle(0); - promptShownRef.current = false; - setFeedbackSessionId(null); - setPromptAllowed(false); - setShowFeedbackPrompt(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - setFeedbackAcknowledged(false); - surfaceStartedAtRef.current = Date.now(); - recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); - }, [feedData]); - - // Only show the full-page spinner on the very first load (no cached data yet). - const loading = (feedLoading && !feedData) || appendLoading; - const error = feedQueryError ? normalizeRecommendationsError(feedQueryError) : appendError; - - // ── append more listings ─────────────────────────────────────────────────── - - const loadMore = useCallback(async () => { - setAppendLoading(true); - setAppendError(null); - try { - const page = await fetchFeedPage({ offset: nextOffsetRef.current }); - setHasMore(page.hasMore); - nextOffsetRef.current = page.nextOffset; - setListings((prev) => [...prev, ...page.listings]); - } catch (err) { - setAppendError(normalizeRecommendationsError(err)); - } finally { - setAppendLoading(false); - } - }, [fetchFeedPage]); - - useEffect(() => { - latestSessionIdRef.current = feedbackSessionId; - }, [feedbackSessionId]); - - useEffect(() => { - latestTokenRef.current = getValidToken; - }, [getValidToken]); - - useEffect(() => { - latestListingsRef.current = listings; - }, [listings]); - - useEffect(() => { - latestRankingContextRef.current = rankingContext; - }, [rankingContext]); - - useEffect(() => { - if (!userId) return; - - if (listings.length === 0 && currentIndex === 0) { - clearDiscoverProgress(userId); - return; - } - - saveDiscoverProgress(userId, { - listings, - currentIndex, - hasMore, - nextOffset: nextOffsetRef.current, - missingCorePreferences, - emptyResultReason, - rankingContext, - targetCity: prefCity, - }); - }, [ - currentIndex, - emptyResultReason, - hasMore, - listings, - missingCorePreferences, - rankingContext, - userId, - ]); - - useEffect(() => { - if (!authState?.accessToken || !userId || loading || error || listings.length === 0) return; - - let cancelled = false; - - const ensureRecommendationSession = async () => { - try { - const response = await apiFetch( - `/interactions/recommendation-sessions`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildSessionPayload(listings, rankingContext)), - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to create discover recommendation session'); - return; - } - - if (response.status === 503) return; - - const result = await response.json(); - if (cancelled) return; - - setFeedbackSessionId(result?.data?.id ?? null); - setPromptAllowed(Boolean(result?.prompt_allowed)); - } catch { - // Best-effort only. - } - }; - - void ensureRecommendationSession(); - - return () => { - cancelled = true; - }; - }, [authState?.accessToken, buildSessionPayload, error, feedbackCycle, listings, loading, rankingContext, userId]); - - useEffect(() => { - if ( - promptShownRef.current || - !feedbackSessionId || - !promptAllowed || - showFeedbackPrompt || - feedbackSubmitting || - loading || - error || - swipesInCycle < DISCOVER_FEEDBACK_SWIPE_THRESHOLD - ) { - return; - } - - promptShownRef.current = true; - setShowFeedbackPrompt(true); - void patchRecommendationSession({ - prompt_presented: true, - likes_count: sessionMetricsRef.current.likesCount, - detail_opens_count: sessionMetricsRef.current.detailOpensCount, - recommendation_count_shown: listings.length, - top_listing_ids_shown: listings.map((listing) => listing?.listing_id).filter(Boolean).slice(0, 20), - surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), - algorithm_version: rankingContext?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, - model_version: rankingContext?.model_version ?? null, - experiment_name: rankingContext?.experiment_name ?? 'discover_ranker_v1', - experiment_variant: rankingContext?.experiment_variant ?? null, - }); - }, [ - error, - feedbackSessionId, - feedbackSubmitting, - listings, - loading, - patchRecommendationSession, - promptAllowed, - rankingContext, - showFeedbackPrompt, - swipesInCycle, - ]); - - useEffect(() => { - return () => { - const getToken = latestTokenRef.current; - const sessionId = latestSessionIdRef.current; - if (!getToken || !sessionId) return; - - const recommendations = latestListingsRef.current || []; - const context = latestRankingContextRef.current; - const topListingIds = recommendations - .map((listing) => listing?.listing_id) - .filter(Boolean) - .slice(0, 20); - const dwellMs = Math.max(0, Date.now() - surfaceStartedAtRef.current); - const metrics = { ...sessionMetricsRef.current }; - - getToken().then((token) => { - if (!token) return; - apiFetch( - `/interactions/recommendation-sessions/${sessionId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mark_ended: true, - surface_dwell_ms: dwellMs, - likes_count: metrics.likesCount, - detail_opens_count: metrics.detailOpensCount, - recommendation_count_shown: recommendations.length, - top_listing_ids_shown: topListingIds, - algorithm_version: context?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, - model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), - experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'discover_ranker_v1' : null), - experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), - }), - keepalive: true, - }, - { token } - ).catch(() => {}); - }).catch(() => {}); - }; - }, []); - - useEffect(() => { - setExpandedImageIndex(0); - }, [expandedListing?.listing_id]); - - useEffect(() => { - const remaining = listings.length - currentIndex; - if (!loading && !error && hasMore && remaining === 0) { - loadMore(); - } - }, [currentIndex, listings.length, loading, error, hasMore, loadMore]); - - // ── swipe actions ───────────────────────────────────────────────────────── - - const persistSwipeEvent = useCallback(async ({ listing, action, position, startedAt }) => { - if (!userId || !listing?.listing_id) return; - const token = await getValidToken(); - if (!token) return; - - if (!swipeSessionIdRef.current) { - swipeSessionIdRef.current = getOrCreateSwipeSessionId(); - } - - const algorithmVersion = - listing?.algorithm_version != null && String(listing.algorithm_version).trim() - ? String(listing.algorithm_version).trim() - : DISCOVER_ALGORITHM_VERSION; - - try { - const response = await apiFetch( - `/interactions/swipes`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - listing_id: listing.listing_id, - action, - surface: 'discover', - session_id: swipeSessionIdRef.current, - position_in_feed: position, - algorithm_version: algorithmVersion, - model_version: listing?.ml_score != null ? 'recommender-v1' : null, - city_filter: listing.city ?? null, - latency_ms: - startedAt != null && typeof performance !== 'undefined' - ? Math.max(0, Math.round(performance.now() - startedAt)) - : undefined, - }), - }, - { token } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to persist swipe interaction'); - } - } catch { - // Best-effort only. - } - }, [userId, getValidToken]); - - const persistSwipeContextEvent = useCallback(async ({ listing, action }) => { - if (!authState?.accessToken || !listing?.listing_id) return; - if (!swipeSessionIdRef.current) { - swipeSessionIdRef.current = getOrCreateSwipeSessionId(); - } - try { - await apiFetch( - `/interactions/swipe-context`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - listing_id: listing.listing_id, - action, - session_id: swipeSessionIdRef.current, - active_filters_snapshot: activeFilterBodyRef.current || null, - device_context: collectDeviceContext(), - }), - }, - { token: authState.accessToken } - ); - } catch { - // Best-effort only. - } - }, [authState?.accessToken]); - - const persistListingViewEvent = useCallback(async ({ listing, surface }) => { - if (!authState?.accessToken || !listing?.listing_id) return; - if (!swipeSessionIdRef.current) { - swipeSessionIdRef.current = getOrCreateSwipeSessionId(); - } - const duration_ms = - cardViewStartRef.current != null - ? Math.max(0, Date.now() - cardViewStartRef.current) - : null; - try { - await apiFetch( - `/interactions/listing-views`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - listing_id: listing.listing_id, - surface, - session_id: swipeSessionIdRef.current, - view_duration_ms: duration_ms, - expanded: cardExpandedRef.current, - photos_viewed_count: cardPhotoCountRef.current, - }), - }, - { token: authState.accessToken } - ); - } catch { - // Best-effort only. - } - }, [authState?.accessToken]); - - // Reset view-tracking refs whenever the top card changes. - useEffect(() => { - if (listings.length === 0 || currentIndex >= listings.length) return; - cardViewStartRef.current = Date.now(); - cardPhotoCountRef.current = 0; - cardExpandedRef.current = false; - }, [currentIndex, listings.length]); - - // ── guest event logger ──────────────────────────────────────────────────── - const logGuestEvent = useCallback(async (eventData) => { - if (!isGuest) return; - try { - let localPrefs = {}; - try { localPrefs = JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch {} - void apiFetch(`/interactions/guest-events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - guest_session_id: guestSessionIdRef.current ?? 'unknown', - guest_prefs_snapshot: localPrefs, - device_context: collectDeviceContext(), - ...eventData, - }), - }).catch(() => {}); - } catch { /* best-effort */ } - }, [isGuest]); - - // Stable handleSwipe: reads currentIndex/listings from refs so the callback - // identity doesn't change on every swipe (prevents SwipeCard re-renders). - - const handleSwipe = useCallback((direction, listing) => { - const action = direction === 'right' ? 'like' : 'pass'; - - // ── Guest interception ──────────────────────────────────────────────────── - if (isGuest) { - const position = listingsRef.current.findIndex(l => l.listing_id === listing?.listing_id); - void logGuestEvent({ - event_type: action === 'like' ? 'swipe_right' : 'swipe_left', - listing_id: listing?.listing_id ?? null, - position_in_feed: position >= 0 ? position : currentIndexRef.current, - }); - - guestSwipeCountRef.current += 1; - - if (action === 'like') { - guestLikeCountRef.current += 1; - const likeCount = guestLikeCountRef.current; - // Show the modal on the 1st like and every 10th after that (1, 10, 20, 30…) - if (likeCount === 1 || likeCount % 10 === 0) { - setPendingGuestLike(listing); - setShowGuestSignupModal(true); - void logGuestEvent({ event_type: 'signup_prompt_shown', listing_id: listing?.listing_id ?? null }); - return; // don't advance until modal is dismissed - } - // All other likes: just advance - setCurrentIndex(prev => prev + 1); - return; - } - - // Pass — advance card - setCurrentIndex(prev => prev + 1); - - // Show nudge after 5 swipes - if (guestSwipeCountRef.current >= 15 && !guestNudgeShownRef.current) { - guestNudgeShownRef.current = true; - setGuestNudgeShown(true); - } - return; - } - // ───────────────────────────────────────────────────────────────────────── - - if (action === 'like') saveLikedListing(listing); - - const currentListings = listingsRef.current; - const currentIdx = currentIndexRef.current; - const top = currentListings[currentIdx]; - const position = - top && listing?.listing_id === top.listing_id - ? currentIdx - : currentListings.findIndex((item) => item.listing_id === listing?.listing_id); - const startedAt = typeof performance !== 'undefined' ? performance.now() : null; - - // Fire data logging events before advancing the index (best-effort, parallel). - void persistListingViewEvent({ listing, surface: 'discover_card' }); - void persistSwipeContextEvent({ listing, action }); - - void persistSwipeEvent({ - listing, - action, - position: position >= 0 ? position : currentIdx, - startedAt, - }); - - sessionMetricsRef.current.swipesCount += 1; - setSwipesInCycle(sessionMetricsRef.current.swipesCount); - if (action === 'like') { - sessionMetricsRef.current.likesCount += 1; - // Signal to the Matches page that behaviour has changed so it can offer - // a "Reload Matches" button the next time the user visits. - if (userId && typeof window !== 'undefined') { - try { - localStorage.setItem(`padly_matches_stale_at_${userId}`, String(Date.now())); - } catch { - // Ignore storage errors. - } - } - } - - setCurrentIndex((prev) => prev + 1); - - if (tourPhase === 'discover') { - window.dispatchEvent(new CustomEvent('padly-tour-swipe', { - detail: { direction }, - })); - } - }, [persistSwipeEvent, persistListingViewEvent, persistSwipeContextEvent, tourPhase, isGuest, logGuestEvent]); - - const handleButton = (direction) => { - if (currentIndexRef.current >= listingsRef.current.length) return; - handleSwipe(direction, listingsRef.current[currentIndexRef.current]); - }; - - const handleModalAction = (direction) => { - closeExpanded(); - setTimeout(() => handleButton(direction), 200); - }; - - const openExpanded = useCallback((listing) => { - cardExpandedRef.current = true; - sessionMetricsRef.current.detailOpensCount += 1; - const position = findListingPosition(listing?.listing_id); - void persistRecommendationEvent({ - eventType: 'detail_open', - listingId: listing?.listing_id, - positionInFeed: position, - metadata: { - match_percent: listing?.match_percent ?? null, - open_type: 'discover_quick_view', - }, - }); - expandedOpenedAtRef.current = Date.now(); - setExpandedImageIndex(0); - setExpandedListing(listing); - }, [findListingPosition, persistRecommendationEvent]); - - const closeExpanded = useCallback(() => { - if (expandedListing?.listing_id) { - const position = findListingPosition(expandedListing.listing_id); - const dwellMs = - expandedOpenedAtRef.current != null - ? Math.max(0, Date.now() - expandedOpenedAtRef.current) - : null; - void persistRecommendationEvent({ - eventType: 'detail_view', - listingId: expandedListing.listing_id, - positionInFeed: position, - dwellMs, - metadata: { - view_type: 'discover_quick_view', - }, - }); - } - - expandedOpenedAtRef.current = null; - setExpandedListing(null); - }, [expandedListing, findListingPosition, persistRecommendationEvent]); - - const submitFeedback = useCallback(async ({ feedbackLabel, reasonLabel = null }) => { - if (!authState?.accessToken || !feedbackSessionId || feedbackSubmitting) return; - - setFeedbackSubmitting(true); - try { - await patchRecommendationSession({ - likes_count: sessionMetricsRef.current.likesCount, - detail_opens_count: sessionMetricsRef.current.detailOpensCount, - recommendation_count_shown: listings.length, - top_listing_ids_shown: listings.map((listing) => listing?.listing_id).filter(Boolean).slice(0, 20), - surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), - algorithm_version: rankingContext?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, - model_version: rankingContext?.model_version ?? null, - experiment_name: rankingContext?.experiment_name ?? 'discover_ranker_v1', - experiment_variant: rankingContext?.experiment_variant ?? null, - }); - - const response = await apiFetch( - `/interactions/recommendation-feedback`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - feedback_label: feedbackLabel, - reason_label: reasonLabel, - }), - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to submit discover recommendation feedback'); - return; - } - - setShowFeedbackPrompt(false); - setPromptAllowed(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - setFeedbackAcknowledged(true); - startNextFeedbackCycle(); - } catch { - // Best-effort only. - } finally { - setFeedbackSubmitting(false); - } - }, [ - authState?.accessToken, - feedbackSessionId, - feedbackSubmitting, - listings, - patchRecommendationSession, - rankingContext, - startNextFeedbackCycle, - ]); - - const dismissFeedbackPrompt = useCallback(async () => { - await patchRecommendationSession({ - prompt_dismissed: true, - likes_count: sessionMetricsRef.current.likesCount, - detail_opens_count: sessionMetricsRef.current.detailOpensCount, - surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), - }); - startNextFeedbackCycle(); - }, [patchRecommendationSession, startNextFeedbackCycle]); - - const handleFeedbackChoice = useCallback(async (value) => { - if (value === 'not_useful') { - setPendingFeedbackLabel(value); - setFeedbackStep('reason'); - return; - } - - await submitFeedback({ feedbackLabel: value }); - }, [submitFeedback]); - - const handleNegativeReason = useCallback(async (reasonLabel) => { - await submitFeedback({ - feedbackLabel: pendingFeedbackLabel || 'not_useful', - reasonLabel, - }); - }, [pendingFeedbackLabel, submitFeedback]); - - // Auto-advance modal images every 5s while modal is open - useEffect(() => { - if (!expandedListing) return; - const imgs = (() => { - const i = expandedListing.images; - if (Array.isArray(i)) return i; - if (typeof i === 'string') { try { return JSON.parse(i); } catch { return []; } } - return []; - })(); - if (imgs.length <= 1) return; - const timer = setInterval(() => { - setExpandedImageIndex(prev => (prev + 1) % imgs.length); - }, 3000); - return () => clearInterval(timer); - }, [expandedListing]); - - const remaining = listings.length - currentIndex; - const noRecommendations = !loading && !error && listings.length === 0 && !hasMore; - const isDone = !loading && !error && listings.length > 0 && remaining === 0 && !hasMore; - - useHotkeys([ - ['ArrowLeft', () => handleButton('left')], - ['ArrowRight', () => handleButton('right')], - ]); - - return ( - - - - - {/* Header */} - - - Discover - - {feedbackAcknowledged && ( - - Thanks. Your feedback was saved for recommendation evaluation. - - )} - {!loading && !isDone && !noRecommendations && ( - - {remaining} listing{remaining !== 1 ? 's' : ''} left - - )} - {isGuest && !guestCity && ( - - )} - - - {/* Guest nudge banner — shown after 5 swipes */} - {isGuest && guestNudgeShown && ( - - - Sign up to save your liked listings and get unlimited access. - - - - - - - )} - - {showFeedbackPrompt && ( - - - - {feedbackStep === 'question' ? ( - <> - - - How useful were these recommendations? - - - Your feedback helps us improve how listings are ranked. - - - - {MATCHES_FEEDBACK_CHOICES.map((choice) => ( - - ))} - - - - ) : ( - <> - - - What felt off? - - - Optional - - - - {MATCHES_NEGATIVE_REASON_CHOICES.map((choice) => ( - - ))} - - - - )} - - - - )} - - {/* Content */} - - - {/* Loading */} - {loading && } - - {/* Error */} - {!loading && error && ( - - {error} - - - )} - - {/* No recommendations */} - {noRecommendations && ( - - - - - - {emptyResultReason === 'missing_preferences' - ? 'Complete your preferences' - : 'No listings match right now'} - - - {emptyResultReason === 'missing_preferences' - ? 'Set your country, state/province, and city to get location-aware recommendations.' - : 'Try broadening one hard constraint like budget, room preference, or move-in date.'} - - - - - - {missingCorePreferences && ( - - Listing ranking improves once your core location constraints are set. - - )} - - )} - - {/* Done */} - {isDone && ( - - 🏠 - You've seen everything! - - Check your liked listings in Matches, or reload for a fresh batch. - - - - - {GROUPS_FEATURE_ENABLED && ( - - )} - - - )} - - {/* Card stack + buttons */} - {!loading && !error && !isDone && !noRecommendations && ( - <> - - - Listing {currentIndex + 1} of {listings.length} - {listings.length - currentIndex - 1} remaining - - 0 ? ((currentIndex) / listings.length) * 100 : 0} - size="xs" - color="teal" - radius="xl" - /> - - - - {[2, 1, 0].map((offset) => { - const idx = currentIndex + offset; - if (idx >= listings.length) return null; - return ( - { cardPhotoCountRef.current += 1; } : undefined} - /> - ); - })} - - - {/* Action buttons */} - - - handleButton('left')} - style={{ boxShadow: '0 4px 20px rgba(255,107,107,0.20)', border: '2px solid #ffc9c9' }} - > - - - Pass - - - - handleButton('right')} - style={{ boxShadow: '0 4px 20px rgba(32,201,151,0.22)', border: '2px solid #96f2d7' }} - > - - - Like - - - ← Pass · → Like - - )} - - - - - {/* Quick-view modal */} - - {expandedListing && (() => { - const imgs = (() => { - const i = expandedListing?.images; - if (Array.isArray(i)) return i; - if (typeof i === 'string') { try { return JSON.parse(i); } catch { return []; } } - return []; - })(); - const safeImageIndex = imgs.length > 0 - ? Math.min(expandedImageIndex, imgs.length - 1) - : 0; - const heroImage = imgs[safeImageIndex] || 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080'; - - const { street: modalStreet, location: modalLocation } = parseListingTitle(expandedListing.title); - const modalCity = expandedListing.city || ''; - const modalDisplayStreet = modalCity - ? modalStreet.replace( - new RegExp(`[,\\s–-]*${modalCity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i'), - '' - ).trim() || modalStreet - : modalStreet; - const modalDisplayLocation = modalLocation || modalCity; - - const amenityKeys = getActiveAmenityKeys(expandedListing.amenities); - - const bedsLabel = expandedListing.number_of_bedrooms === 0 - ? 'Studio' - : expandedListing.number_of_bedrooms != null - ? String(expandedListing.number_of_bedrooms) - : '—'; - - return ( - - {/* Hero image */} - - setFullscreenOpen(true)} - style={{ width: '100%', height: '100%', cursor: 'zoom-in' }} - > - - - - {imgs.length > 1 && ( - <> - - setExpandedImageIndex((prev) => (prev - 1 + imgs.length) % imgs.length)} - style={{ opacity: 0.85 }} - > - - - - - setExpandedImageIndex((prev) => (prev + 1) % imgs.length)} - style={{ opacity: 0.85 }} - > - - - - - )} - - {/* Close button */} - - - - - - - {/* Match badge */} - {expandedListing.match_percent && ( - - {expandedListing.match_percent} match - - )} - - {imgs.length > 1 && ( - - {safeImageIndex + 1} / {imgs.length} - - )} - - {/* Bottom gradient + title overlay */} - - - {modalDisplayStreet || 'Listing'} - - {modalDisplayLocation && ( - - {modalDisplayLocation} - - )} - - - - {imgs.length > 1 && ( - - {imgs.map((img, index) => ( - setExpandedImageIndex(index)} - style={{ - minWidth: 72, - width: 72, - height: 56, - borderRadius: 10, - overflow: 'hidden', - cursor: 'pointer', - border: index === safeImageIndex ? '2px solid #12b886' : '2px solid transparent', - boxShadow: index === safeImageIndex ? '0 0 0 1px rgba(18,184,134,0.18)' : 'none', - backgroundColor: '#f3f4f6', - flexShrink: 0, - }} - > - - - ))} - - )} - - {/* Details section */} - - - {/* Row 1: price + property type */} - - {expandedListing.price_per_month != null && ( - - ${Number(expandedListing.price_per_month).toLocaleString()}/mo - - )} - {expandedListing.property_type && ( - - {formatEnumLabel(expandedListing.property_type)} - - )} - - - {/* Row 2: key stats */} - - - {bedsLabel} - Beds - - - - {expandedListing.number_of_bathrooms != null ? expandedListing.number_of_bathrooms : '—'} - - Baths - - - - {expandedListing.area_sqft != null ? expandedListing.area_sqft : '—'} - - Sqft - - - - - - {/* Row 3: details grid */} - - {expandedListing.available_from && ( - <> - Available from - {expandedListing.available_from} - - )} - {expandedListing.lease_type && ( - <> - Lease type - {formatEnumLabel(expandedListing.lease_type)} - - )} - <> - Furnished - {expandedListing.furnished ? 'Yes' : 'No'} - - {expandedListing.utilities_included != null && ( - <> - Utilities - {expandedListing.utilities_included ? 'Included' : 'Not included'} - - )} - - - {/* Row 4: amenities */} - {amenityKeys.length > 0 && ( - - {amenityKeys.map((key) => ( - - {formatAmenityLabel(key)} - - ))} - - )} - - {/* Row 5: description */} - {expandedListing.description && ( - - {expandedListing.description} - - )} - - {/* Row 6: action buttons */} - - - - - - - - ); - })()} - - - {/* Fullscreen image viewer */} - {expandedListing && fullscreenOpen && (() => { - const imgs = (() => { - const i = expandedListing?.images; - if (Array.isArray(i)) return i; - if (typeof i === 'string') { try { return JSON.parse(i); } catch { return []; } } - return []; - })(); - const safeIdx = imgs.length > 0 ? Math.min(expandedImageIndex, imgs.length - 1) : 0; - return ( - setFullscreenOpen(false)} - size="min(92vw, 900px)" - padding={0} - radius="xl" - withCloseButton={false} - centered - overlayProps={{ backgroundOpacity: 0.75, blur: 10 }} - transitionProps={{ transition: 'fade', duration: 200 }} - styles={{ - body: { backgroundColor: '#111', borderRadius: 'var(--mantine-radius-xl)' }, - content: { backgroundColor: '#111', borderRadius: 'var(--mantine-radius-xl)' }, - }} - > - - - - {/* Close */} - setFullscreenOpen(false)} - style={{ position: 'absolute', top: 16, right: 16, opacity: 0.85 }} - > - - - - {/* Counter */} - {imgs.length > 1 && ( - - {safeIdx + 1} / {imgs.length} - - )} - - {/* Prev */} - {imgs.length > 1 && ( - setExpandedImageIndex((prev) => (prev - 1 + imgs.length) % imgs.length)} - style={{ position: 'absolute', top: '50%', left: 16, transform: 'translateY(-50%)', opacity: 0.85 }} - > - - - )} - - {/* Next */} - {imgs.length > 1 && ( - setExpandedImageIndex((prev) => (prev + 1) % imgs.length)} - style={{ position: 'absolute', top: '50%', right: 16, transform: 'translateY(-50%)', opacity: 0.85 }} - > - - - )} - - - ); - })()} - - {/* Guest signup modal — shown when a guest tries to like a listing */} - { - setShowGuestSignupModal(false); - // Advance past the card the guest tried to like so they can keep browsing - setCurrentIndex(prev => prev + 1); - void logGuestEvent({ event_type: 'signup_prompt_dismissed', listing_id: pendingGuestLike?.listing_id ?? null }); - setPendingGuestLike(null); - }} - size="sm" - radius="lg" - centered - padding="xl" - title={null} - overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} - withCloseButton={false} - > - - 🏠 - - Save this listing - - Create a free account to like listings, see your matches, and get unlimited access. - - - - - - - - Already have an account?{' '} - router.push('/login')} - > - Sign in - - - - - - - ); + const discover = useDiscoverPage(); + return ; } diff --git a/frontend/src/app/groups/[id]/page.jsx b/frontend/src/app/groups/[id]/page.jsx index 3424dca..788fe03 100644 --- a/frontend/src/app/groups/[id]/page.jsx +++ b/frontend/src/app/groups/[id]/page.jsx @@ -1,1303 +1,18 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useRouter, useParams } from 'next/navigation'; -import { - Container, - Title, - Text, - Button, - Stack, - Card, - Badge, - Group, - Avatar, - Divider, - Modal, - TextInput, - ActionIcon, - Menu, - Alert, - Grid, - Paper, - Tabs, - Loader, - Center, - Tooltip, - ScrollArea, - ThemeIcon, - Progress, - Skeleton -} from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { - IconArrowLeft, - IconUsers, - IconMail, - IconUserPlus, - IconDoorExit, - IconEdit, - IconTrash, - IconDotsVertical, - IconCheck, - IconX, - IconMapPin, - IconCurrencyDollar, - IconCalendar, - IconBuildingCommunity, - IconAlertCircle, - IconHome, - IconSearch, - IconSparkles, - IconMoon, - IconVolume, - IconSmokingNo, - IconDog, - IconFriends, - IconInbox, - IconHeart, - IconBookmark -} from '@tabler/icons-react'; -import { useAuth } from '../../contexts/AuthContext'; -import { Navigation } from '../../components/Navigation'; import { ProtectedRoute } from '../../components/ProtectedRoute'; -import { apiFetch } from '../../../../lib/api'; +import { useGroupDetailPage } from '../../../features/groups/detail/hooks/useGroupDetailPage'; +import { GroupDetailPageView } from '../../../features/groups/detail/components/GroupDetailPageView'; -export default function GroupDetailPage() { - const router = useRouter(); - const params = useParams(); - const { user, getValidToken, authState } = useAuth(); - const groupId = params.id; - - const [group, setGroup] = useState(null); - const [members, setMembers] = useState([]); - const [currentUser, setCurrentUser] = useState(null); - const [matches, setMatches] = useState([]); - const [loading, setLoading] = useState(true); - const [inviteModalOpen, setInviteModalOpen] = useState(false); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviting, setInviting] = useState(false); - const [activeTab, setActiveTab] = useState('overview'); - const [inviteTab, setInviteTab] = useState('compatible'); - const [compatibleUsers, setCompatibleUsers] = useState([]); - const [loadingCompatible, setLoadingCompatible] = useState(false); - const [invitingUserId, setInvitingUserId] = useState(null); - const [joinRequests, setJoinRequests] = useState([]); - const [loadingRequests, setLoadingRequests] = useState(false); - const [processingRequestId, setProcessingRequestId] = useState(null); - const [memberSavedListings, setMemberSavedListings] = useState([]); - const [loadingLiked, setLoadingLiked] = useState(false); - - useEffect(() => { - if (groupId) { - fetchGroupData(); - } - }, [groupId]); - - // Fetch current user info if not available from context - useEffect(() => { - const fetchCurrentUser = async () => { - if (!currentUser && authState?.accessToken) { - try { - const response = await apiFetch(`/auth/me`, {}, { token: authState.accessToken }); - const data = await response.json(); - if (response.ok && data.user) { - // Merge auth info with profile data - const profile = data.user.profile || {}; - setCurrentUser({ - ...profile, - email: data.user.email, - auth_id: data.user.id, - id: profile.id || data.user.id - }); - } - } catch (error) { - console.error('Error fetching current user:', error); - } - } - }; - fetchCurrentUser(); - }, [authState, currentUser]); - - // Fetch join requests initially when group data is loaded (for badge count) - useEffect(() => { - if (group && (user || currentUser) && authState?.accessToken) { - fetchJoinRequests(); - } - }, [group, user, currentUser, authState]); - - const fetchGroupData = async () => { - setLoading(true); - try { - const validToken = await getValidToken(); - - // Fetch group details with members - const groupResponse = await apiFetch( - `/roommate-groups/${groupId}?include_members=true`, - {}, - { token: validToken } - ); - const groupData = await groupResponse.json(); - - if (groupResponse.ok && groupData.status === 'success') { - setGroup(groupData.data); - setMembers(groupData.data.members || []); - } - - // Fetch group->listing feed (rule-based ranking). - let listingsFeed = []; - try { - const fallbackResponse = await apiFetch( - `/roommate-groups/${groupId}/ranked-listings?limit=50`, - {}, - { token: validToken } - ); - const fallbackData = await fallbackResponse.json(); - if (fallbackResponse.ok && fallbackData.status === 'success') { - listingsFeed = fallbackData.ranked_listings || []; - } - } catch { - // Listing feed is non-critical; group overview still loads. - } - - setMatches(listingsFeed); - } catch (error) { - console.error('Error fetching group data:', error); - notifications.show({ - title: 'Error', - message: 'Failed to load group details', - color: 'red', - }); - } finally { - setLoading(false); - } - }; - - const fetchMemberSavedListings = async () => { - setLoadingLiked(true); - try { - const validToken = await getValidToken(); - const response = await apiFetch( - `/interactions/swipes/groups/${groupId}/liked?action=group_save`, - {}, - { token: validToken } - ); - const data = await response.json(); - if (response.ok && data.status === 'success') { - setMemberSavedListings(data.data || []); - } - } catch (error) { - console.error('Error fetching member saved listings:', error); - } finally { - setLoadingLiked(false); - } - }; - - const handleInvite = async () => { - if (!inviteEmail) { - notifications.show({ - title: 'Missing Email', - message: 'Please enter an email address', - color: 'orange', - }); - return; - } - - setInviting(true); - try { - const validToken = await getValidToken(); - const response = await apiFetch( - `/roommate-groups/${groupId}/invite`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_email: inviteEmail }), - }, - { token: validToken } - ); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Invitation Sent', - message: `Invitation sent to ${inviteEmail}`, - color: 'green', - icon: , - }); - setInviteModalOpen(false); - setInviteEmail(''); - fetchGroupData(); // Refresh to show pending invitation - } else { - throw new Error(data.detail || 'Failed to send invitation'); - } - } catch (error) { - console.error('Error sending invitation:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to send invitation', - color: 'red', - }); - } finally { - setInviting(false); - } - }; - - const fetchCompatibleUsers = async () => { - setLoadingCompatible(true); - try { - const validToken = await getValidToken(); - if (!validToken) { - throw new Error('Please log in to view compatible users'); - } - const response = await apiFetch(`/roommate-groups/${groupId}/compatible-users`, {}, { token: validToken }); - const data = await response.json(); - - if (response.ok && data.status === 'success') { - setCompatibleUsers(data.users || []); - } else { - throw new Error(data.detail || 'Failed to load compatible users'); - } - } catch (error) { - console.error('Error fetching compatible users:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to load compatible users', - color: 'red', - }); - } finally { - setLoadingCompatible(false); - } - }; - - const handleInviteUser = async (userId, userEmail) => { - setInvitingUserId(userId); - try { - const validToken = await getValidToken(); - const response = await apiFetch( - `/roommate-groups/${groupId}/invite`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_email: userEmail }), - }, - { token: validToken } - ); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Invitation Sent', - message: `Invitation sent to ${userEmail}`, - color: 'green', - icon: , - }); - // Remove user from compatible list - setCompatibleUsers(prev => prev.filter(u => u.id !== userId)); - fetchGroupData(); - } else { - throw new Error(data.detail || 'Failed to send invitation'); - } - } catch (error) { - console.error('Error sending invitation:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to send invitation', - color: 'red', - }); - } finally { - setInvitingUserId(null); - } - }; - - // Fetch compatible users when invite modal opens - useEffect(() => { - if (inviteModalOpen && inviteTab === 'compatible') { - fetchCompatibleUsers(); - } - }, [inviteModalOpen, inviteTab]); - - // Fetch join requests for group creators - const fetchJoinRequests = async () => { - setLoadingRequests(true); - try { - const validToken = await getValidToken(); - if (!validToken) { - throw new Error('Please log in to view join requests'); - } - const response = await apiFetch(`/roommate-groups/${groupId}/pending-requests`, {}, { token: validToken }); - const data = await response.json(); - - if (response.ok && data.status === 'success') { - setJoinRequests(data.requests || []); - } else { - // Not an error if user is not creator - just no requests - if (response.status !== 403) { - throw new Error(data.detail || 'Failed to load join requests'); - } - } - } catch (error) { - console.error('Error fetching join requests:', error); - // Don't show error notification for 403 (not creator) - } finally { - setLoadingRequests(false); - } - }; - - // Fetch join requests when viewing as creator - useEffect(() => { - if (group && user && activeTab === 'requests') { - fetchJoinRequests(); - } - }, [group, user, activeTab]); - - const handleAcceptRequest = async (userId, userName) => { - setProcessingRequestId(userId); - try { - const validToken = await getValidToken(); - const response = await apiFetch(`/roommate-groups/${groupId}/accept-request/${userId}`, { method: 'POST' }, { token: validToken }); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Request Accepted!', - message: `${userName || 'User'} is now a member of your group`, - color: 'green', - icon: , - }); - fetchJoinRequests(); - fetchGroupData(); - } else { - throw new Error(data.detail || 'Failed to accept request'); - } - } catch (error) { - console.error('Error accepting request:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to accept request', - color: 'red', - }); - } finally { - setProcessingRequestId(null); - } - }; - - const handleRejectRequest = async (userId, userName) => { - setProcessingRequestId(userId); - try { - const validToken = await getValidToken(); - const response = await apiFetch(`/roommate-groups/${groupId}/reject-request/${userId}`, { method: 'POST' }, { token: validToken }); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Request Rejected', - message: `Join request from ${userName || 'user'} has been declined`, - color: 'orange', - }); - fetchJoinRequests(); - } else { - throw new Error(data.detail || 'Failed to reject request'); - } - } catch (error) { - console.error('Error rejecting request:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to reject request', - color: 'red', - }); - } finally { - setProcessingRequestId(null); - } - }; - - const handleLeaveGroup = async () => { - const acceptedMembers = members.filter(m => m.status === 'accepted'); - const otherMembers = acceptedMembers.filter(m => m.user_email !== user?.email); - - let confirmMessage = 'Are you sure you want to leave this group?'; - if (isCreator) { - if (otherMembers.length > 0) { - confirmMessage = 'Are you sure you want to leave this group? Ownership will be transferred to another member.'; - } else { - confirmMessage = 'Are you sure you want to leave this group? Since you are the only member, the group will be deleted.'; - } - } - - if (!confirm(confirmMessage)) return; - - try { - const validToken = await getValidToken(); - const response = await apiFetch(`/roommate-groups/${groupId}/leave`, { method: 'DELETE' }, { token: validToken }); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Left Group', - message: data.message || 'You have left the group', - color: 'green', - }); - router.push('/groups'); - } else { - throw new Error(data.detail || 'Failed to leave group'); - } - } catch (error) { - console.error('Error leaving group:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to leave group', - color: 'red', - }); - } - }; - - const handleDeleteGroup = async () => { - if (!confirm('Are you sure you want to delete this group? This action cannot be undone.')) return; - - try { - const validToken = await getValidToken(); - const response = await apiFetch(`/roommate-groups/${groupId}`, { method: 'DELETE' }, { token: validToken }); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Group Deleted', - message: 'The group has been deleted', - color: 'green', - }); - router.push('/groups'); - } else { - throw new Error(data.detail || 'Failed to delete group'); - } - } catch (error) { - console.error('Error deleting group:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to delete group', - color: 'red', - }); - } - }; - - const handleRemoveMember = async (memberId) => { - if (!confirm('Are you sure you want to remove this member?')) return; - - try { - const validToken = await getValidToken(); - const response = await apiFetch(`/roommate-groups/${groupId}/members/${memberId}`, { method: 'DELETE' }, { token: validToken }); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - notifications.show({ - title: 'Member Removed', - message: 'Member has been removed from the group', - color: 'green', - }); - fetchGroupData(); - } else { - throw new Error(data.detail || 'Failed to remove member'); - } - } catch (error) { - console.error('Error removing member:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Failed to remove member', - color: 'red', - }); - } - }; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - if (!group) { - return ( - <> - - - } title="Group Not Found" color="red"> - The group you're looking for doesn't exist or has been deleted. - - - - - ); - } - - // Use currentUser if user from context is null - const activeUser = user || currentUser; - - // Compare by email since user.id is auth_id but members have app's user_id - // Check if current user is creator by finding the creator member - const creatorMember = members.find(m => m.is_creator); - const isCreator = activeUser && creatorMember && creatorMember.user_email === activeUser.email; - - // Check membership by email or user_id (auth_id) - const isMember = activeUser && members.some(m => - (m.user_email === activeUser.email || m.user_id === activeUser.id) && m.status === 'accepted' - ); - - // Debug logging - console.log('User check:', { - fullUser: activeUser, - userEmail: activeUser?.email, - userId: activeUser?.id, - members: members.map(m => ({ email: m.user_email, id: m.user_id, status: m.status })), - isMember, - isCreator - }); - - // Only count accepted members for display - const acceptedMembers = members.filter(m => m.status === 'accepted'); - const acceptedMemberCount = acceptedMembers.length; - - // Check if group is full (only if we have a valid target size) - const isGroupFull = group.target_group_size != null && acceptedMemberCount >= group.target_group_size; - - const statusColor = { - active: 'blue', - matched: 'green', - inactive: 'gray' - }[group.status] || 'gray'; +function GroupDetailPageInner() { + const vm = useGroupDetailPage(); + return ; +} +export default function GroupDetailPage() { return ( - <> - - - - {/* Header */} - - - - - - {isMember && ( - - - - - - - - {isCreator && ( - } - onClick={() => router.push(`/groups/${groupId}/edit`)} - > - Edit Group - - )} - } - color="red" - onClick={handleLeaveGroup} - > - Leave Group - - - - )} - - - {/* Full Group Banner */} - {isGroupFull && ( - } - title="This group is full" - color="red" - variant="filled" - > - This group has reached its maximum capacity of {group.target_group_size} members and is no longer accepting new members. - - )} - - {/* Group Header */} - - - -
- - {group.group_name} - - {group.status} - - - {group.description && ( - {group.description} - )} -
- - {isMember && ( - - )} -
- - - - - - - -
- Target City - {group.target_city} -
-
-
- - - - -
- Budget (per person) - - {group.budget_per_person_min && group.budget_per_person_max - ? `$${group.budget_per_person_min} - $${group.budget_per_person_max}` - : 'Not set'} - -
-
-
- - - - -
- Move-in Date - - {group.target_move_in_date - ? new Date(group.target_move_in_date).toLocaleDateString() - : 'Flexible'} - -
-
-
- - - - -
- Members - - {group.target_group_size != null - ? `${acceptedMemberCount} of ${group.target_group_size}` - : `${acceptedMemberCount}`} - - {group.target_group_size != null && ( - - {isGroupFull ? 'Group full' : `${group.target_group_size - acceptedMemberCount} spot${group.target_group_size - acceptedMemberCount === 1 ? '' : 's'} open`} - - )} -
-
-
-
-
-
- - {/* Tabs */} - - - }> - Members - - } - onClick={() => { if (!loadingLiked) fetchMemberSavedListings(); }} - > - Saved Listings - - {isCreator && ( - } - rightSection={ - joinRequests.length > 0 ? ( - - {joinRequests.length} - - ) : null - } - > - Join Requests - - )} - - - - - - - Group Members - {acceptedMemberCount} members - - - - - {acceptedMemberCount === 0 ? ( - - No members yet - - ) : ( - - {acceptedMembers.map((member) => ( - - - - - {member.user_name?.charAt(0) || 'U'} - -
- - {member.user_name || 'Unknown User'} - - - - {member.user_email} - - {member.is_creator && ( - Creator - )} - {member.status === 'pending' && ( - Pending - )} - -
-
- - {isCreator && !member.is_creator && ( - handleRemoveMember(member.user_id)} - > - - - )} -
-
- ))} -
- )} -
-
-
- - {/* Join Requests Tab (Creator Only) */} - {isCreator && ( - - - - -
- Join Requests - - People who want to join your group - -
- -
- - - - {loadingRequests ? ( - - {[1, 2, 3].map(i => ( - - ))} - - ) : joinRequests.length === 0 ? ( - - - - - - No pending join requests - - - When someone requests to join your group, they'll appear here. - - - ) : ( - - {joinRequests.map((request) => ( - - - - - - {request.full_name?.charAt(0) || request.email?.charAt(0) || 'U'} - -
- - {request.full_name || 'Unknown User'} - {request.verification_status === 'admin_verified' && ( - Verified - )} - {request.verification_status === 'email_verified' && ( - Email Verified - )} - - {request.email} - {request.company_name && ( - Works at {request.company_name} - )} - {request.school_name && ( - Studies at {request.school_name} - )} -
-
- - = 80 ? 'green' : - request.compatibility?.score >= 60 ? 'teal' : - request.compatibility?.score >= 40 ? 'yellow' : 'orange' - } - variant="light" - > - {Math.round(request.compatibility?.score || 0)}% Match - -
- - {/* User Preferences */} - {request.user_preferences && ( - - - {request.user_preferences.target_city && ( - - - {request.user_preferences.target_city} - - )} - {(request.user_preferences.budget_min || request.user_preferences.budget_max) && ( - - - - ${request.user_preferences.budget_min || 0} - ${request.user_preferences.budget_max || '∞'} - - - )} - {request.user_preferences.move_in_date && ( - - - - {new Date(request.user_preferences.move_in_date).toLocaleDateString()} - - - )} - - - )} - - {/* Compatibility Reasons */} - {request.compatibility?.reasons && request.compatibility.reasons.length > 0 && ( - - {request.compatibility.reasons.slice(0, 4).map((reason, idx) => ( - - {reason} - - ))} - - )} - - - Requested {request.requested_at ? new Date(request.requested_at).toLocaleDateString() : 'recently'} - - - {/* Action Buttons */} - - - - -
-
- ))} -
- )} -
-
-
- )} - - {/* Saved Listings Tab */} - - - -
- Saved Listings - Listings your group members bookmarked for the group -
- -
- - {loadingLiked ? ( - - {[1, 2, 3].map(i => )} - - ) : memberSavedListings.length === 0 ? ( - - - - - - No saved listings yet - - When group members save listings from Recommendations, they'll appear here. - - - - ) : ( - - {memberSavedListings.map((listing) => ( - router.push(`/listings/${listing.id}`)} - > - -
- - {listing.title?.includes('|') - ? listing.title.split('|')[0].trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) - : (listing.title || 'Listing')} - - - - - {listing.title?.includes('|') - ? listing.title.split('|')[1].trim() - : (listing.city || 'Location unavailable')} - - - - {listing.price_per_month && ( -
- Price - ${Number(listing.price_per_month).toLocaleString()}/mo -
- )} - {listing.number_of_bedrooms != null && ( -
- Beds - {listing.number_of_bedrooms === 0 ? 'Studio' : listing.number_of_bedrooms} -
- )} - {listing.number_of_bathrooms != null && ( -
- Baths - {listing.number_of_bathrooms} -
- )} -
-
- - Saved by - {(listing.liked_by || []).map((name) => ( - {name} - ))} - -
-
- ))} -
- )} -
-
-
-
- - {/* Invite Modal */} - { - setInviteModalOpen(false); - setInviteEmail(''); - setInviteTab('compatible'); - }} - title={ - - - Invite Members - - } - size="lg" - > - - - }> - Compatible Users - - }> - Invite by Email - - - - - - - These users match your group's hard constraints (city, budget, move-in date). - Hover over each user to see their preferences. - - - {loadingCompatible ? ( - - {[1, 2, 3].map(i => ( - - ))} - - ) : compatibleUsers.length === 0 ? ( - - - - - - - No compatible users found at this time. - - - Try adjusting your group's preferences or check back later. - - - - ) : ( - - - {compatibleUsers.map((user) => ( - - Preferences - - - - - City: {user.preferences?.target_city || 'Any'} - - - - - - Budget: ${user.preferences?.budget_min || 0} - ${user.preferences?.budget_max || '∞'} - - - - - - - Move-in: {user.preferences?.move_in_date - ? new Date(user.preferences.move_in_date).toLocaleDateString() - : 'Flexible'} - - - - {user.preferences?.lifestyle_preferences && Object.keys(user.preferences.lifestyle_preferences).length > 0 && ( - <> - - - {user.preferences.lifestyle_preferences.sleep_time && ( - - - - Sleep: {user.preferences.lifestyle_preferences.sleep_time} - - - )} - - {user.preferences.lifestyle_preferences.noise_level && ( - - - - Noise: {user.preferences.lifestyle_preferences.noise_level} - - - )} - - {user.preferences.lifestyle_preferences.smoking !== undefined && ( - - - - Smoking: {user.preferences.lifestyle_preferences.smoking ? 'Yes' : 'No'} - - - )} - - {user.preferences.lifestyle_preferences.pets !== undefined && ( - - - - Pets: {user.preferences.lifestyle_preferences.pets ? 'Yes' : 'No'} - - - )} - - {user.preferences.lifestyle_preferences.guests && ( - - - - Guests: {user.preferences.lifestyle_preferences.guests} - - - )} - - )} - - - - Compatibility Score: - = 80 ? 'green' : user.compatibility_score >= 50 ? 'yellow' : 'red'} - style={{ flex: 1 }} - /> - {Math.round(user.compatibility_score)}% - - - } - > - - - - - {user.full_name?.charAt(0) || user.email?.charAt(0) || 'U'} - -
- {user.full_name || 'User'} - {user.email} -
-
- - = 80 ? 'green' : user.compatibility_score >= 50 ? 'yellow' : 'orange'} - variant="light" - > - {Math.round(user.compatibility_score)}% match - - - -
-
- - ))} -
- - )} - - - -
- - - - - Enter the email address of someone you'd like to invite to your group. - - } - value={inviteEmail} - onChange={(e) => setInviteEmail(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleInvite()} - /> - - - -
-
-
- +
); } diff --git a/frontend/src/app/matches/page.jsx b/frontend/src/app/matches/page.jsx index 39b8dcf..1c93eb5 100644 --- a/frontend/src/app/matches/page.jsx +++ b/frontend/src/app/matches/page.jsx @@ -1,1031 +1,18 @@ 'use client'; -const MATCHES_SCROLL_TRIGGER_PX = 900; -const MATCHES_TOP_RETURN_PX = 120; -// How long a cached recommendations result stays fresh in localStorage. -// Matches the React Query staleTime so the two layers agree on freshness. -const FEED_CACHE_TTL_MS = 5 * 60 * 1000; -// Cooldown a user must wait before manually retrying after an error (seconds). -const RETRY_COOLDOWN_SECONDS = 60; - -import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Container, Title, Text, Grid, Card, Badge, Button, Stack, Box, ThemeIcon, Group } from '@mantine/core'; -import { SkeletonListingCard } from '../components/Skeletons'; -import { IconSparkles, IconMapPin, IconRefresh } from '@tabler/icons-react'; -import { useRouter } from 'next/navigation'; -import { Navigation } from '../components/Navigation'; import { ProtectedRoute } from '../components/ProtectedRoute'; -import { ImageWithFallback } from '../components/ImageWithFallback'; -import { useAuth } from '../contexts/AuthContext'; -import { usePageTracking } from '../hooks/usePageTracking'; -import { getLikedListings } from '../discover/likedListings'; -import { formatAmenityLabel, getActiveAmenityKeys } from '../../../lib/formatters'; -import { - createAppError, - hasCompleteCorePreferences, - normalizeRecommendationsError, - parseApiErrorResponse, -} from '../../../lib/errorHandling'; -import { - MATCHES_FEEDBACK_CHOICES, - MATCHES_NEGATIVE_REASON_CHOICES, - createRecommendationClientSessionId, - createRecommendationEventId, -} from '../../../lib/recommendationFeedback'; -import { apiFetch } from '../../../lib/api'; +import { useMatchesPage } from '../../features/matches/hooks/useMatchesPage'; +import { MatchesPageView } from '../../features/matches/components/MatchesPageView'; + +function MatchesPageInner() { + const vm = useMatchesPage(); + return ; +} export default function MatchesPage() { return ( - + ); } - -function parseListingTitle(title) { - if (!title) return { street: '', location: '' }; - const parts = title.split('|'); - if (parts.length >= 2) { - return { street: parts[0].trim(), location: parts.slice(1).join(' ').trim() }; - } - return { street: title.trim(), location: '' }; -} - -function MatchesPageContent() { - const router = useRouter(); - const { user, getValidToken, authState } = useAuth(); - const userId = user?.profile?.id; - - // Keep feed cache keyed to current preference location so changing - // preferences forces a fresh recommendations fetch. - const { data: prefsData } = useQuery({ - queryKey: ['user-prefs', userId], - queryFn: async () => { - const token = await getValidToken(); - if (!token || !userId) return null; - const res = await apiFetch(`/preferences/${userId}`, {}, { token }); - if (!res.ok) return null; - const data = await res.json(); - return data?.data || data || null; - }, - enabled: !!userId, - staleTime: 0, - }); - - const prefCountry = prefsData?.target_country ?? null; - const prefState = prefsData?.target_state_province ?? null; - const prefCity = prefsData?.target_city ?? null; - const preferenceLocationKey = `${prefCountry || ''}|${prefState || ''}|${prefCity || ''}`; - - const clientSessionIdRef = useRef(null); - const surfaceStartedAtRef = useRef(Date.now()); - const promptShownRef = useRef(false); - const hasScrolledDeepRef = useRef(false); - const sessionMetricsRef = useRef({ detailOpensCount: 0, savesCount: 0 }); - const latestSessionIdRef = useRef(null); - const latestTokenRef = useRef(null); - const latestListingsRef = useRef([]); - const latestRankingContextRef = useRef(null); - - usePageTracking('matches', authState?.accessToken); - - const [listings, setListings] = useState([]); - const [missingCorePreferences, setMissingCorePreferences] = useState(false); - const [targetStateFallback, setTargetStateFallback] = useState(null); - const [rankingContext, setRankingContext] = useState(null); - const [feedbackSessionId, setFeedbackSessionId] = useState(null); - const [promptAllowed, setPromptAllowed] = useState(false); - const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); - const [feedbackStep, setFeedbackStep] = useState('question'); - const [pendingFeedbackLabel, setPendingFeedbackLabel] = useState(null); - const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); - const [feedbackAcknowledged, setFeedbackAcknowledged] = useState(false); - // Countdown seconds remaining before the user can manually retry after an error. - const [retrySecondsLeft, setRetrySecondsLeft] = useState(0); - // True when preferences or discover likes have changed since the last fetch. - const [hasStaleChanges, setHasStaleChanges] = useState(false); - // Tracks the last feedData object seen so we only sync on a genuine new result - const prevFeedDataRef = useRef(null); - - if (!clientSessionIdRef.current && typeof window !== 'undefined') { - clientSessionIdRef.current = createRecommendationClientSessionId('matches'); - } - - const deriveRankingContext = useCallback((payload) => { - const responseContext = payload?.ranking_context; - if (responseContext) return responseContext; - - const recommendations = payload?.recommendations || []; - const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); - return { - algorithm_version: recommendations[0]?.algorithm_version ?? null, - model_version: hasMlScores ? 'recommender-v1' : null, - experiment_name: recommendations.length > 0 ? 'matches_ranker_v1' : null, - experiment_variant: recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null, - }; - }, []); - - const buildSessionPayload = useCallback((recommendations = listings, context = rankingContext) => { - if (!clientSessionIdRef.current) { - clientSessionIdRef.current = createRecommendationClientSessionId('matches'); - } - - const topListingIds = recommendations - .map((listing) => listing?.listing_id) - .filter(Boolean) - .slice(0, 20); - const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); - - return { - client_session_id: clientSessionIdRef.current, - surface: 'matches', - recommendation_count_shown: recommendations.length, - top_listing_ids_shown: topListingIds, - algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? null, - model_version: context?.model_version ?? (hasMlScores ? 'recommender-v1' : null), - experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'matches_ranker_v1' : null), - experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null), - }; - }, [listings, rankingContext]); - - const patchRecommendationSession = useCallback(async (payload, { keepalive = false } = {}) => { - if (!authState?.accessToken || !feedbackSessionId) return null; - - try { - const response = await apiFetch( - `/interactions/recommendation-sessions/${feedbackSessionId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - keepalive, - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to update recommendation session'); - return null; - } - - if (response.status === 503) return null; - const result = await response.json(); - return result?.data ?? null; - } catch { - return null; - } - }, [authState?.accessToken, feedbackSessionId]); - - const findListingPosition = useCallback((listingId) => { - if (!listingId) return null; - const index = listings.findIndex((item) => item?.listing_id === listingId); - return index >= 0 ? index : null; - }, [listings]); - - const persistRecommendationEvent = useCallback(async ({ - eventType, - listingId = null, - positionInFeed = null, - dwellMs = null, - metadata = {}, - keepalive = false, - }) => { - if (!authState?.accessToken || !feedbackSessionId) return null; - - try { - const response = await apiFetch( - `/interactions/recommendation-events`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - client_event_id: createRecommendationEventId(eventType), - surface: 'matches', - event_type: eventType, - listing_id: listingId, - position_in_feed: positionInFeed, - dwell_ms: dwellMs, - metadata, - }), - keepalive, - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to persist recommendation engagement event'); - } - } catch { - // Best-effort only. - } - }, [authState?.accessToken, feedbackSessionId]); - - // ── cached recommendations (5-min stale time, shared QueryClient) ───────── - - const fetchMatchesFeed = useCallback(async () => { - const token = await getValidToken(); - if (!token) throw new Error('Not authenticated'); - - // Serve from localStorage when the cached result is still fresh. - // Key is scoped to the user + their current preference location so any - // location change automatically bypasses the cache. - const lsCacheKey = `padly_rec_${userId}_${preferenceLocationKey}`; - if (typeof window !== 'undefined' && preferenceLocationKey !== '||') { - try { - const raw = localStorage.getItem(lsCacheKey); - if (raw) { - const cached = JSON.parse(raw); - if (cached?.ts && Date.now() - cached.ts < FEED_CACHE_TTL_MS && cached.result) { - return cached.result; - } - } - } catch { - // localStorage unavailable or corrupt — continue with network fetch. - } - } - - let prefs = {}; - let hasCorePreferences = false; - let behaviorSampleSize; - const liked = getLikedListings(); - const likedExtras = {}; - - const prefRes = await apiFetch(`/preferences/${userId}`, {}, { token }); - if (prefRes.ok) { - const prefData = await prefRes.json(); - prefs = prefData.data || prefData || {}; - hasCorePreferences = hasCompleteCorePreferences(prefs); - } - - if (!hasCorePreferences) { - return { listings: [], missingCorePreferences: true, targetState: prefs.target_state_province ?? null }; - } - - try { - const behaviorRes = await apiFetch(`/interactions/behavior/me?days=180`, {}, { token }); - if (behaviorRes.ok) { - const behaviorPayload = await behaviorRes.json(); - const behavior = behaviorPayload?.data || {}; - if (behavior.liked_mean_price != null) likedExtras.liked_mean_price = behavior.liked_mean_price; - if (behavior.liked_mean_beds != null) likedExtras.liked_mean_beds = behavior.liked_mean_beds; - if (behavior.liked_mean_sqfeet != null) likedExtras.liked_mean_sqfeet = behavior.liked_mean_sqfeet; - if (behavior.sample_size != null) behaviorSampleSize = behavior.sample_size; - } - } catch { - // Behavior vector is optional. - } - - if (liked.length > 0) { - const avg = (arr) => arr.filter(Boolean).reduce((a, b) => a + b, 0) / arr.filter(Boolean).length; - if (likedExtras.liked_mean_price == null) likedExtras.liked_mean_price = avg(liked.map((l) => l.price_per_month)); - if (likedExtras.liked_mean_beds == null) likedExtras.liked_mean_beds = avg(liked.map((l) => l.number_of_bedrooms)); - if (likedExtras.liked_mean_sqfeet == null) likedExtras.liked_mean_sqfeet = avg(liked.map((l) => l.area_sqft)); - } - - const body = { - budget_min: prefs.budget_min ?? undefined, - budget_max: prefs.budget_max ?? undefined, - target_country: prefs.target_country ?? undefined, - target_state_province: prefs.target_state_province ?? undefined, - target_city: prefs.target_city ?? undefined, - required_bedrooms: prefs.required_bedrooms ?? undefined, - target_bathrooms: prefs.target_bathrooms ?? undefined, - desired_beds: prefs.required_bedrooms ?? undefined, - desired_baths: prefs.target_bathrooms ?? undefined, - target_deposit_amount: prefs.target_deposit_amount ?? undefined, - furnished_preference: prefs.furnished_preference ?? undefined, - gender_policy: prefs.gender_policy ?? undefined, - target_lease_type: prefs.target_lease_type ?? undefined, - target_lease_duration_months: prefs.target_lease_duration_months ?? undefined, - move_in_date: prefs.move_in_date ?? undefined, - target_furnished: prefs.target_furnished ?? undefined, - wants_furnished: - prefs.furnished_preference === 'required' || prefs.furnished_preference === 'preferred' - ? 1 - : prefs.target_furnished === true - ? 1 - : undefined, - pref_lat: prefs.target_latitude ?? undefined, - pref_lon: prefs.target_longitude ?? undefined, - top_n: 100, - offset: 0, - behavior_sample_size: behaviorSampleSize, - ...likedExtras, - }; - - const res = await apiFetch( - `/recommendations`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - { token } - ); - - if (!res.ok) { - const apiError = await parseApiErrorResponse(res, 'Failed to fetch recommendations'); - throw createAppError(apiError.message, { - status: apiError.status, - payload: apiError.payload, - rawMessage: apiError.message, - }); - } - - const data = await res.json(); - const result = { - listings: data.recommendations || [], - missingCorePreferences: false, - targetState: prefs.target_state_province ?? null, - rankingContext: deriveRankingContext(data), - }; - - // Persist to localStorage so hard reloads within the TTL skip the API. - if (typeof window !== 'undefined' && preferenceLocationKey !== '||') { - try { - localStorage.setItem( - `padly_rec_${userId}_${preferenceLocationKey}`, - JSON.stringify({ ts: Date.now(), result }), - ); - } catch { - // Storage full or unavailable — safe to ignore. - } - } - - return result; - }, [deriveRankingContext, getValidToken, preferenceLocationKey, userId]); - - const { - data: feedData, - isLoading: feedLoading, - error: feedQueryError, - refetch: refetchFeed, - } = useQuery({ - queryKey: ['matches-feed', userId, preferenceLocationKey], - queryFn: fetchMatchesFeed, - enabled: !!userId, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - retry: false, - }); - - // Reset session state synchronously when the preference location changes so - // the feedData sync below (also useLayoutEffect) can re-apply cache in the - // same commit phase — preventing the old useEffect from wiping listings - // after the layout effect had already restored them from the RQ cache. - useLayoutEffect(() => { - prevFeedDataRef.current = null; - setListings([]); - setMissingCorePreferences(false); - setTargetStateFallback(null); - setRankingContext(null); - setFeedbackSessionId(null); - setPromptAllowed(false); - setShowFeedbackPrompt(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - setFeedbackAcknowledged(false); - sessionMetricsRef.current = { detailOpensCount: 0, savesCount: 0 }; - surfaceStartedAtRef.current = Date.now(); - clientSessionIdRef.current = createRecommendationClientSessionId('matches'); - promptShownRef.current = false; - hasScrolledDeepRef.current = false; - }, [preferenceLocationKey]); - - // Sync query data → state without a visible flash on cache-hit remounts - useLayoutEffect(() => { - if (!feedData || feedData === prevFeedDataRef.current) return; - prevFeedDataRef.current = feedData; - setListings(feedData.listings); - setMissingCorePreferences(feedData.missingCorePreferences); - setTargetStateFallback(feedData.targetState ?? null); - setRankingContext(feedData.rankingContext ?? null); - setFeedbackSessionId(null); - setPromptAllowed(false); - setShowFeedbackPrompt(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - setFeedbackAcknowledged(false); - sessionMetricsRef.current = { detailOpensCount: 0, savesCount: 0 }; - surfaceStartedAtRef.current = Date.now(); - clientSessionIdRef.current = createRecommendationClientSessionId('matches'); - promptShownRef.current = false; - hasScrolledDeepRef.current = false; - }, [feedData]); - - const loading = feedLoading && !feedData; - const error = feedQueryError ? normalizeRecommendationsError(feedQueryError) : null; - - // Decrement retry countdown once per second until it reaches zero. - useEffect(() => { - if (retrySecondsLeft <= 0) return; - const id = setTimeout(() => setRetrySecondsLeft((s) => Math.max(0, s - 1)), 1000); - return () => clearTimeout(id); - }, [retrySecondsLeft]); - - const handleRetry = useCallback(() => { - setRetrySecondsLeft(RETRY_COOLDOWN_SECONDS); - refetchFeed(); - }, [refetchFeed]); - - // Returns true when preferences or discover likes have changed since the - // last successful recommendations fetch for the current user + location. - const checkStaleChanges = useCallback(() => { - if (typeof window === 'undefined' || !userId || preferenceLocationKey === '||') return false; - try { - const staleAt = Number(localStorage.getItem(`padly_matches_stale_at_${userId}`) ?? 0); - if (!staleAt) return false; - const cacheRaw = localStorage.getItem(`padly_rec_${userId}_${preferenceLocationKey}`); - const lastFetchTs = cacheRaw ? (JSON.parse(cacheRaw)?.ts ?? 0) : 0; - return staleAt > lastFetchTs; - } catch { - return false; - } - }, [userId, preferenceLocationKey]); - - // Check for stale changes on mount and whenever the user returns to the tab. - useEffect(() => { - setHasStaleChanges(checkStaleChanges()); - }, [checkStaleChanges]); - - useEffect(() => { - const onVisibility = () => { - if (document.visibilityState === 'visible') { - setHasStaleChanges(checkStaleChanges()); - } - }; - document.addEventListener('visibilitychange', onVisibility); - return () => document.removeEventListener('visibilitychange', onVisibility); - }, [checkStaleChanges]); - - const handleReloadMatches = useCallback(() => { - // Clear the stale signal and the localStorage cache so fetchMatchesFeed - // hits the API instead of returning the cached result. - if (typeof window !== 'undefined' && userId) { - try { - localStorage.removeItem(`padly_matches_stale_at_${userId}`); - localStorage.removeItem(`padly_rec_${userId}_${preferenceLocationKey}`); - } catch { - // Ignore storage errors. - } - } - setHasStaleChanges(false); - refetchFeed(); - }, [preferenceLocationKey, refetchFeed, userId]); - - useEffect(() => { - latestTokenRef.current = getValidToken; - }, [getValidToken]); - - useEffect(() => { - latestSessionIdRef.current = feedbackSessionId; - }, [feedbackSessionId]); - - useEffect(() => { - latestListingsRef.current = listings; - }, [listings]); - - useEffect(() => { - latestRankingContextRef.current = rankingContext; - }, [rankingContext]); - - useEffect(() => { - if (!authState?.accessToken || !userId || loading || error || listings.length === 0) return; - - let cancelled = false; - - const ensureRecommendationSession = async () => { - try { - const response = await apiFetch( - `/interactions/recommendation-sessions`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildSessionPayload(listings, rankingContext)), - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to create recommendation session'); - return; - } - - if (response.status === 503) return; - - const result = await response.json(); - if (cancelled) return; - - setFeedbackSessionId(result?.data?.id ?? null); - setPromptAllowed(Boolean(result?.prompt_allowed)); - } catch { - // Best-effort only. - } - }; - - void ensureRecommendationSession(); - - return () => { - cancelled = true; - }; - }, [authState?.accessToken, buildSessionPayload, error, listings, loading, rankingContext, userId]); - - useEffect(() => { - if ( - !feedbackSessionId || - !promptAllowed || - feedbackSubmitting || - loading || - error || - listings.length === 0 - ) { - return undefined; - } - - const handleScroll = () => { - const scrollY = window.scrollY || window.pageYOffset || 0; - - if (scrollY >= MATCHES_SCROLL_TRIGGER_PX) { - hasScrolledDeepRef.current = true; - } - - if ( - hasScrolledDeepRef.current && - scrollY <= MATCHES_TOP_RETURN_PX && - !promptShownRef.current && - !showFeedbackPrompt - ) { - promptShownRef.current = true; - setShowFeedbackPrompt(true); - void patchRecommendationSession({ - prompt_presented: true, - detail_opens_count: sessionMetricsRef.current.detailOpensCount, - saves_count: sessionMetricsRef.current.savesCount, - recommendation_count_shown: listings.length, - top_listing_ids_shown: listings.map((listing) => listing?.listing_id).filter(Boolean).slice(0, 20), - surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), - algorithm_version: rankingContext?.algorithm_version ?? listings[0]?.algorithm_version ?? null, - model_version: rankingContext?.model_version ?? (listings.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), - experiment_name: rankingContext?.experiment_name ?? (listings.length > 0 ? 'matches_ranker_v1' : null), - experiment_variant: rankingContext?.experiment_variant ?? (listings.length > 0 ? (listings.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), - }); - } - }; - - window.addEventListener('scroll', handleScroll, { passive: true }); - handleScroll(); - - return () => { - window.removeEventListener('scroll', handleScroll); - }; - }, [ - error, - feedbackSessionId, - feedbackSubmitting, - listings, - loading, - patchRecommendationSession, - promptAllowed, - rankingContext, - showFeedbackPrompt, - ]); - - useEffect(() => { - return () => { - const getToken = latestTokenRef.current; - const sessionId = latestSessionIdRef.current; - if (!getToken || !sessionId) return; - - const recommendations = latestListingsRef.current || []; - const context = latestRankingContextRef.current; - const topListingIds = recommendations - .map((listing) => listing?.listing_id) - .filter(Boolean) - .slice(0, 20); - const dwellMs = Math.max(0, Date.now() - surfaceStartedAtRef.current); - const metrics = { ...sessionMetricsRef.current }; - - getToken().then((token) => { - if (!token) return; - apiFetch( - `/interactions/recommendation-sessions/${sessionId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mark_ended: true, - surface_dwell_ms: dwellMs, - detail_opens_count: metrics.detailOpensCount, - saves_count: metrics.savesCount, - recommendation_count_shown: recommendations.length, - top_listing_ids_shown: topListingIds, - algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? null, - model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), - experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'matches_ranker_v1' : null), - experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), - }), - keepalive: true, - }, - { token } - ).catch(() => {}); - }).catch(() => {}); - }; - }, []); - - const submitFeedback = useCallback(async ({ feedbackLabel, reasonLabel = null }) => { - if (!authState?.accessToken || !feedbackSessionId || feedbackSubmitting) return; - - setFeedbackSubmitting(true); - try { - const response = await apiFetch( - `/interactions/recommendation-feedback`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recommendation_session_id: feedbackSessionId, - feedback_label: feedbackLabel, - reason_label: reasonLabel, - }), - }, - { token: authState.accessToken } - ); - - if (!response.ok && response.status !== 503) { - console.warn('Failed to submit recommendation feedback'); - return; - } - - setShowFeedbackPrompt(false); - setPromptAllowed(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - setFeedbackAcknowledged(true); - } catch { - // Best-effort only. - } finally { - setFeedbackSubmitting(false); - } - }, [authState?.accessToken, feedbackSessionId, feedbackSubmitting]); - - const dismissFeedbackPrompt = useCallback(async () => { - await patchRecommendationSession({ - prompt_dismissed: true, - detail_opens_count: sessionMetricsRef.current.detailOpensCount, - saves_count: sessionMetricsRef.current.savesCount, - surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), - }); - setShowFeedbackPrompt(false); - setPromptAllowed(false); - setFeedbackStep('question'); - setPendingFeedbackLabel(null); - setFeedbackAcknowledged(false); - }, [patchRecommendationSession]); - - const handleFeedbackChoice = useCallback(async (value) => { - if (value === 'not_useful') { - setPendingFeedbackLabel(value); - setFeedbackStep('reason'); - return; - } - - await submitFeedback({ feedbackLabel: value }); - }, [submitFeedback]); - - const handleNegativeReason = useCallback(async (reasonLabel) => { - await submitFeedback({ - feedbackLabel: pendingFeedbackLabel || 'not_useful', - reasonLabel, - }); - }, [pendingFeedbackLabel, submitFeedback]); - - const handleViewDetails = useCallback((listing) => { - const position = findListingPosition(listing?.listing_id); - sessionMetricsRef.current.detailOpensCount += 1; - void persistRecommendationEvent({ - eventType: 'detail_open', - listingId: listing?.listing_id, - positionInFeed: position, - metadata: { - match_percent: listing?.match_percent ?? null, - }, - keepalive: true, - }); - - const href = feedbackSessionId - ? `/listings/${listing.listing_id}?source=matches&recommendationSessionId=${encodeURIComponent(feedbackSessionId)}${position != null ? `&position=${position}` : ''}` - : `/listings/${listing.listing_id}?source=matches${position != null ? `&position=${position}` : ''}`; - - router.push(href); - }, [feedbackSessionId, findListingPosition, persistRecommendationEvent, router]); - - return ( - - - - - - - Recommendations - - - Your top listings, ranked by preferences and activity - - {feedbackAcknowledged && ( - - Thanks. Your feedback was saved for recommendation evaluation. - - )} - {hasStaleChanges && !loading && ( - - )} - {!loading && !error && listings.length > 0 && ( - - {listings.length} listings found - - )} - - - {loading && ( - - {Array.from({ length: 6 }).map((_, i) => ( - - - - ))} - - )} - - {!loading && error && ( - - {error} - - - )} - - {!loading && !error && listings.length === 0 && ( - - - - - - - {missingCorePreferences ? 'Complete your preferences to get recommendations' : 'No recommendations yet'} - - - {missingCorePreferences - ? 'Set your country, state/province, and city to start receiving personalised listings.' - : 'Update your preferences or broaden a constraint to surface more listings.'} - - - - - - - - )} - - {!loading && !error && listings.length > 0 && ( - - {showFeedbackPrompt && ( - - - {feedbackStep === 'question' ? ( - <> - - - How useful were these recommendations? - - - Your feedback helps us improve how listings are ranked. - - - - {MATCHES_FEEDBACK_CHOICES.map((choice) => ( - - ))} - - - - ) : ( - <> - - - What felt off? - - - Optional - - - - {MATCHES_NEGATIVE_REASON_CHOICES.map((choice) => ( - - ))} - - - - )} - - - )} - - - {listings.map((listing) => { - const image = - listing.images?.[0] || - 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080'; - - const { street, location } = parseListingTitle(listing.title); - const cityState = [listing.city, listing.state_province || listing.state || targetStateFallback] - .filter(Boolean) - .join(', '); - const locationText = cityState || location; - const amenityBadges = getActiveAmenityKeys(listing.amenities).slice(0, 2); - - return ( - - handleViewDetails(listing)} - > - - - - - {listing.match_percent && ( - - {listing.match_percent} match - - )} - - - - - - {street || listing.title} - - {locationText && ( - - - - {locationText} - - - )} - - - - {[ - listing.number_of_bedrooms != null && (listing.number_of_bedrooms === 0 ? 'Studio' : `${listing.number_of_bedrooms} Bed`), - listing.number_of_bathrooms != null && `${listing.number_of_bathrooms} Bath`, - listing.area_sqft && `${Number(listing.area_sqft).toLocaleString()} sq ft`, - ].filter(Boolean).join(' · ')} - - - {(listing.furnished || amenityBadges.length > 0) && ( - - {listing.furnished && ( - Furnished - )} - {amenityBadges.map((key) => ( - - {formatAmenityLabel(key)} - - ))} - - )} - - {listing.price_per_month && ( - - ${Number(listing.price_per_month).toLocaleString()}/mo - - )} - - - - - - - - ); - })} - - - )} - - - ); -} diff --git a/frontend/src/features/discover/components/DiscoverPageView.jsx b/frontend/src/features/discover/components/DiscoverPageView.jsx new file mode 100644 index 0000000..ead85ec --- /dev/null +++ b/frontend/src/features/discover/components/DiscoverPageView.jsx @@ -0,0 +1,854 @@ +'use client'; + +import { + Container, + Box, + Text, + Title, + Button, + Stack, + ActionIcon, + Group, + Progress, + Modal, + Badge, + Divider, + ThemeIcon, +} from '@mantine/core'; +import { IconX, IconHeart, IconRefresh, IconInfoCircle, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import { ImageWithFallback } from '../../../app/components/ImageWithFallback'; +import { Navigation } from '../../../app/components/Navigation'; +import { SkeletonSwipeCard } from '../../../app/components/Skeletons'; +import { SwipeCard } from '../../../app/components/SwipeCard'; +import { + MATCHES_FEEDBACK_CHOICES, + MATCHES_NEGATIVE_REASON_CHOICES, +} from '../../../../lib/recommendationFeedback'; +import { GROUPS_FEATURE_ENABLED } from '../../../../lib/featureFlags'; +import { formatAmenityLabel, formatEnumLabel, getActiveAmenityKeys, parseListingTitle } from '../../../../lib/formatters'; + +export function DiscoverPageView(props) { + const { + router, + feedbackAcknowledged, + loading, + isDone, + noRecommendations, + remaining, + isGuest, + guestCity, + guestNudgeShown, + setGuestNudgeShown, + showFeedbackPrompt, + feedbackStep, + feedbackSubmitting, + handleFeedbackChoice, + dismissFeedbackPrompt, + handleNegativeReason, + submitFeedback, + pendingFeedbackLabel, + handleDiscoverFeedReload, + error, + emptyResultReason, + missingCorePreferences, + listings, + currentIndex, + handleSwipe, + openExpanded, + cardPhotoCountRef, + handleButton, + expandedListing, + closeExpanded, + expandedImageIndex, + setExpandedImageIndex, + setFullscreenOpen, + fullscreenOpen, + handleModalAction, + showGuestSignupModal, + setShowGuestSignupModal, + setCurrentIndex, + logGuestEvent, + pendingGuestLike, + setPendingGuestLike, + } = props; + + return ( + + + + + {/* Header */} + + + Discover + + {feedbackAcknowledged && ( + + Thanks. Your feedback was saved for recommendation evaluation. + + )} + {!loading && !isDone && !noRecommendations && ( + + {remaining} listing{remaining !== 1 ? 's' : ''} left + + )} + {isGuest && !guestCity && ( + + )} + + + {/* Guest nudge banner — shown after 5 swipes */} + {isGuest && guestNudgeShown && ( + + + Sign up to save your liked listings and get unlimited access. + + + + + + + )} + + {showFeedbackPrompt && ( + + + + {feedbackStep === 'question' ? ( + <> + + + How useful were these recommendations? + + + Your feedback helps us improve how listings are ranked. + + + + {MATCHES_FEEDBACK_CHOICES.map((choice) => ( + + ))} + + + + ) : ( + <> + + + What felt off? + + + Optional + + + + {MATCHES_NEGATIVE_REASON_CHOICES.map((choice) => ( + + ))} + + + + )} + + + + )} + + {/* Content */} + + + {/* Loading */} + {loading && } + + {/* Error */} + {!loading && error && ( + + {error} + + + )} + + {/* No recommendations */} + {noRecommendations && ( + + + + + + {emptyResultReason === 'missing_preferences' + ? 'Complete your preferences' + : 'No listings match right now'} + + + {emptyResultReason === 'missing_preferences' + ? 'Set your country, state/province, and city to get location-aware recommendations.' + : 'Try broadening one hard constraint like budget, room preference, or move-in date.'} + + + + + + {missingCorePreferences && ( + + Listing ranking improves once your core location constraints are set. + + )} + + )} + + {/* Done */} + {isDone && ( + + 🏠 + You've seen everything! + + Check your liked listings in Matches, or reload for a fresh batch. + + + + + {GROUPS_FEATURE_ENABLED && ( + + )} + + + )} + + {/* Card stack + buttons */} + {!loading && !error && !isDone && !noRecommendations && ( + <> + + + Listing {currentIndex + 1} of {listings.length} + {listings.length - currentIndex - 1} remaining + + 0 ? ((currentIndex) / listings.length) * 100 : 0} + size="xs" + color="teal" + radius="xl" + /> + + + + {[2, 1, 0].map((offset) => { + const idx = currentIndex + offset; + if (idx >= listings.length) return null; + return ( + { cardPhotoCountRef.current += 1; } : undefined} + /> + ); + })} + + + {/* Action buttons */} + + + handleButton('left')} + style={{ boxShadow: '0 4px 20px rgba(255,107,107,0.20)', border: '2px solid #ffc9c9' }} + > + + + Pass + + + + handleButton('right')} + style={{ boxShadow: '0 4px 20px rgba(32,201,151,0.22)', border: '2px solid #96f2d7' }} + > + + + Like + + + ← Pass · → Like + + )} + + + + + {/* Quick-view modal */} + + {expandedListing && (() => { + const imgs = (() => { + const i = expandedListing?.images; + if (Array.isArray(i)) return i; + if (typeof i === 'string') { try { return JSON.parse(i); } catch { return []; } } + return []; + })(); + const safeImageIndex = imgs.length > 0 + ? Math.min(expandedImageIndex, imgs.length - 1) + : 0; + const heroImage = imgs[safeImageIndex] || 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080'; + + const { street: modalStreet, location: modalLocation } = parseListingTitle(expandedListing.title); + const modalCity = expandedListing.city || ''; + const modalDisplayStreet = modalCity + ? modalStreet.replace( + new RegExp(`[,\\s–-]*${modalCity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i'), + '' + ).trim() || modalStreet + : modalStreet; + const modalDisplayLocation = modalLocation || modalCity; + + const amenityKeys = getActiveAmenityKeys(expandedListing.amenities); + + const bedsLabel = expandedListing.number_of_bedrooms === 0 + ? 'Studio' + : expandedListing.number_of_bedrooms != null + ? String(expandedListing.number_of_bedrooms) + : '—'; + + return ( + + {/* Hero image */} + + setFullscreenOpen(true)} + style={{ width: '100%', height: '100%', cursor: 'zoom-in' }} + > + + + + {imgs.length > 1 && ( + <> + + setExpandedImageIndex((prev) => (prev - 1 + imgs.length) % imgs.length)} + style={{ opacity: 0.85 }} + > + + + + + setExpandedImageIndex((prev) => (prev + 1) % imgs.length)} + style={{ opacity: 0.85 }} + > + + + + + )} + + {/* Close button */} + + + + + + + {/* Match badge */} + {expandedListing.match_percent && ( + + {expandedListing.match_percent} match + + )} + + {imgs.length > 1 && ( + + {safeImageIndex + 1} / {imgs.length} + + )} + + {/* Bottom gradient + title overlay */} + + + {modalDisplayStreet || 'Listing'} + + {modalDisplayLocation && ( + + {modalDisplayLocation} + + )} + + + + {imgs.length > 1 && ( + + {imgs.map((img, index) => ( + setExpandedImageIndex(index)} + style={{ + minWidth: 72, + width: 72, + height: 56, + borderRadius: 10, + overflow: 'hidden', + cursor: 'pointer', + border: index === safeImageIndex ? '2px solid #12b886' : '2px solid transparent', + boxShadow: index === safeImageIndex ? '0 0 0 1px rgba(18,184,134,0.18)' : 'none', + backgroundColor: '#f3f4f6', + flexShrink: 0, + }} + > + + + ))} + + )} + + {/* Details section */} + + + {/* Row 1: price + property type */} + + {expandedListing.price_per_month != null && ( + + ${Number(expandedListing.price_per_month).toLocaleString()}/mo + + )} + {expandedListing.property_type && ( + + {formatEnumLabel(expandedListing.property_type)} + + )} + + + {/* Row 2: key stats */} + + + {bedsLabel} + Beds + + + + {expandedListing.number_of_bathrooms != null ? expandedListing.number_of_bathrooms : '—'} + + Baths + + + + {expandedListing.area_sqft != null ? expandedListing.area_sqft : '—'} + + Sqft + + + + + + {/* Row 3: details grid */} + + {expandedListing.available_from && ( + <> + Available from + {expandedListing.available_from} + + )} + {expandedListing.lease_type && ( + <> + Lease type + {formatEnumLabel(expandedListing.lease_type)} + + )} + <> + Furnished + {expandedListing.furnished ? 'Yes' : 'No'} + + {expandedListing.utilities_included != null && ( + <> + Utilities + {expandedListing.utilities_included ? 'Included' : 'Not included'} + + )} + + + {/* Row 4: amenities */} + {amenityKeys.length > 0 && ( + + {amenityKeys.map((key) => ( + + {formatAmenityLabel(key)} + + ))} + + )} + + {/* Row 5: description */} + {expandedListing.description && ( + + {expandedListing.description} + + )} + + {/* Row 6: action buttons */} + + + + + + + + ); + })()} + + + {/* Fullscreen image viewer */} + {expandedListing && fullscreenOpen && (() => { + const imgs = (() => { + const i = expandedListing?.images; + if (Array.isArray(i)) return i; + if (typeof i === 'string') { try { return JSON.parse(i); } catch { return []; } } + return []; + })(); + const safeIdx = imgs.length > 0 ? Math.min(expandedImageIndex, imgs.length - 1) : 0; + return ( + setFullscreenOpen(false)} + size="min(92vw, 900px)" + padding={0} + radius="xl" + withCloseButton={false} + centered + overlayProps={{ backgroundOpacity: 0.75, blur: 10 }} + transitionProps={{ transition: 'fade', duration: 200 }} + styles={{ + body: { backgroundColor: '#111', borderRadius: 'var(--mantine-radius-xl)' }, + content: { backgroundColor: '#111', borderRadius: 'var(--mantine-radius-xl)' }, + }} + > + + + + {/* Close */} + setFullscreenOpen(false)} + style={{ position: 'absolute', top: 16, right: 16, opacity: 0.85 }} + > + + + + {/* Counter */} + {imgs.length > 1 && ( + + {safeIdx + 1} / {imgs.length} + + )} + + {/* Prev */} + {imgs.length > 1 && ( + setExpandedImageIndex((prev) => (prev - 1 + imgs.length) % imgs.length)} + style={{ position: 'absolute', top: '50%', left: 16, transform: 'translateY(-50%)', opacity: 0.85 }} + > + + + )} + + {/* Next */} + {imgs.length > 1 && ( + setExpandedImageIndex((prev) => (prev + 1) % imgs.length)} + style={{ position: 'absolute', top: '50%', right: 16, transform: 'translateY(-50%)', opacity: 0.85 }} + > + + + )} + + + ); + })()} + + {/* Guest signup modal — shown when a guest tries to like a listing */} + { + setShowGuestSignupModal(false); + // Advance past the card the guest tried to like so they can keep browsing + setCurrentIndex(prev => prev + 1); + void logGuestEvent({ event_type: 'signup_prompt_dismissed', listing_id: pendingGuestLike?.listing_id ?? null }); + setPendingGuestLike(null); + }} + size="sm" + radius="lg" + centered + padding="xl" + title={null} + overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + withCloseButton={false} + > + + 🏠 + + Save this listing + + Create a free account to like listings, see your matches, and get unlimited access. + + + + + + + + Already have an account?{' '} + router.push('/login')} + > + Sign in + + + + + + + ); +} diff --git a/frontend/src/features/discover/hooks/useDiscoverPage.js b/frontend/src/features/discover/hooks/useDiscoverPage.js new file mode 100644 index 0000000..18cc777 --- /dev/null +++ b/frontend/src/features/discover/hooks/useDiscoverPage.js @@ -0,0 +1,1157 @@ +'use client'; + +import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useHotkeys } from '@mantine/hooks'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '../../../app/contexts/AuthContext'; +import { usePadlyTour } from '../../../app/contexts/TourContext'; +import { usePageTracking } from '../../../app/hooks/usePageTracking'; +import { + createAppError, + hasCompleteCorePreferences, + normalizeRecommendationsError, + parseApiErrorResponse, +} from '../../../../lib/errorHandling'; +import { + createRecommendationClientSessionId, + createRecommendationEventId, +} from '../../../../lib/recommendationFeedback'; +import { apiFetch } from '../../../../lib/api'; +import { getLikedListings, saveLikedListing } from '../../../app/discover/likedListings'; +import { + DISCOVER_FEEDBACK_SWIPE_THRESHOLD, + DISCOVER_ALGORITHM_VERSION, + clearDiscoverProgress, + loadDiscoverProgress, + saveDiscoverProgress, + getOrCreateSwipeSessionId, + collectDeviceContext, +} from '../lib/session'; + +export function useDiscoverPage() { + const { user, getValidToken, authState, isAuthenticated, isLoading: authLoading } = useAuth(); + const { tourPhase } = usePadlyTour(); + const userId = user?.profile?.id; + const router = useRouter(); + + // Lightweight prefs fetch — only used to key the feed query on city so + // changing location always triggers a fresh fetch. + const { data: prefsData } = useQuery({ + queryKey: ['user-prefs', userId], + queryFn: async () => { + const token = await getValidToken(); + if (!token) return null; + const res = await apiFetch(`/preferences/${userId}`, {}, { token }); + if (!res.ok) return null; + const d = await res.json(); + return d.data || d || null; + }, + enabled: !!userId, + staleTime: 0, + }); + const prefCity = prefsData?.target_city ?? null; + usePageTracking('discover', authState?.accessToken); + + // ── guest mode ───────────────────────────────────────────────────────────── + const isGuest = !authLoading && !isAuthenticated; + + // Guest preferences from sessionStorage — read once on mount, never changes + const [guestPrefs] = useState(() => { + if (typeof window === 'undefined') return {}; + try { return JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch { return {}; } + }); + const guestCity = guestPrefs?.target_city ?? null; + + // Stable guest session ID — follows the same ref-init pattern used for + // recommendationClientSessionIdRef elsewhere in this file + const guestSessionIdRef = useRef(null); + if (!guestSessionIdRef.current && typeof window !== 'undefined') { + try { + const _existing = sessionStorage.getItem('padly_guest_session_id'); + if (_existing) { + guestSessionIdRef.current = _existing; + } else { + const _newId = crypto.randomUUID?.() ?? `guest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + sessionStorage.setItem('padly_guest_session_id', _newId); + guestSessionIdRef.current = _newId; + } + } catch { /* best-effort */ } + } + + const guestSwipeCountRef = useRef(0); + const guestNudgeShownRef = useRef(false); + const guestLikeCountRef = useRef(0); // tracks total guest likes to throttle the signup modal + const [guestNudgeShown, setGuestNudgeShown] = useState(false); + const [showGuestSignupModal, setShowGuestSignupModal] = useState(false); + const [pendingGuestLike, setPendingGuestLike] = useState(null); + + /** Bumps React Query cache key so a refetch always reapplies stack state (refetch alone can reuse the same data reference). */ + const [feedReloadNonce, setFeedReloadNonce] = useState(0); + + const [listings, setListings] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [appendLoading, setAppendLoading] = useState(false); + const [appendError, setAppendError] = useState(null); + const [missingCorePreferences, setMissingCorePreferences] = useState(false); + const [emptyResultReason, setEmptyResultReason] = useState(null); + const [expandedListing, setExpandedListing] = useState(null); + const [expandedImageIndex, setExpandedImageIndex] = useState(0); + const [fullscreenOpen, setFullscreenOpen] = useState(false); + const [hasMore, setHasMore] = useState(false); + const swipeSessionIdRef = useRef(null); + const recommendationClientSessionIdRef = useRef(null); + const nextOffsetRef = useRef(0); + // Tracks the last feedData object seen so we only sync on a genuine new result + const prevFeedDataRef = useRef(null); + const restoredProgressRef = useRef(false); + // Stable refs for currentIndex and listings so handleSwipe doesn't need them as deps + const currentIndexRef = useRef(currentIndex); + const listingsRef = useRef(listings); + useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); + useEffect(() => { listingsRef.current = listings; }, [listings]); + + const activeFilterBodyRef = useRef({}); + const cardViewStartRef = useRef(null); + const cardPhotoCountRef = useRef(0); + const cardExpandedRef = useRef(false); + const expandedOpenedAtRef = useRef(null); + const surfaceStartedAtRef = useRef(Date.now()); + const promptShownRef = useRef(false); + const latestSessionIdRef = useRef(null); + const latestTokenRef = useRef(null); + const latestListingsRef = useRef([]); + const latestRankingContextRef = useRef(null); + const sessionMetricsRef = useRef({ likesCount: 0, detailOpensCount: 0, swipesCount: 0 }); + + const [rankingContext, setRankingContext] = useState(null); + const [feedbackSessionId, setFeedbackSessionId] = useState(null); + const [promptAllowed, setPromptAllowed] = useState(false); + const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); + const [feedbackStep, setFeedbackStep] = useState('question'); + const [pendingFeedbackLabel, setPendingFeedbackLabel] = useState(null); + const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); + const [feedbackAcknowledged, setFeedbackAcknowledged] = useState(false); + const [feedbackCycle, setFeedbackCycle] = useState(0); + const [swipesInCycle, setSwipesInCycle] = useState(0); + + if (!recommendationClientSessionIdRef.current && typeof window !== 'undefined') { + recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); + } + + const deriveRankingContext = useCallback((payload) => { + const responseContext = payload?.ranking_context; + if (responseContext) return responseContext; + + const recommendations = payload?.recommendations || []; + const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); + return { + algorithm_version: recommendations[0]?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, + model_version: hasMlScores ? 'recommender-v1' : null, + experiment_name: recommendations.length > 0 ? 'discover_ranker_v1' : null, + experiment_variant: recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null, + }; + }, []); + + const buildSessionPayload = useCallback((recommendations = listings, context = rankingContext) => { + if (!recommendationClientSessionIdRef.current) { + recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); + } + + const topListingIds = recommendations + .map((listing) => listing?.listing_id) + .filter(Boolean) + .slice(0, 20); + const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); + + return { + client_session_id: recommendationClientSessionIdRef.current, + surface: 'discover', + recommendation_count_shown: recommendations.length, + top_listing_ids_shown: topListingIds, + algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, + model_version: context?.model_version ?? (hasMlScores ? 'recommender-v1' : null), + experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'discover_ranker_v1' : null), + experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null), + }; + }, [listings, rankingContext]); + + const startNextFeedbackCycle = useCallback(() => { + recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); + sessionMetricsRef.current = { likesCount: 0, detailOpensCount: 0, swipesCount: 0 }; + setSwipesInCycle(0); + setFeedbackSessionId(null); + setPromptAllowed(false); + setShowFeedbackPrompt(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + promptShownRef.current = false; + surfaceStartedAtRef.current = Date.now(); + setFeedbackCycle((current) => current + 1); + }, []); + + const patchRecommendationSession = useCallback(async (payload, { keepalive = false } = {}) => { + if (!authState?.accessToken || !feedbackSessionId) return null; + + try { + const response = await apiFetch( + `/interactions/recommendation-sessions/${feedbackSessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive, + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to update discover recommendation session'); + return null; + } + + if (response.status === 503) return null; + const result = await response.json(); + return result?.data ?? null; + } catch { + return null; + } + }, [authState?.accessToken, feedbackSessionId]); + + const findListingPosition = useCallback((listingId) => { + if (!listingId) return null; + const index = listings.findIndex((item) => item?.listing_id === listingId); + return index >= 0 ? index : null; + }, [listings]); + + const persistRecommendationEvent = useCallback(async ({ + eventType, + listingId = null, + positionInFeed = null, + dwellMs = null, + metadata = {}, + keepalive = false, + }) => { + if (!authState?.accessToken || !feedbackSessionId) return null; + + try { + const response = await apiFetch( + `/interactions/recommendation-events`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + client_event_id: createRecommendationEventId(eventType), + surface: 'discover', + event_type: eventType, + listing_id: listingId, + position_in_feed: positionInFeed, + dwell_ms: dwellMs, + metadata, + }), + keepalive, + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to persist discover recommendation event'); + } + } catch { + // Best-effort only. + } + }, [authState?.accessToken, feedbackSessionId]); + + // ── shared fetch helper (used by the query AND loadMore) ────────────────── + + const fetchFeedPage = useCallback(async ({ offset = 0 } = {}) => { + // Guest path — no auth token, preferences come from sessionStorage + if (!userId) { + let localGuestPrefs = {}; + try { localGuestPrefs = JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch {} + if (!localGuestPrefs.target_city) { + return { listings: [], prefs: localGuestPrefs, hasCorePreferences: false, hasMore: false, nextOffset: 0 }; + } + const body = { + target_city: localGuestPrefs.target_city, + target_country: localGuestPrefs.target_country || undefined, + target_state_province: localGuestPrefs.target_state_province || undefined, + budget_min: localGuestPrefs.budget_min || undefined, + budget_max: localGuestPrefs.budget_max || undefined, + required_bedrooms: localGuestPrefs.required_bedrooms || undefined, + target_bathrooms: localGuestPrefs.target_bathrooms || undefined, + top_n: 20, + offset: 0, + }; + activeFilterBodyRef.current = body; + const res = await apiFetch(`/recommendations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Failed to fetch recommendations'); + const data = await res.json(); + return { + listings: data.recommendations || [], + prefs: localGuestPrefs, + hasCorePreferences: true, + hasMore: false, + nextOffset: 0, + rankingContext: deriveRankingContext(data), + }; + } + + const token = await getValidToken(); + if (!token) throw new Error('Not authenticated'); + + let prefs = {}; + let hasCorePreferences = false; + const swipedIds = new Set(); + + const prefRes = await apiFetch(`/preferences/${userId}`, {}, { token }); + if (prefRes.ok) { + const prefData = await prefRes.json(); + prefs = prefData.data || prefData || {}; + hasCorePreferences = hasCompleteCorePreferences(prefs); + } + + if (!hasCorePreferences) { + return { listings: [], prefs, hasCorePreferences, hasMore: false, nextOffset: 0 }; + } + + try { + const swipesRes = await apiFetch(`/interactions/swipes/me?limit=500`, {}, { token }); + if (swipesRes.ok) { + const swipesPayload = await swipesRes.json(); + for (const event of swipesPayload?.data || []) { + if (event?.listing_id) swipedIds.add(event.listing_id); + } + } + } catch { + // Swipe history fetch is optional; fall back to local liked cache only. + } + + const liked = getLikedListings(); + const likedExtras = {}; + let behaviorSampleSize; + + try { + const behaviorRes = await apiFetch(`/interactions/behavior/me?days=180`, {}, { token }); + if (behaviorRes.ok) { + const behaviorPayload = await behaviorRes.json(); + const behavior = behaviorPayload?.data || {}; + if (behavior.liked_mean_price != null) likedExtras.liked_mean_price = behavior.liked_mean_price; + if (behavior.liked_mean_beds != null) likedExtras.liked_mean_beds = behavior.liked_mean_beds; + if (behavior.liked_mean_sqfeet != null) likedExtras.liked_mean_sqfeet = behavior.liked_mean_sqfeet; + if (behavior.sample_size != null) behaviorSampleSize = behavior.sample_size; + } + } catch { + // Behavior vector is optional. + } + + if (liked.length > 0) { + const avg = (arr) => arr.filter(Boolean).reduce((a, b) => a + b, 0) / arr.filter(Boolean).length; + if (likedExtras.liked_mean_price == null) likedExtras.liked_mean_price = avg(liked.map((l) => l.price_per_month)); + if (likedExtras.liked_mean_beds == null) likedExtras.liked_mean_beds = avg(liked.map((l) => l.number_of_bedrooms)); + if (likedExtras.liked_mean_sqfeet == null) likedExtras.liked_mean_sqfeet = avg(liked.map((l) => l.area_sqft)); + } + + const body = { + budget_min: prefs.budget_min ?? undefined, + budget_max: prefs.budget_max ?? undefined, + target_country: prefs.target_country ?? undefined, + target_state_province: prefs.target_state_province ?? undefined, + target_city: prefs.target_city ?? undefined, + required_bedrooms: prefs.required_bedrooms ?? undefined, + target_bathrooms: prefs.target_bathrooms ?? undefined, + desired_beds: prefs.required_bedrooms ?? undefined, + desired_baths: prefs.target_bathrooms ?? undefined, + target_deposit_amount: prefs.target_deposit_amount ?? undefined, + furnished_preference: prefs.furnished_preference ?? undefined, + gender_policy: prefs.gender_policy ?? undefined, + target_lease_type: prefs.target_lease_type ?? undefined, + target_lease_duration_months: prefs.target_lease_duration_months ?? undefined, + allow_larger_layouts: prefs?.lifestyle_preferences?.allow_larger_layouts ?? undefined, + move_in_date: prefs.move_in_date ?? undefined, + target_furnished: prefs.target_furnished ?? undefined, + wants_furnished: + prefs.furnished_preference === 'required' || prefs.furnished_preference === 'preferred' + ? 1 + : prefs.target_furnished === true + ? 1 + : undefined, + pref_lat: prefs.target_latitude ?? undefined, + pref_lon: prefs.target_longitude ?? undefined, + top_n: 50, + offset, + behavior_sample_size: behaviorSampleSize, + ...likedExtras, + }; + + // Cache the filter body so swipe-context events can reference it. + activeFilterBodyRef.current = body; + + const res = await apiFetch( + `/recommendations`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { token } + ); + + if (!res.ok) { + const apiError = await parseApiErrorResponse(res, 'Failed to fetch recommendations'); + throw createAppError(apiError.message, { + status: apiError.status, + payload: apiError.payload, + rawMessage: apiError.message, + }); + } + + const data = await res.json(); + const nextRankingContext = deriveRankingContext(data); + + const likedIds = new Set(getLikedListings().map((l) => l.listing_id)); + const fresh = (data.recommendations || []).filter( + (l) => !likedIds.has(l.listing_id) && !swipedIds.has(l.listing_id) + ); + + // Fire search query event — best-effort. + void apiFetch( + `/interactions/search-queries`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: getOrCreateSwipeSessionId(), + filter_snapshot: body, + results_returned: (data.recommendations || []).length, + offset, + }), + }, + { token } + ).catch(() => {}); + + return { + listings: fresh, + prefs, + hasCorePreferences, + hasMore: Boolean(data.has_more), + nextOffset: (data.offset || 0) + (data.count || 0), + rankingContext: nextRankingContext, + }; + }, [deriveRankingContext, getValidToken, userId]); + + // ── cached initial feed (5-min stale time) ───────────────────────────────── + + const { + data: feedData, + isLoading: feedLoading, + error: feedQueryError, + } = useQuery({ + queryKey: isGuest + ? ['discover-feed-guest', guestCity, feedReloadNonce] + : ['discover-feed', userId, prefCity, feedReloadNonce], + queryFn: () => fetchFeedPage({ offset: 0 }), + enabled: isGuest ? !!guestCity : (!!userId && prefCity !== null), + staleTime: 0, + gcTime: 10 * 60 * 1000, + retry: false, + }); + + const handleDiscoverFeedReload = useCallback(() => { + if (!userId) return; + clearDiscoverProgress(userId); + prevFeedDataRef.current = null; + setFeedReloadNonce((n) => n + 1); + }, [userId]); + + useLayoutEffect(() => { + if (!userId) return; + + const restored = loadDiscoverProgress(userId); + if (!restored) return; + + // If the user changed their city since this progress was saved, discard it. + if (prefCity && restored.targetCity && restored.targetCity !== prefCity) { + clearDiscoverProgress(userId); + return; + } + + restoredProgressRef.current = true; + setListings(Array.isArray(restored.listings) ? restored.listings : []); + setCurrentIndex( + Number.isFinite(restored.currentIndex) + ? Math.max(0, Math.min(restored.currentIndex, restored.listings?.length || 0)) + : 0 + ); + setHasMore(Boolean(restored.hasMore)); + setMissingCorePreferences(Boolean(restored.missingCorePreferences)); + setEmptyResultReason(restored.emptyResultReason ?? null); + setRankingContext(restored.rankingContext ?? null); + nextOffsetRef.current = Number.isFinite(restored.nextOffset) ? restored.nextOffset : 0; + }, [userId]); + + // Sync query data → swipe-stack state. + // useLayoutEffect runs before paint so there is no visible flash of empty state on + // remounts that have a cache hit (feedData is available immediately). + useLayoutEffect(() => { + if (!feedData || feedData === prevFeedDataRef.current) return; + prevFeedDataRef.current = feedData; + if (restoredProgressRef.current) { + restoredProgressRef.current = false; + return; + } + setListings(feedData.listings); + setCurrentIndex(0); + setHasMore(feedData.hasMore); + setMissingCorePreferences(!feedData.hasCorePreferences); + setRankingContext(feedData.rankingContext ?? null); + setEmptyResultReason( + feedData.listings.length === 0 + ? (feedData.hasCorePreferences ? 'strict_constraints' : 'missing_preferences') + : null + ); + nextOffsetRef.current = feedData.nextOffset; + sessionMetricsRef.current = { likesCount: 0, detailOpensCount: 0, swipesCount: 0 }; + setSwipesInCycle(0); + setFeedbackCycle(0); + promptShownRef.current = false; + setFeedbackSessionId(null); + setPromptAllowed(false); + setShowFeedbackPrompt(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + setFeedbackAcknowledged(false); + surfaceStartedAtRef.current = Date.now(); + recommendationClientSessionIdRef.current = createRecommendationClientSessionId('discover'); + }, [feedData]); + + // Only show the full-page spinner on the very first load (no cached data yet). + const loading = (feedLoading && !feedData) || appendLoading; + const error = feedQueryError ? normalizeRecommendationsError(feedQueryError) : appendError; + + // ── append more listings ─────────────────────────────────────────────────── + + const loadMore = useCallback(async () => { + setAppendLoading(true); + setAppendError(null); + try { + const page = await fetchFeedPage({ offset: nextOffsetRef.current }); + setHasMore(page.hasMore); + nextOffsetRef.current = page.nextOffset; + setListings((prev) => [...prev, ...page.listings]); + } catch (err) { + setAppendError(normalizeRecommendationsError(err)); + } finally { + setAppendLoading(false); + } + }, [fetchFeedPage]); + + useEffect(() => { + latestSessionIdRef.current = feedbackSessionId; + }, [feedbackSessionId]); + + useEffect(() => { + latestTokenRef.current = getValidToken; + }, [getValidToken]); + + useEffect(() => { + latestListingsRef.current = listings; + }, [listings]); + + useEffect(() => { + latestRankingContextRef.current = rankingContext; + }, [rankingContext]); + + useEffect(() => { + if (!userId) return; + + if (listings.length === 0 && currentIndex === 0) { + clearDiscoverProgress(userId); + return; + } + + saveDiscoverProgress(userId, { + listings, + currentIndex, + hasMore, + nextOffset: nextOffsetRef.current, + missingCorePreferences, + emptyResultReason, + rankingContext, + targetCity: prefCity, + }); + }, [ + currentIndex, + emptyResultReason, + hasMore, + listings, + missingCorePreferences, + rankingContext, + userId, + ]); + + useEffect(() => { + if (!authState?.accessToken || !userId || loading || error || listings.length === 0) return; + + let cancelled = false; + + const ensureRecommendationSession = async () => { + try { + const response = await apiFetch( + `/interactions/recommendation-sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSessionPayload(listings, rankingContext)), + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to create discover recommendation session'); + return; + } + + if (response.status === 503) return; + + const result = await response.json(); + if (cancelled) return; + + setFeedbackSessionId(result?.data?.id ?? null); + setPromptAllowed(Boolean(result?.prompt_allowed)); + } catch { + // Best-effort only. + } + }; + + void ensureRecommendationSession(); + + return () => { + cancelled = true; + }; + }, [authState?.accessToken, buildSessionPayload, error, feedbackCycle, listings, loading, rankingContext, userId]); + + useEffect(() => { + if ( + promptShownRef.current || + !feedbackSessionId || + !promptAllowed || + showFeedbackPrompt || + feedbackSubmitting || + loading || + error || + swipesInCycle < DISCOVER_FEEDBACK_SWIPE_THRESHOLD + ) { + return; + } + + promptShownRef.current = true; + setShowFeedbackPrompt(true); + void patchRecommendationSession({ + prompt_presented: true, + likes_count: sessionMetricsRef.current.likesCount, + detail_opens_count: sessionMetricsRef.current.detailOpensCount, + recommendation_count_shown: listings.length, + top_listing_ids_shown: listings.map((listing) => listing?.listing_id).filter(Boolean).slice(0, 20), + surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), + algorithm_version: rankingContext?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, + model_version: rankingContext?.model_version ?? null, + experiment_name: rankingContext?.experiment_name ?? 'discover_ranker_v1', + experiment_variant: rankingContext?.experiment_variant ?? null, + }); + }, [ + error, + feedbackSessionId, + feedbackSubmitting, + listings, + loading, + patchRecommendationSession, + promptAllowed, + rankingContext, + showFeedbackPrompt, + swipesInCycle, + ]); + + useEffect(() => { + return () => { + const getToken = latestTokenRef.current; + const sessionId = latestSessionIdRef.current; + if (!getToken || !sessionId) return; + + const recommendations = latestListingsRef.current || []; + const context = latestRankingContextRef.current; + const topListingIds = recommendations + .map((listing) => listing?.listing_id) + .filter(Boolean) + .slice(0, 20); + const dwellMs = Math.max(0, Date.now() - surfaceStartedAtRef.current); + const metrics = { ...sessionMetricsRef.current }; + + getToken().then((token) => { + if (!token) return; + apiFetch( + `/interactions/recommendation-sessions/${sessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mark_ended: true, + surface_dwell_ms: dwellMs, + likes_count: metrics.likesCount, + detail_opens_count: metrics.detailOpensCount, + recommendation_count_shown: recommendations.length, + top_listing_ids_shown: topListingIds, + algorithm_version: context?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, + model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), + experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'discover_ranker_v1' : null), + experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), + }), + keepalive: true, + }, + { token } + ).catch(() => {}); + }).catch(() => {}); + }; + }, []); + + useEffect(() => { + setExpandedImageIndex(0); + }, [expandedListing?.listing_id]); + + useEffect(() => { + const remaining = listings.length - currentIndex; + if (!loading && !error && hasMore && remaining === 0) { + loadMore(); + } + }, [currentIndex, listings.length, loading, error, hasMore, loadMore]); + + // ── swipe actions ───────────────────────────────────────────────────────── + + const persistSwipeEvent = useCallback(async ({ listing, action, position, startedAt }) => { + if (!userId || !listing?.listing_id) return; + const token = await getValidToken(); + if (!token) return; + + if (!swipeSessionIdRef.current) { + swipeSessionIdRef.current = getOrCreateSwipeSessionId(); + } + + const algorithmVersion = + listing?.algorithm_version != null && String(listing.algorithm_version).trim() + ? String(listing.algorithm_version).trim() + : DISCOVER_ALGORITHM_VERSION; + + try { + const response = await apiFetch( + `/interactions/swipes`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listing.listing_id, + action, + surface: 'discover', + session_id: swipeSessionIdRef.current, + position_in_feed: position, + algorithm_version: algorithmVersion, + model_version: listing?.ml_score != null ? 'recommender-v1' : null, + city_filter: listing.city ?? null, + latency_ms: + startedAt != null && typeof performance !== 'undefined' + ? Math.max(0, Math.round(performance.now() - startedAt)) + : undefined, + }), + }, + { token } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to persist swipe interaction'); + } + } catch { + // Best-effort only. + } + }, [userId, getValidToken]); + + const persistSwipeContextEvent = useCallback(async ({ listing, action }) => { + if (!authState?.accessToken || !listing?.listing_id) return; + if (!swipeSessionIdRef.current) { + swipeSessionIdRef.current = getOrCreateSwipeSessionId(); + } + try { + await apiFetch( + `/interactions/swipe-context`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listing.listing_id, + action, + session_id: swipeSessionIdRef.current, + active_filters_snapshot: activeFilterBodyRef.current || null, + device_context: collectDeviceContext(), + }), + }, + { token: authState.accessToken } + ); + } catch { + // Best-effort only. + } + }, [authState?.accessToken]); + + const persistListingViewEvent = useCallback(async ({ listing, surface }) => { + if (!authState?.accessToken || !listing?.listing_id) return; + if (!swipeSessionIdRef.current) { + swipeSessionIdRef.current = getOrCreateSwipeSessionId(); + } + const duration_ms = + cardViewStartRef.current != null + ? Math.max(0, Date.now() - cardViewStartRef.current) + : null; + try { + await apiFetch( + `/interactions/listing-views`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + listing_id: listing.listing_id, + surface, + session_id: swipeSessionIdRef.current, + view_duration_ms: duration_ms, + expanded: cardExpandedRef.current, + photos_viewed_count: cardPhotoCountRef.current, + }), + }, + { token: authState.accessToken } + ); + } catch { + // Best-effort only. + } + }, [authState?.accessToken]); + + // Reset view-tracking refs whenever the top card changes. + useEffect(() => { + if (listings.length === 0 || currentIndex >= listings.length) return; + cardViewStartRef.current = Date.now(); + cardPhotoCountRef.current = 0; + cardExpandedRef.current = false; + }, [currentIndex, listings.length]); + + // ── guest event logger ──────────────────────────────────────────────────── + const logGuestEvent = useCallback(async (eventData) => { + if (!isGuest) return; + try { + let localPrefs = {}; + try { localPrefs = JSON.parse(sessionStorage.getItem('guest_preferences') || '{}'); } catch {} + void apiFetch(`/interactions/guest-events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + guest_session_id: guestSessionIdRef.current ?? 'unknown', + guest_prefs_snapshot: localPrefs, + device_context: collectDeviceContext(), + ...eventData, + }), + }).catch(() => {}); + } catch { /* best-effort */ } + }, [isGuest]); + + // Stable handleSwipe: reads currentIndex/listings from refs so the callback + // identity doesn't change on every swipe (prevents SwipeCard re-renders). + + const handleSwipe = useCallback((direction, listing) => { + const action = direction === 'right' ? 'like' : 'pass'; + + // ── Guest interception ──────────────────────────────────────────────────── + if (isGuest) { + const position = listingsRef.current.findIndex(l => l.listing_id === listing?.listing_id); + void logGuestEvent({ + event_type: action === 'like' ? 'swipe_right' : 'swipe_left', + listing_id: listing?.listing_id ?? null, + position_in_feed: position >= 0 ? position : currentIndexRef.current, + }); + + guestSwipeCountRef.current += 1; + + if (action === 'like') { + guestLikeCountRef.current += 1; + const likeCount = guestLikeCountRef.current; + // Show the modal on the 1st like and every 10th after that (1, 10, 20, 30…) + if (likeCount === 1 || likeCount % 10 === 0) { + setPendingGuestLike(listing); + setShowGuestSignupModal(true); + void logGuestEvent({ event_type: 'signup_prompt_shown', listing_id: listing?.listing_id ?? null }); + return; // don't advance until modal is dismissed + } + // All other likes: just advance + setCurrentIndex(prev => prev + 1); + return; + } + + // Pass — advance card + setCurrentIndex(prev => prev + 1); + + // Show nudge after 5 swipes + if (guestSwipeCountRef.current >= 15 && !guestNudgeShownRef.current) { + guestNudgeShownRef.current = true; + setGuestNudgeShown(true); + } + return; + } + // ───────────────────────────────────────────────────────────────────────── + + if (action === 'like') saveLikedListing(listing); + + const currentListings = listingsRef.current; + const currentIdx = currentIndexRef.current; + const top = currentListings[currentIdx]; + const position = + top && listing?.listing_id === top.listing_id + ? currentIdx + : currentListings.findIndex((item) => item.listing_id === listing?.listing_id); + const startedAt = typeof performance !== 'undefined' ? performance.now() : null; + + // Fire data logging events before advancing the index (best-effort, parallel). + void persistListingViewEvent({ listing, surface: 'discover_card' }); + void persistSwipeContextEvent({ listing, action }); + + void persistSwipeEvent({ + listing, + action, + position: position >= 0 ? position : currentIdx, + startedAt, + }); + + sessionMetricsRef.current.swipesCount += 1; + setSwipesInCycle(sessionMetricsRef.current.swipesCount); + if (action === 'like') { + sessionMetricsRef.current.likesCount += 1; + // Signal to the Matches page that behaviour has changed so it can offer + // a "Reload Matches" button the next time the user visits. + if (userId && typeof window !== 'undefined') { + try { + localStorage.setItem(`padly_matches_stale_at_${userId}`, String(Date.now())); + } catch { + // Ignore storage errors. + } + } + } + + setCurrentIndex((prev) => prev + 1); + + if (tourPhase === 'discover') { + window.dispatchEvent(new CustomEvent('padly-tour-swipe', { + detail: { direction }, + })); + } + }, [persistSwipeEvent, persistListingViewEvent, persistSwipeContextEvent, tourPhase, isGuest, logGuestEvent]); + + const handleButton = (direction) => { + if (currentIndexRef.current >= listingsRef.current.length) return; + handleSwipe(direction, listingsRef.current[currentIndexRef.current]); + }; + + const handleModalAction = (direction) => { + closeExpanded(); + setTimeout(() => handleButton(direction), 200); + }; + + const openExpanded = useCallback((listing) => { + cardExpandedRef.current = true; + sessionMetricsRef.current.detailOpensCount += 1; + const position = findListingPosition(listing?.listing_id); + void persistRecommendationEvent({ + eventType: 'detail_open', + listingId: listing?.listing_id, + positionInFeed: position, + metadata: { + match_percent: listing?.match_percent ?? null, + open_type: 'discover_quick_view', + }, + }); + expandedOpenedAtRef.current = Date.now(); + setExpandedImageIndex(0); + setExpandedListing(listing); + }, [findListingPosition, persistRecommendationEvent]); + + const closeExpanded = useCallback(() => { + if (expandedListing?.listing_id) { + const position = findListingPosition(expandedListing.listing_id); + const dwellMs = + expandedOpenedAtRef.current != null + ? Math.max(0, Date.now() - expandedOpenedAtRef.current) + : null; + void persistRecommendationEvent({ + eventType: 'detail_view', + listingId: expandedListing.listing_id, + positionInFeed: position, + dwellMs, + metadata: { + view_type: 'discover_quick_view', + }, + }); + } + + expandedOpenedAtRef.current = null; + setExpandedListing(null); + }, [expandedListing, findListingPosition, persistRecommendationEvent]); + + const submitFeedback = useCallback(async ({ feedbackLabel, reasonLabel = null }) => { + if (!authState?.accessToken || !feedbackSessionId || feedbackSubmitting) return; + + setFeedbackSubmitting(true); + try { + await patchRecommendationSession({ + likes_count: sessionMetricsRef.current.likesCount, + detail_opens_count: sessionMetricsRef.current.detailOpensCount, + recommendation_count_shown: listings.length, + top_listing_ids_shown: listings.map((listing) => listing?.listing_id).filter(Boolean).slice(0, 20), + surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), + algorithm_version: rankingContext?.algorithm_version ?? DISCOVER_ALGORITHM_VERSION, + model_version: rankingContext?.model_version ?? null, + experiment_name: rankingContext?.experiment_name ?? 'discover_ranker_v1', + experiment_variant: rankingContext?.experiment_variant ?? null, + }); + + const response = await apiFetch( + `/interactions/recommendation-feedback`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + feedback_label: feedbackLabel, + reason_label: reasonLabel, + }), + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to submit discover recommendation feedback'); + return; + } + + setShowFeedbackPrompt(false); + setPromptAllowed(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + setFeedbackAcknowledged(true); + startNextFeedbackCycle(); + } catch { + // Best-effort only. + } finally { + setFeedbackSubmitting(false); + } + }, [ + authState?.accessToken, + feedbackSessionId, + feedbackSubmitting, + listings, + patchRecommendationSession, + rankingContext, + startNextFeedbackCycle, + ]); + + const dismissFeedbackPrompt = useCallback(async () => { + await patchRecommendationSession({ + prompt_dismissed: true, + likes_count: sessionMetricsRef.current.likesCount, + detail_opens_count: sessionMetricsRef.current.detailOpensCount, + surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), + }); + startNextFeedbackCycle(); + }, [patchRecommendationSession, startNextFeedbackCycle]); + + const handleFeedbackChoice = useCallback(async (value) => { + if (value === 'not_useful') { + setPendingFeedbackLabel(value); + setFeedbackStep('reason'); + return; + } + + await submitFeedback({ feedbackLabel: value }); + }, [submitFeedback]); + + const handleNegativeReason = useCallback(async (reasonLabel) => { + await submitFeedback({ + feedbackLabel: pendingFeedbackLabel || 'not_useful', + reasonLabel, + }); + }, [pendingFeedbackLabel, submitFeedback]); + + // Auto-advance modal images every 5s while modal is open + useEffect(() => { + if (!expandedListing) return; + const imgs = (() => { + const i = expandedListing.images; + if (Array.isArray(i)) return i; + if (typeof i === 'string') { try { return JSON.parse(i); } catch { return []; } } + return []; + })(); + if (imgs.length <= 1) return; + const timer = setInterval(() => { + setExpandedImageIndex(prev => (prev + 1) % imgs.length); + }, 3000); + return () => clearInterval(timer); + }, [expandedListing]); + + const remaining = listings.length - currentIndex; + const noRecommendations = !loading && !error && listings.length === 0 && !hasMore; + const isDone = !loading && !error && listings.length > 0 && remaining === 0 && !hasMore; + + useHotkeys([ + ['ArrowLeft', () => handleButton('left')], + ['ArrowRight', () => handleButton('right')], + ]); + + return { + router, + feedbackAcknowledged, + loading, + isDone, + noRecommendations, + remaining, + isGuest, + guestCity, + guestNudgeShown, + setGuestNudgeShown, + showFeedbackPrompt, + feedbackStep, + feedbackSubmitting, + handleFeedbackChoice, + dismissFeedbackPrompt, + handleNegativeReason, + submitFeedback, + pendingFeedbackLabel, + handleDiscoverFeedReload, + error, + emptyResultReason, + missingCorePreferences, + listings, + currentIndex, + handleSwipe, + openExpanded, + cardPhotoCountRef, + handleButton, + expandedListing, + closeExpanded, + expandedImageIndex, + setExpandedImageIndex, + setFullscreenOpen, + fullscreenOpen, + handleModalAction, + showGuestSignupModal, + setShowGuestSignupModal, + setCurrentIndex, + logGuestEvent, + pendingGuestLike, + setPendingGuestLike, + }; +} diff --git a/frontend/src/features/discover/lib/session.js b/frontend/src/features/discover/lib/session.js new file mode 100644 index 0000000..a95aa9d --- /dev/null +++ b/frontend/src/features/discover/lib/session.js @@ -0,0 +1,110 @@ +/** @typedef {Record} GuestPrefs */ + +export const DISCOVER_FEEDBACK_SWIPE_THRESHOLD = 10; +export const DISCOVER_PROGRESS_TTL_MS = 30 * 60 * 1000; +export const SWIPE_SESSION_KEY = 'padly_swipe_session_id'; + +/** Stable client label for swipe telemetry (backend requires algorithm_version). */ +export const DISCOVER_ALGORITHM_VERSION = 'discover-v1'; + +export function getDiscoverProgressKey(userId) { + return `padly_discover_progress:${userId}`; +} + +export function clearDiscoverProgress(userId) { + if (typeof window === 'undefined' || !userId) return; + + try { + sessionStorage.removeItem(getDiscoverProgressKey(userId)); + } catch { + // Best-effort only. + } +} + +export function loadDiscoverProgress(userId) { + if (typeof window === 'undefined' || !userId) return null; + + try { + const raw = sessionStorage.getItem(getDiscoverProgressKey(userId)); + if (!raw) return null; + + const parsed = JSON.parse(raw); + if (!parsed?.savedAt || Date.now() - parsed.savedAt > DISCOVER_PROGRESS_TTL_MS) { + sessionStorage.removeItem(getDiscoverProgressKey(userId)); + return null; + } + + const hasListings = Array.isArray(parsed.listings) && parsed.listings.length > 0; + const hasProgress = hasListings && Number.isFinite(parsed.currentIndex) && parsed.currentIndex > 0; + const isCompletedStack = + hasListings && Number.isFinite(parsed.currentIndex) && parsed.currentIndex >= parsed.listings.length; + if (!hasProgress && !isCompletedStack) { + sessionStorage.removeItem(getDiscoverProgressKey(userId)); + return null; + } + + return parsed; + } catch { + return null; + } +} + +export function saveDiscoverProgress(userId, payload) { + if (typeof window === 'undefined' || !userId) return; + + try { + sessionStorage.setItem( + getDiscoverProgressKey(userId), + JSON.stringify({ + ...payload, + savedAt: Date.now(), + }) + ); + } catch { + // Best-effort only. + } +} + +export function getOrCreateSwipeSessionId() { + if (typeof window === 'undefined') return 'server-session'; + const existing = sessionStorage.getItem(SWIPE_SESSION_KEY); + if (existing) return existing; + + const generated = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `session-${Date.now()}-${Math.random().toString(16).slice(2)}`; + sessionStorage.setItem(SWIPE_SESSION_KEY, generated); + return generated; +} + +export function collectDeviceContext() { + if (typeof window === 'undefined') return {}; + const ua = navigator.userAgent || ''; + const isTablet = /Tablet|iPad/i.test(ua); + const isMobile = /Mobi|Android/i.test(ua); + const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop'; + + let os = 'unknown'; + if (/Windows/i.test(ua)) os = 'windows'; + else if (/Mac OS X/i.test(ua)) os = 'macos'; + else if (/Android/i.test(ua)) os = 'android'; + else if (/iPhone|iPad|iPod/i.test(ua)) os = 'ios'; + else if (/Linux/i.test(ua)) os = 'linux'; + + let browser = 'unknown'; + if (/Firefox\/\d/i.test(ua)) browser = 'firefox'; + else if (/Edg\/\d/i.test(ua)) browser = 'edge'; + else if (/Chrome\/\d/i.test(ua) && !/Chromium/i.test(ua)) browser = 'chrome'; + else if (/Safari\/\d/i.test(ua) && !/Chrome/i.test(ua)) browser = 'safari'; + + const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + return { + device_type: deviceType, + os, + browser, + screen_width: window.screen?.width ?? null, + screen_height: window.screen?.height ?? null, + connection_type: conn?.effectiveType ?? conn?.type ?? null, + }; +} diff --git a/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx b/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx new file mode 100644 index 0000000..a3eda31 --- /dev/null +++ b/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx @@ -0,0 +1,882 @@ +'use client'; + +import { + Container, + Title, + Text, + Button, + Stack, + Card, + Badge, + Group, + Avatar, + Divider, + Modal, + TextInput, + ActionIcon, + Menu, + Alert, + Grid, + Paper, + Tabs, + Loader, + Center, + Tooltip, + ScrollArea, + ThemeIcon, + Progress, + Skeleton, +} from '@mantine/core'; +import { + IconArrowLeft, + IconUsers, + IconMail, + IconUserPlus, + IconDoorExit, + IconEdit, + IconTrash, + IconDotsVertical, + IconCheck, + IconX, + IconMapPin, + IconCurrencyDollar, + IconCalendar, + IconBuildingCommunity, + IconAlertCircle, + IconHome, + IconSearch, + IconSparkles, + IconMoon, + IconVolume, + IconSmokingNo, + IconDog, + IconFriends, + IconInbox, + IconHeart, + IconBookmark, +} from '@tabler/icons-react'; +import { Navigation } from '../../../app/components/Navigation'; + +export function GroupDetailPageView(props) { + const { + router, + groupId, + user, + currentUser, + group, + members, + matches, + loading, + inviteModalOpen, + setInviteModalOpen, + inviteEmail, + setInviteEmail, + inviting, + activeTab, + setActiveTab, + inviteTab, + setInviteTab, + compatibleUsers, + loadingCompatible, + invitingUserId, + joinRequests, + loadingRequests, + processingRequestId, + memberSavedListings, + loadingLiked, + fetchGroupData, + fetchMemberSavedListings, + handleInvite, + fetchCompatibleUsers, + handleInviteUser, + fetchJoinRequests, + handleAcceptRequest, + handleRejectRequest, + handleLeaveGroup, + handleDeleteGroup, + handleRemoveMember, + } = props; + + if (loading) { + return ( + <> + +
+ +
+ + ); + } + if (!group) { + return ( + <> + + + } title="Group Not Found" color="red"> + The group you're looking for doesn't exist or has been deleted. + + + + + ); + } + // Use currentUser if user from context is null + const activeUser = user || currentUser; + + // Compare by email since user.id is auth_id but members have app's user_id + // Check if current user is creator by finding the creator member + const creatorMember = members.find(m => m.is_creator); + const isCreator = activeUser && creatorMember && creatorMember.user_email === activeUser.email; + + // Check membership by email or user_id (auth_id) + const isMember = activeUser && members.some(m => + (m.user_email === activeUser.email || m.user_id === activeUser.id) && m.status === 'accepted' + ); + + // Debug logging + console.log('User check:', { + fullUser: activeUser, + userEmail: activeUser?.email, + userId: activeUser?.id, + members: members.map(m => ({ email: m.user_email, id: m.user_id, status: m.status })), + isMember, + isCreator + }); + + // Only count accepted members for display + const acceptedMembers = members.filter(m => m.status === 'accepted'); + const acceptedMemberCount = acceptedMembers.length; + + // Check if group is full (only if we have a valid target size) + const isGroupFull = group.target_group_size != null && acceptedMemberCount >= group.target_group_size; + + const statusColor = { + active: 'blue', + matched: 'green', + inactive: 'gray' + }[group.status] || 'gray'; + return ( + <> + + + + {/* Header */} + + + + + + {isMember && ( + + + + + + + + {isCreator && ( + } + onClick={() => router.push(`/groups/${groupId}/edit`)} + > + Edit Group + + )} + } + color="red" + onClick={handleLeaveGroup} + > + Leave Group + + + + )} + + + {/* Full Group Banner */} + {isGroupFull && ( + } + title="This group is full" + color="red" + variant="filled" + > + This group has reached its maximum capacity of {group.target_group_size} members and is no longer accepting new members. + + )} + + {/* Group Header */} + + + +
+ + {group.group_name} + + {group.status} + + + {group.description && ( + {group.description} + )} +
+ + {isMember && ( + + )} +
+ + + + + + + +
+ Target City + {group.target_city} +
+
+
+ + + + +
+ Budget (per person) + + {group.budget_per_person_min && group.budget_per_person_max + ? `$${group.budget_per_person_min} - $${group.budget_per_person_max}` + : 'Not set'} + +
+
+
+ + + + +
+ Move-in Date + + {group.target_move_in_date + ? new Date(group.target_move_in_date).toLocaleDateString() + : 'Flexible'} + +
+
+
+ + + + +
+ Members + + {group.target_group_size != null + ? `${acceptedMemberCount} of ${group.target_group_size}` + : `${acceptedMemberCount}`} + + {group.target_group_size != null && ( + + {isGroupFull ? 'Group full' : `${group.target_group_size - acceptedMemberCount} spot${group.target_group_size - acceptedMemberCount === 1 ? '' : 's'} open`} + + )} +
+
+
+
+
+
+ + {/* Tabs */} + + + }> + Members + + } + onClick={() => { if (!loadingLiked) fetchMemberSavedListings(); }} + > + Saved Listings + + {isCreator && ( + } + rightSection={ + joinRequests.length > 0 ? ( + + {joinRequests.length} + + ) : null + } + > + Join Requests + + )} + + + + + + + Group Members + {acceptedMemberCount} members + + + + + {acceptedMemberCount === 0 ? ( + + No members yet + + ) : ( + + {acceptedMembers.map((member) => ( + + + + + {member.user_name?.charAt(0) || 'U'} + +
+ + {member.user_name || 'Unknown User'} + + + + {member.user_email} + + {member.is_creator && ( + Creator + )} + {member.status === 'pending' && ( + Pending + )} + +
+
+ + {isCreator && !member.is_creator && ( + handleRemoveMember(member.user_id)} + > + + + )} +
+
+ ))} +
+ )} +
+
+
+ + {/* Join Requests Tab (Creator Only) */} + {isCreator && ( + + + + +
+ Join Requests + + People who want to join your group + +
+ +
+ + + + {loadingRequests ? ( + + {[1, 2, 3].map(i => ( + + ))} + + ) : joinRequests.length === 0 ? ( + + + + + + No pending join requests + + + When someone requests to join your group, they'll appear here. + + + ) : ( + + {joinRequests.map((request) => ( + + + + + + {request.full_name?.charAt(0) || request.email?.charAt(0) || 'U'} + +
+ + {request.full_name || 'Unknown User'} + {request.verification_status === 'admin_verified' && ( + Verified + )} + {request.verification_status === 'email_verified' && ( + Email Verified + )} + + {request.email} + {request.company_name && ( + Works at {request.company_name} + )} + {request.school_name && ( + Studies at {request.school_name} + )} +
+
+ + = 80 ? 'green' : + request.compatibility?.score >= 60 ? 'teal' : + request.compatibility?.score >= 40 ? 'yellow' : 'orange' + } + variant="light" + > + {Math.round(request.compatibility?.score || 0)}% Match + +
+ + {/* User Preferences */} + {request.user_preferences && ( + + + {request.user_preferences.target_city && ( + + + {request.user_preferences.target_city} + + )} + {(request.user_preferences.budget_min || request.user_preferences.budget_max) && ( + + + + ${request.user_preferences.budget_min || 0} - ${request.user_preferences.budget_max || '∞'} + + + )} + {request.user_preferences.move_in_date && ( + + + + {new Date(request.user_preferences.move_in_date).toLocaleDateString()} + + + )} + + + )} + + {/* Compatibility Reasons */} + {request.compatibility?.reasons && request.compatibility.reasons.length > 0 && ( + + {request.compatibility.reasons.slice(0, 4).map((reason, idx) => ( + + {reason} + + ))} + + )} + + + Requested {request.requested_at ? new Date(request.requested_at).toLocaleDateString() : 'recently'} + + + {/* Action Buttons */} + + + + +
+
+ ))} +
+ )} +
+
+
+ )} + + {/* Saved Listings Tab */} + + + +
+ Saved Listings + Listings your group members bookmarked for the group +
+ +
+ + {loadingLiked ? ( + + {[1, 2, 3].map(i => )} + + ) : memberSavedListings.length === 0 ? ( + + + + + + No saved listings yet + + When group members save listings from Recommendations, they'll appear here. + + + + ) : ( + + {memberSavedListings.map((listing) => ( + router.push(`/listings/${listing.id}`)} + > + +
+ + {listing.title?.includes('|') + ? listing.title.split('|')[0].trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) + : (listing.title || 'Listing')} + + + + + {listing.title?.includes('|') + ? listing.title.split('|')[1].trim() + : (listing.city || 'Location unavailable')} + + + + {listing.price_per_month && ( +
+ Price + ${Number(listing.price_per_month).toLocaleString()}/mo +
+ )} + {listing.number_of_bedrooms != null && ( +
+ Beds + {listing.number_of_bedrooms === 0 ? 'Studio' : listing.number_of_bedrooms} +
+ )} + {listing.number_of_bathrooms != null && ( +
+ Baths + {listing.number_of_bathrooms} +
+ )} +
+
+ + Saved by + {(listing.liked_by || []).map((name) => ( + {name} + ))} + +
+
+ ))} +
+ )} +
+
+
+
+ + {/* Invite Modal */} + { + setInviteModalOpen(false); + setInviteEmail(''); + setInviteTab('compatible'); + }} + title={ + + + Invite Members + + } + size="lg" + > + + + }> + Compatible Users + + }> + Invite by Email + + + + + + + These users match your group's hard constraints (city, budget, move-in date). + Hover over each user to see their preferences. + + + {loadingCompatible ? ( + + {[1, 2, 3].map(i => ( + + ))} + + ) : compatibleUsers.length === 0 ? ( + + + + + + + No compatible users found at this time. + + + Try adjusting your group's preferences or check back later. + + + + ) : ( + + + {compatibleUsers.map((user) => ( + + Preferences + + + + + City: {user.preferences?.target_city || 'Any'} + + + + + + Budget: ${user.preferences?.budget_min || 0} - ${user.preferences?.budget_max || '∞'} + + + + + + + Move-in: {user.preferences?.move_in_date + ? new Date(user.preferences.move_in_date).toLocaleDateString() + : 'Flexible'} + + + + {user.preferences?.lifestyle_preferences && Object.keys(user.preferences.lifestyle_preferences).length > 0 && ( + <> + + + {user.preferences.lifestyle_preferences.sleep_time && ( + + + + Sleep: {user.preferences.lifestyle_preferences.sleep_time} + + + )} + + {user.preferences.lifestyle_preferences.noise_level && ( + + + + Noise: {user.preferences.lifestyle_preferences.noise_level} + + + )} + + {user.preferences.lifestyle_preferences.smoking !== undefined && ( + + + + Smoking: {user.preferences.lifestyle_preferences.smoking ? 'Yes' : 'No'} + + + )} + + {user.preferences.lifestyle_preferences.pets !== undefined && ( + + + + Pets: {user.preferences.lifestyle_preferences.pets ? 'Yes' : 'No'} + + + )} + + {user.preferences.lifestyle_preferences.guests && ( + + + + Guests: {user.preferences.lifestyle_preferences.guests} + + + )} + + )} + + + + Compatibility Score: + = 80 ? 'green' : user.compatibility_score >= 50 ? 'yellow' : 'red'} + style={{ flex: 1 }} + /> + {Math.round(user.compatibility_score)}% + + + } + > + + + + + {user.full_name?.charAt(0) || user.email?.charAt(0) || 'U'} + +
+ {user.full_name || 'User'} + {user.email} +
+
+ + = 80 ? 'green' : user.compatibility_score >= 50 ? 'yellow' : 'orange'} + variant="light" + > + {Math.round(user.compatibility_score)}% match + + + +
+
+ + ))} +
+ + )} + + + +
+ + + + + Enter the email address of someone you'd like to invite to your group. + + } + value={inviteEmail} + onChange={(e) => setInviteEmail(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleInvite()} + /> + + + +
+
+
+ + ); +} diff --git a/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js b/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js new file mode 100644 index 0000000..1997e8b --- /dev/null +++ b/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js @@ -0,0 +1,498 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { notifications } from '@mantine/notifications'; +import { IconCheck } from '@tabler/icons-react'; +import { useAuth } from '../../../app/contexts/AuthContext'; +import { apiFetch } from '../../../../lib/api'; + +export function useGroupDetailPage() { + const router = useRouter(); + const params = useParams(); + const { user, getValidToken, authState } = useAuth(); + const groupId = params.id; + + const [group, setGroup] = useState(null); + const [members, setMembers] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [matches, setMatches] = useState([]); + const [loading, setLoading] = useState(true); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviting, setInviting] = useState(false); + const [activeTab, setActiveTab] = useState('overview'); + const [inviteTab, setInviteTab] = useState('compatible'); + const [compatibleUsers, setCompatibleUsers] = useState([]); + const [loadingCompatible, setLoadingCompatible] = useState(false); + const [invitingUserId, setInvitingUserId] = useState(null); + const [joinRequests, setJoinRequests] = useState([]); + const [loadingRequests, setLoadingRequests] = useState(false); + const [processingRequestId, setProcessingRequestId] = useState(null); + const [memberSavedListings, setMemberSavedListings] = useState([]); + const [loadingLiked, setLoadingLiked] = useState(false); + + useEffect(() => { + if (groupId) { + fetchGroupData(); + } + }, [groupId]); + + // Fetch current user info if not available from context + useEffect(() => { + const fetchCurrentUser = async () => { + if (!currentUser && authState?.accessToken) { + try { + const response = await apiFetch(`/auth/me`, {}, { token: authState.accessToken }); + const data = await response.json(); + if (response.ok && data.user) { + // Merge auth info with profile data + const profile = data.user.profile || {}; + setCurrentUser({ + ...profile, + email: data.user.email, + auth_id: data.user.id, + id: profile.id || data.user.id + }); + } + } catch (error) { + console.error('Error fetching current user:', error); + } + } + }; + fetchCurrentUser(); + }, [authState, currentUser]); + + // Fetch join requests initially when group data is loaded (for badge count) + useEffect(() => { + if (group && (user || currentUser) && authState?.accessToken) { + fetchJoinRequests(); + } + }, [group, user, currentUser, authState]); + + const fetchGroupData = async () => { + setLoading(true); + try { + const validToken = await getValidToken(); + + // Fetch group details with members + const groupResponse = await apiFetch( + `/roommate-groups/${groupId}?include_members=true`, + {}, + { token: validToken } + ); + const groupData = await groupResponse.json(); + + if (groupResponse.ok && groupData.status === 'success') { + setGroup(groupData.data); + setMembers(groupData.data.members || []); + } + + // Fetch group->listing feed (rule-based ranking). + let listingsFeed = []; + try { + const fallbackResponse = await apiFetch( + `/roommate-groups/${groupId}/ranked-listings?limit=50`, + {}, + { token: validToken } + ); + const fallbackData = await fallbackResponse.json(); + if (fallbackResponse.ok && fallbackData.status === 'success') { + listingsFeed = fallbackData.ranked_listings || []; + } + } catch { + // Listing feed is non-critical; group overview still loads. + } + + setMatches(listingsFeed); + } catch (error) { + console.error('Error fetching group data:', error); + notifications.show({ + title: 'Error', + message: 'Failed to load group details', + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + const fetchMemberSavedListings = async () => { + setLoadingLiked(true); + try { + const validToken = await getValidToken(); + const response = await apiFetch( + `/interactions/swipes/groups/${groupId}/liked?action=group_save`, + {}, + { token: validToken } + ); + const data = await response.json(); + if (response.ok && data.status === 'success') { + setMemberSavedListings(data.data || []); + } + } catch (error) { + console.error('Error fetching member saved listings:', error); + } finally { + setLoadingLiked(false); + } + }; + + const handleInvite = async () => { + if (!inviteEmail) { + notifications.show({ + title: 'Missing Email', + message: 'Please enter an email address', + color: 'orange', + }); + return; + } + + setInviting(true); + try { + const validToken = await getValidToken(); + const response = await apiFetch( + `/roommate-groups/${groupId}/invite`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_email: inviteEmail }), + }, + { token: validToken } + ); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Invitation Sent', + message: `Invitation sent to ${inviteEmail}`, + color: 'green', + icon: , + }); + setInviteModalOpen(false); + setInviteEmail(''); + fetchGroupData(); // Refresh to show pending invitation + } else { + throw new Error(data.detail || 'Failed to send invitation'); + } + } catch (error) { + console.error('Error sending invitation:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to send invitation', + color: 'red', + }); + } finally { + setInviting(false); + } + }; + + const fetchCompatibleUsers = async () => { + setLoadingCompatible(true); + try { + const validToken = await getValidToken(); + if (!validToken) { + throw new Error('Please log in to view compatible users'); + } + const response = await apiFetch(`/roommate-groups/${groupId}/compatible-users`, {}, { token: validToken }); + const data = await response.json(); + + if (response.ok && data.status === 'success') { + setCompatibleUsers(data.users || []); + } else { + throw new Error(data.detail || 'Failed to load compatible users'); + } + } catch (error) { + console.error('Error fetching compatible users:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to load compatible users', + color: 'red', + }); + } finally { + setLoadingCompatible(false); + } + }; + + const handleInviteUser = async (userId, userEmail) => { + setInvitingUserId(userId); + try { + const validToken = await getValidToken(); + const response = await apiFetch( + `/roommate-groups/${groupId}/invite`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_email: userEmail }), + }, + { token: validToken } + ); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Invitation Sent', + message: `Invitation sent to ${userEmail}`, + color: 'green', + icon: , + }); + // Remove user from compatible list + setCompatibleUsers(prev => prev.filter(u => u.id !== userId)); + fetchGroupData(); + } else { + throw new Error(data.detail || 'Failed to send invitation'); + } + } catch (error) { + console.error('Error sending invitation:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to send invitation', + color: 'red', + }); + } finally { + setInvitingUserId(null); + } + }; + + // Fetch compatible users when invite modal opens + useEffect(() => { + if (inviteModalOpen && inviteTab === 'compatible') { + fetchCompatibleUsers(); + } + }, [inviteModalOpen, inviteTab]); + + // Fetch join requests for group creators + const fetchJoinRequests = async () => { + setLoadingRequests(true); + try { + const validToken = await getValidToken(); + if (!validToken) { + throw new Error('Please log in to view join requests'); + } + const response = await apiFetch(`/roommate-groups/${groupId}/pending-requests`, {}, { token: validToken }); + const data = await response.json(); + + if (response.ok && data.status === 'success') { + setJoinRequests(data.requests || []); + } else { + // Not an error if user is not creator - just no requests + if (response.status !== 403) { + throw new Error(data.detail || 'Failed to load join requests'); + } + } + } catch (error) { + console.error('Error fetching join requests:', error); + // Don't show error notification for 403 (not creator) + } finally { + setLoadingRequests(false); + } + }; + + // Fetch join requests when viewing as creator + useEffect(() => { + if (group && user && activeTab === 'requests') { + fetchJoinRequests(); + } + }, [group, user, activeTab]); + + const handleAcceptRequest = async (userId, userName) => { + setProcessingRequestId(userId); + try { + const validToken = await getValidToken(); + const response = await apiFetch(`/roommate-groups/${groupId}/accept-request/${userId}`, { method: 'POST' }, { token: validToken }); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Request Accepted!', + message: `${userName || 'User'} is now a member of your group`, + color: 'green', + icon: , + }); + fetchJoinRequests(); + fetchGroupData(); + } else { + throw new Error(data.detail || 'Failed to accept request'); + } + } catch (error) { + console.error('Error accepting request:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to accept request', + color: 'red', + }); + } finally { + setProcessingRequestId(null); + } + }; + + const handleRejectRequest = async (userId, userName) => { + setProcessingRequestId(userId); + try { + const validToken = await getValidToken(); + const response = await apiFetch(`/roommate-groups/${groupId}/reject-request/${userId}`, { method: 'POST' }, { token: validToken }); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Request Rejected', + message: `Join request from ${userName || 'user'} has been declined`, + color: 'orange', + }); + fetchJoinRequests(); + } else { + throw new Error(data.detail || 'Failed to reject request'); + } + } catch (error) { + console.error('Error rejecting request:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to reject request', + color: 'red', + }); + } finally { + setProcessingRequestId(null); + } + }; + + const handleLeaveGroup = async () => { + const acceptedMembers = members.filter(m => m.status === 'accepted'); + const otherMembers = acceptedMembers.filter(m => m.user_email !== user?.email); + + let confirmMessage = 'Are you sure you want to leave this group?'; + if (isCreator) { + if (otherMembers.length > 0) { + confirmMessage = 'Are you sure you want to leave this group? Ownership will be transferred to another member.'; + } else { + confirmMessage = 'Are you sure you want to leave this group? Since you are the only member, the group will be deleted.'; + } + } + + if (!confirm(confirmMessage)) return; + + try { + const validToken = await getValidToken(); + const response = await apiFetch(`/roommate-groups/${groupId}/leave`, { method: 'DELETE' }, { token: validToken }); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Left Group', + message: data.message || 'You have left the group', + color: 'green', + }); + router.push('/groups'); + } else { + throw new Error(data.detail || 'Failed to leave group'); + } + } catch (error) { + console.error('Error leaving group:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to leave group', + color: 'red', + }); + } + }; + + const handleDeleteGroup = async () => { + if (!confirm('Are you sure you want to delete this group? This action cannot be undone.')) return; + + try { + const validToken = await getValidToken(); + const response = await apiFetch(`/roommate-groups/${groupId}`, { method: 'DELETE' }, { token: validToken }); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Group Deleted', + message: 'The group has been deleted', + color: 'green', + }); + router.push('/groups'); + } else { + throw new Error(data.detail || 'Failed to delete group'); + } + } catch (error) { + console.error('Error deleting group:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to delete group', + color: 'red', + }); + } + }; + + const handleRemoveMember = async (memberId) => { + if (!confirm('Are you sure you want to remove this member?')) return; + + try { + const validToken = await getValidToken(); + const response = await apiFetch(`/roommate-groups/${groupId}/members/${memberId}`, { method: 'DELETE' }, { token: validToken }); + + const data = await response.json(); + + if (response.ok && data.status === 'success') { + notifications.show({ + title: 'Member Removed', + message: 'Member has been removed from the group', + color: 'green', + }); + fetchGroupData(); + } else { + throw new Error(data.detail || 'Failed to remove member'); + } + } catch (error) { + console.error('Error removing member:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to remove member', + color: 'red', + }); + } + }; + + return { + router, + groupId, + user, + currentUser, + group, + members, + matches, + loading, + inviteModalOpen, + setInviteModalOpen, + inviteEmail, + setInviteEmail, + inviting, + activeTab, + setActiveTab, + inviteTab, + setInviteTab, + compatibleUsers, + loadingCompatible, + invitingUserId, + joinRequests, + loadingRequests, + processingRequestId, + memberSavedListings, + loadingLiked, + fetchGroupData, + fetchMemberSavedListings, + handleInvite, + fetchCompatibleUsers, + handleInviteUser, + fetchJoinRequests, + handleAcceptRequest, + handleRejectRequest, + handleLeaveGroup, + handleDeleteGroup, + handleRemoveMember, + }; +} diff --git a/frontend/src/features/matches/components/MatchesPageView.jsx b/frontend/src/features/matches/components/MatchesPageView.jsx new file mode 100644 index 0000000..0a36720 --- /dev/null +++ b/frontend/src/features/matches/components/MatchesPageView.jsx @@ -0,0 +1,359 @@ +'use client'; + +import { + Container, + Title, + Text, + Grid, + Card, + Badge, + Button, + Stack, + Box, + ThemeIcon, + Group, +} from '@mantine/core'; +import { SkeletonListingCard } from '../../../app/components/Skeletons'; +import { IconSparkles, IconMapPin, IconRefresh } from '@tabler/icons-react'; +import { Navigation } from '../../../app/components/Navigation'; +import { ImageWithFallback } from '../../../app/components/ImageWithFallback'; +import { formatAmenityLabel, getActiveAmenityKeys } from '../../../../lib/formatters'; +import { + MATCHES_FEEDBACK_CHOICES, + MATCHES_NEGATIVE_REASON_CHOICES, +} from '../../../../lib/recommendationFeedback'; +import { parseListingTitle } from '../lib/parseListingTitle'; + +export function MatchesPageView(props) { + const { + router, + feedbackAcknowledged, + hasStaleChanges, + handleReloadMatches, + loading, + error, + listings, + retrySecondsLeft, + handleRetry, + missingCorePreferences, + showFeedbackPrompt, + feedbackStep, + feedbackSubmitting, + handleFeedbackChoice, + dismissFeedbackPrompt, + handleNegativeReason, + submitFeedback, + pendingFeedbackLabel, + targetStateFallback, + handleViewDetails, + } = props; + + return ( + + + + + + + Recommendations + + + Your top listings, ranked by preferences and activity + + {feedbackAcknowledged && ( + + Thanks. Your feedback was saved for recommendation evaluation. + + )} + {hasStaleChanges && !loading && ( + + )} + {!loading && !error && listings.length > 0 && ( + + {listings.length} listings found + + )} + + + {loading && ( + + {Array.from({ length: 6 }).map((_, i) => ( + + + + ))} + + )} + + {!loading && error && ( + + {error} + + + )} + + {!loading && !error && listings.length === 0 && ( + + + + + + + {missingCorePreferences ? 'Complete your preferences to get recommendations' : 'No recommendations yet'} + + + {missingCorePreferences + ? 'Set your country, state/province, and city to start receiving personalised listings.' + : 'Update your preferences or broaden a constraint to surface more listings.'} + + + + + + + + )} + + {!loading && !error && listings.length > 0 && ( + + {showFeedbackPrompt && ( + + + {feedbackStep === 'question' ? ( + <> + + + How useful were these recommendations? + + + Your feedback helps us improve how listings are ranked. + + + + {MATCHES_FEEDBACK_CHOICES.map((choice) => ( + + ))} + + + + ) : ( + <> + + + What felt off? + + + Optional + + + + {MATCHES_NEGATIVE_REASON_CHOICES.map((choice) => ( + + ))} + + + + )} + + + )} + + + {listings.map((listing) => { + const image = + listing.images?.[0] || + 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080'; + + const { street, location } = parseListingTitle(listing.title); + const cityState = [listing.city, listing.state_province || listing.state || targetStateFallback] + .filter(Boolean) + .join(', '); + const locationText = cityState || location; + const amenityBadges = getActiveAmenityKeys(listing.amenities).slice(0, 2); + + return ( + + handleViewDetails(listing)} + > + + + + + {listing.match_percent && ( + + {listing.match_percent} match + + )} + + + + + + {street || listing.title} + + {locationText && ( + + + + {locationText} + + + )} + + + + {[ + listing.number_of_bedrooms != null && (listing.number_of_bedrooms === 0 ? 'Studio' : `${listing.number_of_bedrooms} Bed`), + listing.number_of_bathrooms != null && `${listing.number_of_bathrooms} Bath`, + listing.area_sqft && `${Number(listing.area_sqft).toLocaleString()} sq ft`, + ].filter(Boolean).join(' · ')} + + + {(listing.furnished || amenityBadges.length > 0) && ( + + {listing.furnished && ( + Furnished + )} + {amenityBadges.map((key) => ( + + {formatAmenityLabel(key)} + + ))} + + )} + + {listing.price_per_month && ( + + ${Number(listing.price_per_month).toLocaleString()}/mo + + )} + + + + + + + + ); + })} + + + )} + + + ); +} + diff --git a/frontend/src/features/matches/hooks/useMatchesPage.js b/frontend/src/features/matches/hooks/useMatchesPage.js new file mode 100644 index 0000000..ad4b861 --- /dev/null +++ b/frontend/src/features/matches/hooks/useMatchesPage.js @@ -0,0 +1,718 @@ +'use client'; + +import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '../../../app/contexts/AuthContext'; +import { usePageTracking } from '../../../app/hooks/usePageTracking'; +import { getLikedListings } from '../../../app/discover/likedListings'; +import { + createAppError, + hasCompleteCorePreferences, + normalizeRecommendationsError, + parseApiErrorResponse, +} from '../../../../lib/errorHandling'; +import { + createRecommendationClientSessionId, + createRecommendationEventId, +} from '../../../../lib/recommendationFeedback'; +import { apiFetch } from '../../../../lib/api'; +import { + FEED_CACHE_TTL_MS, + MATCHES_SCROLL_TRIGGER_PX, + MATCHES_TOP_RETURN_PX, + RETRY_COOLDOWN_SECONDS, +} from '../lib/constants'; + +export function useMatchesPage() { + const router = useRouter(); + const { user, getValidToken, authState } = useAuth(); + const userId = user?.profile?.id; + + // Keep feed cache keyed to current preference location so changing + // preferences forces a fresh recommendations fetch. + const { data: prefsData } = useQuery({ + queryKey: ['user-prefs', userId], + queryFn: async () => { + const token = await getValidToken(); + if (!token || !userId) return null; + const res = await apiFetch(`/preferences/${userId}`, {}, { token }); + if (!res.ok) return null; + const data = await res.json(); + return data?.data || data || null; + }, + enabled: !!userId, + staleTime: 0, + }); + + const prefCountry = prefsData?.target_country ?? null; + const prefState = prefsData?.target_state_province ?? null; + const prefCity = prefsData?.target_city ?? null; + const preferenceLocationKey = `${prefCountry || ''}|${prefState || ''}|${prefCity || ''}`; + + const clientSessionIdRef = useRef(null); + const surfaceStartedAtRef = useRef(Date.now()); + const promptShownRef = useRef(false); + const hasScrolledDeepRef = useRef(false); + const sessionMetricsRef = useRef({ detailOpensCount: 0, savesCount: 0 }); + const latestSessionIdRef = useRef(null); + const latestTokenRef = useRef(null); + const latestListingsRef = useRef([]); + const latestRankingContextRef = useRef(null); + + usePageTracking('matches', authState?.accessToken); + + const [listings, setListings] = useState([]); + const [missingCorePreferences, setMissingCorePreferences] = useState(false); + const [targetStateFallback, setTargetStateFallback] = useState(null); + const [rankingContext, setRankingContext] = useState(null); + const [feedbackSessionId, setFeedbackSessionId] = useState(null); + const [promptAllowed, setPromptAllowed] = useState(false); + const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); + const [feedbackStep, setFeedbackStep] = useState('question'); + const [pendingFeedbackLabel, setPendingFeedbackLabel] = useState(null); + const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); + const [feedbackAcknowledged, setFeedbackAcknowledged] = useState(false); + // Countdown seconds remaining before the user can manually retry after an error. + const [retrySecondsLeft, setRetrySecondsLeft] = useState(0); + // True when preferences or discover likes have changed since the last fetch. + const [hasStaleChanges, setHasStaleChanges] = useState(false); + // Tracks the last feedData object seen so we only sync on a genuine new result + const prevFeedDataRef = useRef(null); + + if (!clientSessionIdRef.current && typeof window !== 'undefined') { + clientSessionIdRef.current = createRecommendationClientSessionId('matches'); + } + + const deriveRankingContext = useCallback((payload) => { + const responseContext = payload?.ranking_context; + if (responseContext) return responseContext; + + const recommendations = payload?.recommendations || []; + const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); + return { + algorithm_version: recommendations[0]?.algorithm_version ?? null, + model_version: hasMlScores ? 'recommender-v1' : null, + experiment_name: recommendations.length > 0 ? 'matches_ranker_v1' : null, + experiment_variant: recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null, + }; + }, []); + + const buildSessionPayload = useCallback((recommendations = listings, context = rankingContext) => { + if (!clientSessionIdRef.current) { + clientSessionIdRef.current = createRecommendationClientSessionId('matches'); + } + + const topListingIds = recommendations + .map((listing) => listing?.listing_id) + .filter(Boolean) + .slice(0, 20); + const hasMlScores = recommendations.some((listing) => listing?.ml_score != null); + + return { + client_session_id: clientSessionIdRef.current, + surface: 'matches', + recommendation_count_shown: recommendations.length, + top_listing_ids_shown: topListingIds, + algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? null, + model_version: context?.model_version ?? (hasMlScores ? 'recommender-v1' : null), + experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'matches_ranker_v1' : null), + experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (hasMlScores ? 'two_tower' : 'baseline') : null), + }; + }, [listings, rankingContext]); + + const patchRecommendationSession = useCallback(async (payload, { keepalive = false } = {}) => { + if (!authState?.accessToken || !feedbackSessionId) return null; + + try { + const response = await apiFetch( + `/interactions/recommendation-sessions/${feedbackSessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive, + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to update recommendation session'); + return null; + } + + if (response.status === 503) return null; + const result = await response.json(); + return result?.data ?? null; + } catch { + return null; + } + }, [authState?.accessToken, feedbackSessionId]); + + const findListingPosition = useCallback((listingId) => { + if (!listingId) return null; + const index = listings.findIndex((item) => item?.listing_id === listingId); + return index >= 0 ? index : null; + }, [listings]); + + const persistRecommendationEvent = useCallback(async ({ + eventType, + listingId = null, + positionInFeed = null, + dwellMs = null, + metadata = {}, + keepalive = false, + }) => { + if (!authState?.accessToken || !feedbackSessionId) return null; + + try { + const response = await apiFetch( + `/interactions/recommendation-events`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + client_event_id: createRecommendationEventId(eventType), + surface: 'matches', + event_type: eventType, + listing_id: listingId, + position_in_feed: positionInFeed, + dwell_ms: dwellMs, + metadata, + }), + keepalive, + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to persist recommendation engagement event'); + } + } catch { + // Best-effort only. + } + }, [authState?.accessToken, feedbackSessionId]); + + // ── cached recommendations (5-min stale time, shared QueryClient) ───────── + + const fetchMatchesFeed = useCallback(async () => { + const token = await getValidToken(); + if (!token) throw new Error('Not authenticated'); + + // Serve from localStorage when the cached result is still fresh. + // Key is scoped to the user + their current preference location so any + // location change automatically bypasses the cache. + const lsCacheKey = `padly_rec_${userId}_${preferenceLocationKey}`; + if (typeof window !== 'undefined' && preferenceLocationKey !== '||') { + try { + const raw = localStorage.getItem(lsCacheKey); + if (raw) { + const cached = JSON.parse(raw); + if (cached?.ts && Date.now() - cached.ts < FEED_CACHE_TTL_MS && cached.result) { + return cached.result; + } + } + } catch { + // localStorage unavailable or corrupt — continue with network fetch. + } + } + + let prefs = {}; + let hasCorePreferences = false; + let behaviorSampleSize; + const liked = getLikedListings(); + const likedExtras = {}; + + const prefRes = await apiFetch(`/preferences/${userId}`, {}, { token }); + if (prefRes.ok) { + const prefData = await prefRes.json(); + prefs = prefData.data || prefData || {}; + hasCorePreferences = hasCompleteCorePreferences(prefs); + } + + if (!hasCorePreferences) { + return { listings: [], missingCorePreferences: true, targetState: prefs.target_state_province ?? null }; + } + + try { + const behaviorRes = await apiFetch(`/interactions/behavior/me?days=180`, {}, { token }); + if (behaviorRes.ok) { + const behaviorPayload = await behaviorRes.json(); + const behavior = behaviorPayload?.data || {}; + if (behavior.liked_mean_price != null) likedExtras.liked_mean_price = behavior.liked_mean_price; + if (behavior.liked_mean_beds != null) likedExtras.liked_mean_beds = behavior.liked_mean_beds; + if (behavior.liked_mean_sqfeet != null) likedExtras.liked_mean_sqfeet = behavior.liked_mean_sqfeet; + if (behavior.sample_size != null) behaviorSampleSize = behavior.sample_size; + } + } catch { + // Behavior vector is optional. + } + + if (liked.length > 0) { + const avg = (arr) => arr.filter(Boolean).reduce((a, b) => a + b, 0) / arr.filter(Boolean).length; + if (likedExtras.liked_mean_price == null) likedExtras.liked_mean_price = avg(liked.map((l) => l.price_per_month)); + if (likedExtras.liked_mean_beds == null) likedExtras.liked_mean_beds = avg(liked.map((l) => l.number_of_bedrooms)); + if (likedExtras.liked_mean_sqfeet == null) likedExtras.liked_mean_sqfeet = avg(liked.map((l) => l.area_sqft)); + } + + const body = { + budget_min: prefs.budget_min ?? undefined, + budget_max: prefs.budget_max ?? undefined, + target_country: prefs.target_country ?? undefined, + target_state_province: prefs.target_state_province ?? undefined, + target_city: prefs.target_city ?? undefined, + required_bedrooms: prefs.required_bedrooms ?? undefined, + target_bathrooms: prefs.target_bathrooms ?? undefined, + desired_beds: prefs.required_bedrooms ?? undefined, + desired_baths: prefs.target_bathrooms ?? undefined, + target_deposit_amount: prefs.target_deposit_amount ?? undefined, + furnished_preference: prefs.furnished_preference ?? undefined, + gender_policy: prefs.gender_policy ?? undefined, + target_lease_type: prefs.target_lease_type ?? undefined, + target_lease_duration_months: prefs.target_lease_duration_months ?? undefined, + move_in_date: prefs.move_in_date ?? undefined, + target_furnished: prefs.target_furnished ?? undefined, + wants_furnished: + prefs.furnished_preference === 'required' || prefs.furnished_preference === 'preferred' + ? 1 + : prefs.target_furnished === true + ? 1 + : undefined, + pref_lat: prefs.target_latitude ?? undefined, + pref_lon: prefs.target_longitude ?? undefined, + top_n: 100, + offset: 0, + behavior_sample_size: behaviorSampleSize, + ...likedExtras, + }; + + const res = await apiFetch( + `/recommendations`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { token } + ); + + if (!res.ok) { + const apiError = await parseApiErrorResponse(res, 'Failed to fetch recommendations'); + throw createAppError(apiError.message, { + status: apiError.status, + payload: apiError.payload, + rawMessage: apiError.message, + }); + } + + const data = await res.json(); + const result = { + listings: data.recommendations || [], + missingCorePreferences: false, + targetState: prefs.target_state_province ?? null, + rankingContext: deriveRankingContext(data), + }; + + // Persist to localStorage so hard reloads within the TTL skip the API. + if (typeof window !== 'undefined' && preferenceLocationKey !== '||') { + try { + localStorage.setItem( + `padly_rec_${userId}_${preferenceLocationKey}`, + JSON.stringify({ ts: Date.now(), result }), + ); + } catch { + // Storage full or unavailable — safe to ignore. + } + } + + return result; + }, [deriveRankingContext, getValidToken, preferenceLocationKey, userId]); + + const { + data: feedData, + isLoading: feedLoading, + error: feedQueryError, + refetch: refetchFeed, + } = useQuery({ + queryKey: ['matches-feed', userId, preferenceLocationKey], + queryFn: fetchMatchesFeed, + enabled: !!userId, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: false, + }); + + // Reset session state synchronously when the preference location changes so + // the feedData sync below (also useLayoutEffect) can re-apply cache in the + // same commit phase — preventing the old useEffect from wiping listings + // after the layout effect had already restored them from the RQ cache. + useLayoutEffect(() => { + prevFeedDataRef.current = null; + setListings([]); + setMissingCorePreferences(false); + setTargetStateFallback(null); + setRankingContext(null); + setFeedbackSessionId(null); + setPromptAllowed(false); + setShowFeedbackPrompt(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + setFeedbackAcknowledged(false); + sessionMetricsRef.current = { detailOpensCount: 0, savesCount: 0 }; + surfaceStartedAtRef.current = Date.now(); + clientSessionIdRef.current = createRecommendationClientSessionId('matches'); + promptShownRef.current = false; + hasScrolledDeepRef.current = false; + }, [preferenceLocationKey]); + + // Sync query data → state without a visible flash on cache-hit remounts + useLayoutEffect(() => { + if (!feedData || feedData === prevFeedDataRef.current) return; + prevFeedDataRef.current = feedData; + setListings(feedData.listings); + setMissingCorePreferences(feedData.missingCorePreferences); + setTargetStateFallback(feedData.targetState ?? null); + setRankingContext(feedData.rankingContext ?? null); + setFeedbackSessionId(null); + setPromptAllowed(false); + setShowFeedbackPrompt(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + setFeedbackAcknowledged(false); + sessionMetricsRef.current = { detailOpensCount: 0, savesCount: 0 }; + surfaceStartedAtRef.current = Date.now(); + clientSessionIdRef.current = createRecommendationClientSessionId('matches'); + promptShownRef.current = false; + hasScrolledDeepRef.current = false; + }, [feedData]); + + const loading = feedLoading && !feedData; + const error = feedQueryError ? normalizeRecommendationsError(feedQueryError) : null; + + // Decrement retry countdown once per second until it reaches zero. + useEffect(() => { + if (retrySecondsLeft <= 0) return; + const id = setTimeout(() => setRetrySecondsLeft((s) => Math.max(0, s - 1)), 1000); + return () => clearTimeout(id); + }, [retrySecondsLeft]); + + const handleRetry = useCallback(() => { + setRetrySecondsLeft(RETRY_COOLDOWN_SECONDS); + refetchFeed(); + }, [refetchFeed]); + + // Returns true when preferences or discover likes have changed since the + // last successful recommendations fetch for the current user + location. + const checkStaleChanges = useCallback(() => { + if (typeof window === 'undefined' || !userId || preferenceLocationKey === '||') return false; + try { + const staleAt = Number(localStorage.getItem(`padly_matches_stale_at_${userId}`) ?? 0); + if (!staleAt) return false; + const cacheRaw = localStorage.getItem(`padly_rec_${userId}_${preferenceLocationKey}`); + const lastFetchTs = cacheRaw ? (JSON.parse(cacheRaw)?.ts ?? 0) : 0; + return staleAt > lastFetchTs; + } catch { + return false; + } + }, [userId, preferenceLocationKey]); + + // Check for stale changes on mount and whenever the user returns to the tab. + useEffect(() => { + setHasStaleChanges(checkStaleChanges()); + }, [checkStaleChanges]); + + useEffect(() => { + const onVisibility = () => { + if (document.visibilityState === 'visible') { + setHasStaleChanges(checkStaleChanges()); + } + }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, [checkStaleChanges]); + + const handleReloadMatches = useCallback(() => { + // Clear the stale signal and the localStorage cache so fetchMatchesFeed + // hits the API instead of returning the cached result. + if (typeof window !== 'undefined' && userId) { + try { + localStorage.removeItem(`padly_matches_stale_at_${userId}`); + localStorage.removeItem(`padly_rec_${userId}_${preferenceLocationKey}`); + } catch { + // Ignore storage errors. + } + } + setHasStaleChanges(false); + refetchFeed(); + }, [preferenceLocationKey, refetchFeed, userId]); + + useEffect(() => { + latestTokenRef.current = getValidToken; + }, [getValidToken]); + + useEffect(() => { + latestSessionIdRef.current = feedbackSessionId; + }, [feedbackSessionId]); + + useEffect(() => { + latestListingsRef.current = listings; + }, [listings]); + + useEffect(() => { + latestRankingContextRef.current = rankingContext; + }, [rankingContext]); + + useEffect(() => { + if (!authState?.accessToken || !userId || loading || error || listings.length === 0) return; + + let cancelled = false; + + const ensureRecommendationSession = async () => { + try { + const response = await apiFetch( + `/interactions/recommendation-sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSessionPayload(listings, rankingContext)), + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to create recommendation session'); + return; + } + + if (response.status === 503) return; + + const result = await response.json(); + if (cancelled) return; + + setFeedbackSessionId(result?.data?.id ?? null); + setPromptAllowed(Boolean(result?.prompt_allowed)); + } catch { + // Best-effort only. + } + }; + + void ensureRecommendationSession(); + + return () => { + cancelled = true; + }; + }, [authState?.accessToken, buildSessionPayload, error, listings, loading, rankingContext, userId]); + + useEffect(() => { + if ( + !feedbackSessionId || + !promptAllowed || + feedbackSubmitting || + loading || + error || + listings.length === 0 + ) { + return undefined; + } + + const handleScroll = () => { + const scrollY = window.scrollY || window.pageYOffset || 0; + + if (scrollY >= MATCHES_SCROLL_TRIGGER_PX) { + hasScrolledDeepRef.current = true; + } + + if ( + hasScrolledDeepRef.current && + scrollY <= MATCHES_TOP_RETURN_PX && + !promptShownRef.current && + !showFeedbackPrompt + ) { + promptShownRef.current = true; + setShowFeedbackPrompt(true); + void patchRecommendationSession({ + prompt_presented: true, + detail_opens_count: sessionMetricsRef.current.detailOpensCount, + saves_count: sessionMetricsRef.current.savesCount, + recommendation_count_shown: listings.length, + top_listing_ids_shown: listings.map((listing) => listing?.listing_id).filter(Boolean).slice(0, 20), + surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), + algorithm_version: rankingContext?.algorithm_version ?? listings[0]?.algorithm_version ?? null, + model_version: rankingContext?.model_version ?? (listings.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), + experiment_name: rankingContext?.experiment_name ?? (listings.length > 0 ? 'matches_ranker_v1' : null), + experiment_variant: rankingContext?.experiment_variant ?? (listings.length > 0 ? (listings.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), + }); + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [ + error, + feedbackSessionId, + feedbackSubmitting, + listings, + loading, + patchRecommendationSession, + promptAllowed, + rankingContext, + showFeedbackPrompt, + ]); + + useEffect(() => { + return () => { + const getToken = latestTokenRef.current; + const sessionId = latestSessionIdRef.current; + if (!getToken || !sessionId) return; + + const recommendations = latestListingsRef.current || []; + const context = latestRankingContextRef.current; + const topListingIds = recommendations + .map((listing) => listing?.listing_id) + .filter(Boolean) + .slice(0, 20); + const dwellMs = Math.max(0, Date.now() - surfaceStartedAtRef.current); + const metrics = { ...sessionMetricsRef.current }; + + getToken().then((token) => { + if (!token) return; + apiFetch( + `/interactions/recommendation-sessions/${sessionId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mark_ended: true, + surface_dwell_ms: dwellMs, + detail_opens_count: metrics.detailOpensCount, + saves_count: metrics.savesCount, + recommendation_count_shown: recommendations.length, + top_listing_ids_shown: topListingIds, + algorithm_version: context?.algorithm_version ?? recommendations[0]?.algorithm_version ?? null, + model_version: context?.model_version ?? (recommendations.some((listing) => listing?.ml_score != null) ? 'recommender-v1' : null), + experiment_name: context?.experiment_name ?? (recommendations.length > 0 ? 'matches_ranker_v1' : null), + experiment_variant: context?.experiment_variant ?? (recommendations.length > 0 ? (recommendations.some((listing) => listing?.ml_score != null) ? 'two_tower' : 'baseline') : null), + }), + keepalive: true, + }, + { token } + ).catch(() => {}); + }).catch(() => {}); + }; + }, []); + + const submitFeedback = useCallback(async ({ feedbackLabel, reasonLabel = null }) => { + if (!authState?.accessToken || !feedbackSessionId || feedbackSubmitting) return; + + setFeedbackSubmitting(true); + try { + const response = await apiFetch( + `/interactions/recommendation-feedback`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recommendation_session_id: feedbackSessionId, + feedback_label: feedbackLabel, + reason_label: reasonLabel, + }), + }, + { token: authState.accessToken } + ); + + if (!response.ok && response.status !== 503) { + console.warn('Failed to submit recommendation feedback'); + return; + } + + setShowFeedbackPrompt(false); + setPromptAllowed(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + setFeedbackAcknowledged(true); + } catch { + // Best-effort only. + } finally { + setFeedbackSubmitting(false); + } + }, [authState?.accessToken, feedbackSessionId, feedbackSubmitting]); + + const dismissFeedbackPrompt = useCallback(async () => { + await patchRecommendationSession({ + prompt_dismissed: true, + detail_opens_count: sessionMetricsRef.current.detailOpensCount, + saves_count: sessionMetricsRef.current.savesCount, + surface_dwell_ms: Math.max(0, Date.now() - surfaceStartedAtRef.current), + }); + setShowFeedbackPrompt(false); + setPromptAllowed(false); + setFeedbackStep('question'); + setPendingFeedbackLabel(null); + setFeedbackAcknowledged(false); + }, [patchRecommendationSession]); + + const handleFeedbackChoice = useCallback(async (value) => { + if (value === 'not_useful') { + setPendingFeedbackLabel(value); + setFeedbackStep('reason'); + return; + } + + await submitFeedback({ feedbackLabel: value }); + }, [submitFeedback]); + + const handleNegativeReason = useCallback(async (reasonLabel) => { + await submitFeedback({ + feedbackLabel: pendingFeedbackLabel || 'not_useful', + reasonLabel, + }); + }, [pendingFeedbackLabel, submitFeedback]); + + const handleViewDetails = useCallback((listing) => { + const position = findListingPosition(listing?.listing_id); + sessionMetricsRef.current.detailOpensCount += 1; + void persistRecommendationEvent({ + eventType: 'detail_open', + listingId: listing?.listing_id, + positionInFeed: position, + metadata: { + match_percent: listing?.match_percent ?? null, + }, + keepalive: true, + }); + + const href = feedbackSessionId + ? `/listings/${listing.listing_id}?source=matches&recommendationSessionId=${encodeURIComponent(feedbackSessionId)}${position != null ? `&position=${position}` : ''}` + : `/listings/${listing.listing_id}?source=matches${position != null ? `&position=${position}` : ''}`; + + router.push(href); + }, [feedbackSessionId, findListingPosition, persistRecommendationEvent, router]); + + return { + router, + feedbackAcknowledged, + hasStaleChanges, + handleReloadMatches, + loading, + error, + listings, + retrySecondsLeft, + handleRetry, + missingCorePreferences, + showFeedbackPrompt, + feedbackStep, + feedbackSubmitting, + handleFeedbackChoice, + dismissFeedbackPrompt, + handleNegativeReason, + submitFeedback, + pendingFeedbackLabel, + targetStateFallback, + handleViewDetails, + }; +} diff --git a/frontend/src/features/matches/lib/constants.js b/frontend/src/features/matches/lib/constants.js new file mode 100644 index 0000000..bc93db8 --- /dev/null +++ b/frontend/src/features/matches/lib/constants.js @@ -0,0 +1,7 @@ +export const MATCHES_SCROLL_TRIGGER_PX = 900; +export const MATCHES_TOP_RETURN_PX = 120; +// How long a cached recommendations result stays fresh in localStorage. +// Matches the React Query staleTime so the two layers agree on freshness. +export const FEED_CACHE_TTL_MS = 5 * 60 * 1000; +// Cooldown a user must wait before manually retrying after an error (seconds). +export const RETRY_COOLDOWN_SECONDS = 60; diff --git a/frontend/src/features/matches/lib/parseListingTitle.js b/frontend/src/features/matches/lib/parseListingTitle.js new file mode 100644 index 0000000..d85f2a4 --- /dev/null +++ b/frontend/src/features/matches/lib/parseListingTitle.js @@ -0,0 +1,8 @@ +export function parseListingTitle(title) { + if (!title) return { street: '', location: '' }; + const parts = title.split('|'); + if (parts.length >= 2) { + return { street: parts[0].trim(), location: parts.slice(1).join(' ').trim() }; + } + return { street: title.trim(), location: '' }; +} From 0a7242f27f707eb10c3d79f641ef25b9523552a4 Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 21:15:38 -0400 Subject: [PATCH 4/8] Consolidate preference logic + change rate limit --- backend/app/dependencies/rate_limit.py | 4 +- .../src/app/components/PreferencesForm.jsx | 537 +++++------------- frontend/src/app/preferences-setup/page.jsx | 383 ++++++------- .../preferences/components/PriceHistogram.jsx | 35 ++ .../preferences/components/RoomCounter.jsx | 43 ++ .../preferences/hooks/useLocationOptions.js | 131 +++++ .../preferences/hooks/usePriceHistogram.js | 101 ++++ .../src/features/preferences/lib/index.js | 127 +++++ 8 files changed, 740 insertions(+), 621 deletions(-) create mode 100644 frontend/src/features/preferences/components/PriceHistogram.jsx create mode 100644 frontend/src/features/preferences/components/RoomCounter.jsx create mode 100644 frontend/src/features/preferences/hooks/useLocationOptions.js create mode 100644 frontend/src/features/preferences/hooks/usePriceHistogram.js create mode 100644 frontend/src/features/preferences/lib/index.js diff --git a/backend/app/dependencies/rate_limit.py b/backend/app/dependencies/rate_limit.py index b02fc0f..02990aa 100644 --- a/backend/app/dependencies/rate_limit.py +++ b/backend/app/dependencies/rate_limit.py @@ -26,8 +26,8 @@ # Recommendations is an expensive DB scan + ML scoring pass; frontend caches # results in localStorage for 5 min so legitimate users rarely exceed 1 req/min. # Keeping this low prevents any single user from hammering the pipeline. -RECOMMENDATIONS_AUTH_LIMIT = 5 -RECOMMENDATIONS_GUEST_LIMIT = 3 +RECOMMENDATIONS_AUTH_LIMIT = 10 +RECOMMENDATIONS_GUEST_LIMIT = 5 # ip -> deque of monotonic timestamps within the current window _request_log: dict[str, deque] = defaultdict(deque) diff --git a/frontend/src/app/components/PreferencesForm.jsx b/frontend/src/app/components/PreferencesForm.jsx index 46f5716..aa84855 100644 --- a/frontend/src/app/components/PreferencesForm.jsx +++ b/frontend/src/app/components/PreferencesForm.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ActionIcon, @@ -18,39 +18,30 @@ import { } from '@mantine/core'; import { RangeSlider } from '@mantine/core'; import { DatePickerInput } from '@mantine/dates'; -import { IconAlertCircle, IconCheck, IconMinus, IconPlus, IconX } from '@tabler/icons-react'; +import { IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react'; import { useAuth } from '../contexts/AuthContext'; import { usePadlyTour } from '../contexts/TourContext'; import { parseApiErrorResponse } from '../../../lib/errorHandling'; import { GENDER_IDENTITY_OPTIONS, normalizeGenderIdentity } from '../../../lib/genderIdentity'; import { apiFetch } from '../../../lib/api'; +import { + FURNISHED_PREF_OPTIONS, + GENDER_POLICY_OPTIONS, + LEASE_TYPE_OPTIONS, + PREFERENCE_PAYLOAD_KEYS, + formatPrice, + normalizeBathroomsPreference, + normalizeIntInput, + normalizeNumericInput, + pickPreferenceFields, + withSelectedOption, +} from '../../features/preferences/lib/index'; +import { useLocationOptions } from '../../features/preferences/hooks/useLocationOptions'; +import { usePriceHistogram } from '../../features/preferences/hooks/usePriceHistogram'; +import { PriceHistogram } from '../../features/preferences/components/PriceHistogram'; +import { RoomCounter } from '../../features/preferences/components/RoomCounter'; -const NUM_HISTOGRAM_BINS = 30; const PREFS_SHADOW_TTL_MS = 60 * 1000; -const PREFERENCE_PAYLOAD_KEYS = [ - 'target_country', - 'target_state_province', - 'target_city', - 'required_bedrooms', - 'target_bathrooms', - 'target_deposit_amount', - 'furnished_preference', - 'gender_policy', - 'move_in_date', - 'target_lease_type', - 'target_lease_duration_months', - 'lifestyle_preferences', -]; - -function pickPreferenceFields(source) { - const out = {}; - for (const key of PREFERENCE_PAYLOAD_KEYS) { - if (Object.prototype.hasOwnProperty.call(source || {}, key)) { - out[key] = source[key]; - } - } - return out; -} function prefsShadowKey(userId) { return userId ? `padly:prefs-shadow:${userId}` : null; @@ -84,109 +75,20 @@ function writePrefsShadow(userId, prefs) { } } -function formatPrice(val) { - return `$${Math.round(val).toLocaleString()}`; -} - -function normalizeNumericInput(value) { - if (value === '' || value == null) return null; - const parsed = typeof value === 'number' ? value : Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function normalizeIntInput(value) { - const parsed = normalizeNumericInput(value); - if (parsed == null) return null; - return Math.trunc(parsed); -} - -function normalizeBathroomsPreference(value) { - const parsed = normalizeNumericInput(value); - if (parsed == null) return null; - return parsed < 1 ? 1 : Math.round(parsed); -} - -function normalizeOptionText(value) { - return String(value || '').trim().toLowerCase(); -} - -function findMatchingOption(options, selectedValue) { - if (!selectedValue) return null; - const selectedNorm = normalizeOptionText(selectedValue); - return options.find((opt) => { - const valueNorm = normalizeOptionText(opt?.value); - const labelNorm = normalizeOptionText(opt?.label); - return selectedNorm === valueNorm || selectedNorm === labelNorm; - }) || null; -} - -function withSelectedOption(options, selectedValue) { - if (!selectedValue) return options; - const exists = options.some((opt) => (opt?.value ?? null) === selectedValue); - if (exists) return options; - return [{ value: selectedValue, label: selectedValue }, ...options]; -} - -function RoomCounter({ label, value, onChange }) { - const decrement = () => onChange(value === null || value <= 1 ? null : value - 1); - const increment = () => onChange(value === null ? 1 : value + 1); - - return ( - - {label} - - - - - - {value === null ? 'Any' : value} - - - - - - - ); -} - -function PriceHistogram({ bins, maxCount, rangeValue }) { - if (!bins || bins.length === 0) return null; - const [lo, hi] = rangeValue; - return ( - - {bins.map((bin, i) => { - const heightPct = maxCount > 0 ? Math.max((bin.count / maxCount) * 100, 2) : 2; - const binMid = (bin.range_min + bin.range_max) / 2; - const active = binMid >= lo && binMid <= hi; - return ( - - ); - })} - - ); -} +const DEFAULT_PREFS = { + target_country: 'US', + target_state_province: null, + target_city: null, + required_bedrooms: null, + target_bathrooms: null, + target_deposit_amount: null, + furnished_preference: 'no_preference', + gender_policy: 'mixed_ok', + move_in_date: null, + target_lease_type: null, + target_lease_duration_months: null, + lifestyle_preferences: null, +}; export function PreferencesForm() { const { user, authState, isLoading: authLoading } = useAuth(); @@ -200,40 +102,45 @@ export function PreferencesForm() { const [error, setError] = useState(null); const [success, setSuccess] = useState(false); - // Location options - const [countryOptions, setCountryOptions] = useState([]); - const [stateOptions, setStateOptions] = useState([]); - const [cityOptions, setCityOptions] = useState([]); const [citySearch, setCitySearch] = useState(''); - const [loadingStates, setLoadingStates] = useState(false); - const [loadingCities, setLoadingCities] = useState(false); - - // Price histogram state - const [histogram, setHistogram] = useState({ bins: [], global_min: 0, global_max: 0 }); - const [priceRange, setPriceRange] = useState([0, 0]); - const [priceSliderActive, setPriceSliderActive] = useState(false); - const [loadingPrices, setLoadingPrices] = useState(false); const [allowLargerLayouts, setAllowLargerLayouts] = useState(false); const [genderIdentity, setGenderIdentity] = useState(null); - const [prefs, setPrefs] = useState({ - target_country: 'US', - target_state_province: null, - target_city: null, - required_bedrooms: null, - target_bathrooms: null, - target_deposit_amount: null, - furnished_preference: 'no_preference', - gender_policy: 'mixed_ok', - move_in_date: null, - target_lease_type: null, - target_lease_duration_months: null, - lifestyle_preferences: null, - }); + const [prefs, setPrefs] = useState(DEFAULT_PREFS); + const updatePref = useCallback((key, value) => setPrefs((prev) => ({ ...prev, [key]: value })), []); - const updatePref = (key, value) => setPrefs((prev) => ({ ...prev, [key]: value })); + // ── Location options ─────────────────────────────────────────────────────── - useEffect(() => { + const { countryOptions, stateOptions, cityOptions, loadingStates, loadingCities } = + useLocationOptions({ + country: prefs.target_country, + state: prefs.target_state_province, + city: prefs.target_city, + citySearch, + onStateNormalize: useCallback((v) => updatePref('target_state_province', v), [updatePref]), + onCityNormalize: useCallback((v) => updatePref('target_city', v), [updatePref]), + }); + + // ── Price histogram ──────────────────────────────────────────────────────── + + const { + histogram, + priceRange, + setPriceRange, + priceSliderActive, + setPriceSliderActive, + loadingPrices, + sliderMin, + sliderMax, + maxBinCount, + listingsInRange, + } = usePriceHistogram({ city: prefs.target_city, preserveRange: true }); + + // ── Shadow prefs (localStorage optimistic cache) ─────────────────────────── + + // Apply shadow prefs exactly once after userId is known, before the API + // response arrives, so the form doesn't appear blank. + useLayoutEffect(() => { if (!userId || appliedShadowRef.current) return; const shadow = readPrefsShadow(userId); if (!shadow?.prefs) return; @@ -246,16 +153,12 @@ export function PreferencesForm() { target_city: shadowPrefs.target_city || null, target_bathrooms: normalizeBathroomsPreference(shadowPrefs.target_bathrooms), })); - setStateOptions((prev) => withSelectedOption(prev, shadowPrefs.target_state_province)); - if (shadowPrefs.target_city) { - setCityOptions((prev) => withSelectedOption(prev, shadowPrefs.target_city)); - } setAllowLargerLayouts(Boolean(shadowPrefs?.lifestyle_preferences?.allow_larger_layouts)); setGenderIdentity(normalizeGenderIdentity(shadowPrefs?.lifestyle_preferences?.gender_identity)); appliedShadowRef.current = true; }, [userId]); - // ── Cached preferences fetch ────────────────────────────────────────────── + // ── Saved preferences query ──────────────────────────────────────────────── const { data: savedPrefs, isLoading: prefsLoading } = useQuery({ queryKey: ['preferences', userId], @@ -266,35 +169,27 @@ export function PreferencesForm() { }, enabled: !!userId && !!authState?.accessToken, staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, + gcTime: 10 * 60 * 1000, }); - // Sync query data → controlled form state exactly once per new result + // Sync query data → form state exactly once per new result. useLayoutEffect(() => { if (!savedPrefs || savedPrefs === prevPrefsRef.current) return; + // Don't overwrite form if the user has shadow prefs with a different location + // (they made changes before the fetch completed). const shadow = readPrefsShadow(userId); if (shadow?.prefs) { - const shadowPrefs = shadow.prefs; + const sp = shadow.prefs; const locationMismatch = - (savedPrefs.target_country || null) !== (shadowPrefs.target_country || null) || - (savedPrefs.target_state_province || null) !== (shadowPrefs.target_state_province || null) || - (savedPrefs.target_city || null) !== (shadowPrefs.target_city || null); - if (locationMismatch) { - return; - } + (savedPrefs.target_country || null) !== (sp.target_country || null) || + (savedPrefs.target_state_province || null) !== (sp.target_state_province || null) || + (savedPrefs.target_city || null) !== (sp.target_city || null); + if (locationMismatch) return; } prevPrefsRef.current = savedPrefs; - if (savedPrefs.target_state_province) { - setStateOptions((prev) => withSelectedOption(prev, savedPrefs.target_state_province)); - } - - if (savedPrefs.target_city) { - setCityOptions([{ value: savedPrefs.target_city, label: savedPrefs.target_city }]); - } - setPrefs({ target_country: savedPrefs.target_country || 'US', target_state_province: savedPrefs.target_state_province || null, @@ -316,131 +211,10 @@ export function PreferencesForm() { setPriceRange([savedPrefs.budget_min ?? 0, savedPrefs.budget_max ?? 5000]); setPriceSliderActive(true); } - }, [savedPrefs, userId]); + }, [savedPrefs, userId, setPriceRange, setPriceSliderActive]); - // Only show the loading skeleton on the very first fetch (no cached data yet) const loading = prefsLoading && !savedPrefs; - // ── Effects ────────────────────────────────────────────────────────────── - - useEffect(() => { - apiFetch(`/options/countries`) - .then((r) => r.ok ? r.json() : null) - .then((d) => d && setCountryOptions(d.data || [])) - .catch(() => {}); - }, []); - - useEffect(() => { - const country = prefs.target_country; - if (!country) { - setStateOptions([]); - setLoadingStates(false); - return; - } - - const controller = new AbortController(); - setLoadingStates(true); - - apiFetch(`/options/states?country_code=${encodeURIComponent(country)}`, { signal: controller.signal }) - .then((r) => r.ok ? r.json() : null) - .then((d) => { - if (!d) return; - const apiOptions = d.data || []; - const current = prefs.target_state_province; - setStateOptions(withSelectedOption(apiOptions, current)); - - if (!current) return; - const matched = findMatchingOption(apiOptions, current); - if (matched && matched.value !== current) { - updatePref('target_state_province', matched.value); - } - }) - .catch((err) => { - if (err?.name !== 'AbortError') { - setStateOptions([]); - } - }) - .finally(() => setLoadingStates(false)); - - return () => controller.abort(); - }, [prefs.target_country, prefs.target_state_province]); - - useEffect(() => { - const { target_country, target_state_province } = prefs; - if (!target_country || !target_state_province) { - setCityOptions([]); - setLoadingCities(false); - return; - } - - const controller = new AbortController(); - setLoadingCities(true); - - apiFetch( - `/options/cities?country_code=${encodeURIComponent(target_country)}&state_code=${encodeURIComponent(target_state_province)}&q=${encodeURIComponent(citySearch)}&limit=250`, - { signal: controller.signal } - ) - .then((r) => r.ok ? r.json() : null) - .then((d) => { - if (!d) return; - const apiOptions = d.data || []; - const currentCity = prefs.target_city; - setCityOptions(withSelectedOption(apiOptions, currentCity)); - - if (!currentCity) return; - const matched = findMatchingOption(apiOptions, currentCity); - if (matched && matched.value !== currentCity) { - updatePref('target_city', matched.value); - } - }) - .catch((err) => { - if (err?.name !== 'AbortError') { - setCityOptions([]); - } - }) - .finally(() => setLoadingCities(false)); - - return () => controller.abort(); - }, [prefs.target_country, prefs.target_state_province, citySearch]); - - // Fetch price histogram whenever city changes - useEffect(() => { - const city = prefs.target_city; - if (!city) { - setHistogram({ bins: [], global_min: 0, global_max: 0 }); - setPriceSliderActive(false); - return; - } - setLoadingPrices(true); - apiFetch( - `/listings/price-histogram?city=${encodeURIComponent(city)}&status=active&bins=${NUM_HISTOGRAM_BINS}` - ) - .then((r) => r.ok ? r.json() : null) - .then((d) => { - const data = d?.data; - if (data && data.total_count > 0) { - const effectiveMax = data.display_max ?? data.global_max; - setHistogram({ bins: data.bins, global_min: data.global_min, global_max: effectiveMax }); - // Only reset slider to full range if not already set from saved prefs - setPriceRange((prev) => { - const alreadySet = prev[0] !== 0 || prev[1] !== 0; - if (alreadySet) return prev; - return [data.global_min, effectiveMax]; - }); - } else { - setHistogram({ bins: [], global_min: 500, global_max: 5000 }); - setPriceRange((prev) => (prev[0] !== 0 || prev[1] !== 0) ? prev : [500, 5000]); - } - setPriceSliderActive(true); - }) - .catch(() => { - setHistogram({ bins: [], global_min: 500, global_max: 5000 }); - setPriceRange((prev) => (prev[0] !== 0 || prev[1] !== 0) ? prev : [500, 5000]); - setPriceSliderActive(true); - }) - .finally(() => setLoadingPrices(false)); - }, [prefs.target_city]); - // ── Save ───────────────────────────────────────────────────────────────── const handleSave = async () => { @@ -484,9 +258,10 @@ export function PreferencesForm() { target_lease_duration_months: normalizeIntInput(prefs.target_lease_duration_months), target_lease_type: prefs.target_lease_type || 'any', lifestyle_preferences: lifestylePayload, - move_in_date: prefs.move_in_date instanceof Date - ? prefs.move_in_date.toISOString().split('T')[0] - : prefs.move_in_date, + move_in_date: + prefs.move_in_date instanceof Date + ? prefs.move_in_date.toISOString().split('T')[0] + : prefs.move_in_date, }; const response = await apiFetch( @@ -506,40 +281,31 @@ export function PreferencesForm() { const saveResult = await response.json(); const persistedPrefs = saveResult?.data || payload; - const previousPersistedPrefs = prevPrefsRef.current || savedPrefs || {}; - const previousLocation = { - target_country: previousPersistedPrefs.target_country || null, - target_state_province: previousPersistedPrefs.target_state_province || null, - target_city: previousPersistedPrefs.target_city || null, - }; - const nextLocation = { - target_country: persistedPrefs.target_country || null, - target_state_province: persistedPrefs.target_state_province || null, - target_city: persistedPrefs.target_city || null, - }; + + const prevLocation = prevPrefsRef.current || savedPrefs || {}; const locationChanged = - previousLocation.target_country !== nextLocation.target_country || - previousLocation.target_state_province !== nextLocation.target_state_province || - previousLocation.target_city !== nextLocation.target_city; + (prevLocation.target_country || null) !== (persistedPrefs.target_country || null) || + (prevLocation.target_state_province || null) !== (persistedPrefs.target_state_province || null) || + (prevLocation.target_city || null) !== (persistedPrefs.target_city || null); queryClient.setQueryData(['preferences', userId], persistedPrefs); queryClient.setQueryData(['user-prefs', userId], persistedPrefs); writePrefsShadow(userId, persistedPrefs); prevPrefsRef.current = persistedPrefs; + setPrefs((prev) => ({ ...prev, target_country: persistedPrefs.target_country || prev.target_country, target_state_province: persistedPrefs.target_state_province || null, target_city: persistedPrefs.target_city || null, })); - setStateOptions((prev) => withSelectedOption(prev, persistedPrefs.target_state_province)); - if (persistedPrefs.target_city) { - setCityOptions((prev) => withSelectedOption(prev, persistedPrefs.target_city)); - } - setGenderIdentity(normalizeGenderIdentity(persistedPrefs?.lifestyle_preferences?.gender_identity)); + setGenderIdentity( + normalizeGenderIdentity(persistedPrefs?.lifestyle_preferences?.gender_identity) + ); setSuccess(true); setTimeout(() => setSuccess(false), 3000); + queryClient.invalidateQueries({ queryKey: ['preferences', userId], refetchType: 'inactive' }); queryClient.invalidateQueries({ queryKey: ['user-prefs', userId] }); queryClient.invalidateQueries({ queryKey: ['discover-feed', userId], refetchType: 'all' }); @@ -577,42 +343,27 @@ export function PreferencesForm() { // ── Derived values ──────────────────────────────────────────────────────── - const sliderMin = histogram.global_min || 0; - const sliderMax = histogram.global_max || 5000; - - const maxBinCount = histogram.bins.length > 0 - ? Math.max(...histogram.bins.map((b) => b.count)) - : 0; - - const listingsInRange = useMemo(() => { - if (!histogram.bins.length) return 0; - const [lo, hi] = priceRange; - return histogram.bins.reduce((sum, bin) => { - if (bin.range_max <= lo || bin.range_min >= hi) return sum; - if (bin.range_min >= lo && bin.range_max <= hi) return sum + bin.count; - const overlap = Math.min(bin.range_max, hi) - Math.max(bin.range_min, lo); - const width = bin.range_max - bin.range_min; - return sum + Math.round(bin.count * (overlap / width)); - }, 0); - }, [histogram.bins, priceRange]); - - // Bathroom counter: null=Any, 1+=exact bathroom count - const bathroomCounterValue = prefs.target_bathrooms == null - ? null - : Math.max(1, Math.round(prefs.target_bathrooms)); + const bathroomCounterValue = + prefs.target_bathrooms == null ? null : Math.max(1, Math.round(prefs.target_bathrooms)); const handleBathroomChange = (counterVal) => { - if (counterVal === null) { updatePref('target_bathrooms', null); return; } - updatePref('target_bathrooms', counterVal); + updatePref('target_bathrooms', counterVal === null ? null : counterVal); }; // ── Loading ─────────────────────────────────────────────────────────────── if (authLoading || loading) { return ( - + - {authLoading ? 'Checking authentication...' : 'Loading your preferences...'} + + {authLoading ? 'Checking authentication...' : 'Loading your preferences...'} + ); } @@ -647,8 +398,6 @@ export function PreferencesForm() { updatePref('target_country', v); updatePref('target_state_province', null); updatePref('target_city', null); - setStateOptions([]); - setCityOptions([]); setCitySearch(''); }} required @@ -662,7 +411,6 @@ export function PreferencesForm() { onChange={(v) => { updatePref('target_state_province', v); updatePref('target_city', null); - setCityOptions([]); setCitySearch(''); }} searchable @@ -681,22 +429,24 @@ export function PreferencesForm() { searchValue={citySearch} onSearchChange={setCitySearch} disabled={!prefs.target_state_province || loadingCities} - rightSection={prefs.target_city ? ( - { - event.stopPropagation(); - updatePref('target_city', null); - setCitySearch(''); - }} - > - - - ) : null} + rightSection={ + prefs.target_city ? ( + { + event.stopPropagation(); + updatePref('target_city', null); + setCitySearch(''); + }} + > + + + ) : null + } rightSectionPointerEvents={prefs.target_city ? 'all' : 'none'} required /> @@ -717,7 +467,9 @@ export function PreferencesForm() { {!prefs.target_city && ( - + Select a city above to see prices in that area )} @@ -738,7 +490,14 @@ export function PreferencesForm() { root: { marginTop: 0 }, track: { height: 3, backgroundColor: '#d0d5da' }, bar: { backgroundColor: '#20c997' }, - thumb: { width: 26, height: 26, borderWidth: 2, borderColor: '#adb5bd', backgroundColor: '#fff', boxShadow: '0 1px 4px rgba(0,0,0,0.18)' }, + thumb: { + width: 26, + height: 26, + borderWidth: 2, + borderColor: '#adb5bd', + backgroundColor: '#fff', + boxShadow: '0 1px 4px rgba(0,0,0,0.18)', + }, }} /> @@ -758,7 +517,9 @@ export function PreferencesForm() { Maximum - {priceRange[1] >= sliderMax ? `${formatPrice(priceRange[1])}+` : formatPrice(priceRange[1])} + {priceRange[1] >= sliderMax + ? `${formatPrice(priceRange[1])}+` + : formatPrice(priceRange[1])} @@ -796,11 +557,7 @@ export function PreferencesForm() { - + @@ -814,11 +571,7 @@ export function PreferencesForm() { updatePref('target_lease_type', v)} clearable @@ -849,7 +597,7 @@ export function PreferencesForm() { - {/* ── TIMING ── */} + {/* ── TIMING & HOUSEHOLD ── */} Timing & Household @@ -860,7 +608,9 @@ export function PreferencesForm() { description="Listings available within 60 days of this date will be prioritised" placeholder="Select date" value={prefs.move_in_date ? new Date(prefs.move_in_date) : null} - onChange={(date) => updatePref('move_in_date', date ? date.toISOString().split('T')[0] : null)} + onChange={(date) => + updatePref('move_in_date', date ? date.toISOString().split('T')[0] : null) + } minDate={new Date()} clearable /> @@ -879,10 +629,7 @@ export function PreferencesForm() { { setTargetState(val); setCitySearch(''); setLocationError(null); }} + onChange={(val) => { + setTargetState(val); + setTargetCity(null); + setCitySearch(''); + setLocationError(null); + }} searchable searchValue={stateSearch} onSearchChange={setStateSearch} - disabled={!targetCountry} + disabled={!targetCountry || loadingStates} nothingFoundMessage={stateOptions.length === 0 ? 'Loading…' : 'No results'} /> @@ -431,7 +345,7 @@ export default function PreferencesSetupPage() { onChange={(val) => { setTargetCity(val); setLocationError(null); }} onSearchChange={setCitySearch} searchValue={citySearch} - disabled={!targetState} + disabled={!targetState || loadingCities} searchable nothingFoundMessage={targetState ? 'No cities found' : 'Select a state first'} /> @@ -439,7 +353,7 @@ export default function PreferencesSetupPage() { - {/* ── Section 2: Price range ── */} + {/* ── Price range ── */} @@ -452,7 +366,14 @@ export default function PreferencesSetupPage() { {!targetCity && ( - + Select a city above to see prices in that area )} @@ -473,7 +394,14 @@ export default function PreferencesSetupPage() { root: { marginTop: 0 }, track: { height: 3, backgroundColor: '#d0d5da' }, bar: { backgroundColor: '#20c997' }, - thumb: { width: 26, height: 26, borderWidth: 2, borderColor: '#adb5bd', backgroundColor: '#fff', boxShadow: '0 1px 4px rgba(0,0,0,0.18)' }, + thumb: { + width: 26, + height: 26, + borderWidth: 2, + borderColor: '#adb5bd', + backgroundColor: '#fff', + boxShadow: '0 1px 4px rgba(0,0,0,0.18)', + }, }} /> @@ -493,7 +421,9 @@ export default function PreferencesSetupPage() { Maximum - {priceRange[1] >= sliderMax ? `${formatPrice(priceRange[1])}+` : formatPrice(priceRange[1])} + {priceRange[1] >= sliderMax + ? `${formatPrice(priceRange[1])}+` + : formatPrice(priceRange[1])} @@ -504,7 +434,7 @@ export default function PreferencesSetupPage() { - {/* ── Section 3: Rooms ── */} + {/* ── Rooms and beds ── */} Rooms and beds @@ -550,7 +480,9 @@ export default function PreferencesSetupPage() { More filters - {showMoreFilters ? : } + {showMoreFilters + ? + : } @@ -569,12 +501,7 @@ export default function PreferencesSetupPage() { setGenderPolicy(v || 'mixed_ok')} /> @@ -624,11 +544,26 @@ export default function PreferencesSetupPage() { {/* ── Actions ── */} - {isGuest && ( - )} diff --git a/frontend/src/features/preferences/components/PriceHistogram.jsx b/frontend/src/features/preferences/components/PriceHistogram.jsx new file mode 100644 index 0000000..3e4be15 --- /dev/null +++ b/frontend/src/features/preferences/components/PriceHistogram.jsx @@ -0,0 +1,35 @@ +'use client'; + +import { Box } from '@mantine/core'; + +/** + * Renders a bar chart of price distribution bins above a RangeSlider. + * Bins within the selected range are highlighted in teal; others are grey. + * + * @param {{ bins: array, maxCount: number, rangeValue: [number, number] }} props + */ +export function PriceHistogram({ bins, maxCount, rangeValue }) { + if (!bins || bins.length === 0) return null; + const [lo, hi] = rangeValue; + return ( + + {bins.map((bin, i) => { + const heightPct = maxCount > 0 ? Math.max((bin.count / maxCount) * 100, 2) : 2; + const binMid = (bin.range_min + bin.range_max) / 2; + const active = binMid >= lo && binMid <= hi; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/features/preferences/components/RoomCounter.jsx b/frontend/src/features/preferences/components/RoomCounter.jsx new file mode 100644 index 0000000..a893c95 --- /dev/null +++ b/frontend/src/features/preferences/components/RoomCounter.jsx @@ -0,0 +1,43 @@ +'use client'; + +import { ActionIcon, Group, Text } from '@mantine/core'; +import { IconMinus, IconPlus } from '@tabler/icons-react'; + +/** + * A +/- counter for bedrooms or bathrooms. + * A value of `null` renders as "Any" and the decrement button is disabled. + */ +export function RoomCounter({ label, value, onChange }) { + const decrement = () => onChange(value === null || value <= 1 ? null : value - 1); + const increment = () => onChange(value === null ? 1 : value + 1); + + return ( + + {label} + + + + + + {value === null ? 'Any' : value} + + + + + + + ); +} diff --git a/frontend/src/features/preferences/hooks/useLocationOptions.js b/frontend/src/features/preferences/hooks/useLocationOptions.js new file mode 100644 index 0000000..5bd8e95 --- /dev/null +++ b/frontend/src/features/preferences/hooks/useLocationOptions.js @@ -0,0 +1,131 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { apiFetch } from '../../../../lib/api'; +import { withSelectedOption, findMatchingOption } from '../lib/index'; + +/** + * Manages country, state, and city select options, including async loading and + * optional value normalisation (correcting stored values to the API canonical form). + * + * @param {object} params + * @param {string|null} params.country - Currently selected country code. + * @param {string|null} params.state - Currently selected state/province code. + * @param {string|null} params.city - Currently selected city value. + * @param {string} params.citySearch - The current city search string. + * @param {function} [params.onStateNormalize] - Called with the canonical state value when + * the API returns a differently-cased/formatted match for the stored state. Optional. + * @param {function} [params.onCityNormalize] - Same, for city. Optional. + * + * @returns {{ + * countryOptions: array, + * stateOptions: array, + * cityOptions: array, + * loadingStates: boolean, + * loadingCities: boolean, + * }} + */ +export function useLocationOptions({ + country, + state, + city, + citySearch, + onStateNormalize, + onCityNormalize, +}) { + const [countryOptions, setCountryOptions] = useState([]); + const [stateOptions, setStateOptions] = useState([]); + const [cityOptions, setCityOptions] = useState([]); + const [loadingStates, setLoadingStates] = useState(false); + const [loadingCities, setLoadingCities] = useState(false); + + // Stable refs so callbacks never need to be in effect dependency arrays. + const onStateNormalizeRef = useRef(onStateNormalize); + const onCityNormalizeRef = useRef(onCityNormalize); + useEffect(() => { onStateNormalizeRef.current = onStateNormalize; }); + useEffect(() => { onCityNormalizeRef.current = onCityNormalize; }); + + // Load countries once on mount. + useEffect(() => { + apiFetch('/options/countries') + .then((r) => (r.ok ? r.json() : null)) + .then((d) => d && setCountryOptions(d.data || [])) + .catch(() => {}); + }, []); + + // Load states whenever the selected country (or state) changes. + // `state` is included so the current value is always injected into the list + // via `withSelectedOption`, even if it arrived from saved/shadow prefs before + // the API response returned. + useEffect(() => { + if (!country) { + setStateOptions([]); + setLoadingStates(false); + return; + } + + const controller = new AbortController(); + setLoadingStates(true); + + apiFetch(`/options/states?country_code=${encodeURIComponent(country)}`, { + signal: controller.signal, + }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!d) return; + const apiOptions = d.data || []; + setStateOptions(withSelectedOption(apiOptions, state)); + + if (state && onStateNormalizeRef.current) { + const matched = findMatchingOption(apiOptions, state); + if (matched && matched.value !== state) { + onStateNormalizeRef.current(matched.value); + } + } + }) + .catch((err) => { + if (err?.name !== 'AbortError') setStateOptions([]); + }) + .finally(() => setLoadingStates(false)); + + return () => controller.abort(); + }, [country, state]); + + // Load cities whenever country, state, or citySearch changes. + useEffect(() => { + if (!country || !state) { + setCityOptions([]); + setLoadingCities(false); + return; + } + + const controller = new AbortController(); + setLoadingCities(true); + + apiFetch( + `/options/cities?country_code=${encodeURIComponent(country)}&state_code=${encodeURIComponent(state)}&q=${encodeURIComponent(citySearch ?? '')}&limit=250`, + { signal: controller.signal } + ) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!d) return; + const apiOptions = d.data || []; + setCityOptions(withSelectedOption(apiOptions, city)); + + if (city && onCityNormalizeRef.current) { + const matched = findMatchingOption(apiOptions, city); + if (matched && matched.value !== city) { + onCityNormalizeRef.current(matched.value); + } + } + }) + .catch((err) => { + if (err?.name !== 'AbortError') setCityOptions([]); + }) + .finally(() => setLoadingCities(false)); + + return () => controller.abort(); + }, [country, state, citySearch]); + + return { countryOptions, stateOptions, cityOptions, loadingStates, loadingCities }; +} diff --git a/frontend/src/features/preferences/hooks/usePriceHistogram.js b/frontend/src/features/preferences/hooks/usePriceHistogram.js new file mode 100644 index 0000000..aa9af95 --- /dev/null +++ b/frontend/src/features/preferences/hooks/usePriceHistogram.js @@ -0,0 +1,101 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { apiFetch } from '../../../../lib/api'; +import { NUM_HISTOGRAM_BINS, calcListingsInRange } from '../lib/index'; + +const FALLBACK_MIN = 500; +const FALLBACK_MAX = 5000; + +/** + * Fetches and manages the price histogram for a given city. + * + * @param {object} options + * @param {string|null} options.city - The target city to fetch prices for. + * @param {boolean} [options.preserveRange=false] - When true, skips resetting the + * price range if one is already set (non-zero). Use this on the edit-preferences form + * so previously-saved budget bounds survive a city re-fetch. + * + * @returns {{ + * histogram: { bins: array, global_min: number, global_max: number }, + * priceRange: [number, number], + * setPriceRange: function, + * priceSliderActive: boolean, + * setPriceSliderActive: function, + * loadingPrices: boolean, + * sliderMin: number, + * sliderMax: number, + * maxBinCount: number, + * listingsInRange: number, + * }} + */ +export function usePriceHistogram({ city, preserveRange = false }) { + const [histogram, setHistogram] = useState({ bins: [], global_min: 0, global_max: 0 }); + const [priceRange, setPriceRange] = useState([0, 0]); + const [priceSliderActive, setPriceSliderActive] = useState(false); + const [loadingPrices, setLoadingPrices] = useState(false); + + useEffect(() => { + if (!city) { + setHistogram({ bins: [], global_min: 0, global_max: 0 }); + setPriceSliderActive(false); + return; + } + + setLoadingPrices(true); + + const applyRange = (min, max) => { + setPriceRange((prev) => { + if (preserveRange && (prev[0] !== 0 || prev[1] !== 0)) return prev; + return [min, max]; + }); + }; + + apiFetch( + `/listings/price-histogram?city=${encodeURIComponent(city)}&status=active&bins=${NUM_HISTOGRAM_BINS}` + ) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + const data = d?.data; + if (data && data.total_count > 0) { + const effectiveMax = data.display_max ?? data.global_max; + setHistogram({ bins: data.bins, global_min: data.global_min, global_max: effectiveMax }); + applyRange(data.global_min, effectiveMax); + } else { + setHistogram({ bins: [], global_min: FALLBACK_MIN, global_max: FALLBACK_MAX }); + applyRange(FALLBACK_MIN, FALLBACK_MAX); + } + setPriceSliderActive(true); + }) + .catch(() => { + setHistogram({ bins: [], global_min: FALLBACK_MIN, global_max: FALLBACK_MAX }); + applyRange(FALLBACK_MIN, FALLBACK_MAX); + setPriceSliderActive(true); + }) + .finally(() => setLoadingPrices(false)); + }, [city, preserveRange]); + + const sliderMin = histogram.global_min || 0; + const sliderMax = histogram.global_max || 5000; + + const maxBinCount = + histogram.bins.length > 0 ? Math.max(...histogram.bins.map((b) => b.count)) : 0; + + const listingsInRange = useMemo( + () => calcListingsInRange(histogram.bins, priceRange), + [histogram.bins, priceRange] + ); + + return { + histogram, + priceRange, + setPriceRange, + priceSliderActive, + setPriceSliderActive, + loadingPrices, + sliderMin, + sliderMax, + maxBinCount, + listingsInRange, + }; +} diff --git a/frontend/src/features/preferences/lib/index.js b/frontend/src/features/preferences/lib/index.js new file mode 100644 index 0000000..1f5ce0c --- /dev/null +++ b/frontend/src/features/preferences/lib/index.js @@ -0,0 +1,127 @@ +// ── Constants ───────────────────────────────────────────────────────────────── + +export const NUM_HISTOGRAM_BINS = 30; + +export const PREFERENCE_PAYLOAD_KEYS = [ + 'target_country', + 'target_state_province', + 'target_city', + 'required_bedrooms', + 'target_bathrooms', + 'target_deposit_amount', + 'furnished_preference', + 'gender_policy', + 'move_in_date', + 'target_lease_type', + 'target_lease_duration_months', + 'lifestyle_preferences', +]; + +// ── Select option arrays ────────────────────────────────────────────────────── + +export const LEASE_TYPE_OPTIONS = [ + { value: 'fixed', label: 'Fixed-term lease' }, + { value: 'month_to_month', label: 'Month-to-month' }, + { value: 'sublet', label: 'Sublet' }, + { value: 'any', label: 'Any' }, +]; + +export const FURNISHED_PREF_OPTIONS = [ + { value: 'required', label: 'Must be furnished' }, + { value: 'preferred', label: 'Prefer furnished' }, + { value: 'no_preference', label: 'No preference' }, +]; + +export const GENDER_POLICY_OPTIONS = [ + { value: 'mixed_ok', label: 'Mixed gender is okay' }, + { value: 'same_gender_only', label: 'Same gender only' }, +]; + +// ── Payload helpers ─────────────────────────────────────────────────────────── + +/** Picks only the recognised preference keys from a source object. */ +export function pickPreferenceFields(source) { + const out = {}; + for (const key of PREFERENCE_PAYLOAD_KEYS) { + if (Object.prototype.hasOwnProperty.call(source || {}, key)) { + out[key] = source[key]; + } + } + return out; +} + +// ── Normalisation helpers ───────────────────────────────────────────────────── + +export function normalizeNumericInput(value) { + if (value === '' || value == null) return null; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function normalizeIntInput(value) { + const parsed = normalizeNumericInput(value); + if (parsed == null) return null; + return Math.trunc(parsed); +} + +export function normalizeBathroomsPreference(value) { + const parsed = normalizeNumericInput(value); + if (parsed == null) return null; + return parsed < 1 ? 1 : Math.round(parsed); +} + +// ── Select option helpers ───────────────────────────────────────────────────── + +function normalizeOptionText(value) { + return String(value || '').trim().toLowerCase(); +} + +/** + * Looks for an option whose value or label case-insensitively matches + * `selectedValue`. Returns the option object or null. + */ +export function findMatchingOption(options, selectedValue) { + if (!selectedValue) return null; + const selectedNorm = normalizeOptionText(selectedValue); + return ( + options.find((opt) => { + const valueNorm = normalizeOptionText(opt?.value); + const labelNorm = normalizeOptionText(opt?.label); + return selectedNorm === valueNorm || selectedNorm === labelNorm; + }) || null + ); +} + +/** + * Returns `options` with `selectedValue` injected at the front when it is not + * already present. This keeps the currently-selected item visible in a Select + * even before the full option list is loaded. + */ +export function withSelectedOption(options, selectedValue) { + if (!selectedValue) return options; + const exists = options.some((opt) => (opt?.value ?? null) === selectedValue); + if (exists) return options; + return [{ value: selectedValue, label: selectedValue }, ...options]; +} + +// ── Price helpers ───────────────────────────────────────────────────────────── + +export function formatPrice(val) { + return `$${Math.round(val).toLocaleString()}`; +} + +/** + * Approximates the number of listings whose price midpoint falls within + * [lo, hi], using proportional overlap for partial bins. + */ +export function calcListingsInRange(bins, priceRange) { + if (!bins || !bins.length) return 0; + const [lo, hi] = priceRange; + return bins.reduce((sum, bin) => { + if (bin.range_max <= lo || bin.range_min >= hi) return sum; + if (bin.range_min >= lo && bin.range_max <= hi) return sum + bin.count; + const overlap = Math.min(bin.range_max, hi) - Math.max(bin.range_min, lo); + const width = bin.range_max - bin.range_min; + return sum + Math.round(bin.count * (overlap / width)); + }, 0); +} From 09b00bbf94b151c4917b4d65678f3297972c8ada Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 21:18:01 -0400 Subject: [PATCH 5/8] Change rate limit --- backend/app/dependencies/rate_limit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/dependencies/rate_limit.py b/backend/app/dependencies/rate_limit.py index 02990aa..11a2fe5 100644 --- a/backend/app/dependencies/rate_limit.py +++ b/backend/app/dependencies/rate_limit.py @@ -26,8 +26,8 @@ # Recommendations is an expensive DB scan + ML scoring pass; frontend caches # results in localStorage for 5 min so legitimate users rarely exceed 1 req/min. # Keeping this low prevents any single user from hammering the pipeline. -RECOMMENDATIONS_AUTH_LIMIT = 10 -RECOMMENDATIONS_GUEST_LIMIT = 5 +RECOMMENDATIONS_AUTH_LIMIT = 20 +RECOMMENDATIONS_GUEST_LIMIT = 10 # ip -> deque of monotonic timestamps within the current window _request_log: dict[str, deque] = defaultdict(deque) @@ -121,7 +121,7 @@ async def recommendations_rate_limit( if len(log) >= limit: raise HTTPException( status_code=429, - detail=f"Too many requests. Recommendations are limited to {limit} per minute. Please wait before refreshing.", + detail=f"Too many requests. Please wait before refreshing.", headers={"Retry-After": str(window)}, ) From 8a3b2c9e3ba5078adf854ba55345ca4ed3d8c884 Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 21:54:28 -0400 Subject: [PATCH 6/8] update rate limit desc --- backend/app/dependencies/rate_limit.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/app/dependencies/rate_limit.py b/backend/app/dependencies/rate_limit.py index 11a2fe5..70654a4 100644 --- a/backend/app/dependencies/rate_limit.py +++ b/backend/app/dependencies/rate_limit.py @@ -101,10 +101,12 @@ async def recommendations_rate_limit( """ Rate limit for the recommendations endpoint — applies to ALL callers. - Guests: 3 req/min — low because they have no persistent state. - Authenticated: 5 req/min — the frontend caches results in localStorage - for 5 min, so legitimate users rarely exceed 1 req/min. - The lower limit prevents pipeline abuse even with auth. + Guests: 10 req/min — reasonable for unauthenticated browsing. + Authenticated: 20 req/min — covers normal multi-page browsing patterns + (Discover has staleTime=0 so every mount fires a request; + Matches caches for 5 min in localStorage so contributes + far less). 20 is well above legitimate use while still + blocking deliberate pipeline hammering. """ ip = _get_client_ip(request) now = time.monotonic() From d998954e10799577f87a1782d4e124f27639cc44 Mon Sep 17 00:00:00 2001 From: QA1S <77554399+QA1S@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:18:28 -0400 Subject: [PATCH 7/8] Fix import paths in useGroupDetailPage hook --- .../src/features/groups/detail/hooks/useGroupDetailPage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js b/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js index 1997e8b..8faaa78 100644 --- a/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js +++ b/frontend/src/features/groups/detail/hooks/useGroupDetailPage.js @@ -4,8 +4,8 @@ import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { notifications } from '@mantine/notifications'; import { IconCheck } from '@tabler/icons-react'; -import { useAuth } from '../../../app/contexts/AuthContext'; -import { apiFetch } from '../../../../lib/api'; +import { useAuth } from '../../../../app/contexts/AuthContext'; +import { apiFetch } from '../../../../../lib/api'; export function useGroupDetailPage() { const router = useRouter(); From 2965005c22c4f3bf8f71fc060c4b6aa58034bbf5 Mon Sep 17 00:00:00 2001 From: Qais A Date: Thu, 9 Apr 2026 22:26:40 -0400 Subject: [PATCH 8/8] frontend ci pass --- frontend/lib/supabaseClient.js | 9 +++++++-- .../groups/detail/components/GroupDetailPageView.jsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/lib/supabaseClient.js b/frontend/lib/supabaseClient.js index 976782d..a481a7c 100644 --- a/frontend/lib/supabaseClient.js +++ b/frontend/lib/supabaseClient.js @@ -1,7 +1,12 @@ import { createClient } from '@supabase/supabase-js'; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; -const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; +// Empty URL throws in createClient; prerender still loads this via Navigation → authService. +// Defaults match frontend CI when env is unset. +const supabaseUrl = + process.env.NEXT_PUBLIC_SUPABASE_URL?.trim() || + 'https://example.supabase.co'; +const supabaseAnonKey = + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim() || 'dummy-anon-key'; export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx b/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx index a3eda31..01192a4 100644 --- a/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx +++ b/frontend/src/features/groups/detail/components/GroupDetailPageView.jsx @@ -55,7 +55,7 @@ import { IconHeart, IconBookmark, } from '@tabler/icons-react'; -import { Navigation } from '../../../app/components/Navigation'; +import { Navigation } from '../../../../app/components/Navigation'; export function GroupDetailPageView(props) { const {