mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
dashboard redesign
This commit is contained in:
1 parent
cc916e472a
commit
8aaf4fc3d7
23 files changed
+2326
-1044
No files matched your search
@@ -74,6 +74,35 @@ Verify mutations `invalidateQueries`; consider optimistic insert so reviews appe
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Implementation status
|
## Implementation status
|
||||||
|
- [x] P2 — investigated, **no code change** (no measurable win post-P1).
|
||||||
|
`avg_score_expr()` is used only in `ORDER BY` (`flavors.rs:97/145/199`), never
|
||||||
|
selected as a column — it's *required* for sorting+paginating by rating and
|
||||||
|
can't be dropped. The Rust avg is computed inside the single rating loop that
|
||||||
|
also builds distribution/followed-avg/viewer-score (`service.rs:341-400`),
|
||||||
|
which runs regardless; the avg is `sum/n` accumulated in that same loop, so
|
||||||
|
removing it saves one division per flavor (≤50/page) — negligible. The real
|
||||||
|
cost (loading all ratings for `list`) is intentional: `FlavorList.tsx:144-171`
|
||||||
|
reads `flavor.ratings` for the comment count + previews, so `list` keeps
|
||||||
|
`include_ratings=true`. Slimming that is a P1-class payload change needing a
|
||||||
|
frontend refactor, out of P2 scope. Closed as wontfix.
|
||||||
|
- [x] Q4 follow-up — `useCurrentUserLite.ts` had the same `...({ skipAuthRedirect:
|
||||||
|
true } as object)` cast as the (now-fixed) `AuthContext`; switched it to
|
||||||
|
`{ skipAuthRedirect: true } as RetriableConfig` using the type exported in Q4.
|
||||||
|
`tsc` + ESLint (0 errors) clean.
|
||||||
|
- [x] U1 — optimistic UI on rating + reply submit. `useCreateRating` /
|
||||||
|
`useCreateReply` (`useRatingMutations.ts`) now run `onMutate`/`onError`/`onSettled`
|
||||||
|
against the `flavorDetail` cache: insert a temp row (negative id, replaced on
|
||||||
|
refetch), roll back on error from the snapshot, and invalidate on settle. Author
|
||||||
|
info is passed through the input — `RatingForm` supplies `optimistic` from
|
||||||
|
`useAuth().user`; `FlavorDetail.handleReplySubmit` supplies `flavorId` +
|
||||||
|
`optimisticUser`. Both optimistic paths are opt-in (skip if author info absent),
|
||||||
|
so other callers are unaffected. New review/reply appears instantly, reconciles
|
||||||
|
on the round-trip. `tsc`, ESLint (0 errors), 27 tests clean.
|
||||||
|
- [x] Q3 — fixed stale CLAUDE.md frontend Auth paragraph. It described the
|
||||||
|
retired localStorage `access`/`refresh` JWT scheme; rewritten to match
|
||||||
|
`lib/api.ts`: httpOnly `access_token`/`refresh_token` cookies, `withCredentials`,
|
||||||
|
401 auto-refresh via `auth/token/refresh/`, `skipAuthRedirect`/`RetriableConfig`
|
||||||
|
escape hatch, and `clearLegacyToken()` purging all three retired localStorage keys.
|
||||||
- [x] Q4 — dropped the `as object` cast. Exported `RetriableConfig` from
|
- [x] Q4 — dropped the `as object` cast. Exported `RetriableConfig` from
|
||||||
`lib/api.ts`; `AuthContext.refetchUser` now passes `{ skipAuthRedirect: true }
|
`lib/api.ts`; `AuthContext.refetchUser` now passes `{ skipAuthRedirect: true }
|
||||||
as RetriableConfig` (the field is part of that type) instead of the
|
as RetriableConfig` (the field is part of that type) instead of the
|
||||||
|
|||||||
+64
-1
@@ -159,14 +159,77 @@ pub struct PublicProfileOut {
|
|||||||
pub following: Vec<UserOut>,
|
pub following: Vec<UserOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's most-rated category.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct FavoriteCategory {
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate stats over the current user's own ratings, surfaced on the dashboard.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct DashboardStats {
|
||||||
|
/// Mean of the scores the user has given (`None` when they have no ratings).
|
||||||
|
pub average_score: Option<f64>,
|
||||||
|
/// Count of the user's ratings per score, keyed `"1".."10"` (always all 10 keys).
|
||||||
|
pub score_distribution: std::collections::BTreeMap<String, i64>,
|
||||||
|
/// The category the user has rated most (`None` when they have no ratings).
|
||||||
|
pub favorite_category: Option<FavoriteCategory>,
|
||||||
|
/// Ratings the user created in the current calendar month (UTC).
|
||||||
|
pub ratings_this_month: i64,
|
||||||
|
/// Number of distinct categories the user has rated in.
|
||||||
|
pub category_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Custom payload of the `users/dashboard` action.
|
/// Custom payload of the `users/dashboard` action.
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct DashboardOut {
|
pub struct DashboardOut {
|
||||||
pub user: UserOut,
|
pub user: UserOut,
|
||||||
pub rated_count: i64,
|
pub rated_count: i64,
|
||||||
pub missing_count: i64,
|
pub missing_count: i64,
|
||||||
pub missing_flavors: Vec<FlavorOut>,
|
|
||||||
pub my_ratings: Vec<RatingOut>,
|
pub my_ratings: Vec<RatingOut>,
|
||||||
|
pub stats: DashboardStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recommended flavor (card payload + the recommendation metadata). Mirrors the
|
||||||
|
/// fields the frontend flavor card reads, plus the prediction + reason.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct RecommendationOut {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub category_name: String,
|
||||||
|
pub category_slug: String,
|
||||||
|
pub average_rating: Option<f64>,
|
||||||
|
pub is_legacy: bool,
|
||||||
|
pub is_available: bool,
|
||||||
|
/// Predicted score for this user (CF) or the flavor's damped community score
|
||||||
|
/// (popularity fallback).
|
||||||
|
pub predicted_score: f64,
|
||||||
|
/// "N tasters like you" count (CF) or the flavor's rating count (popularity).
|
||||||
|
pub contributing_neighbours: i64,
|
||||||
|
/// `"cf"` or `"popular"` — frontend picks the reason copy. Names are never
|
||||||
|
/// surfaced (privacy decision).
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item-based "people who liked this flavor also liked…" card, served on
|
||||||
|
/// `GET /api/flavors/{id}/similar/`. Flavor card fields plus the similarity metadata.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SimilarFlavorOut {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub category_name: String,
|
||||||
|
pub category_slug: String,
|
||||||
|
pub average_rating: Option<f64>,
|
||||||
|
pub is_legacy: bool,
|
||||||
|
pub is_available: bool,
|
||||||
|
/// Adjusted-cosine similarity to the target flavor, in (0, 1].
|
||||||
|
pub similarity: f64,
|
||||||
|
/// Users who rated both this flavor and the target (the confidence count).
|
||||||
|
pub co_raters: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod openapi;
|
|||||||
mod pagination;
|
mod pagination;
|
||||||
mod password;
|
mod password;
|
||||||
mod password_policy;
|
mod password_policy;
|
||||||
|
mod recommend;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod search;
|
mod search;
|
||||||
mod service;
|
mod service;
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
//! Flavor recommendations: "tasters like you also liked X".
|
||||||
|
//!
|
||||||
|
//! User-based collaborative filtering (CF) with a Bayesian-popularity fallback for
|
||||||
|
//! cold-start users. Kept pure (no DB) so the scoring math is unit-testable; the
|
||||||
|
//! route handler loads the rating matrix and calls [`recommend`].
|
||||||
|
//!
|
||||||
|
//! At the current scale (tens of users, hundreds of ratings) this runs in-process
|
||||||
|
//! per request in well under a millisecond. If the dataset grows by orders of
|
||||||
|
//! magnitude, move the call behind a scheduled job that precomputes per-user results
|
||||||
|
//! — the math here is unchanged.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
/// One cell of the user×flavor rating matrix.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct RatingInput {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub flavor_id: i32,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a recommendation came from — drives the frontend copy ("cf" → "tasters like
|
||||||
|
/// you", "popular" → "popular in the community").
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RecSource {
|
||||||
|
/// Collaborative filtering over similar users.
|
||||||
|
Cf,
|
||||||
|
/// Cold-start / fallback Bayesian-popularity ranking.
|
||||||
|
Popular,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecSource {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RecSource::Cf => "cf",
|
||||||
|
RecSource::Popular => "popular",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Recommendation {
|
||||||
|
pub flavor_id: i32,
|
||||||
|
pub predicted_score: f64,
|
||||||
|
/// Number of similar users whose ratings backed this (CF), or the flavor's total
|
||||||
|
/// rating count (popularity). Surfaced as the "N tasters" reason.
|
||||||
|
pub contributing_neighbours: i64,
|
||||||
|
pub source: RecSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tunables ----------------------------------------------------------------
|
||||||
|
/// Below this many of their own ratings a user is "cold" → popularity fallback.
|
||||||
|
const MIN_PROFILE: usize = 5;
|
||||||
|
/// A neighbour must share at least this many co-rated flavors to be trusted.
|
||||||
|
const MIN_OVERLAP: usize = 3;
|
||||||
|
/// Significance-weighting cap: similarity is scaled by `min(overlap, K)/K`.
|
||||||
|
const SIG_K: f64 = 5.0;
|
||||||
|
/// A CF candidate needs at least this many contributing neighbours.
|
||||||
|
const MIN_NEIGHBOURS: i64 = 2;
|
||||||
|
/// Bayesian prior strength for the popularity fallback.
|
||||||
|
const BAYES_C: f64 = 5.0;
|
||||||
|
|
||||||
|
/// Recommend up to `limit` flavors for `target`, given the full rating matrix.
|
||||||
|
pub fn recommend(target: i32, ratings: &[RatingInput], limit: usize) -> Vec<Recommendation> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// user -> (flavor -> score)
|
||||||
|
let mut by_user: HashMap<i32, HashMap<i32, f64>> = HashMap::new();
|
||||||
|
for r in ratings {
|
||||||
|
by_user
|
||||||
|
.entry(r.user_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(r.flavor_id, r.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_ratings = by_user.get(&target).cloned().unwrap_or_default();
|
||||||
|
let target_rated: HashSet<i32> = target_ratings.keys().copied().collect();
|
||||||
|
|
||||||
|
// Cold start: too thin a profile to trust similarity → popularity.
|
||||||
|
if target_ratings.len() < MIN_PROFILE {
|
||||||
|
return popularity(&target_rated, ratings, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
let means: HashMap<i32, f64> = by_user
|
||||||
|
.iter()
|
||||||
|
.map(|(&u, rs)| (u, mean(rs.values().copied())))
|
||||||
|
.collect();
|
||||||
|
let target_mean = *means.get(&target).unwrap_or(&0.0);
|
||||||
|
|
||||||
|
// Similarity (positive Pearson, significance-weighted) to each other user.
|
||||||
|
let mut sims: HashMap<i32, f64> = HashMap::new();
|
||||||
|
for (&v, v_ratings) in &by_user {
|
||||||
|
if v == target {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let v_mean = *means.get(&v).unwrap_or(&0.0);
|
||||||
|
let common: Vec<i32> = target_ratings
|
||||||
|
.keys()
|
||||||
|
.filter(|f| v_ratings.contains_key(f))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if common.len() < MIN_OVERLAP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (mut num, mut du, mut dv) = (0.0, 0.0, 0.0);
|
||||||
|
for f in &common {
|
||||||
|
let a = target_ratings[f] - target_mean;
|
||||||
|
let b = v_ratings[f] - v_mean;
|
||||||
|
num += a * b;
|
||||||
|
du += a * a;
|
||||||
|
dv += b * b;
|
||||||
|
}
|
||||||
|
let den = du.sqrt() * dv.sqrt();
|
||||||
|
if den == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut sim = num / den;
|
||||||
|
if sim <= 0.0 {
|
||||||
|
continue; // only positively-correlated tasters
|
||||||
|
}
|
||||||
|
sim *= (common.len().min(SIG_K as usize) as f64) / SIG_K;
|
||||||
|
sims.insert(v, sim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predict a score for every flavor the target hasn't rated, from its neighbours.
|
||||||
|
let mut acc: HashMap<i32, (f64, f64, i64)> = HashMap::new(); // flavor -> (num, |sim| sum, count)
|
||||||
|
for (&v, &sim) in &sims {
|
||||||
|
let v_mean = *means.get(&v).unwrap_or(&0.0);
|
||||||
|
for (&f, &score) in &by_user[&v] {
|
||||||
|
if target_rated.contains(&f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let e = acc.entry(f).or_insert((0.0, 0.0, 0));
|
||||||
|
e.0 += sim * (score - v_mean);
|
||||||
|
e.1 += sim.abs();
|
||||||
|
e.2 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut recs: Vec<Recommendation> = acc
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, (_, den, count))| *count >= MIN_NEIGHBOURS && *den > 0.0)
|
||||||
|
.map(|(flavor_id, (num, den, count))| Recommendation {
|
||||||
|
flavor_id,
|
||||||
|
predicted_score: (target_mean + num / den).clamp(1.0, 10.0),
|
||||||
|
contributing_neighbours: count,
|
||||||
|
source: RecSource::Cf,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// No confident CF candidates → fall back to popularity.
|
||||||
|
if recs.is_empty() {
|
||||||
|
return popularity(&target_rated, ratings, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_recs(&mut recs);
|
||||||
|
recs.truncate(limit);
|
||||||
|
recs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bayesian-average popularity ranking over flavors the target hasn't rated. Damps
|
||||||
|
/// flavors with few ratings toward the global mean so one 10/10 can't top the chart.
|
||||||
|
fn popularity(
|
||||||
|
target_rated: &HashSet<i32>,
|
||||||
|
ratings: &[RatingInput],
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<Recommendation> {
|
||||||
|
let global_mean = mean(ratings.iter().map(|r| r.score));
|
||||||
|
let mut agg: HashMap<i32, (f64, i64)> = HashMap::new(); // flavor -> (sum, count)
|
||||||
|
for r in ratings {
|
||||||
|
if target_rated.contains(&r.flavor_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let e = agg.entry(r.flavor_id).or_insert((0.0, 0));
|
||||||
|
e.0 += r.score;
|
||||||
|
e.1 += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut recs: Vec<Recommendation> = agg
|
||||||
|
.into_iter()
|
||||||
|
.map(|(flavor_id, (sum, count))| Recommendation {
|
||||||
|
flavor_id,
|
||||||
|
predicted_score: (BAYES_C * global_mean + sum) / (BAYES_C + count as f64),
|
||||||
|
contributing_neighbours: count,
|
||||||
|
source: RecSource::Popular,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
sort_recs(&mut recs);
|
||||||
|
recs.truncate(limit);
|
||||||
|
recs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic ordering: predicted score desc, then more neighbours, then flavor id.
|
||||||
|
fn sort_recs(recs: &mut [Recommendation]) {
|
||||||
|
recs.sort_by(|a, b| {
|
||||||
|
b.predicted_score
|
||||||
|
.partial_cmp(&a.predicted_score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then(b.contributing_neighbours.cmp(&a.contributing_neighbours))
|
||||||
|
.then(a.flavor_id.cmp(&b.flavor_id))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mean(values: impl Iterator<Item = f64>) -> f64 {
|
||||||
|
let (sum, n) = values.fold((0.0, 0i64), |(s, n), v| (s + v, n + 1));
|
||||||
|
if n == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
sum / n as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Item-based CF: "people who liked this flavor also liked…" ---------------
|
||||||
|
/// A flavor co-rated needs at least this many shared raters with the target to be
|
||||||
|
/// trusted — similarity over 1 shared user is noise.
|
||||||
|
const MIN_CORATERS: usize = 2;
|
||||||
|
|
||||||
|
/// One flavor similar to a target flavor.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SimilarFlavor {
|
||||||
|
pub flavor_id: i32,
|
||||||
|
/// Adjusted-cosine similarity in (0, 1]. Higher = more alike.
|
||||||
|
pub similarity: f64,
|
||||||
|
/// How many users rated both this flavor and the target (the confidence count).
|
||||||
|
pub co_raters: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flavors most similar to `target_flavor`, by **adjusted cosine** over the user
|
||||||
|
/// rating vectors (each user's ratings mean-centred to cancel rater bias). Pure (no
|
||||||
|
/// DB) and computed on demand; at current scale this is sub-millisecond.
|
||||||
|
///
|
||||||
|
/// Returns only positively-correlated flavors sharing at least [`MIN_CORATERS`]
|
||||||
|
/// raters with the target, ranked by similarity. Empty if the target has too few
|
||||||
|
/// raters to compare against.
|
||||||
|
pub fn similar_flavors(
|
||||||
|
target_flavor: i32,
|
||||||
|
ratings: &[RatingInput],
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<SimilarFlavor> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// user -> mean of their ratings (for mean-centring)
|
||||||
|
let mut by_user: HashMap<i32, Vec<f64>> = HashMap::new();
|
||||||
|
for r in ratings {
|
||||||
|
by_user.entry(r.user_id).or_default().push(r.score);
|
||||||
|
}
|
||||||
|
let user_mean: HashMap<i32, f64> = by_user
|
||||||
|
.iter()
|
||||||
|
.map(|(&u, vs)| (u, mean(vs.iter().copied())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// flavor -> (user -> mean-centred score)
|
||||||
|
let mut by_flavor: HashMap<i32, HashMap<i32, f64>> = HashMap::new();
|
||||||
|
for r in ratings {
|
||||||
|
let centred = r.score - user_mean.get(&r.user_id).copied().unwrap_or(0.0);
|
||||||
|
by_flavor
|
||||||
|
.entry(r.flavor_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(r.user_id, centred);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(target_vec) = by_flavor.get(&target_flavor) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sims: Vec<SimilarFlavor> = Vec::new();
|
||||||
|
for (&f, f_vec) in &by_flavor {
|
||||||
|
if f == target_flavor {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// co-rated users
|
||||||
|
let (mut num, mut dt, mut df, mut co) = (0.0, 0.0, 0.0, 0i64);
|
||||||
|
for (&u, &a) in target_vec {
|
||||||
|
if let Some(&b) = f_vec.get(&u) {
|
||||||
|
num += a * b;
|
||||||
|
dt += a * a;
|
||||||
|
df += b * b;
|
||||||
|
co += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (co as usize) < MIN_CORATERS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let den = dt.sqrt() * df.sqrt();
|
||||||
|
if den == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sim = num / den;
|
||||||
|
if sim <= 0.0 {
|
||||||
|
continue; // only positively-correlated flavors
|
||||||
|
}
|
||||||
|
sims.push(SimilarFlavor {
|
||||||
|
flavor_id: f,
|
||||||
|
similarity: sim,
|
||||||
|
co_raters: co,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic: similarity desc, then more co-raters, then flavor id.
|
||||||
|
sims.sort_by(|a, b| {
|
||||||
|
b.similarity
|
||||||
|
.partial_cmp(&a.similarity)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then(b.co_raters.cmp(&a.co_raters))
|
||||||
|
.then(a.flavor_id.cmp(&b.flavor_id))
|
||||||
|
});
|
||||||
|
sims.truncate(limit);
|
||||||
|
sims
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn r(user_id: i32, flavor_id: i32, score: f64) -> RatingInput {
|
||||||
|
RatingInput {
|
||||||
|
user_id,
|
||||||
|
flavor_id,
|
||||||
|
score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cold_start_user_gets_popularity() {
|
||||||
|
// Target (1) has only 1 rating → cold. Others rate flavors 10/20.
|
||||||
|
let ratings = vec![
|
||||||
|
r(1, 100, 9.0),
|
||||||
|
r(2, 10, 9.0),
|
||||||
|
r(2, 20, 4.0),
|
||||||
|
r(3, 10, 8.0),
|
||||||
|
r(3, 20, 5.0),
|
||||||
|
];
|
||||||
|
let recs = recommend(1, &ratings, 10);
|
||||||
|
assert!(!recs.is_empty());
|
||||||
|
assert!(recs.iter().all(|x| x.source == RecSource::Popular));
|
||||||
|
// Flavor 10 (avg ~8.5) should outrank flavor 20 (avg ~4.5).
|
||||||
|
assert_eq!(recs[0].flavor_id, 10);
|
||||||
|
// Already-rated flavor 100 is excluded.
|
||||||
|
assert!(recs.iter().all(|x| x.flavor_id != 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_recommends_what_similar_tasters_liked() {
|
||||||
|
// Target (1) has a full profile (>= MIN_PROFILE ratings). Users 2 & 4 share
|
||||||
|
// its taste and both loved flavor 99, which the target hasn't tried. User 3
|
||||||
|
// is an opposite-taste decoy who rated 99 low — must be filtered out.
|
||||||
|
let mut ratings = vec![
|
||||||
|
// target's profile
|
||||||
|
r(1, 1, 9.0),
|
||||||
|
r(1, 2, 8.0),
|
||||||
|
r(1, 3, 7.0),
|
||||||
|
r(1, 4, 9.0),
|
||||||
|
r(1, 5, 6.0),
|
||||||
|
// similar taster 2 (agrees, plus loves 99)
|
||||||
|
r(2, 1, 9.0),
|
||||||
|
r(2, 2, 8.0),
|
||||||
|
r(2, 3, 7.0),
|
||||||
|
r(2, 4, 9.0),
|
||||||
|
r(2, 99, 10.0),
|
||||||
|
// similar taster 4 (>= MIN_OVERLAP, also loves 99) → 2nd neighbour for 99
|
||||||
|
r(4, 1, 9.0),
|
||||||
|
r(4, 2, 8.0),
|
||||||
|
r(4, 3, 7.0),
|
||||||
|
r(4, 99, 10.0),
|
||||||
|
];
|
||||||
|
// opposite-taste user rates 99 low — negative correlation, must be filtered
|
||||||
|
ratings.push(r(3, 1, 1.0));
|
||||||
|
ratings.push(r(3, 2, 2.0));
|
||||||
|
ratings.push(r(3, 3, 3.0));
|
||||||
|
ratings.push(r(3, 99, 1.0));
|
||||||
|
|
||||||
|
let recs = recommend(1, &ratings, 10);
|
||||||
|
assert!(!recs.is_empty(), "expected CF recommendations");
|
||||||
|
assert!(recs.iter().any(|x| x.source == RecSource::Cf));
|
||||||
|
// The standout shared pick (99) should be recommended.
|
||||||
|
let top = &recs[0];
|
||||||
|
assert_eq!(top.flavor_id, 99);
|
||||||
|
assert!(top.contributing_neighbours >= MIN_NEIGHBOURS);
|
||||||
|
assert!(top.predicted_score > 7.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excludes_already_rated_and_respects_limit() {
|
||||||
|
let ratings = vec![
|
||||||
|
r(1, 1, 9.0),
|
||||||
|
r(1, 2, 8.0),
|
||||||
|
r(1, 3, 7.0),
|
||||||
|
r(1, 4, 6.0),
|
||||||
|
r(1, 5, 5.0),
|
||||||
|
r(2, 1, 9.0),
|
||||||
|
r(2, 2, 8.0),
|
||||||
|
r(2, 3, 7.0),
|
||||||
|
r(2, 6, 9.0),
|
||||||
|
r(2, 7, 8.0),
|
||||||
|
r(3, 1, 9.0),
|
||||||
|
r(3, 2, 8.0),
|
||||||
|
r(3, 3, 7.0),
|
||||||
|
r(3, 6, 9.0),
|
||||||
|
r(3, 7, 8.0),
|
||||||
|
];
|
||||||
|
let recs = recommend(1, &ratings, 1);
|
||||||
|
assert_eq!(recs.len(), 1);
|
||||||
|
// never recommend a flavor the target already rated
|
||||||
|
assert!(recs.iter().all(|x| ![1, 2, 3, 4, 5].contains(&x.flavor_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similar_flavors_finds_co_liked() {
|
||||||
|
// Flavors 1 & 2 move together (users who like one like the other); flavor 3
|
||||||
|
// moves opposite. Target = flavor 1 → expect 2 ranked above (or instead of) 3.
|
||||||
|
let ratings = vec![
|
||||||
|
r(10, 1, 9.0),
|
||||||
|
r(10, 2, 9.0),
|
||||||
|
r(10, 3, 2.0),
|
||||||
|
r(11, 1, 8.0),
|
||||||
|
r(11, 2, 8.0),
|
||||||
|
r(11, 3, 3.0),
|
||||||
|
r(12, 1, 3.0),
|
||||||
|
r(12, 2, 2.0),
|
||||||
|
r(12, 3, 9.0),
|
||||||
|
];
|
||||||
|
let sims = similar_flavors(1, &ratings, 10);
|
||||||
|
assert!(!sims.is_empty(), "expected similar flavors");
|
||||||
|
assert_eq!(sims[0].flavor_id, 2, "flavor 2 moves with flavor 1");
|
||||||
|
assert!(sims[0].co_raters >= MIN_CORATERS as i64);
|
||||||
|
// The anti-correlated flavor 3 is filtered out (negative similarity).
|
||||||
|
assert!(sims.iter().all(|s| s.flavor_id != 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similar_flavors_needs_min_coraters() {
|
||||||
|
// Only one shared rater between flavor 1 and 2 → below MIN_CORATERS → empty.
|
||||||
|
let ratings = vec![r(10, 1, 9.0), r(10, 2, 9.0), r(11, 1, 8.0), r(12, 2, 7.0)];
|
||||||
|
assert!(similar_flavors(1, &ratings, 10).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similar_flavors_unknown_target_is_empty() {
|
||||||
|
let ratings = vec![r(10, 1, 9.0), r(11, 1, 8.0)];
|
||||||
|
assert!(similar_flavors(999, &ratings, 10).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,17 +9,21 @@ use sea_orm::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::auth::{AuthUser, OptionalUser};
|
use crate::auth::{AuthUser, OptionalUser};
|
||||||
use crate::dto::{FlavorOut, SearchHit};
|
use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut};
|
||||||
use crate::entities::prelude::*;
|
use crate::entities::prelude::*;
|
||||||
use crate::entities::{category, flavor, rating};
|
use crate::entities::{category, flavor, rating};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::media::image_field_url;
|
use crate::media::image_field_url;
|
||||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||||
|
use crate::recommend::{similar_flavors, RatingInput};
|
||||||
use crate::search::{extract_category_slug, query_words, score_relevance};
|
use crate::search::{extract_category_slug, query_words, score_relevance};
|
||||||
use crate::service::build_flavors;
|
use crate::service::build_flavors;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::web::RequestCtx;
|
use crate::web::RequestCtx;
|
||||||
|
|
||||||
|
/// Max "similar flavors" returned by `/flavors/{id}/similar/`.
|
||||||
|
const SIMILAR_LIMIT: usize = 8;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/flavors/", get(list))
|
.route("/flavors/", get(list))
|
||||||
@@ -28,6 +32,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/flavors/newest/", get(newest))
|
.route("/flavors/newest/", get(newest))
|
||||||
.route("/flavors/followed_top/", get(followed_top))
|
.route("/flavors/followed_top/", get(followed_top))
|
||||||
.route("/flavors/{id}/", get(retrieve))
|
.route("/flavors/{id}/", get(retrieve))
|
||||||
|
.route("/flavors/{id}/similar/", get(similar))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -122,6 +127,69 @@ async fn retrieve(
|
|||||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/flavors/{id}/similar/
|
||||||
|
///
|
||||||
|
/// Item-based collaborative filtering: "people who liked this flavor also liked…".
|
||||||
|
/// Adjusted-cosine over the rating matrix (see `crate::recommend::similar_flavors`).
|
||||||
|
/// Empty (not 404) when the catalog has too few co-ratings to compare.
|
||||||
|
async fn similar(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ctx: RequestCtx,
|
||||||
|
OptionalUser(viewer): OptionalUser,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> ApiResult<Json<Vec<SimilarFlavorOut>>> {
|
||||||
|
// Target must exist.
|
||||||
|
Flavor::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
// Whole rating matrix (hundreds of rows at current scale) → in-process scoring.
|
||||||
|
let all = Rating::find().all(&state.db).await?;
|
||||||
|
let inputs: Vec<RatingInput> = all
|
||||||
|
.iter()
|
||||||
|
.map(|r| RatingInput {
|
||||||
|
user_id: r.user_id,
|
||||||
|
flavor_id: r.flavor_id,
|
||||||
|
score: r.score as f64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sims = similar_flavors(id, &inputs, SIMILAR_LIMIT);
|
||||||
|
if sims.is_empty() {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve ids to card payloads, then re-zip in similarity-ranked order.
|
||||||
|
let sim_ids: Vec<i32> = sims.iter().map(|s| s.flavor_id).collect();
|
||||||
|
let models = Flavor::find()
|
||||||
|
.filter(flavor::Column::Id.is_in(sim_ids))
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let flavors = build_flavors(&state, &ctx, models, viewer, &[], false, false).await?;
|
||||||
|
let by_id: std::collections::HashMap<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
|
||||||
|
|
||||||
|
let out: Vec<SimilarFlavorOut> = sims
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
by_id.get(&s.flavor_id).map(|f| SimilarFlavorOut {
|
||||||
|
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,
|
||||||
|
similarity: s.similarity,
|
||||||
|
co_raters: s.co_raters,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CategoryParam {
|
struct CategoryParam {
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
|
|||||||
+206
-44
@@ -16,6 +16,7 @@ use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
|
|||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::routing::{get, patch, post};
|
use axum::routing::{get, patch, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
use chrono::Datelike;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
|
||||||
@@ -26,11 +27,15 @@ use serde_json::{json, Value};
|
|||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::auth::{AuthUser, OptionalUser};
|
use crate::auth::{AuthUser, OptionalUser};
|
||||||
use crate::dto::{DashboardOut, ProfileCommentOut, PublicProfileOut, UserOut};
|
use crate::dto::{
|
||||||
|
DashboardOut, DashboardStats, FavoriteCategory, ProfileCommentOut, PublicProfileOut,
|
||||||
|
RecommendationOut, UserOut,
|
||||||
|
};
|
||||||
use crate::entities::prelude::*;
|
use crate::entities::prelude::*;
|
||||||
use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip};
|
use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||||
|
use crate::recommend::{recommend, RatingInput};
|
||||||
use crate::service::{
|
use crate::service::{
|
||||||
build_flavors, build_profile_comments, build_ratings, build_user, build_users,
|
build_flavors, build_profile_comments, build_ratings, build_user, build_users,
|
||||||
};
|
};
|
||||||
@@ -91,6 +96,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
post(confirm_account_deletion),
|
post(confirm_account_deletion),
|
||||||
)
|
)
|
||||||
.route("/users/dashboard/", get(dashboard))
|
.route("/users/dashboard/", get(dashboard))
|
||||||
|
.route("/users/recommendations/", get(recommendations))
|
||||||
.route("/users/{id}/", get(retrieve))
|
.route("/users/{id}/", get(retrieve))
|
||||||
.route("/users/{id}/follow/", post(follow))
|
.route("/users/{id}/follow/", post(follow))
|
||||||
.route("/users/{id}/unfollow/", post(unfollow))
|
.route("/users/{id}/unfollow/", post(unfollow))
|
||||||
@@ -1315,7 +1321,6 @@ async fn dashboard(
|
|||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> ApiResult<Json<DashboardOut>> {
|
) -> ApiResult<Json<DashboardOut>> {
|
||||||
let me = load_user(&state, uid).await?;
|
let me = load_user(&state, uid).await?;
|
||||||
let followed = following_ids(&state, uid).await?;
|
|
||||||
|
|
||||||
let my_ratings_models = Rating::find()
|
let my_ratings_models = Rating::find()
|
||||||
.filter(rating::Column::UserId.eq(uid))
|
.filter(rating::Column::UserId.eq(uid))
|
||||||
@@ -1325,10 +1330,24 @@ async fn dashboard(
|
|||||||
let rated_count = my_ratings_models.len() as i64;
|
let rated_count = my_ratings_models.len() as i64;
|
||||||
let rated_flavor_ids: std::collections::HashSet<i32> =
|
let rated_flavor_ids: std::collections::HashSet<i32> =
|
||||||
my_ratings_models.iter().map(|r| r.flavor_id).collect();
|
my_ratings_models.iter().map(|r| r.flavor_id).collect();
|
||||||
let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?;
|
|
||||||
|
|
||||||
// Missing flavors: not rated by me, excluding the "Packs and other" category,
|
// Ratings created in the current UTC month — computed from the models while we
|
||||||
// ordered by category name then flavor name.
|
// still have proper datetimes (RatingOut.created_at is a formatted string).
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
let (this_year, this_month) = (now.year(), now.month());
|
||||||
|
let ratings_this_month = my_ratings_models
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.created_at.year() == this_year && r.created_at.month() == this_month)
|
||||||
|
.count() as i64;
|
||||||
|
|
||||||
|
let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?;
|
||||||
|
let stats = dashboard_stats(&my_ratings, ratings_this_month);
|
||||||
|
|
||||||
|
// "To try" count: flavors the user hasn't rated, excluding the "Packs and other"
|
||||||
|
// category. We no longer ship the full unrated flavor list — the dashboard's old
|
||||||
|
// "explore" grid is gone (browsing lives on the main flavor list, discovery on the
|
||||||
|
// recommendations endpoint), which drops a large per-request payload + the
|
||||||
|
// per-flavor aggregate pass. Only the count is needed now, so load ids alone.
|
||||||
let packs = crate::entities::category::Entity::find()
|
let packs = crate::entities::category::Entity::find()
|
||||||
.filter(crate::entities::category::Column::Name.eq("Packs and other"))
|
.filter(crate::entities::category::Column::Name.eq("Packs and other"))
|
||||||
.one(&state.db)
|
.one(&state.db)
|
||||||
@@ -1337,52 +1356,195 @@ async fn dashboard(
|
|||||||
if let Some(p) = packs {
|
if let Some(p) = packs {
|
||||||
q = q.filter(flavor::Column::CategoryId.ne(p.id));
|
q = q.filter(flavor::Column::CategoryId.ne(p.id));
|
||||||
}
|
}
|
||||||
let mut all_flavors = q.all(&state.db).await?;
|
let missing_count = q
|
||||||
all_flavors.retain(|f| !rated_flavor_ids.contains(&f.id));
|
.all(&state.db)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !rated_flavor_ids.contains(&f.id))
|
||||||
|
.count() as i64;
|
||||||
|
|
||||||
// Order by category name then flavor name (resolve category names first).
|
|
||||||
let cat_ids: Vec<i32> = all_flavors.iter().map(|f| f.category_id).collect();
|
|
||||||
let cat_names: std::collections::HashMap<i32, String> =
|
|
||||||
crate::entities::category::Entity::find()
|
|
||||||
.filter(crate::entities::category::Column::Id.is_in(cat_ids))
|
|
||||||
.all(&state.db)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| (c.id, c.name))
|
|
||||||
.collect();
|
|
||||||
all_flavors.sort_by(|a, b| {
|
|
||||||
let ca = cat_names
|
|
||||||
.get(&a.category_id)
|
|
||||||
.map(String::as_str)
|
|
||||||
.unwrap_or("");
|
|
||||||
let cb = cat_names
|
|
||||||
.get(&b.category_id)
|
|
||||||
.map(String::as_str)
|
|
||||||
.unwrap_or("");
|
|
||||||
ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
|
|
||||||
});
|
|
||||||
|
|
||||||
let missing_count = all_flavors.len() as i64;
|
|
||||||
// The dashboard "explore" grid sorts these by community / followed average
|
|
||||||
// but never reads the nested ratings, and this query loads *every* unrated
|
|
||||||
// flavor — so skip building a RatingOut per rating (large payload win).
|
|
||||||
let missing_flavors = build_flavors(
|
|
||||||
&state,
|
|
||||||
&ctx,
|
|
||||||
all_flavors,
|
|
||||||
Some(uid),
|
|
||||||
&followed,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let user = build_user(&state, &ctx, me, Some(uid)).await?;
|
let user = build_user(&state, &ctx, me, Some(uid)).await?;
|
||||||
|
|
||||||
Ok(Json(DashboardOut {
|
Ok(Json(DashboardOut {
|
||||||
user,
|
user,
|
||||||
rated_count,
|
rated_count,
|
||||||
missing_count,
|
missing_count,
|
||||||
missing_flavors,
|
|
||||||
my_ratings,
|
my_ratings,
|
||||||
|
stats,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Max recommendations returned by `/users/recommendations/`.
|
||||||
|
const RECOMMENDATION_LIMIT: usize = 12;
|
||||||
|
|
||||||
|
/// GET /api/users/recommendations/
|
||||||
|
///
|
||||||
|
/// "Tasters like you also liked…" — user-based collaborative filtering with a
|
||||||
|
/// Bayesian-popularity cold-start fallback (see `crate::recommend`).
|
||||||
|
async fn recommendations(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ctx: RequestCtx,
|
||||||
|
AuthUser(uid): AuthUser,
|
||||||
|
) -> ApiResult<Json<Vec<RecommendationOut>>> {
|
||||||
|
// Whole rating matrix (hundreds of rows at current scale) → in-process scoring.
|
||||||
|
let all = Rating::find().all(&state.db).await?;
|
||||||
|
let inputs: Vec<RatingInput> = all
|
||||||
|
.iter()
|
||||||
|
.map(|r| RatingInput {
|
||||||
|
user_id: r.user_id,
|
||||||
|
flavor_id: r.flavor_id,
|
||||||
|
score: r.score as f64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let recs = recommend(uid, &inputs, RECOMMENDATION_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<i32> = recs.iter().map(|r| r.flavor_id).collect();
|
||||||
|
let models = Flavor::find()
|
||||||
|
.filter(flavor::Column::Id.is_in(rec_ids.clone()))
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let followed = following_ids(&state, uid).await?;
|
||||||
|
let flavors = build_flavors(&state, &ctx, models, Some(uid), &followed, false, false).await?;
|
||||||
|
let by_id: std::collections::HashMap<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
|
||||||
|
|
||||||
|
let out: Vec<RecommendationOut> = 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate the dashboard stats block from the user's own ratings. `this_month` is
|
||||||
|
/// passed in because it's computed from the rating models (which carry real
|
||||||
|
/// datetimes) before they're turned into the string-timestamped `RatingOut`s.
|
||||||
|
fn dashboard_stats(my_ratings: &[crate::dto::RatingOut], this_month: i64) -> DashboardStats {
|
||||||
|
let mut score_distribution: std::collections::BTreeMap<String, i64> =
|
||||||
|
(1..=10).map(|s| (s.to_string(), 0)).collect();
|
||||||
|
let mut sum: i64 = 0;
|
||||||
|
// category slug -> (display name, count)
|
||||||
|
let mut cat_counts: std::collections::HashMap<String, (String, i64)> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for r in my_ratings {
|
||||||
|
if (1..=10).contains(&r.score) {
|
||||||
|
*score_distribution.get_mut(&r.score.to_string()).unwrap() += 1;
|
||||||
|
}
|
||||||
|
sum += r.score as i64;
|
||||||
|
let entry = cat_counts
|
||||||
|
.entry(r.category_slug.clone())
|
||||||
|
.or_insert((r.category_name.clone(), 0));
|
||||||
|
entry.1 += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = my_ratings.len() as i64;
|
||||||
|
let average_score = (n > 0).then(|| sum as f64 / n as f64);
|
||||||
|
// Highest count wins; ties broken by slug so the result is deterministic
|
||||||
|
// (HashMap iteration order isn't).
|
||||||
|
let favorite_category = cat_counts
|
||||||
|
.into_iter()
|
||||||
|
.max_by(|(slug_a, (_, count_a)), (slug_b, (_, count_b))| {
|
||||||
|
count_a.cmp(count_b).then_with(|| slug_b.cmp(slug_a))
|
||||||
|
})
|
||||||
|
.map(|(slug, (name, count))| FavoriteCategory { name, slug, count });
|
||||||
|
|
||||||
|
let category_count = my_ratings
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.category_slug.as_str())
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.len() as i64;
|
||||||
|
|
||||||
|
DashboardStats {
|
||||||
|
average_score,
|
||||||
|
score_distribution,
|
||||||
|
favorite_category,
|
||||||
|
ratings_this_month: this_month,
|
||||||
|
category_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dashboard_stats_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::RatingOut;
|
||||||
|
|
||||||
|
fn rating(score: i32, cat_slug: &str, cat_name: &str) -> RatingOut {
|
||||||
|
RatingOut {
|
||||||
|
id: 0,
|
||||||
|
user: String::new(),
|
||||||
|
user_id: 0,
|
||||||
|
user_avatar: None,
|
||||||
|
flavor: 0,
|
||||||
|
flavor_name: String::new(),
|
||||||
|
flavor_image: None,
|
||||||
|
category_name: cat_name.into(),
|
||||||
|
category_slug: cat_slug.into(),
|
||||||
|
is_available: true,
|
||||||
|
is_legacy: false,
|
||||||
|
score,
|
||||||
|
comment: None,
|
||||||
|
created_at: String::new(),
|
||||||
|
replies: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_ratings_yield_zeroed_stats() {
|
||||||
|
let s = dashboard_stats(&[], 0);
|
||||||
|
assert_eq!(s.average_score, None);
|
||||||
|
assert!(s.favorite_category.is_none());
|
||||||
|
assert_eq!(s.category_count, 0);
|
||||||
|
assert_eq!(s.ratings_this_month, 0);
|
||||||
|
// Distribution still carries all 10 keys, all zero.
|
||||||
|
assert_eq!(s.score_distribution.len(), 10);
|
||||||
|
assert!(s.score_distribution.values().all(|&v| v == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregates_average_distribution_and_favorite() {
|
||||||
|
let ratings = vec![
|
||||||
|
rating(10, "fruity", "Fruity"),
|
||||||
|
rating(8, "fruity", "Fruity"),
|
||||||
|
rating(6, "minty", "Minty"),
|
||||||
|
];
|
||||||
|
let s = dashboard_stats(&ratings, 2);
|
||||||
|
assert_eq!(s.average_score, Some(8.0));
|
||||||
|
assert_eq!(s.score_distribution["10"], 1);
|
||||||
|
assert_eq!(s.score_distribution["8"], 1);
|
||||||
|
assert_eq!(s.score_distribution["6"], 1);
|
||||||
|
assert_eq!(s.score_distribution["1"], 0);
|
||||||
|
assert_eq!(s.category_count, 2);
|
||||||
|
assert_eq!(s.ratings_this_month, 2);
|
||||||
|
let fav = s.favorite_category.unwrap();
|
||||||
|
assert_eq!(fav.slug, "fruity");
|
||||||
|
assert_eq!(fav.count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn favorite_category_tie_breaks_by_slug_deterministically() {
|
||||||
|
// Equal counts (1 each) → smaller slug ("apple") wins, stable across runs.
|
||||||
|
let ratings = vec![rating(5, "banana", "Banana"), rating(5, "apple", "Apple")];
|
||||||
|
let fav = dashboard_stats(&ratings, 0).favorite_category.unwrap();
|
||||||
|
assert_eq!(fav.slug, "apple");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Dashboard Redesign — Audit & Plan
|
||||||
|
|
||||||
|
Scope: the authenticated user dashboard (`frontend/src/pages/Dashboard.tsx`, backend
|
||||||
|
`GET /api/users/dashboard/` in `backend-rs/src/routes/users.rs:1311`). Goal: cleaner,
|
||||||
|
more intuitive, more robust UX, plus algorithmic recommendations ("tasters like you
|
||||||
|
also liked X").
|
||||||
|
|
||||||
|
Data scale (current, `backend/db.sqlite3`): **13 users · 219 flavors · 331 ratings ·
|
||||||
|
~25 ratings/user**. Sparse and small — everything fits in memory; recommendations can
|
||||||
|
be computed in-process per request. No Redis/precompute needed yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Audit of the current dashboard
|
||||||
|
|
||||||
|
### Architecture / code quality
|
||||||
|
- **A1. 1039-line monolith.** `Dashboard.tsx` is one component holding the profile
|
||||||
|
header, stats, two tabs, two filter toolbars, two card lists, and inline
|
||||||
|
rating-edit + reply editors. Nothing is extracted, nothing is unit-testable in
|
||||||
|
isolation.
|
||||||
|
- **A2. Duplicated rating-edit/reply logic.** The edit-score, edit-comment, reply
|
||||||
|
submit, delete-reply handlers (`Dashboard.tsx:148-185`) re-implement what
|
||||||
|
`FlavorDetail.tsx` already does. Same bug surface in two places (the Q1 notif-menu
|
||||||
|
dedup pattern applies here too).
|
||||||
|
- **A3. ColorThief on every load.** `Dashboard.tsx:109-146` fetches the avatar as a
|
||||||
|
CORS `Image`, runs `colorthief.getPalette(...,10)` on the main thread, and derives
|
||||||
|
HSL just to tint a decorative header gradient. Extra dependency, main-thread work,
|
||||||
|
CORS dependency — all for cosmetics. Already skipped on mobile (`isXs`).
|
||||||
|
|
||||||
|
### Backend / performance
|
||||||
|
- **A4. Loads every unrated flavor, every hit.** The dashboard handler builds
|
||||||
|
`missing_flavors` from *all* flavors the user hasn't rated (~200 rows), with
|
||||||
|
per-flavor aggregate rollups, on every dashboard request
|
||||||
|
(`users.rs:1340-1378`). No pagination. (P1 already trimmed the *per-rating* payload
|
||||||
|
here via `include_ratings=false`, but the row count itself is unbounded.)
|
||||||
|
- **A5. All filter/sort is client-side** over the full arrays (`Dashboard.tsx:187-237`).
|
||||||
|
Fine at 200 items; breaks down as the catalog grows and blocks server-side
|
||||||
|
pagination.
|
||||||
|
|
||||||
|
### UX / product
|
||||||
|
- **A6. Thin stats.** Only two numbers surfaced: `rated_count`, `missing_count`
|
||||||
|
(`Dashboard.tsx:405-408`). No average score the user gives, no rating distribution,
|
||||||
|
no favorite category, no recent-activity sense.
|
||||||
|
- **A7. "Explore" is an undifferentiated dump.** Tab 2 is the full unrated list sorted
|
||||||
|
by community or circle average. No curation, no "for you", overwhelming, no
|
||||||
|
pagination, no recommendations.
|
||||||
|
- **A8. Flat two-tab IA** (My Reviews / Explore New). No personalized landing; the
|
||||||
|
user's *taste* is never reflected back to them.
|
||||||
|
- **A9. Rough edges.** `"Please login to view dashboard."` is a hardcoded,
|
||||||
|
untranslated string (`Dashboard.tsx:255`); several toast messages are hardcoded
|
||||||
|
English (`'Failed to update review'`, etc.) instead of i18n keys.
|
||||||
|
- **A10. No recommendations exist** anywhere in the product today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Recommendation engine (the headline feature)
|
||||||
|
|
||||||
|
"Users with the same taste also like X" = **user-based collaborative filtering (CF)**,
|
||||||
|
with a popularity/quality fallback for cold-start users.
|
||||||
|
|
||||||
|
### Algorithm (user-based CF)
|
||||||
|
1. Load the full rating matrix (331 cells — trivial) into memory: `user → {flavor: score}`.
|
||||||
|
2. For the target user `u`, compute similarity to every other user `v` over their
|
||||||
|
**co-rated** flavors using **Pearson correlation** (mean-centred, so it cancels out
|
||||||
|
the fact that some users rate high and some low).
|
||||||
|
3. **Min-overlap guard:** ignore any `v` sharing fewer than ~3 co-rated flavors —
|
||||||
|
correlation over 1–2 points is noise. Apply **significance weighting**
|
||||||
|
`sim *= min(overlap, K)/K` (K≈5) to down-weight thin overlaps.
|
||||||
|
4. For each candidate flavor `f` the target hasn't rated, predict:
|
||||||
|
`pred(u,f) = mean_u + Σ_v sim(u,v)·(r_vf − mean_v) / Σ |sim(u,v)|`
|
||||||
|
over neighbours `v` who rated `f`. Require ≥2 contributing neighbours for confidence.
|
||||||
|
5. Rank candidates by `pred`, return top-N with metadata: `predicted_score`,
|
||||||
|
`contributing_neighbours` (the "based on N tasters like you" count), and the
|
||||||
|
neighbour usernames (optional, for "because Alice & Bob loved it").
|
||||||
|
|
||||||
|
### Cold-start fallback (user with < ~5 ratings, or no confident neighbours)
|
||||||
|
Rank unrated flavors by a **Bayesian average** to stop a single 10/10 from topping the
|
||||||
|
chart: `score = (C·m + Σr) / (C + n)` where `m` = global mean score, `n` = that
|
||||||
|
flavor's rating count, `C` ≈ 5 (confidence prior). Optionally bias toward categories the
|
||||||
|
user has already engaged with.
|
||||||
|
|
||||||
|
### Why in-process, on-demand
|
||||||
|
At 13 users / 331 ratings the whole computation is sub-millisecond. Compute it inside
|
||||||
|
the recommendations endpoint; add a short per-user cache only if profiling says so.
|
||||||
|
If the dataset grows orders of magnitude, promote it to a **background job**
|
||||||
|
(`src/jobs/` already exists) that precomputes per-user recommendations into a table on a
|
||||||
|
schedule — the engine code stays the same, only the trigger changes.
|
||||||
|
|
||||||
|
### Optional companion: item-based CF
|
||||||
|
Flavor↔flavor cosine over rating vectors powers "people who liked *this* flavor also
|
||||||
|
liked…" — useful on `FlavorDetail` too, not just the dashboard. Separate, later phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Phased implementation plan
|
||||||
|
|
||||||
|
Each phase is independently shippable and verified (`tsc` + ESLint + vitest on the
|
||||||
|
frontend; `cargo fmt`/`clippy`/`test` on the backend).
|
||||||
|
|
||||||
|
### Phase 0 — Decompose (no behaviour change) — ✅ DONE
|
||||||
|
Split the 1039-line `Dashboard.tsx` (now ~115 lines, thin orchestrator) into
|
||||||
|
`src/pages/dashboard/`:
|
||||||
|
- `DashboardHeader.tsx` — action bar + profile + stats.
|
||||||
|
- `MyReviewsTab.tsx` — filter toolbar + list; owns its filter/sort/category state.
|
||||||
|
- `MyReviewCard.tsx` — one own-review card; **owns its own** edit/reply/expand state
|
||||||
|
+ mutation calls (replaces the Dashboard-level state maps keyed by rating id).
|
||||||
|
- `ExploreTab.tsx` — filter toolbar + grid; owns its own filter state.
|
||||||
|
- `usePalette.ts` — the ColorThief effect, isolated (deleted in Phase 3).
|
||||||
|
|
||||||
|
Also fixed A9's hardcoded login string → `t('dashboard.loginRequired')` (EN+DE).
|
||||||
|
`tsc` clean, ESLint 0 errors (1 pre-existing setState-in-effect warning in the
|
||||||
|
soon-to-be-deleted `usePalette`), 27 tests pass, `vite build` clean.
|
||||||
|
|
||||||
|
**Deviation from original plan:** did *not* force a single shared `ReviewCard`
|
||||||
|
across Dashboard + `FlavorDetail`. On inspection the two cards are structurally
|
||||||
|
different — the dashboard card is flavor-centric (one user, many flavors), the
|
||||||
|
detail card is user-centric (one flavor, many users). Unifying would over-couple two
|
||||||
|
genuinely different layouts. The cross-file A2 dedup is reduced to a possible future
|
||||||
|
extraction of just the shared reply-thread atom, tracked but not forced.
|
||||||
|
|
||||||
|
### Phase 1 — Enrich the backend payload — ✅ DONE (stats); payload-slim deferred
|
||||||
|
- Added a **`stats` block** to `DashboardOut` (`dto.rs`): `average_score`,
|
||||||
|
`score_distribution` (1–10 histogram, always all 10 keys), `favorite_category`
|
||||||
|
(`{name, slug, count}`, deterministic tie-break by slug), `ratings_this_month`
|
||||||
|
(current UTC month), `category_count`. Computed in the `dashboard` handler
|
||||||
|
(`users.rs`) by a pure `dashboard_stats()` helper over the already-loaded ratings —
|
||||||
|
**no new queries**. 3 unit tests (empty / aggregate / tie-break). `cargo fmt` +
|
||||||
|
`clippy` clean, 23 backend tests pass.
|
||||||
|
- Frontend: extended `DashboardData` with `DashboardStats`/`FavoriteCategory`;
|
||||||
|
`DashboardHeader` now shows Avg Score + This Month tiles and a "Top category" line
|
||||||
|
(i18n EN+DE). `tsc` + ESLint (0 errors) clean, 27 tests, build clean.
|
||||||
|
|
||||||
|
**Sequencing change (deliberate):** the planned payload slim — dropping the
|
||||||
|
all-unrated `missing_flavors` dump (A4) — is **deferred to Phase 3**, where the
|
||||||
|
Explore tab that still consumes it is removed. Dropping it now would break a live UI
|
||||||
|
mid-refactor. Phase 1 stayed purely additive.
|
||||||
|
|
||||||
|
### Phase 2 — Recommendations engine — ✅ DONE
|
||||||
|
- New pure module `backend-rs/src/recommend.rs` (no DB → unit-testable): user-based CF
|
||||||
|
with Pearson similarity (mean-centred), `MIN_OVERLAP=3`, significance weighting
|
||||||
|
`min(overlap,5)/5`, positive-correlation-only neighbours, `MIN_NEIGHBOURS=2`,
|
||||||
|
prediction clamped to 1–10. Cold start (`<5` own ratings, or no confident CF
|
||||||
|
candidates) → Bayesian-popularity fallback (`C=5`). Deterministic ordering (score →
|
||||||
|
neighbours → id). 3 unit tests: cold-start, CF-finds-shared-pick (with opposite-taste
|
||||||
|
decoy filtered), exclude-already-rated + limit.
|
||||||
|
- New endpoint `GET /api/users/recommendations/` (`users.rs`) → `Vec<RecommendationOut>`
|
||||||
|
(`dto.rs`): card fields + `predicted_score`, `contributing_neighbours`, `reason`
|
||||||
|
(`"cf"`/`"popular"`). **Count only — no neighbour names** (locked decision). Loads
|
||||||
|
the full rating matrix once, scores in-process, resolves ids via `build_flavors`
|
||||||
|
(`include_ratings=false`). `cargo fmt`/`clippy` clean, 26 backend tests pass.
|
||||||
|
- Frontend plumbing: `Recommendation` type + `useRecommendations()` query hook
|
||||||
|
(key `recommendations`, 5 min staleTime) + `queryKeys.recommendations()`. UI wiring
|
||||||
|
is Phase 3. `tsc` + ESLint (0 errors) clean, 27 tests pass.
|
||||||
|
|
||||||
|
### Phase 3 — UI redesign ("For You" first) — ✅ DONE
|
||||||
|
- **Backend payload slim (the deferred A4):** `DashboardOut` no longer ships
|
||||||
|
`missing_flavors` (the full unrated `Vec<FlavorOut>` with per-flavor aggregates).
|
||||||
|
The handler now computes `missing_count` from flavor ids alone — no `build_flavors`
|
||||||
|
pass over ~200 rows, no extra category/rating/user queries. `cargo fmt`/`clippy`
|
||||||
|
clean, 26 backend tests pass.
|
||||||
|
- **Frontend IA:** removed the two-tab layout. Dashboard is now a single page —
|
||||||
|
`DashboardHeader` (hero stats) → `RecommendationsSection` ("Tasters like you
|
||||||
|
loved", count-only reason chips, skeleton while loading, hidden when empty) → "My
|
||||||
|
Reviews" list. Deleted `ExploreTab.tsx`.
|
||||||
|
- **Dropped ColorThief from the dashboard (A3):** `DashboardHeader` uses a
|
||||||
|
theme-derived gradient; deleted `usePalette.ts`. (The npm dep stays — `PublicProfile`
|
||||||
|
still uses ColorThief; that's out of dashboard scope.)
|
||||||
|
- New components: `RecommendationCard.tsx` (image + predicted-score badge + reason
|
||||||
|
chip), `RecommendationsSection.tsx` (wraps `useRecommendations`).
|
||||||
|
- i18n EN+DE: `recHeading`, `recReasonCf`, `recReasonPopular`, `predictedForYou` (plus
|
||||||
|
the earlier `loginRequired`/`avgScore`/`thisMonth`/`favoriteCategory`).
|
||||||
|
- `tsc` + ESLint (0 warnings — the old `usePalette` setState-in-effect warning is gone
|
||||||
|
with the file), 27 tests, `vite build` clean.
|
||||||
|
|
||||||
|
**Scope note:** the plan's separate "curated rows" (top community / newest) are
|
||||||
|
covered by the recommendation engine's popularity fallback — a cold or
|
||||||
|
rated-everything user still gets popularity-ranked picks in the same carousel — so a
|
||||||
|
distinct curated row was not added. Browsing the full catalog now lives on the main
|
||||||
|
flavor list, as decided. Leftover unused i18n keys from the old Explore tab
|
||||||
|
(`searchMissing`, `communityRating`, `circleRating`, `allRated`, `exploreNew`) were
|
||||||
|
left in place (harmless); can be swept later.
|
||||||
|
|
||||||
|
### Phase 4 — Item-based "similar flavors" — ✅ DONE
|
||||||
|
- **Engine:** added `similar_flavors()` to `backend-rs/src/recommend.rs` — **adjusted
|
||||||
|
cosine** over the rating matrix (each user's scores mean-centred to cancel rater
|
||||||
|
bias), positive-correlation-only, `MIN_CORATERS=2`, deterministic ordering
|
||||||
|
(similarity → co-raters → id). Returns `SimilarFlavor { flavor_id, similarity,
|
||||||
|
co_raters }`. 3 unit tests (co-liked / min-coraters guard / unknown target).
|
||||||
|
- **Endpoint:** `GET /api/flavors/{id}/similar/` (`routes/flavors.rs`, `SIMILAR_LIMIT=8`)
|
||||||
|
→ `Vec<SimilarFlavorOut>` (`dto.rs`: card fields + `similarity` + `co_raters`).
|
||||||
|
404 if the flavor doesn't exist; **empty list** (not 404) when too few co-ratings to
|
||||||
|
compute. Loads the matrix once, resolves ids via `build_flavors` (no nested
|
||||||
|
ratings/replies). `cargo fmt`/`clippy` clean, **29 backend tests pass**.
|
||||||
|
- **Frontend:** `SimilarFlavor` type + `queryKeys.similarFlavors(id)` +
|
||||||
|
`useSimilarFlavors()` hook (public, 5 min staleTime). New
|
||||||
|
`pages/flavors/components/SimilarFlavorsSection.tsx` — "People who liked this also
|
||||||
|
liked" grid (compact cards: image + avg-rating badge + status + "N rated both"
|
||||||
|
chip), skeleton while loading, **hidden when empty**. Wired into `FlavorDetail.tsx`
|
||||||
|
below the ratings grid. i18n EN+DE (`similarHeading`, `similarCoRaters`,
|
||||||
|
`similarTooltip`). `tsc` + ESLint (0 warnings), 27 tests, `vite build` clean.
|
||||||
|
|
||||||
|
**Deferred (not built):** promoting recommendations to a scheduled precompute job —
|
||||||
|
unnecessary at current scale (sub-ms in-process); the engine code is unchanged when
|
||||||
|
that day comes, only the trigger moves. Tracked, not done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved decisions (user)
|
||||||
|
- **Recommendation reason = count only.** Show "N tasters like you loved this"; do not
|
||||||
|
name neighbours (no privacy exposure). The endpoint still returns
|
||||||
|
`contributing_neighbours` as a number; neighbour usernames are not surfaced.
|
||||||
|
- **Drop ColorThief.** The avatar-derived header gradient is replaced by a
|
||||||
|
theme-derived gradient — removes the dependency and the main-thread work (A3). No
|
||||||
|
Web Worker needed.
|
||||||
|
- **Collapse "Explore".** The dashboard no longer hosts a full browsable unrated
|
||||||
|
catalog. Dashboard = "For You" (recommendations) + curated rows + My Reviews.
|
||||||
|
General browsing lives on the main flavor list. This drops the all-unrated-flavors
|
||||||
|
load (A4) entirely from the dashboard payload — a large simplification.
|
||||||
|
|
||||||
|
### Plan adjustments from these decisions
|
||||||
|
- **Phase 1** no longer needs a paginated "browse unrated" view in the dashboard; it
|
||||||
|
just adds the stats block and the bounded curated rows. The `missing_flavors` dump
|
||||||
|
is removed from the dashboard response outright.
|
||||||
|
- **Phase 2** endpoint shape: `[{ ...flavor fields, predicted_score,
|
||||||
|
contributing_neighbours, reason_key }]` — no neighbour usernames.
|
||||||
|
- **Phase 3** IA becomes: hero stats → "Tasters like you loved…" carousel → curated
|
||||||
|
rows → My Reviews. The second tab ("Explore New") is removed.
|
||||||
@@ -6,11 +6,13 @@
|
|||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
categories: () => ['categories'] as const,
|
categories: () => ['categories'] as const,
|
||||||
dashboard: () => ['dashboard'] as const,
|
dashboard: () => ['dashboard'] as const,
|
||||||
|
recommendations: () => ['recommendations'] as const,
|
||||||
publicProfile: (username: string) => ['publicProfile', username] as const,
|
publicProfile: (username: string) => ['publicProfile', username] as const,
|
||||||
publicProfileAll: () => ['publicProfile'] as const,
|
publicProfileAll: () => ['publicProfile'] as const,
|
||||||
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
|
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
|
||||||
flavorsAll: () => ['flavors'] as const,
|
flavorsAll: () => ['flavors'] as const,
|
||||||
flavorDetail: (id: number | string) => ['flavorDetail', String(id)] as const,
|
flavorDetail: (id: number | string) => ['flavorDetail', String(id)] as const,
|
||||||
|
similarFlavors: (id: number | string) => ['similarFlavors', String(id)] as const,
|
||||||
activeBanner: (username: string) => ['activeBanner', username] as const,
|
activeBanner: (username: string) => ['activeBanner', username] as const,
|
||||||
notifications: () => ['notifications'] as const,
|
notifications: () => ['notifications'] as const,
|
||||||
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
|
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../lib/api';
|
import api from '../../lib/api';
|
||||||
import { queryKeys } from '../keys';
|
import { queryKeys } from '../keys';
|
||||||
|
import type {
|
||||||
|
FlavorDetailData,
|
||||||
|
FlavorDetailRating,
|
||||||
|
FlavorDetailReply,
|
||||||
|
} from '../queries/useFlavorDetail';
|
||||||
|
|
||||||
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
|
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
|
||||||
@@ -37,19 +42,53 @@ export interface CreateRatingInput {
|
|||||||
flavor: number;
|
flavor: number;
|
||||||
score: number;
|
score: number;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
|
/** Author info for the optimistic row shown on the flavor-detail page before
|
||||||
|
* the server round-trip lands. Omit to skip the optimistic insert. */
|
||||||
|
optimistic?: { user: string; user_avatar: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCreateRating = () => {
|
export const useCreateRating = () => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: CreateRatingInput) => api.post('ratings/', input),
|
mutationFn: ({ flavor, score, comment }: CreateRatingInput) =>
|
||||||
onSuccess: () => invalidateRatingViews(qc),
|
api.post('ratings/', { flavor, score, comment }),
|
||||||
|
onMutate: async ({ flavor, score, comment, optimistic }) => {
|
||||||
|
if (!optimistic) return;
|
||||||
|
const key = queryKeys.flavorDetail(flavor);
|
||||||
|
await qc.cancelQueries({ queryKey: key });
|
||||||
|
const prev = qc.getQueryData<FlavorDetailData>(key);
|
||||||
|
if (prev) {
|
||||||
|
const temp: FlavorDetailRating = {
|
||||||
|
id: -Date.now(), // negative temp id; replaced on refetch
|
||||||
|
user: optimistic.user,
|
||||||
|
user_avatar: optimistic.user_avatar,
|
||||||
|
score,
|
||||||
|
comment: comment ?? '',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
qc.setQueryData<FlavorDetailData>(key, {
|
||||||
|
...prev,
|
||||||
|
ratings: [temp, ...prev.ratings],
|
||||||
|
user_rating: score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { key, prev };
|
||||||
|
},
|
||||||
|
onError: (_e, _v, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => invalidateRatingViews(qc),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReplyInput {
|
export interface ReplyInput {
|
||||||
ratingId: number;
|
ratingId: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Flavor id + author for the optimistic reply on the flavor-detail page.
|
||||||
|
* Omit either to skip the optimistic insert. */
|
||||||
|
flavorId?: number;
|
||||||
|
optimisticUser?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCreateReply = () => {
|
export const useCreateReply = () => {
|
||||||
@@ -57,7 +96,31 @@ export const useCreateReply = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ ratingId, text }: ReplyInput) =>
|
mutationFn: ({ ratingId, text }: ReplyInput) =>
|
||||||
api.post(`ratings/${ratingId}/reply/`, { text }),
|
api.post(`ratings/${ratingId}/reply/`, { text }),
|
||||||
onSuccess: () => invalidateRatingViews(qc),
|
onMutate: async ({ ratingId, text, flavorId, optimisticUser }) => {
|
||||||
|
if (!flavorId || !optimisticUser) return;
|
||||||
|
const key = queryKeys.flavorDetail(flavorId);
|
||||||
|
await qc.cancelQueries({ queryKey: key });
|
||||||
|
const prev = qc.getQueryData<FlavorDetailData>(key);
|
||||||
|
if (prev) {
|
||||||
|
const temp: FlavorDetailReply = {
|
||||||
|
id: -Date.now(), // negative temp id; replaced on refetch
|
||||||
|
user: optimisticUser,
|
||||||
|
text,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
qc.setQueryData<FlavorDetailData>(key, {
|
||||||
|
...prev,
|
||||||
|
ratings: prev.ratings.map((r) =>
|
||||||
|
r.id === ratingId ? { ...r, replies: [...r.replies, temp] } : r,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { key, prev };
|
||||||
|
},
|
||||||
|
onError: (_e, _v, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => invalidateRatingViews(qc),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../lib/api';
|
import api, { type RetriableConfig } from '../../lib/api';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
export interface CurrentUserLite {
|
export interface CurrentUserLite {
|
||||||
@@ -19,8 +19,8 @@ export const useCurrentUserLite = () => {
|
|||||||
const res = await api.get<CurrentUserLite>('users/me/', {
|
const res = await api.get<CurrentUserLite>('users/me/', {
|
||||||
// Stale access tokens are common; never bounce a public page reader
|
// Stale access tokens are common; never bounce a public page reader
|
||||||
// to /login because of a background "am I logged in?" check.
|
// to /login because of a background "am I logged in?" check.
|
||||||
...({ skipAuthRedirect: true } as object),
|
skipAuthRedirect: true,
|
||||||
});
|
} as RetriableConfig);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '../../lib/api';
|
||||||
|
import { queryKeys } from '../keys';
|
||||||
|
import type { Recommendation } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Tasters like you also liked…" — user-based collaborative filtering with a
|
||||||
|
* popularity cold-start fallback (backend `GET /users/recommendations/`). Enabled
|
||||||
|
* only when logged in; recommendations shift slowly, so a long staleTime is fine.
|
||||||
|
*/
|
||||||
|
export const useRecommendations = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: queryKeys.recommendations(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Recommendation[]>('users/recommendations/');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '../../lib/api';
|
||||||
|
import { queryKeys } from '../keys';
|
||||||
|
import type { SimilarFlavor } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "People who liked this flavor also liked…" — item-based collaborative filtering
|
||||||
|
* (backend `GET /flavors/{id}/similar/`). Public (no auth needed); the result moves
|
||||||
|
* slowly with the rating matrix, so a long staleTime is fine.
|
||||||
|
*/
|
||||||
|
export const useSimilarFlavors = (flavorId: number | undefined, enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: queryKeys.similarFlavors(flavorId ?? 0),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<SimilarFlavor[]>(`flavors/${flavorId}/similar/`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: enabled && flavorId != null,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
@@ -32,10 +32,57 @@ export interface MissingFlavor {
|
|||||||
is_available?: boolean;
|
is_available?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FavoriteCategory {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
average_score: number | null;
|
||||||
|
/** Count per score, keyed "1".."10". */
|
||||||
|
score_distribution: Record<string, number>;
|
||||||
|
favorite_category: FavoriteCategory | null;
|
||||||
|
ratings_this_month: number;
|
||||||
|
category_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
image_url: string | null;
|
||||||
|
category_name: string;
|
||||||
|
category_slug: string;
|
||||||
|
average_rating: number | null;
|
||||||
|
is_legacy: boolean;
|
||||||
|
is_available: boolean;
|
||||||
|
predicted_score: number;
|
||||||
|
/** "N tasters like you" count (cf) or rating count (popular). */
|
||||||
|
contributing_neighbours: number;
|
||||||
|
/** "cf" | "popular" — frontend picks the reason copy. */
|
||||||
|
reason: 'cf' | 'popular';
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
user: { username: string; avatar: string | null };
|
user: { username: string; avatar: string | null };
|
||||||
rated_count: number;
|
rated_count: number;
|
||||||
missing_count: number;
|
missing_count: number;
|
||||||
missing_flavors: MissingFlavor[];
|
|
||||||
my_ratings: RatedItem[];
|
my_ratings: RatedItem[];
|
||||||
|
stats: DashboardStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Item-based "people who liked this flavor also liked…" card. */
|
||||||
|
export interface SimilarFlavor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
image_url: string | null;
|
||||||
|
category_name: string;
|
||||||
|
category_slug: string;
|
||||||
|
average_rating: number | null;
|
||||||
|
is_legacy: boolean;
|
||||||
|
is_available: boolean;
|
||||||
|
/** Adjusted-cosine similarity to the target flavor, in (0, 1]. */
|
||||||
|
similarity: number;
|
||||||
|
/** Users who rated both this flavor and the target. */
|
||||||
|
co_raters: number;
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,14 @@ const resources = {
|
|||||||
welcome: 'Welcome back, {{username}}!',
|
welcome: 'Welcome back, {{username}}!',
|
||||||
shareProfile: 'Share Profile',
|
shareProfile: 'Share Profile',
|
||||||
copySuccess: 'Link copied to clipboard!',
|
copySuccess: 'Link copied to clipboard!',
|
||||||
|
loginRequired: 'Please log in to view your dashboard.',
|
||||||
|
avgScore: 'Avg Score',
|
||||||
|
thisMonth: 'This Month',
|
||||||
|
favoriteCategory: 'Top category: {{category}} ({{count}})',
|
||||||
|
recHeading: 'Tasters like you loved',
|
||||||
|
recReasonCf: '{{count}} tasters like you',
|
||||||
|
recReasonPopular: 'Popular pick',
|
||||||
|
predictedForYou: 'Predicted for you',
|
||||||
myRatings: 'My Ratings',
|
myRatings: 'My Ratings',
|
||||||
missing: 'Missing',
|
missing: 'Missing',
|
||||||
noRatings: "You haven't rated any flavors yet.",
|
noRatings: "You haven't rated any flavors yet.",
|
||||||
@@ -141,6 +149,9 @@ const resources = {
|
|||||||
outOfStock: 'Out of Stock',
|
outOfStock: 'Out of Stock',
|
||||||
},
|
},
|
||||||
flavorDetail: {
|
flavorDetail: {
|
||||||
|
similarHeading: 'People who liked this also liked',
|
||||||
|
similarCoRaters: '{{count}} rated both',
|
||||||
|
similarTooltip: 'Tasters who rated both this flavor and the one above',
|
||||||
rateThisFlavor: 'Rate this flavor',
|
rateThisFlavor: 'Rate this flavor',
|
||||||
scoreLabel: 'Your score, 1 to 10',
|
scoreLabel: 'Your score, 1 to 10',
|
||||||
submitReview: 'Submit Review',
|
submitReview: 'Submit Review',
|
||||||
@@ -336,6 +347,14 @@ const resources = {
|
|||||||
welcome: 'Willkommen zurück, {{username}}!',
|
welcome: 'Willkommen zurück, {{username}}!',
|
||||||
shareProfile: 'Profil teilen',
|
shareProfile: 'Profil teilen',
|
||||||
copySuccess: 'Link in die Zwischenablage kopiert!',
|
copySuccess: 'Link in die Zwischenablage kopiert!',
|
||||||
|
loginRequired: 'Bitte melde dich an, um dein Dashboard zu sehen.',
|
||||||
|
avgScore: 'Ø Bewertung',
|
||||||
|
thisMonth: 'Diesen Monat',
|
||||||
|
favoriteCategory: 'Top-Kategorie: {{category}} ({{count}})',
|
||||||
|
recHeading: 'Tester wie du mochten',
|
||||||
|
recReasonCf: '{{count}} Tester wie du',
|
||||||
|
recReasonPopular: 'Beliebte Wahl',
|
||||||
|
predictedForYou: 'Für dich vorhergesagt',
|
||||||
myRatings: 'Meine Bewertungen',
|
myRatings: 'Meine Bewertungen',
|
||||||
missing: 'Fehlend',
|
missing: 'Fehlend',
|
||||||
noRatings: 'Du hast noch keine Sorten bewertet.',
|
noRatings: 'Du hast noch keine Sorten bewertet.',
|
||||||
@@ -448,6 +467,9 @@ const resources = {
|
|||||||
outOfStock: 'Ausverkauft',
|
outOfStock: 'Ausverkauft',
|
||||||
},
|
},
|
||||||
flavorDetail: {
|
flavorDetail: {
|
||||||
|
similarHeading: 'Wer das mochte, mochte auch',
|
||||||
|
similarCoRaters: '{{count}} bewerteten beide',
|
||||||
|
similarTooltip: 'Tester, die diese Sorte und die obige bewertet haben',
|
||||||
rateThisFlavor: 'Diese Sorte bewerten',
|
rateThisFlavor: 'Diese Sorte bewerten',
|
||||||
scoreLabel: 'Deine Bewertung, 1 bis 10',
|
scoreLabel: 'Deine Bewertung, 1 bis 10',
|
||||||
submitReview: 'Bewertung abschicken',
|
submitReview: 'Bewertung abschicken',
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,261 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import ShareIcon from '@mui/icons-material/Share';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { DashboardStats } from '../../api/types';
|
||||||
|
import { GlassCard, GlassSurface } from '../../components/ui';
|
||||||
|
|
||||||
|
export interface DashboardHeaderProps {
|
||||||
|
user: { username: string; avatar: string | null };
|
||||||
|
ratedCount: number;
|
||||||
|
missingCount: number;
|
||||||
|
stats: DashboardStats;
|
||||||
|
onBack: () => void;
|
||||||
|
onShare: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||||
|
user,
|
||||||
|
ratedCount,
|
||||||
|
missingCount,
|
||||||
|
stats,
|
||||||
|
onBack,
|
||||||
|
onShare,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isXs = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
// Theme-derived gradient (replaced the avatar-sampled ColorThief palette).
|
||||||
|
const grad = [theme.palette.primary.main, theme.palette.secondary.main];
|
||||||
|
|
||||||
|
const statTiles = [
|
||||||
|
{ label: t('dashboard.myRatings'), val: ratedCount },
|
||||||
|
{
|
||||||
|
label: t('dashboard.avgScore'),
|
||||||
|
val: stats.average_score != null ? stats.average_score.toFixed(1) : '–',
|
||||||
|
},
|
||||||
|
{ label: t('dashboard.thisMonth'), val: stats.ratings_this_month },
|
||||||
|
{ label: t('dashboard.missing'), val: missingCount },
|
||||||
|
];
|
||||||
|
const fav = stats.favorite_category;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Action Bar */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{isXs ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onBack}
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: '900', display: { xs: 'none', md: 'block' } }}>
|
||||||
|
{t('dashboard.title')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isXs ? (
|
||||||
|
<Tooltip title={t('dashboard.shareProfile')}>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={onShare}
|
||||||
|
aria-label={t('dashboard.shareProfile')}
|
||||||
|
sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<ShareIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onShare}
|
||||||
|
startIcon={<ShareIcon />}
|
||||||
|
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{t('dashboard.shareProfile')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Profile Header — theme-derived gradient (ColorThief dropped in the redesign) */}
|
||||||
|
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
|
||||||
|
{!isXs && (
|
||||||
|
<Box sx={{ height: 100, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: `
|
||||||
|
radial-gradient(at 0% 0%, ${alpha(grad[0], 0.6)} 0px, transparent 55%),
|
||||||
|
radial-gradient(at 100% 0%, ${alpha(grad[1], 0.5)} 0px, transparent 55%),
|
||||||
|
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.3)} 0px, transparent 55%),
|
||||||
|
linear-gradient(135deg, ${alpha(grad[0], 0.1)} 0%, ${alpha(grad[1], 0.1)} 100%)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent
|
||||||
|
sx={{
|
||||||
|
pt: { xs: 2, sm: 0 },
|
||||||
|
px: { xs: 2, sm: 4 },
|
||||||
|
pb: { xs: 2, sm: 4 },
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'row', sm: 'row' },
|
||||||
|
alignItems: { xs: 'center', sm: 'flex-end' },
|
||||||
|
gap: { xs: 2, sm: 4 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
mt: { xs: 0, sm: -6 },
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `linear-gradient(135deg, ${grad[0]}, ${grad[1]})`,
|
||||||
|
boxShadow: { xs: 'none', sm: '0 12px 48px rgba(0,0,0,0.25)' },
|
||||||
|
display: 'flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={user.avatar || undefined}
|
||||||
|
sx={{
|
||||||
|
width: { xs: 56, sm: 110 },
|
||||||
|
height: { xs: 56, sm: 110 },
|
||||||
|
border: { xs: '2px solid', sm: '4px solid' },
|
||||||
|
borderColor: theme.palette.background.paper,
|
||||||
|
fontSize: { xs: '1.5rem', sm: '3rem' },
|
||||||
|
bgcolor: theme.palette.background.paper,
|
||||||
|
color: 'primary.main',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!user.avatar && user.username.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: { xs: 'left', sm: 'left' },
|
||||||
|
mt: { xs: 0, sm: 4 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant={isXs ? 'h5' : 'h3'}
|
||||||
|
sx={{
|
||||||
|
fontWeight: '900',
|
||||||
|
mb: 0.25,
|
||||||
|
letterSpacing: -1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</Typography>
|
||||||
|
{!isXs && (
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
|
||||||
|
{t('dashboard.welcome', { username: user.username })}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{fav && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: { xs: 0.5, sm: 0 } }}>
|
||||||
|
{t('dashboard.favoriteCategory', {
|
||||||
|
category: t(`categories.${fav.slug}`, { defaultValue: fav.name }),
|
||||||
|
count: fav.count,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mt: { xs: 1, sm: 2 } }}>
|
||||||
|
<GlassSurface
|
||||||
|
intensity="subtle"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statTiles.map((stat, i) => (
|
||||||
|
<React.Fragment key={stat.label}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: { xs: 0.5, sm: 1 },
|
||||||
|
width: { xs: 80, sm: 140 },
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{ fontWeight: '900', lineHeight: 1, color: 'text.primary' }}
|
||||||
|
>
|
||||||
|
{stat.val}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: '900',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
opacity: 0.5,
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stat.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{i < statTiles.length - 1 && (
|
||||||
|
<Divider
|
||||||
|
orientation="vertical"
|
||||||
|
flexItem
|
||||||
|
sx={{ borderStyle: 'solid', opacity: 0.1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</GlassSurface>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</GlassCard>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardHeader;
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CardContent,
|
||||||
|
Collapse,
|
||||||
|
Rating as MuiRating,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CommentIcon from '@mui/icons-material/Comment';
|
||||||
|
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { RatedItem } from '../../api/types';
|
||||||
|
import {
|
||||||
|
useCreateReply,
|
||||||
|
useDeleteRating,
|
||||||
|
useDeleteReply,
|
||||||
|
useUpdateRating,
|
||||||
|
} from '../../api/mutations/useRatingMutations';
|
||||||
|
import RatingBadge from '../../components/RatingBadge';
|
||||||
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
|
import MentionTextField from '../../components/MentionTextField';
|
||||||
|
import RichText from '../../components/RichText';
|
||||||
|
import { formatDate } from '../../utils/date';
|
||||||
|
import { GlassCard } from '../../components/ui';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
|
||||||
|
export interface MyReviewCardProps {
|
||||||
|
rating: RatedItem;
|
||||||
|
/** Current user's username — drives the "this is mine" verified badge + reply delete. */
|
||||||
|
currentUsername: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the current user's own reviews on the dashboard: flavor-centric header,
|
||||||
|
* inline score/comment edit, and a collapsible reply thread. Owns its own edit /
|
||||||
|
* reply / expand state (previously hoisted into the Dashboard monolith as shared
|
||||||
|
* maps keyed by rating id).
|
||||||
|
*/
|
||||||
|
const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isXs = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||||
|
const { notify } = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
|
const updateRating = useUpdateRating();
|
||||||
|
const deleteRating = useDeleteRating();
|
||||||
|
const createReply = useCreateReply();
|
||||||
|
const deleteReply = useDeleteReply();
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editScore, setEditScore] = useState(rating.score);
|
||||||
|
const [editComment, setEditComment] = useState(rating.comment || '');
|
||||||
|
const [replyInput, setReplyInput] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
setEditScore(rating.score);
|
||||||
|
setEditComment(rating.comment || '');
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await updateRating.mutateAsync({ id: rating.id, score: editScore, comment: editComment });
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch {
|
||||||
|
notify({ message: 'Failed to update review', severity: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await deleteRating.mutateAsync(rating.id);
|
||||||
|
} catch {
|
||||||
|
notify({ message: 'Failed to delete review', severity: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplySubmit = async () => {
|
||||||
|
if (!replyInput) return;
|
||||||
|
try {
|
||||||
|
await createReply.mutateAsync({ ratingId: rating.id, text: replyInput });
|
||||||
|
setReplyInput('');
|
||||||
|
setExpanded(true);
|
||||||
|
} catch {
|
||||||
|
notify({ message: 'Failed to send reply', severity: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteReply = async (replyId: number) => {
|
||||||
|
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await deleteReply.mutateAsync(replyId);
|
||||||
|
} catch {
|
||||||
|
notify({ message: 'Failed to delete reply', severity: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard>
|
||||||
|
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{t('dashboard.editingReview', { flavor: rating.flavor_name })}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography variant="body2">{t('dashboard.newScore')}</Typography>
|
||||||
|
<MuiRating max={10} value={editScore} onChange={(_, val) => setEditScore(val || 0)} />
|
||||||
|
</Stack>
|
||||||
|
<MentionTextField multiline rows={3} value={editComment} onChange={setEditComment} />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="contained" size="small" onClick={handleUpdate}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" onClick={() => setIsEditing(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: { xs: 'stretch', sm: 'flex-start' },
|
||||||
|
gap: { xs: 1.5, sm: 2 },
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, minWidth: 0, flex: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: { xs: 56, sm: 64 },
|
||||||
|
height: { xs: 56, sm: 64 },
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={rating.flavor_image || undefined}
|
||||||
|
alt={rating.flavor_name}
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant={isXs ? 'subtitle1' : 'h6'}
|
||||||
|
sx={{ fontWeight: 'bold', lineHeight: 1.2, wordBreak: 'break-word' }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/flavor/${rating.flavor}`}
|
||||||
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{rating.flavor_name}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
flexWrap="wrap"
|
||||||
|
sx={{ mt: 0.5, rowGap: 0.5 }}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t(`categories.${rating.category_slug}`, {
|
||||||
|
defaultValue: rating.category_name,
|
||||||
|
})}{' '}
|
||||||
|
• {formatDate(rating.created_at)}
|
||||||
|
</Typography>
|
||||||
|
<StatusBadge
|
||||||
|
isLegacy={rating.is_legacy}
|
||||||
|
isAvailable={rating.is_available}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ alignSelf: { xs: 'flex-start', sm: 'auto' }, flexShrink: 0 }}>
|
||||||
|
<RatingBadge
|
||||||
|
score={rating.score}
|
||||||
|
size={isXs ? 'medium' : isMobile ? 'medium' : 'large'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: alpha(theme.palette.text.primary, 0.03),
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rating.comment ? (
|
||||||
|
<RichText text={rating.comment} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||||
|
{t('dashboard.noComment')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<CommentIcon fontSize="small" />}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 'bold', color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
{rating.replies.length} {t('common.replies')}
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Button size="small" sx={{ fontWeight: 'bold' }} onClick={startEdit}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" color="error" sx={{ fontWeight: 'bold' }} onClick={handleDelete}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
|
{rating.replies.map((reply) => (
|
||||||
|
<Box
|
||||||
|
key={reply.id}
|
||||||
|
sx={{ mb: 2, pl: 2, borderLeft: '3px solid', borderColor: 'primary.main' }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
{reply.user}
|
||||||
|
{reply.user === currentUsername && (
|
||||||
|
<VerifiedIcon
|
||||||
|
sx={{ fontSize: '0.8rem', color: 'primary.main', ml: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatDate(reply.created_at)}
|
||||||
|
</Typography>
|
||||||
|
{reply.user === currentUsername && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }}
|
||||||
|
onClick={() => handleDeleteReply(reply.id)}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<RichText text={reply.text} />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ mt: 2 }}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<MentionTextField
|
||||||
|
placeholder={t('community.writeReply')}
|
||||||
|
value={replyInput}
|
||||||
|
onChange={setReplyInput}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReplySubmit()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleReplySubmit}
|
||||||
|
disabled={!replyInput}
|
||||||
|
sx={{ height: 40, px: 3, alignSelf: { xs: 'flex-end', sm: 'auto' } }}
|
||||||
|
>
|
||||||
|
{t('common.reply')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyReviewCard;
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Grid,
|
||||||
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import SortIcon from '@mui/icons-material/Sort';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { RatedItem } from '../../api/types';
|
||||||
|
import { GlassSurface, EmptyState } from '../../components/ui';
|
||||||
|
import MyReviewCard from './MyReviewCard';
|
||||||
|
|
||||||
|
export interface MyReviewsTabProps {
|
||||||
|
ratings: RatedItem[];
|
||||||
|
currentUsername: string;
|
||||||
|
/** Empty-state CTA: jump the user to the explore/discover view. */
|
||||||
|
onExplore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RatedSort = 'date' | 'rating';
|
||||||
|
|
||||||
|
const MyReviewsTab: React.FC<MyReviewsTabProps> = ({ ratings, currentUsername, onExplore }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [category, setCategory] = useState('All');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sort, setSort] = useState<RatedSort>('date');
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Set(ratings.map((r) => r.category_name));
|
||||||
|
return ['All', ...Array.from(cats).sort()];
|
||||||
|
}, [ratings]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const items = ratings.filter((r) => {
|
||||||
|
const matchCat = category === 'All' || r.category_name === category;
|
||||||
|
const matchSearch = r.flavor_name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchCat && matchSearch;
|
||||||
|
});
|
||||||
|
if (sort === 'rating') {
|
||||||
|
items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.score - a.score || new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
items.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [ratings, category, search, sort]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<GlassSurface intensity="subtle" sx={{ p: { xs: 1.5, sm: 2 }, mb: 3 }}>
|
||||||
|
<Grid container spacing={{ xs: 1.5, lg: 1.5 }} alignItems="center">
|
||||||
|
<Grid size={{ xs: 8, sm: 12, lg: 4, xl: 5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder={t('dashboard.searchRated')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, sm: 12, lg: 'auto' }} order={{ xs: 2, sm: 3, lg: 3 }}>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value as RatedSort)}
|
||||||
|
startAdornment={
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SortIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
sx={{ minWidth: { lg: 140 }, fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
<MenuItem value="date">{t('dashboard.date')}</MenuItem>
|
||||||
|
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
size={{ xs: 12, lg: 'auto' }}
|
||||||
|
order={{ xs: 3, sm: 2, lg: 2 }}
|
||||||
|
sx={{ flexGrow: 1, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
overflowX: 'auto',
|
||||||
|
py: 0.5,
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Chip
|
||||||
|
key={cat}
|
||||||
|
label={
|
||||||
|
cat === 'All'
|
||||||
|
? t('common.all')
|
||||||
|
: t(
|
||||||
|
`categories.${ratings.find((r) => r.category_name === cat)?.category_slug}`,
|
||||||
|
{
|
||||||
|
defaultValue: cat,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => setCategory(cat)}
|
||||||
|
color={category === cat ? 'primary' : 'default'}
|
||||||
|
variant={category === cat ? 'filled' : 'outlined'}
|
||||||
|
sx={{ fontWeight: 'bold', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('dashboard.noRatings')}
|
||||||
|
action={
|
||||||
|
<Button onClick={onExplore} variant="contained" sx={{ borderRadius: 2 }}>
|
||||||
|
{t('dashboard.exploreFlavors')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
filtered.map((rating) => (
|
||||||
|
<MyReviewCard key={rating.id} rating={rating} currentUsername={currentUsername} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyReviewsTab;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, CardContent, Chip, Tooltip, Typography } from '@mui/material';
|
||||||
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
import GroupsIcon from '@mui/icons-material/Groups';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { Recommendation } from '../../api/types';
|
||||||
|
import RatingBadge from '../../components/RatingBadge';
|
||||||
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
|
import { GlassCard } from '../../components/ui';
|
||||||
|
|
||||||
|
export interface RecommendationCardProps {
|
||||||
|
rec: Recommendation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single recommended flavor. The reason chip is **count-only** (no neighbour
|
||||||
|
* names, per product decision): CF → "N tasters like you", popularity → "Popular".
|
||||||
|
*/
|
||||||
|
const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const reasonLabel =
|
||||||
|
rec.reason === 'cf'
|
||||||
|
? t('dashboard.recReasonCf', { count: rec.contributing_neighbours })
|
||||||
|
: t('dashboard.recReasonPopular');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
intensity="subtle"
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
boxShadow: (th) => th.tokens.elevation.md,
|
||||||
|
},
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link to={`/flavor/${rec.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={rec.image_url || undefined}
|
||||||
|
alt={rec.name}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
transition: 'transform 0.5s ease',
|
||||||
|
'&:hover': { transform: 'scale(1.1)' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('dashboard.predictedForYou')}>
|
||||||
|
<Box>
|
||||||
|
<RatingBadge
|
||||||
|
score={rec.predicted_score}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 24, bgcolor: 'primary.main' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
<StatusBadge isLegacy={rec.is_legacy} isAvailable={rec.is_available} size="small" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<CardContent sx={{ p: 2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
mb: 0.5,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 1,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rec.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
|
||||||
|
{t(`categories.${rec.category_slug}`, { defaultValue: rec.category_name })}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={rec.reason === 'cf' ? <GroupsIcon /> : <AutoAwesomeIcon />}
|
||||||
|
label={reasonLabel}
|
||||||
|
color={rec.reason === 'cf' ? 'primary' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 'bold', maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecommendationCard;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Grid, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRecommendations } from '../../api/queries/useRecommendations';
|
||||||
|
import RecommendationCard from './RecommendationCard';
|
||||||
|
|
||||||
|
export interface RecommendationsSectionProps {
|
||||||
|
/** Gate the query on auth (the dashboard only renders when logged in). */
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Tasters like you loved…" — the personalized discovery row. Hidden entirely when
|
||||||
|
* the engine returns nothing (e.g. the user has rated everything); a thin profile
|
||||||
|
* still yields popularity-fallback picks, so this is rarely empty.
|
||||||
|
*/
|
||||||
|
const RecommendationsSection: React.FC<RecommendationsSectionProps> = ({ enabled }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useRecommendations(enabled);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={240} height={36} sx={{ mb: 2 }} />
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Grid key={i} size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||||
|
<Skeleton variant="rounded" height={260} sx={{ borderRadius: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<AutoAwesomeIcon color="primary" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: '900' }}>
|
||||||
|
{t('dashboard.recHeading')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{data.map((rec) => (
|
||||||
|
<Grid key={rec.id} size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||||
|
<RecommendationCard rec={rec} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecommendationsSection;
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
RatingDistribution,
|
RatingDistribution,
|
||||||
} from '../../components/ui';
|
} from '../../components/ui';
|
||||||
import RatingForm from './components/RatingForm';
|
import RatingForm from './components/RatingForm';
|
||||||
|
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
|
||||||
import { useToast } from '../../hooks/useToast';
|
import { useToast } from '../../hooks/useToast';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
|
||||||
@@ -143,7 +144,12 @@ const FlavorDetail: React.FC = () => {
|
|||||||
const text = replyInputs[ratingId];
|
const text = replyInputs[ratingId];
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
await createReply.mutateAsync({ ratingId, text });
|
await createReply.mutateAsync({
|
||||||
|
ratingId,
|
||||||
|
text,
|
||||||
|
flavorId: flavor?.id,
|
||||||
|
optimisticUser: currentUser ?? undefined,
|
||||||
|
});
|
||||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -772,6 +778,8 @@ const FlavorDetail: React.FC = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<SimilarFlavorsSection flavorId={flavor.id} />
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ScoreInput from '../../../components/ui/ScoreInput';
|
|||||||
import MentionTextField from '../../../components/MentionTextField';
|
import MentionTextField from '../../../components/MentionTextField';
|
||||||
import { useCreateRating } from '../../../api/mutations/useRatingMutations';
|
import { useCreateRating } from '../../../api/mutations/useRatingMutations';
|
||||||
import { useToast } from '../../../hooks/useToast';
|
import { useToast } from '../../../hooks/useToast';
|
||||||
|
import { useAuth } from '../../../hooks/useAuth';
|
||||||
|
|
||||||
export interface RatingFormProps {
|
export interface RatingFormProps {
|
||||||
flavorId: number;
|
flavorId: number;
|
||||||
@@ -14,6 +15,7 @@ export interface RatingFormProps {
|
|||||||
const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
const createRating = useCreateRating();
|
const createRating = useCreateRating();
|
||||||
const [score, setScore] = useState<number | null>(null);
|
const [score, setScore] = useState<number | null>(null);
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
@@ -25,7 +27,12 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createRating.mutateAsync({ flavor: flavorId, score, comment });
|
await createRating.mutateAsync({
|
||||||
|
flavor: flavorId,
|
||||||
|
score,
|
||||||
|
comment,
|
||||||
|
optimistic: user ? { user: user.username, user_avatar: user.avatar } : undefined,
|
||||||
|
});
|
||||||
setScore(null);
|
setScore(null);
|
||||||
setComment('');
|
setComment('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, CardContent, Chip, Grid, Skeleton, Stack, Tooltip, Typography } from '@mui/material';
|
||||||
|
import RecommendIcon from '@mui/icons-material/Recommend';
|
||||||
|
import GroupsIcon from '@mui/icons-material/Groups';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSimilarFlavors } from '../../../api/queries/useSimilarFlavors';
|
||||||
|
import type { SimilarFlavor } from '../../../api/types';
|
||||||
|
import RatingBadge from '../../../components/RatingBadge';
|
||||||
|
import StatusBadge from '../../../components/StatusBadge';
|
||||||
|
import { GlassCard } from '../../../components/ui';
|
||||||
|
|
||||||
|
export interface SimilarFlavorsSectionProps {
|
||||||
|
flavorId: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One compact "similar flavor" card: image, score badge, name, co-rater count. */
|
||||||
|
const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
intensity="subtle"
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
boxShadow: (th) => th.tokens.elevation.md,
|
||||||
|
},
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link to={`/flavor/${item.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={item.image_url || undefined}
|
||||||
|
alt={item.name}
|
||||||
|
loading="lazy"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
transition: 'transform 0.5s ease',
|
||||||
|
'&:hover': { transform: 'scale(1.1)' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.average_rating != null && (
|
||||||
|
<Box sx={{ position: 'absolute', top: 10, left: 10, zIndex: 2 }}>
|
||||||
|
<RatingBadge score={item.average_rating} size="small" sx={{ height: 24 }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 2 }}>
|
||||||
|
<StatusBadge isLegacy={item.is_legacy} isAvailable={item.is_available} size="small" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<CardContent sx={{ p: 2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
mb: 0.5,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 1,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
|
||||||
|
{t(`categories.${item.category_slug}`, { defaultValue: item.category_name })}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={t('flavorDetail.similarTooltip')}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<GroupsIcon />}
|
||||||
|
label={t('flavorDetail.similarCoRaters', { count: item.co_raters })}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 'bold', maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "People who liked this also liked…" — item-based collaborative filtering on the
|
||||||
|
* flavor detail page. Hidden entirely when the catalog has too few co-ratings to
|
||||||
|
* compute a confident neighbourhood (cold flavors return an empty list).
|
||||||
|
*/
|
||||||
|
const SimilarFlavorsSection: React.FC<SimilarFlavorsSectionProps> = ({ flavorId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useSimilarFlavors(flavorId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Skeleton variant="text" width={280} height={36} sx={{ mb: 2 }} />
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Grid key={i} size={{ xs: 6, sm: 4, md: 3 }}>
|
||||||
|
<Skeleton variant="rounded" height={240} sx={{ borderRadius: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<RecommendIcon color="primary" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: '900' }}>
|
||||||
|
{t('flavorDetail.similarHeading')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{data.map((item) => (
|
||||||
|
<Grid key={item.id} size={{ xs: 6, sm: 4, md: 3 }}>
|
||||||
|
<SimilarCard item={item} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SimilarFlavorsSection;
|
||||||
Reference in new issue
Block a user