diff --git a/backend-rs/src/routes/flavors.rs b/backend-rs/src/routes/flavors.rs index 69decac..b70e04e 100644 --- a/backend-rs/src/routes/flavors.rs +++ b/backend-rs/src/routes/flavors.rs @@ -9,13 +9,13 @@ use sea_orm::{ use serde::Deserialize; use crate::auth::{AuthUser, OptionalUser}; -use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut}; +use crate::dto::{FlavorOut, RecommendationOut, SearchHit, SimilarFlavorOut}; use crate::entities::prelude::*; use crate::entities::{category, flavor, rating}; use crate::error::{ApiError, ApiResult}; use crate::media::image_field_url; use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE}; -use crate::recommend::{similar_flavors, RatingInput}; +use crate::recommend::{recommend, similar_flavors, RatingInput}; use crate::search::{extract_category_slug, query_words, score_relevance}; use crate::service::build_flavors; use crate::state::AppState; @@ -24,12 +24,21 @@ use crate::web::RequestCtx; /// Max "similar flavors" returned by `/flavors/{id}/similar/`. const SIMILAR_LIMIT: usize = 8; +/// Max "popular picks" returned by `/flavors/popular/`. +const POPULAR_LIMIT: usize = 12; + +/// Sentinel target for the public popularity ranking: a user id that owns no +/// ratings, so the recommender cold-starts straight into its Bayesian-popularity +/// fallback over the whole catalog (nothing excluded as "already rated"). +const POPULAR_SENTINEL: i32 = -1; + pub fn router() -> Router { Router::new() .route("/flavors/", get(list)) .route("/flavors/search/", get(search)) .route("/flavors/top/", get(top)) .route("/flavors/newest/", get(newest)) + .route("/flavors/popular/", get(popular)) .route("/flavors/followed_top/", get(followed_top)) .route("/flavors/{id}/", get(retrieve)) .route("/flavors/{id}/similar/", get(similar)) @@ -238,6 +247,64 @@ async fn newest( )) } +/// GET /api/flavors/popular/ — public algorithmic "popular picks" for the front +/// page (logged-out discovery). Runs the recommender against a ratingless sentinel +/// user so it returns the Bayesian-popularity ranking over the whole catalog — the +/// same engine + payload shape as `/users/recommendations/`, so the frontend reuses +/// the recommendation card. All rows carry `reason: "popular"`. +async fn popular( + State(state): State, + ctx: RequestCtx, + OptionalUser(viewer): OptionalUser, +) -> ApiResult>> { + // Whole rating matrix (hundreds of rows at current scale) → in-process scoring. + let all = Rating::find().all(&state.db).await?; + let inputs: Vec = all + .iter() + .map(|r| RatingInput { + user_id: r.user_id, + flavor_id: r.flavor_id, + score: r.score as f64, + }) + .collect(); + + let recs = recommend(POPULAR_SENTINEL, &inputs, POPULAR_LIMIT); + if recs.is_empty() { + return Ok(Json(Vec::new())); + } + + // Resolve the recommended flavor ids to card payloads, then re-zip with the + // recommendation metadata in the engine's ranked order. + let rec_ids: Vec = recs.iter().map(|r| r.flavor_id).collect(); + let models = Flavor::find() + .filter(flavor::Column::Id.is_in(rec_ids)) + .all(&state.db) + .await?; + let flavors = build_flavors(&state, &ctx, models, viewer, &[], false, false).await?; + let by_id: std::collections::HashMap = flavors.into_iter().map(|f| (f.id, f)).collect(); + + let out: Vec = recs + .into_iter() + .filter_map(|rec| { + by_id.get(&rec.flavor_id).map(|f| RecommendationOut { + id: f.id, + name: f.name.clone(), + image_url: f.image_url.clone(), + category_name: f.category_name.clone(), + category_slug: f.category_slug.clone(), + average_rating: f.average_rating, + is_legacy: f.is_legacy, + is_available: f.is_available, + predicted_score: rec.predicted_score, + contributing_neighbours: rec.contributing_neighbours, + reason: rec.source.as_str().to_string(), + }) + }) + .collect(); + + Ok(Json(out)) +} + /// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the /// caller follows. Requires authentication. async fn followed_top( diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index f78c951..ad4cf12 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -7,6 +7,7 @@ export const queryKeys = { categories: () => ['categories'] as const, dashboard: () => ['dashboard'] as const, recommendations: () => ['recommendations'] as const, + popularRecommendations: () => ['recommendations', 'popular'] as const, publicProfile: (username: string) => ['publicProfile', username] as const, publicProfileAll: () => ['publicProfile'] as const, flavors: (params: Record) => ['flavors', params] as const, diff --git a/frontend/src/api/queries/useRecommendations.ts b/frontend/src/api/queries/useRecommendations.ts index b68bed5..c09198a 100644 --- a/frontend/src/api/queries/useRecommendations.ts +++ b/frontend/src/api/queries/useRecommendations.ts @@ -18,3 +18,20 @@ export const useRecommendations = (enabled = true) => enabled, staleTime: 5 * 60_000, }); + +/** + * Public "popular picks" — the recommender's Bayesian-popularity ranking over the + * whole catalog (backend `GET /flavors/popular/`), used for logged-out front-page + * discovery. Same payload shape as {@link useRecommendations}; every row is + * `reason: "popular"`. Popularity shifts slowly, so a long staleTime is fine. + */ +export const usePopularRecommendations = (enabled = true) => + useQuery({ + queryKey: queryKeys.popularRecommendations(), + queryFn: async () => { + const res = await api.get('flavors/popular/'); + return res.data; + }, + enabled, + staleTime: 5 * 60_000, + }); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 8276d38..6e9986e 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -107,6 +107,10 @@ const resources = { exploreFlavors: 'Explore Flavors', hallOfFame: 'Hall of Fame', hallOfFameSubtitle: 'Highest-rated flavors in the archive', + recForYou: 'Recommended for You', + recForYouSubtitle: 'Picked by tasters who share your palate', + popularPicks: 'Popular Picks', + popularPicksSubtitle: 'Crowd favorites worth discovering', communityVoice: 'Community Voice', newArrivals: 'New Arrivals', newArrivalsSubtitle: 'The latest additions to the catalog', @@ -425,6 +429,10 @@ const resources = { exploreFlavors: 'Sorten entdecken', hallOfFame: 'Hall of Fame', hallOfFameSubtitle: 'Die bestbewerteten Sorten im Archiv', + recForYou: 'Für dich empfohlen', + recForYouSubtitle: 'Ausgewählt von Testern mit ähnlichem Geschmack', + popularPicks: 'Beliebte Sorten', + popularPicksSubtitle: 'Favoriten der Community zum Entdecken', communityVoice: 'Community-Stimmen', newArrivals: 'Neuheiten', newArrivalsSubtitle: 'Die neuesten Zugänge im Katalog', diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 2a71bb4..0d6798f 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -31,6 +31,7 @@ import { FlavorCard, EmptyState, } from '../components/ui'; +import MainPageRecommendations from './MainPageRecommendations'; const TOP_LIMIT = 5; @@ -459,6 +460,9 @@ const MainPage: React.FC = () => { )} + {/* Algorithmic discovery: personalized (members) or popular picks (visitors) */} + + {/* Newest Arrivals */} diff --git a/frontend/src/pages/MainPageRecommendations.tsx b/frontend/src/pages/MainPageRecommendations.tsx new file mode 100644 index 0000000..c12d2ed --- /dev/null +++ b/frontend/src/pages/MainPageRecommendations.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Box, Skeleton } from '@mui/material'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import { useTranslation } from 'react-i18next'; +import { useRecommendations, usePopularRecommendations } from '../api/queries/useRecommendations'; +import RecommendationCard from './dashboard/RecommendationCard'; +import { SectionHeader } from '../components/ui'; + +export interface MainPageRecommendationsProps { + /** Logged-in → personalized CF recs; logged-out → public popularity picks. */ + isLoggedIn: boolean; +} + +const GRID_SX = { + display: 'grid', + gridTemplateColumns: { + xs: 'repeat(2, minmax(0, 1fr))', + sm: 'repeat(3, minmax(0, 1fr))', + md: 'repeat(4, minmax(0, 1fr))', + lg: 'repeat(6, minmax(0, 1fr))', + }, + gap: 2, + width: '100%', + minWidth: 0, +} as const; + +/** + * Algorithmic discovery row for the front page. Members get "Recommended for you" + * (collaborative filtering); visitors get "Popular Picks" (Bayesian-popularity + * ranking over the whole catalog). Both reuse {@link RecommendationCard}. The + * section hides itself entirely when the engine returns nothing. + */ +const MainPageRecommendations: React.FC = ({ isLoggedIn }) => { + const { t } = useTranslation(); + + const personalized = useRecommendations(isLoggedIn); + const popular = usePopularRecommendations(!isLoggedIn); + const { data, isLoading } = isLoggedIn ? personalized : popular; + + if (isLoading) { + return ( + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + + ); + } + + if (!data || data.length === 0) return null; + + return ( + + + + {isLoggedIn ? t('home.recForYou') : t('home.popularPicks')} + + } + subtitle={isLoggedIn ? t('home.recForYouSubtitle') : t('home.popularPicksSubtitle')} + /> + + {data.map((rec) => ( + + ))} + + + ); +}; + +export default MainPageRecommendations;