- {/* Header row with title */}
Suggested For You
@@ -102,33 +37,10 @@ const SuggestedMeals = () => {
✨
- {/* Horizontally scrollable card row */}
-
-
-
-
- {meals.map((meal) => (
-
- ))}
-
- {/* Spacer so short lists don't look broken on wide screens */}
- {meals.length < 4 && (
-
- )}
-
+
+ {filteredMeals.map((meal) => (
+
+ ))}
diff --git a/src/pages/Home/Sections/SuggestedMealsTeaser.jsx b/src/pages/Home/Sections/SuggestedMealsTeaser.jsx
new file mode 100644
index 0000000..49c1740
--- /dev/null
+++ b/src/pages/Home/Sections/SuggestedMealsTeaser.jsx
@@ -0,0 +1,109 @@
+import { Link } from "react-router-dom";
+import RegularFoodCard from "../../../components/UI/RegularFoodCard";
+
+const PLACEHOLDER_MEALS = [
+ {
+ id: 1,
+ name: "Mystery Meal",
+ price: 12.99,
+ hasDiscount: false,
+ discountPercentage: 0,
+ nutrients: [],
+ imageUrl: null,
+ },
+ {
+ id: 2,
+ name: "Mystery Meal",
+ price: 9.99,
+ hasDiscount: false,
+ discountPercentage: 0,
+ nutrients: [],
+ imageUrl: null,
+ },
+ {
+ id: 3,
+ name: "Mystery Meal",
+ price: 15.99,
+ hasDiscount: false,
+ discountPercentage: 0,
+ nutrients: [],
+ imageUrl: null,
+ },
+ {
+ id: 4,
+ name: "Mystery Meal",
+ price: 11.99,
+ hasDiscount: false,
+ discountPercentage: 0,
+ nutrients: [],
+ imageUrl: null,
+ },
+];
+
+const SuggestedMealsTeaser = () => {
+ return (
+
+
+
+
+ Suggested For You
+
+ ✨
+
+
+
+ {/* Cards with light blur */}
+
+ {PLACEHOLDER_MEALS.map((meal) => (
+
+ ))}
+
+
+ {/* Overlay */}
+
+ {/* Lock Icon */}
+
+
+
+ Meals tailored just for you!
+
+
+ Sign in to get AI-powered meal recommendations based on your
+ health goals
+
+
+
+ Sign In
+
+
+ Create Account
+
+
+
+
+
+
+ );
+};
+
+export default SuggestedMealsTeaser;
diff --git a/src/pages/Menu/Menu.jsx b/src/pages/Menu/Menu.jsx
index ef0e38b..a72443b 100644
--- a/src/pages/Menu/Menu.jsx
+++ b/src/pages/Menu/Menu.jsx
@@ -1,75 +1,60 @@
-import {
- useMenuStore,
- useAuthStore,
- useRecommendationStore,
-} from "../../store";
+import { useMenuStore, useAuthStore } from "../../store";
import { useMenuItems } from "../../hooks/dashboard/useMenuItems";
-import { useMemo, useEffect } from "react";
+import { useMemo } from "react";
import MenuFilter from "./Sections/MenuFilter";
import OffersSection from "./Sections/OffersSection";
-import SuggestedMealsSection from "./Sections/SuggestedMealsSection";
import RegularFood from "../Home/Sections/RegularFood";
+import SuggestedMeals from "../Home/Sections/SuggestedMeals";
+import SuggestedMealsTeaser from "../Home/Sections/SuggestedMealsTeaser";
export default function Menu() {
- const { user } = useAuthStore();
- const {
- recommendations,
- fetchRecommendations,
- } = useRecommendationStore();
- const { data: meals = [], isLoading: mealsLoading, error: mealsErrorObj } = useMenuItems({});
- const mealsError = mealsErrorObj ? (mealsErrorObj.message || "Failed to load meals") : null;
- const { selectedCategory } = useMenuStore();
-
- const isGuest = !user;
-
- useEffect(() => {
- // If user is logged in, fetch personalized recommendations
- if (!isGuest) {
- fetchRecommendations(user?.id);
- }
- }, [user, isGuest, fetchRecommendations]);
-
-
- const filteredMeals = useMemo(() => {
- return meals.filter((item) => {
- return selectedCategory === "All" || item.category === selectedCategory;
- });
- }, [selectedCategory, meals]);
-
- const filteredRecommendations = useMemo(() => {
- return (recommendations || []).filter((item) => {
- return selectedCategory === "All" || item.category === selectedCategory;
- });
- }, [selectedCategory, recommendations]);
-
- return (
-
-
-
-
-
- {/* Suggested Meals Section (Displayed for logged-in users when recommendations exist) */}
- {!isGuest && (
-
- )}
-
- {/* Regular Food Section (Always displayed for everyone) */}
- {mealsLoading && meals.length === 0 ? (
-
- ) : mealsError && meals.length === 0 ? (
-
- ) : filteredMeals.length === 0 ? (
-
-
No meals available in this category
-
- ) : (
-
- )}
-
-
- );
+ const { isAuthenticated } = useAuthStore();
+ const {
+ data: meals = [],
+ isLoading: mealsLoading,
+ error: mealsErrorObj,
+ } = useMenuItems({});
+ const mealsError = mealsErrorObj
+ ? mealsErrorObj.message || "Failed to load meals"
+ : null;
+ const { selectedCategory } = useMenuStore();
+
+ const filteredMeals = useMemo(() => {
+ return meals.filter((item) => {
+ return selectedCategory === "All" || item.category === selectedCategory;
+ });
+ }, [selectedCategory, meals]);
+
+ return (
+
+
+
+
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ )}
+
+ {mealsLoading && meals.length === 0 ? (
+
+ ) : mealsError && meals.length === 0 ? (
+
+ ) : filteredMeals.length === 0 ? (
+
+
+ No meals available in this category
+
+
+ ) : (
+
+ )}
+
+
+ );
}
diff --git a/src/pages/Profile/Profile.jsx b/src/pages/Profile/Profile.jsx
index f192e12..efe6dc2 100644
--- a/src/pages/Profile/Profile.jsx
+++ b/src/pages/Profile/Profile.jsx
@@ -7,36 +7,44 @@ import { toast } from "sonner";
export default function Profile() {
const user = useProfileStore((s) => s.user);
+ const fetchProfile = useProfileStore((s) => s.fetchProfile);
const updateHealth = useProfileStore((s) => s.updateHealth);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
- const joinDate = user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : "-";
- const profile = user?.profile || {};
+ useEffect(() => {
+ fetchProfile();
+ }, []);
return (
-
setEditing(true)} />
+ setEditing(true)}
+ />
{!editing ? (
-
+
) : (
setEditing(false)}
onSave={async (form) => {
setSaving(true);
try {
const updated = await updateHealth(form);
- if (!updated) throw new Error("Failed to update user profile. Please try again.");
+ if (!updated)
+ throw new Error("Failed to update profile. Please try again.");
toast.success("Profile updated successfully.");
setEditing(false);
} catch (err) {
- toast.error(err?.message || "Failed to update user profile. Please try again.");
+ toast.error(
+ err?.message || "Failed to update profile. Please try again.",
+ );
} finally {
setSaving(false);
}
diff --git a/src/pages/Profile/Profile.test.jsx b/src/pages/Profile/Profile.test.jsx
new file mode 100644
index 0000000..3ce2280
--- /dev/null
+++ b/src/pages/Profile/Profile.test.jsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import Profile from "./Profile";
+
+const mockFetchProfile = vi.fn();
+const mockUpdateHealth = vi.fn();
+
+vi.mock("../../store", () => ({
+ useProfileStore: (selector) =>
+ selector({
+ user: { createdAt: "2024-01-15T00:00:00.000Z" },
+ fetchProfile: mockFetchProfile,
+ updateHealth: mockUpdateHealth,
+ }),
+}));
+
+describe("Profile", () => {
+ it("renders the join date from the profile data", () => {
+ render();
+
+ expect(screen.getByText(/Join date/i)).toBeTruthy();
+ expect(screen.getByText(/2024-01-15/i)).toBeTruthy();
+ });
+});
diff --git a/src/services/api.js b/src/services/api.js
index ecf4145..db56e1c 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -1,47 +1,43 @@
import axios from "axios";
+import { useAuthStore } from "../store";
import { restoreSessionService } from "./auth.service";
// ============================================================
-// API CONFIGURATION
+// API BASE URL
// ============================================================
+const ENV = import.meta.env.VITE_ENV || "prod";
-// Determine baseURL based on VITE_ENV (local, dev, prod)
-const getBaseURL = () => {
- const env = import.meta.env.VITE_ENV || "local";
- if (env === "prod") {
- if (!import.meta.env.VITE_API_URL_PROD) throw new Error("VITE_API_URL_PROD is not defined");
- return import.meta.env.VITE_API_URL_PROD;
- }
- if (env === "dev") {
- if (!import.meta.env.VITE_API_URL_DEV) throw new Error("VITE_API_URL_DEV is not defined");
- return import.meta.env.VITE_API_URL_DEV;
- }
- return import.meta.env.VITE_API_URL_LOCAL || "http://localhost:8080/";
+const BASE_URLS = {
+ local: import.meta.env.VITE_API_URL_LOCAL,
+ dev: import.meta.env.VITE_API_URL_DEV,
+ prod: import.meta.env.VITE_API_URL_PROD,
};
+const BASE_URL = BASE_URLS[ENV] || import.meta.env.VITE_API_URL_PROD;
+
// Create axios instance
export const api = axios.create({
- baseURL: getBaseURL(),
- withCredentials: true, // refresh token is sent in httpOnly cookie
+ baseURL: BASE_URL,
+ withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
// ============================================================
-// INTERCEPTORS (active in both mock and real mode)
+// INTERCEPTORS
// ============================================================
-let isRefreshing = false; // flag to indicate if token refresh is in progress
-const refreshQueue = []; // queue to hold requests while token is being refreshed
+let isRefreshing = false;
+const refreshQueue = [];
// Attach access token to every outgoing request
api.interceptors.request.use(
- async (config) => {
- const useAuthStore = (await import("../store/authStore")).default;
+ (config) => {
const { getAccessToken } = useAuthStore.getState();
- getAccessToken() &&
- (config.headers["Authorization"] = `Bearer ${getAccessToken()}`);
+ if (getAccessToken()) {
+ config.headers["Authorization"] = `Bearer ${getAccessToken()}`;
+ }
return config;
},
(error) => Promise.reject(error),
@@ -52,10 +48,9 @@ api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
- const useAuthStore = (await import("../store/authStore")).default;
const { getAccessToken, setAccessToken, logout } = useAuthStore.getState();
- // If refresh endpoint fails, refresh token expired — user must re-login
+ // If refresh endpoint fails → session expired → logout
if (originalRequest.url?.includes("/auth/refresh")) {
isRefreshing = false;
refreshQueue.forEach((p) => p.reject(new Error("Session expired")));
@@ -64,45 +59,31 @@ api.interceptors.response.use(
return Promise.reject(error);
}
- // If any endpoint returns 401 (unauthorized), attempt to refresh the access token
- if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/login')) {
- // If already refreshing, queue this request and wait
+ // If 401 → attempt token refresh
+ if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
})
- .then(() => {
+ .then((token) => {
+ originalRequest.headers["Authorization"] = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
- // Start token refresh process
isRefreshing = true;
originalRequest._retry = true;
try {
const res = await restoreSessionService();
- const token = res.data.token;
-
- let expiresAt = Date.now() + 1000 * 60 * 60 * 24; // fallback
- try {
- if (token && typeof token === 'string' && token.split('.').length === 3) {
- const base64Url = token.split('.')[1];
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
- const payload = JSON.parse(atob(base64));
- if (payload.exp) {
- expiresAt = payload.exp * 1000;
- }
- }
- } catch (e) {
- console.warn("Failed to decode JWT payload", e);
- }
-
- setAccessToken(token, expiresAt);
+ const expiresAt = Date.now() + 1000 * 60 * 60 * 24;
+ setAccessToken(res.data.token, expiresAt);
isRefreshing = false;
- refreshQueue.forEach((p) => p.resolve(token));
+ originalRequest.headers["Authorization"] = `Bearer ${getAccessToken()}`;
+
+ refreshQueue.forEach((p) => p.resolve(getAccessToken()));
refreshQueue.length = 0;
return api(originalRequest);
diff --git a/src/services/recommendation.service.js b/src/services/recommendation.service.js
index 398dc94..f04927c 100644
--- a/src/services/recommendation.service.js
+++ b/src/services/recommendation.service.js
@@ -1,11 +1,67 @@
+import axios from "axios";
import { api } from "./api";
+import { getMenu } from "./menu.service";
+import { useAuthStore } from "../store";
+
+const AI_API_URL =
+ "https://youssef-ashraf-healthy-meal-ai-api.hf.space/recommend";
/**
- * Fetch AI-suggested meals for the authenticated user from the backend recommendation engine.
+ * Fetch AI meal recommendations for the authenticated user.
+ * 1. Fetches client profile + all meals in parallel
+ * 2. Sends both to the AI recommendation engine
+ * 3. Enriches the recommendations with imageUrl & nutrients from the original meals
*
- * @param {string|number} userId - The authenticated user's ID
- * @returns {Promise}
+ * @param {string} role - The authenticated user's role (e.g. "CLIENT")
+ * @returns {{ data: Array }} - Enriched recommended meals
*/
-export const getSuggestedMeals = (userId) => {
- return api.get(`/api/menu/recommendations/${userId}`);
+export const getSuggestedMeals = async () => {
+ const [profileRes, mealsRes] = await Promise.allSettled([
+ api.get(`/api/clients/profile/${useAuthStore.getState().user?.id}`),
+ getMenu(),
+ ]);
+
+ if (profileRes.status !== "fulfilled") {
+ throw new Error("Failed to fetch user profile");
+ }
+ if (mealsRes.status !== "fulfilled") {
+ throw new Error("Failed to fetch meals");
+ }
+
+ const user = profileRes.value.data;
+ const meals = mealsRes.value.data;
+
+ const aiResponse = await axios.post(
+ AI_API_URL,
+ {
+ user,
+ meals,
+ top_n: 5,
+ },
+ { timeout: 10000 },
+ );
+
+ const aiPayload = aiResponse?.data;
+ const recommendations = Array.isArray(aiPayload?.recommendations)
+ ? aiPayload.recommendations
+ : [];
+
+ // Enrich recommendations with original meal data (imageUrl, nutrients, discount)
+ const enrichedMeals = recommendations.map((rec) => {
+ const originalMeal = meals.find((m) => m.id === rec.meal_id);
+ return {
+ id: rec.meal_id,
+ name: rec.name,
+ price: rec.price,
+ category: rec.category,
+ imageUrl: originalMeal?.imageUrl ?? null,
+ nutrients: originalMeal?.nutrients ?? [],
+ hasDiscount: originalMeal?.hasDiscount ?? false,
+ discountPercentage: originalMeal?.discountPercentage ?? 0,
+ score: rec.score_percentage,
+ reasons: rec.reasons,
+ };
+ });
+
+ return { data: enrichedMeals };
};
diff --git a/src/services/user.service.js b/src/services/user.service.js
index a1385b6..b613261 100644
--- a/src/services/user.service.js
+++ b/src/services/user.service.js
@@ -1,21 +1,33 @@
-import { api } from './api';
+import { api } from "./api";
-// Get current user profile
+// Get current logged-in client's profile (all profiles - admin)
export const getProfile = () => {
- return api.get('/users/me');
+ return api.get("/api/clients/profile");
};
-// Update profile
-export const updateProfile = (data) => {
- return api.put('/users/me', data);
+// Get specific client profile by ID
+export const getProfileById = (id) => {
+ return api.get(`/api/clients/profile/${id}`);
};
-// Update health profile
-export const updateHealthProfile = (data) => {
- return api.put('/users/me/health', data);
+// Update client profile by ID
+export const updateProfile = (id, data) => {
+ return api.put(`/api/clients/profile/${id}`, data);
};
-// Get order history
-export const getOrderHistory = () => {
- return api.get('/users/me/orders');
+// Delete client profile by ID
+export const deleteProfile = (id) => {
+ return api.delete(`/api/clients/profile/${id}`);
+};
+
+// Upload profile picture
+export const uploadProfilePicture = (id, file) => {
+ const formData = new FormData();
+ formData.append("file", file);
+ return api.patch(`/api/clients/profile/${id}/picture`, formData);
+};
+
+// Delete profile picture
+export const deleteProfilePicture = (id) => {
+ return api.delete(`/api/clients/profile/${id}/picture`);
};
diff --git a/src/store/__tests__/profileStore.test.js b/src/store/__tests__/profileStore.test.js
new file mode 100644
index 0000000..16af4ac
--- /dev/null
+++ b/src/store/__tests__/profileStore.test.js
@@ -0,0 +1,32 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import useProfileStore from "../profileStore";
+import useAuthStore from "../authStore";
+
+vi.mock("../../services/user.service", () => ({
+ getProfileById: vi.fn(),
+ updateProfile: vi.fn(),
+}));
+
+const { getProfileById } = await import("../../services/user.service");
+
+describe("useProfileStore", () => {
+ beforeEach(() => {
+ useProfileStore.setState({ user: null, loading: false, error: null }, true);
+ useAuthStore.setState(
+ { user: { id: 1 }, token: null, expiresAt: null, isAuthenticated: true },
+ true,
+ );
+ vi.clearAllMocks();
+ });
+
+ it("does not reuse a cached profile from a different auth user", async () => {
+ useProfileStore.setState({ user: { id: 99, firstName: "Old" } }, true);
+
+ getProfileById.mockResolvedValue({ data: { id: 1, firstName: "New" } });
+
+ const result = await useProfileStore.getState().fetchProfile();
+
+ expect(result).toEqual({ id: 1, firstName: "New" });
+ expect(getProfileById).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/src/store/profileStore.js b/src/store/profileStore.js
index 0f55685..998e473 100644
--- a/src/store/profileStore.js
+++ b/src/store/profileStore.js
@@ -1,12 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import { getProfile, updateProfile, updateHealthProfile } from "../services/user.service";
-
-/**
- * Profile Store
- * - Holds user profile/meta and health sub-object
- * - Contains actions that call services so components stay dumb
- */
+import { getProfileById, updateProfile } from "../services/user.service";
+import useAuthStore from "../store/authStore";
const useProfileStore = create(
persist(
(set, get) => ({
@@ -15,9 +10,22 @@ const useProfileStore = create(
error: null,
fetchProfile: async () => {
+ const activeUserId = useAuthStore.getState().user?.id;
+ const cachedUser = get().user;
+
+ if (cachedUser && cachedUser.id === activeUserId) {
+ return cachedUser;
+ }
+
+ if (cachedUser && cachedUser.id != null && activeUserId != null) {
+ set({ user: null, loading: false, error: null });
+ }
+
set({ loading: true, error: null });
try {
- const res = await getProfile();
+ const id = useAuthStore.getState().user?.id;
+ if (!id) throw new Error("User ID not found");
+ const res = await getProfileById(id);
const user = res?.data || null;
if (!user) {
set({ error: "Profile not found", loading: false });
@@ -34,7 +42,9 @@ const useProfileStore = create(
updateUser: async (data) => {
set({ loading: true, error: null });
try {
- const res = await updateProfile(data);
+ const id = useAuthStore.getState().user?.id;
+ if (!id) throw new Error("User ID not found");
+ const res = await updateProfile(id, data);
const user = res?.data || null;
if (user) set({ user, loading: false, error: null });
else set({ loading: false });
@@ -45,65 +55,9 @@ const useProfileStore = create(
}
},
+ // updateHealth بقى نفس updateUser لأن مفيش endpoint منفصل للـ health
updateHealth: async (data) => {
- set({ loading: true, error: null });
- try {
- const res = await updateHealthProfile(data);
- const user = res?.data || null;
- if (user) set({ user, loading: false, error: null });
- return user;
- } catch (error) {
- set({ error: error.message, loading: false });
- return null;
- } finally {
- set({ loading: false });
- }
- },
-
- addAllergy: (allergy) => {
- if (!allergy || typeof allergy !== "string") {
- set({ error: "Allergy cannot be empty" });
- return;
- }
-
- const normalized = allergy.trim().toLowerCase();
- const user = get().user;
- const current = user?.profile?.allergies || [];
- if (current.includes(normalized)) {
- set({ error: "Allergy already added" });
- return;
- }
-
- const updatedUser = {
- ...user,
- profile: {
- ...user?.profile,
- allergies: [...current, normalized],
- },
- };
-
- set({ user: updatedUser, error: null });
- },
-
- removeAllergy: (allergy) => {
- if (!allergy) return;
- const normalized = allergy.trim().toLowerCase();
- const user = get().user;
- const current = user?.profile?.allergies || [];
- if (!current.includes(normalized)) {
- set({ error: "Allergy not found" });
- return;
- }
-
- const updatedUser = {
- ...user,
- profile: {
- ...user?.profile,
- allergies: current.filter((a) => a !== normalized),
- },
- };
-
- set({ user: updatedUser, error: null });
+ return get().updateUser(data);
},
clearError: () => set({ error: null }),
@@ -111,8 +65,8 @@ const useProfileStore = create(
{
name: "revive-profile-store",
partialize: (state) => ({ user: state.user }),
- }
- )
+ },
+ ),
);
export default useProfileStore;
diff --git a/src/store/recommendationStore.js b/src/store/recommendationStore.js
index 6632779..b0d544d 100644
--- a/src/store/recommendationStore.js
+++ b/src/store/recommendationStore.js
@@ -31,18 +31,21 @@ const useRecommendationStore = create(
*
* @param {string|number|Object} [param] - userId or context object
*/
- fetchRecommendations: async (param) => {
- const userId =
- typeof param === "number" || typeof param === "string"
- ? param
- : param?.userId || param?.id || useAuthStore.getState().user?.id || "guest";
+ fetchRecommendations: async () => {
+ const role = useAuthStore.getState().user?.role;
+
+ if (!role || role !== "CLIENT") {
+ set({ recommendations: [], isLoading: false, error: null });
+ return;
+ }
set({ isLoading: true, error: null });
try {
- const response = await getSuggestedMeals(userId);
- const data = response.data;
- const validRecommendations = Array.isArray(data) ? data : [];
+ const response = await getSuggestedMeals(role);
+ const validRecommendations = Array.isArray(response.data)
+ ? response.data
+ : [];
set({
recommendations: validRecommendations,
@@ -75,5 +78,3 @@ const useRecommendationStore = create(
);
export default useRecommendationStore;
-
-