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
+2319
-1037
No files matched your search
@@ -74,6 +74,35 @@ Verify mutations `invalidateQueries`; consider optimistic insert so reviews appe
|
||||
---
|
||||
|
||||
## 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
|
||||
`lib/api.ts`; `AuthContext.refetchUser` now passes `{ skipAuthRedirect: true }
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DashboardOut {
|
||||
pub user: UserOut,
|
||||
pub rated_count: i64,
|
||||
pub missing_count: i64,
|
||||
pub missing_flavors: Vec<FlavorOut>,
|
||||
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)]
|
||||
|
||||
@@ -17,6 +17,7 @@ mod openapi;
|
||||
mod pagination;
|
||||
mod password;
|
||||
mod password_policy;
|
||||
mod recommend;
|
||||
mod routes;
|
||||
mod search;
|
||||
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 crate::auth::{AuthUser, OptionalUser};
|
||||
use crate::dto::{FlavorOut, SearchHit};
|
||||
use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{category, flavor, rating};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::media::image_field_url;
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::recommend::{similar_flavors, RatingInput};
|
||||
use crate::search::{extract_category_slug, query_words, score_relevance};
|
||||
use crate::service::build_flavors;
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
/// Max "similar flavors" returned by `/flavors/{id}/similar/`.
|
||||
const SIMILAR_LIMIT: usize = 8;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/flavors/", get(list))
|
||||
@@ -28,6 +32,7 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/flavors/newest/", get(newest))
|
||||
.route("/flavors/followed_top/", get(followed_top))
|
||||
.route("/flavors/{id}/", get(retrieve))
|
||||
.route("/flavors/{id}/similar/", get(similar))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -122,6 +127,69 @@ async fn retrieve(
|
||||
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)]
|
||||
struct CategoryParam {
|
||||
category: Option<String>,
|
||||
|
||||
+204
-42
@@ -16,6 +16,7 @@ use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::Datelike;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
|
||||
@@ -26,11 +27,15 @@ use serde_json::{json, Value};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
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::{flavor, notification, profile_comment, rating, user, user_ip};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::recommend::{recommend, RatingInput};
|
||||
use crate::service::{
|
||||
build_flavors, build_profile_comments, build_ratings, build_user, build_users,
|
||||
};
|
||||
@@ -91,6 +96,7 @@ pub fn router() -> Router<AppState> {
|
||||
post(confirm_account_deletion),
|
||||
)
|
||||
.route("/users/dashboard/", get(dashboard))
|
||||
.route("/users/recommendations/", get(recommendations))
|
||||
.route("/users/{id}/", get(retrieve))
|
||||
.route("/users/{id}/follow/", post(follow))
|
||||
.route("/users/{id}/unfollow/", post(unfollow))
|
||||
@@ -1315,7 +1321,6 @@ async fn dashboard(
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<DashboardOut>> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let followed = following_ids(&state, uid).await?;
|
||||
|
||||
let my_ratings_models = Rating::find()
|
||||
.filter(rating::Column::UserId.eq(uid))
|
||||
@@ -1325,10 +1330,24 @@ async fn dashboard(
|
||||
let rated_count = my_ratings_models.len() as i64;
|
||||
let rated_flavor_ids: std::collections::HashSet<i32> =
|
||||
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,
|
||||
// ordered by category name then flavor name.
|
||||
// Ratings created in the current UTC month — computed from the models while we
|
||||
// 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()
|
||||
.filter(crate::entities::category::Column::Name.eq("Packs and other"))
|
||||
.one(&state.db)
|
||||
@@ -1337,52 +1356,195 @@ async fn dashboard(
|
||||
if let Some(p) = packs {
|
||||
q = q.filter(flavor::Column::CategoryId.ne(p.id));
|
||||
}
|
||||
let mut all_flavors = q.all(&state.db).await?;
|
||||
all_flavors.retain(|f| !rated_flavor_ids.contains(&f.id));
|
||||
|
||||
// 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))
|
||||
let missing_count = q
|
||||
.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))
|
||||
});
|
||||
.iter()
|
||||
.filter(|f| !rated_flavor_ids.contains(&f.id))
|
||||
.count() as i64;
|
||||
|
||||
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?;
|
||||
|
||||
Ok(Json(DashboardOut {
|
||||
user,
|
||||
rated_count,
|
||||
missing_count,
|
||||
missing_flavors,
|
||||
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 = {
|
||||
categories: () => ['categories'] as const,
|
||||
dashboard: () => ['dashboard'] as const,
|
||||
recommendations: () => ['recommendations'] as const,
|
||||
publicProfile: (username: string) => ['publicProfile', username] as const,
|
||||
publicProfileAll: () => ['publicProfile'] as const,
|
||||
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
|
||||
flavorsAll: () => ['flavors'] 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,
|
||||
notifications: () => ['notifications'] as const,
|
||||
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../lib/api';
|
||||
import { queryKeys } from '../keys';
|
||||
import type {
|
||||
FlavorDetailData,
|
||||
FlavorDetailRating,
|
||||
FlavorDetailReply,
|
||||
} from '../queries/useFlavorDetail';
|
||||
|
||||
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
|
||||
@@ -37,19 +42,53 @@ export interface CreateRatingInput {
|
||||
flavor: number;
|
||||
score: number;
|
||||
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 = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateRatingInput) => api.post('ratings/', input),
|
||||
onSuccess: () => invalidateRatingViews(qc),
|
||||
mutationFn: ({ flavor, score, comment }: CreateRatingInput) =>
|
||||
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 {
|
||||
ratingId: number;
|
||||
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 = () => {
|
||||
@@ -57,7 +96,31 @@ export const useCreateReply = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ ratingId, text }: ReplyInput) =>
|
||||
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 api from '../../lib/api';
|
||||
import api, { type RetriableConfig } from '../../lib/api';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export interface CurrentUserLite {
|
||||
@@ -19,8 +19,8 @@ export const useCurrentUserLite = () => {
|
||||
const res = await api.get<CurrentUserLite>('users/me/', {
|
||||
// Stale access tokens are common; never bounce a public page reader
|
||||
// to /login because of a background "am I logged in?" check.
|
||||
...({ skipAuthRedirect: true } as object),
|
||||
});
|
||||
skipAuthRedirect: true,
|
||||
} as RetriableConfig);
|
||||
return res.data;
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
user: { username: string; avatar: string | null };
|
||||
rated_count: number;
|
||||
missing_count: number;
|
||||
missing_flavors: MissingFlavor[];
|
||||
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}}!',
|
||||
shareProfile: 'Share Profile',
|
||||
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',
|
||||
missing: 'Missing',
|
||||
noRatings: "You haven't rated any flavors yet.",
|
||||
@@ -141,6 +149,9 @@ const resources = {
|
||||
outOfStock: 'Out of Stock',
|
||||
},
|
||||
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',
|
||||
scoreLabel: 'Your score, 1 to 10',
|
||||
submitReview: 'Submit Review',
|
||||
@@ -336,6 +347,14 @@ const resources = {
|
||||
welcome: 'Willkommen zurück, {{username}}!',
|
||||
shareProfile: 'Profil teilen',
|
||||
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',
|
||||
missing: 'Fehlend',
|
||||
noRatings: 'Du hast noch keine Sorten bewertet.',
|
||||
@@ -448,6 +467,9 @@ const resources = {
|
||||
outOfStock: 'Ausverkauft',
|
||||
},
|
||||
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',
|
||||
scoreLabel: 'Deine Bewertung, 1 bis 10',
|
||||
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,
|
||||
} from '../../components/ui';
|
||||
import RatingForm from './components/RatingForm';
|
||||
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
|
||||
@@ -143,7 +144,12 @@ const FlavorDetail: React.FC = () => {
|
||||
const text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await createReply.mutateAsync({ ratingId, text });
|
||||
await createReply.mutateAsync({
|
||||
ratingId,
|
||||
text,
|
||||
flavorId: flavor?.id,
|
||||
optimisticUser: currentUser ?? undefined,
|
||||
});
|
||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
||||
} catch {
|
||||
@@ -772,6 +778,8 @@ const FlavorDetail: React.FC = () => {
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<SimilarFlavorsSection flavorId={flavor.id} />
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import ScoreInput from '../../../components/ui/ScoreInput';
|
||||
import MentionTextField from '../../../components/MentionTextField';
|
||||
import { useCreateRating } from '../../../api/mutations/useRatingMutations';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useAuth } from '../../../hooks/useAuth';
|
||||
|
||||
export interface RatingFormProps {
|
||||
flavorId: number;
|
||||
@@ -14,6 +15,7 @@ export interface RatingFormProps {
|
||||
const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { notify } = useToast();
|
||||
const { user } = useAuth();
|
||||
const createRating = useCreateRating();
|
||||
const [score, setScore] = useState<number | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
@@ -25,7 +27,12 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
setComment('');
|
||||
} 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