From 7dfc70a4414d648e1d98a6eb2465707b308ab165 Mon Sep 17 00:00:00 2001 From: rhshahed Date: Sun, 12 Apr 2026 00:55:10 +0600 Subject: [PATCH] added new feature for profile --- client/src/contexts/AuthContext.tsx | 28 +++- client/src/index.css | 126 ++++++++++++++++++ .../src/services/recipeSuggestionService.ts | 30 +++-- client/src/views/Profile.tsx | 124 ++++++++++++----- client/src/views/Recipes.tsx | 65 +++++---- 5 files changed, 295 insertions(+), 78 deletions(-) diff --git a/client/src/contexts/AuthContext.tsx b/client/src/contexts/AuthContext.tsx index f0da05d..20c1c77 100644 --- a/client/src/contexts/AuthContext.tsx +++ b/client/src/contexts/AuthContext.tsx @@ -26,11 +26,31 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { // Check if user is already logged in - const currentUser = api.getCurrentUser(); - if (currentUser) { - setUser(currentUser); + const token = localStorage.getItem('auth_token'); + if (token) { + // Fetch user from server to get fresh data + api + .getMe() + .then((response) => { + if (response.user) { + setUser(response.user); + // Update localStorage with fresh user data + localStorage.setItem('user', JSON.stringify(response.user)); + } + }) + .catch(() => { + // If getMe fails, try localStorage fallback + const currentUser = api.getCurrentUser(); + if (currentUser) { + setUser(currentUser); + } + }) + .finally(() => { + setLoading(false); + }); + } else { + setLoading(false); } - setLoading(false); }, []); const login = async (email: string, password: string) => { diff --git a/client/src/index.css b/client/src/index.css index 9d3f838..1b5228c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -535,6 +535,132 @@ footer { text-decoration: underline; } +.profile-page { + max-width: 1100px; +} + +.profile-page .page-heading-row { + align-items: center; +} + +.profile-badge { + background: rgba(27, 47, 86, 0.9) !important; + border: 1px solid rgba(255, 227, 169, 0.5); +} + +.profile-card { + overflow: hidden; +} + +.profile-loading-text { + color: #1b2f56; + font-weight: 700; +} + +.profile-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + margin: 0 auto; + display: grid; + place-items: center; + font-size: 34px; + font-weight: 800; + color: #f2dca0; + background: linear-gradient(135deg, #1b2f56, #172646); + border: 2px solid #c8912e; + box-shadow: + 0 10px 22px rgba(0, 0, 0, 0.14), + 0 0 0 1px rgba(255, 227, 169, 0.4); +} + +.profile-avatar--guest { + background: linear-gradient(135deg, #7a849a, #636f88); +} + +.profile-title { + color: #1b2f56; + font-weight: 800; + margin-bottom: 6px; +} + +.profile-subtitle { + color: #1f2a44; + font-weight: 600; +} + +.profile-email-pill { + margin: 14px auto 0; + max-width: 100%; + display: inline-flex; + padding: 8px 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(200, 145, 46, 0.45); + color: #1f2a44; + font-weight: 700; +} + +.profile-status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #28a745; + box-shadow: 0 0 8px rgba(40, 167, 69, 0.5); +} + +.profile-status-text { + color: #1f2a44; + font-weight: 700; + font-size: 16px; +} + +.profile-section-title { + color: #1b2f56; + font-size: 22px; + font-weight: 800; +} + +.profile-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.profile-info-item { + background: rgba(255, 255, 255, 0.76); + border: 1px solid rgba(200, 145, 46, 0.4); + border-radius: 12px; + padding: 12px 14px; +} + +.profile-info-label { + display: block; + color: #5e6472; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.6px; + font-weight: 700; +} + +.profile-info-value { + display: block; + margin-top: 2px; + color: #1f2a44; + font-size: 18px; +} + +@media (max-width: 768px) { + .profile-info-grid { + grid-template-columns: 1fr; + } + + .profile-section-title { + font-size: 20px; + } +} + @media (max-width: 576px) { .contact-card__name { font-size: 22px; diff --git a/client/src/services/recipeSuggestionService.ts b/client/src/services/recipeSuggestionService.ts index 798e818..546fecd 100644 --- a/client/src/services/recipeSuggestionService.ts +++ b/client/src/services/recipeSuggestionService.ts @@ -336,7 +336,7 @@ function buildRecipe( export async function getRecipesFromInventory( inventoryArray: string[], inventoryUnitHints: Array<{ name: string; unit: string }> = [], - cuisine: CuisineType = DEFAULT_CUISINE + cuisine?: CuisineType ): Promise { if (!Array.isArray(inventoryArray) || inventoryArray.length === 0) { throw new Error('Please add ingredients to your inventory first.'); @@ -351,8 +351,10 @@ export async function getRecipesFromInventory( } const preferredUnits = preferredUnitMap(inventoryUnitHints); - const normalizedCuisine = normalizeCuisine(cuisine, DEFAULT_CUISINE) ?? DEFAULT_CUISINE; - const cuisineLabel = CUISINE_OPTIONS.find((option) => option.value === normalizedCuisine)?.label ?? normalizedCuisine; + const normalizedCuisine = normalizeCuisine(cuisine); + const randomCuisineGuide = CUISINE_OPTIONS.map((option) => option.value).join(', '); + const fallbackCuisine = + normalizedCuisine ?? CUISINE_OPTIONS[Math.floor(Math.random() * CUISINE_OPTIONS.length)]?.value ?? DEFAULT_CUISINE; const ingredients = cleanedInventory.join(', '); const unitHintText = inventoryUnitHints @@ -367,7 +369,11 @@ export async function getRecipesFromInventory( const prompt = `I have these ingredients: ${ingredients}. Role: You are a Culinary Data Engineer Agent. -Task: Based on the provided ingredient list, suggest 3 distinct recipes classified as quick, healthy, and surprise with a ${cuisineLabel} cuisine focus. +Task: Based on the provided ingredient list, suggest 3 distinct recipes classified as quick, healthy, and surprise${ + normalizedCuisine + ? ` with a ${CUISINE_OPTIONS.find((option) => option.value === normalizedCuisine)?.label ?? normalizedCuisine} cuisine focus` + : ` with random cuisine inspiration chosen from ${randomCuisineGuide}` + }. Core Logic Rules: - The Single-Person Rule is mandatory: calculate all ingredient measurements for exactly one person. @@ -378,14 +384,18 @@ Core Logic Rules: - Pantry matching is mandatory: use only provided inventory ingredients, but common staples (salt, water, oil) are allowed. - Structure is mandatory: each recipe must have exactly 3 concise steps. - Instruction text may use user-friendly wording and convenient kitchen measurements for readability. -- Set the cuisine field on every recipe to "${normalizedCuisine}". +- Set the cuisine field on every recipe.${ + normalizedCuisine + ? ` Use exactly "${normalizedCuisine}".` + : ` Choose one cuisine value from: ${randomCuisineGuide}.` + } Respond with ONLY valid JSON in this exact structure: { "recipes": [ { "type": "quick", - "cuisine": "${normalizedCuisine}", + "cuisine": "${normalizedCuisine ?? fallbackCuisine}", "title": "", "description": "", "preparationTime": "15 mins", @@ -397,7 +407,7 @@ Respond with ONLY valid JSON in this exact structure: }, { "type": "healthy", - "cuisine": "${normalizedCuisine}", + "cuisine": "${normalizedCuisine ?? fallbackCuisine}", "title": "", "description": "", "preparationTime": "20 mins", @@ -409,7 +419,7 @@ Respond with ONLY valid JSON in this exact structure: }, { "type": "surprise", - "cuisine": "${normalizedCuisine}", + "cuisine": "${normalizedCuisine ?? fallbackCuisine}", "title": "", "description": "", "preparationTime": "25 mins", @@ -452,7 +462,7 @@ Respond with ONLY valid JSON in this exact structure: recipeByType.set(type, { type, - cuisine: normalizeCuisine(match.cuisine, normalizedCuisine) ?? normalizedCuisine, + cuisine: normalizeCuisine(match.cuisine, fallbackCuisine) ?? fallbackCuisine, title: String(match.title ?? `${type} recipe`).trim() || `${type} recipe`, description: String(match.description ?? '').trim() || `${type} recipe for one person.`, preparationTime: String(match.preparationTime ?? '20 mins').trim() || '20 mins', @@ -466,7 +476,7 @@ Respond with ONLY valid JSON in this exact structure: } const recipes = RECIPE_TYPES.map( - (type) => recipeByType.get(type) ?? buildRecipe(type, cleanedInventory, preferredUnits, normalizedCuisine) + (type) => recipeByType.get(type) ?? buildRecipe(type, cleanedInventory, preferredUnits, fallbackCuisine) ); return { recipes }; } catch (error: unknown) { diff --git a/client/src/views/Profile.tsx b/client/src/views/Profile.tsx index 1a4c144..223806f 100644 --- a/client/src/views/Profile.tsx +++ b/client/src/views/Profile.tsx @@ -1,4 +1,4 @@ -import { Card, Button } from 'react-bootstrap'; +import { Alert, Badge, Button, Card, Col, Row, Spinner } from 'react-bootstrap'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; @@ -6,59 +6,111 @@ export default function Profile() { const navigate = useNavigate(); const { user, loading } = useAuth(); + const initials = user?.username + ? user.username + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join('') + : 'U'; + if (loading) { return ( -
- - -
👤
-

Loading profile...

+
+
+
My Profile
+
+ + + + + Loading your profile... -
+ ); } if (!user) { return ( -
- - -
👤
-

No profile available

-

Please log in to view your profile.

-
-
+ ); } return ( -
- - -
👤
-

Welcome, {user.username}!

-

Your profile information

+
+
+
My Profile
+ + Kitchen Member + +
-
-

Name: {user.username}

-

Email: {user.email}

-
+ + + + +
{initials}
+

{user.username}

+
+ + Active +
+
{user.email}
+
+
+ -
- - -
- - -
+ + + +

Account Snapshot

+
+
+ Username + {user.username} +
+
+ User ID + #{String(user.id).padStart(3, '0')} +
+
+ +
+ + + +
+
+
+ + + ); } diff --git a/client/src/views/Recipes.tsx b/client/src/views/Recipes.tsx index b5e0bf8..5f71ffb 100644 --- a/client/src/views/Recipes.tsx +++ b/client/src/views/Recipes.tsx @@ -43,25 +43,34 @@ function normalizeCuisine(cuisine: unknown): CuisineType | '' { return CUISINE_OPTIONS.some((option) => option.value === normalized) ? (normalized as CuisineType) : ''; } -type SelectedCuisine = CuisineType | 'all' | ''; +type SelectedCuisine = CuisineType | 'none' | ''; function normalizeSelectedCuisine(cuisine: unknown): SelectedCuisine { const normalized = String(cuisine ?? '').trim().toLowerCase(); - if (normalized === 'all') { - return 'all'; + if (normalized === 'none') { + return 'none'; } return normalizeCuisine(normalized); } function cuisineLabel(cuisine: SelectedCuisine): string { - if (cuisine === 'all') { - return 'All cuisines'; + if (cuisine === 'none') { + return 'No cuisine'; } return CUISINE_OPTIONS.find((option) => option.value === cuisine)?.label ?? 'Cuisine'; } +function resolveFallbackCuisine(cuisine: SelectedCuisine): CuisineType { + if (cuisine && cuisine !== 'none') { + return cuisine; + } + + const randomIndex = Math.floor(Math.random() * CUISINE_OPTIONS.length); + return CUISINE_OPTIONS[randomIndex]?.value ?? 'indian'; +} + function formatAmount(amount: number): string { return Number.isInteger(amount) ? amount.toString() : amount.toFixed(2).replace(/\.00$/, ''); } @@ -422,8 +431,7 @@ export default function Recipes() { }, [cooldownSeconds]); const canLoadFromDb = inventoryNames.length > 0 && selectedCuisine !== '' && !isLoading; - const canGenerate = - inventoryNames.length > 0 && selectedCuisine !== '' && selectedCuisine !== 'all' && !isLoading && cooldownSeconds === 0; + const canGenerate = inventoryNames.length > 0 && selectedCuisine !== '' && !isLoading && cooldownSeconds === 0; const parseRetryAfterSeconds = (message: string): number | null => { const match = message.match(/retry after\s+(\d+)s/i); @@ -448,7 +456,7 @@ export default function Recipes() { setLoadingAction('db'); try { - const savedRecipesResponse = await api.getMatchingRecipes(selectedCuisine === 'all' ? undefined : selectedCuisine); + const savedRecipesResponse = await api.getMatchingRecipes(selectedCuisine === 'none' ? undefined : selectedCuisine); const savedRecipes = Array.isArray(savedRecipesResponse?.recipes) ? normalizeRecipesForDisplay(savedRecipesResponse.recipes) : []; @@ -458,8 +466,8 @@ export default function Recipes() { setRecipeSourceMode('database'); setCurrentPage(1); setError( - selectedCuisine === 'all' - ? 'No recipes found in database. Try Generate New Recipes for a specific cuisine.' + selectedCuisine === 'none' + ? 'No recipes found in database. Try Generate New Recipes for random cuisine recipes.' : `No ${cuisineLabel(selectedCuisine)} recipes found in database. Try Generate New Recipes.` ); return; @@ -468,7 +476,11 @@ export default function Recipes() { setRecipes(savedRecipes); setRecipeSourceMode('database'); setCurrentPage(1); - setSuccessMessage(`Loaded ${cuisineLabel(selectedCuisine)} recipes from the database.`); + setSuccessMessage( + selectedCuisine === 'none' + ? 'Loaded recipes from the database.' + : `Loaded ${cuisineLabel(selectedCuisine)} recipes from the database.` + ); } catch { setError('Failed to load recipes from database right now.'); } finally { @@ -491,24 +503,17 @@ export default function Recipes() { return; } - if (selectedCuisine === 'all') { - setError('Please choose a specific cuisine to generate new recipes.'); - setIsLoading(false); - setLoadingAction(null); - return; - } - try { const generated = await getRecipesFromInventory( inventoryNames, inventoryItems.map((item) => ({ name: item.name, unit: item.unit })), - selectedCuisine + selectedCuisine === 'none' ? undefined : selectedCuisine ); try { const payloadWithCuisine = generated.recipes.map((recipe) => ({ ...recipe, - cuisine: selectedCuisine, + cuisine: selectedCuisine === 'none' ? recipe.cuisine : selectedCuisine, })); const savedResponse = await api.saveGeneratedRecipes(payloadWithCuisine); const savedRecipes = Array.isArray(savedResponse?.recipes) @@ -517,7 +522,11 @@ export default function Recipes() { setRecipes(savedRecipes); setRecipeSourceMode('generated'); setCurrentPage(1); - setSuccessMessage(`Generated new ${cuisineLabel(selectedCuisine)} recipes.`); + setSuccessMessage( + selectedCuisine === 'none' + ? 'Generated new random cuisine recipes.' + : `Generated new ${cuisineLabel(selectedCuisine)} recipes.` + ); } catch (saveErr: unknown) { setRecipes(normalizeRecipesForDisplay(generated.recipes)); setRecipeSourceMode('generated'); @@ -545,7 +554,7 @@ export default function Recipes() { } else { setCooldownSeconds(30); } - const fallbackRecipes = localFallbackRecipes(inventoryNames, selectedCuisine); + const fallbackRecipes = localFallbackRecipes(inventoryNames, resolveFallbackCuisine(selectedCuisine)); setRecipes(normalizeRecipesForDisplay(fallbackRecipes)); setRecipeSourceMode('fallback'); setCurrentPage(1); @@ -561,7 +570,7 @@ export default function Recipes() { ) { const retryAfter = parseRetryAfterSeconds(message) ?? 30; setCooldownSeconds(Math.max(retryAfter, 10)); - const fallbackRecipes = localFallbackRecipes(inventoryNames, selectedCuisine); + const fallbackRecipes = localFallbackRecipes(inventoryNames, resolveFallbackCuisine(selectedCuisine)); setRecipes(normalizeRecipesForDisplay(fallbackRecipes)); setRecipeSourceMode('fallback'); setCurrentPage(1); @@ -570,7 +579,7 @@ export default function Recipes() { message.toLowerCase().includes('unexpected format') || message.toLowerCase().includes('json') ) { - const fallbackRecipes = localFallbackRecipes(inventoryNames, selectedCuisine); + const fallbackRecipes = localFallbackRecipes(inventoryNames, resolveFallbackCuisine(selectedCuisine)); setRecipes(normalizeRecipesForDisplay(fallbackRecipes)); setRecipeSourceMode('fallback'); setCurrentPage(1); @@ -581,13 +590,13 @@ export default function Recipes() { message.toLowerCase().includes('permission') || message.toLowerCase().includes('leaked') ) { - const fallbackRecipes = localFallbackRecipes(inventoryNames, selectedCuisine); + const fallbackRecipes = localFallbackRecipes(inventoryNames, resolveFallbackCuisine(selectedCuisine)); setRecipes(normalizeRecipesForDisplay(fallbackRecipes)); setRecipeSourceMode('fallback'); setCurrentPage(1); setError(`${message} Showing local fallback recipes for now.`); } else { - const fallbackRecipes = localFallbackRecipes(inventoryNames, selectedCuisine); + const fallbackRecipes = localFallbackRecipes(inventoryNames, resolveFallbackCuisine(selectedCuisine)); setRecipes(normalizeRecipesForDisplay(fallbackRecipes)); setRecipeSourceMode('fallback'); setCurrentPage(1); @@ -623,7 +632,7 @@ export default function Recipes() { setInventoryNames(items.map((item) => item.name).filter(Boolean)); const matchingResponse = await api.getMatchingRecipes( - selectedCuisine && selectedCuisine !== 'all' ? selectedCuisine : undefined + selectedCuisine && selectedCuisine !== 'none' ? selectedCuisine : undefined ); const matchingRecipes = Array.isArray(matchingResponse?.recipes) ? normalizeRecipesForDisplay(matchingResponse.recipes) @@ -691,7 +700,7 @@ export default function Recipes() { }} > - + {CUISINE_OPTIONS.map((option) => (