diff --git a/backend/app/dependencies/rate_limit.py b/backend/app/dependencies/rate_limit.py index b02fc0f..70654a4 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 = 20 +RECOMMENDATIONS_GUEST_LIMIT = 10 # ip -> deque of monotonic timestamps within the current window _request_log: dict[str, deque] = defaultdict(deque) @@ -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() @@ -121,7 +123,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)}, ) 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/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/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..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 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 = [ - '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,57 +153,43 @@ 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], 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 || {}; }, 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, @@ -318,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(() => { - fetch(`${API_BASE}/api/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); - - fetch(`${API_BASE}/api/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); - - fetch( - `${API_BASE}/api/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); - fetch( - `${API_BASE}/api/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 () => { @@ -486,16 +258,21 @@ 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 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'); @@ -504,45 +281,46 @@ 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' }); 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}`); @@ -565,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...'} + ); } @@ -635,8 +398,6 @@ export function PreferencesForm() { updatePref('target_country', v); updatePref('target_state_province', null); updatePref('target_city', null); - setStateOptions([]); - setCityOptions([]); setCitySearch(''); }} required @@ -650,7 +411,6 @@ export function PreferencesForm() { onChange={(v) => { updatePref('target_state_province', v); updatePref('target_city', null); - setCityOptions([]); setCitySearch(''); }} searchable @@ -669,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 /> @@ -705,7 +467,9 @@ export function PreferencesForm() { {!prefs.target_city && ( - + Select a city above to see prices in that area )} @@ -726,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)', + }, }} /> @@ -746,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])} @@ -784,11 +557,7 @@ export function PreferencesForm() { - + @@ -802,11 +571,7 @@ export function PreferencesForm() { updatePref('target_lease_type', v)} clearable @@ -837,7 +597,7 @@ export function PreferencesForm() { - {/* ── TIMING ── */} + {/* ── TIMING & HOUSEHOLD ── */} Timing & Household @@ -848,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 /> @@ -867,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'} /> @@ -429,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'} /> @@ -437,7 +353,7 @@ export default function PreferencesSetupPage() { - {/* ── Section 2: Price range ── */} + {/* ── Price range ── */} @@ -450,7 +366,14 @@ export default function PreferencesSetupPage() { {!targetCity && ( - + Select a city above to see prices in that area )} @@ -471,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)', + }, }} /> @@ -491,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])} @@ -502,7 +434,7 @@ export default function PreferencesSetupPage() { - {/* ── Section 3: Rooms ── */} + {/* ── Rooms and beds ── */} Rooms and beds @@ -548,7 +480,9 @@ export default function PreferencesSetupPage() { More filters - {showMoreFilters ? : } + {showMoreFilters + ? + : } @@ -567,12 +501,7 @@ export default function PreferencesSetupPage() { setGenderPolicy(v || 'mixed_ok')} /> @@ -622,11 +544,26 @@ export default function PreferencesSetupPage() { {/* ── Actions ── */} - {isGuest && ( - )} 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..01192a4 --- /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..8faaa78 --- /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: '' }; +} 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); +}