diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index eb0eaf2..4073ba4 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -613,7 +613,7 @@ paths: get: tags: [User Management] summary: Get Recent User Data Submissions - description: Retrieves a list of recent data submissions made about the authenticated user. + description: Retrieves a list of recent data submissions made about the authenticated user, with optional filtering and searching. operationId: getRecentUserData parameters: - name: limit @@ -630,9 +630,42 @@ paths: schema: type: integer default: 1 + - name: dataType + in: query + description: Filter by data type (purchase or search) + required: false + schema: + type: string + enum: [purchase, search] + - name: storeId + in: query + description: Filter by store ID + required: false + schema: + type: string + - name: startDate + in: query + description: Filter by start date (ISO 8601 format YYYY-MM-DD) + required: false + schema: + type: string + format: date + - name: endDate + in: query + description: Filter by end date (ISO 8601 format YYYY-MM-DD) + required: false + schema: + type: string + format: date + - name: searchTerm + in: query + description: Search term for entries (e.g., item name, search query) + required: false + schema: + type: string responses: "200": - description: Recent data submissions retrieved successfully + description: Recent user data retrieved successfully. content: application/json: schema: @@ -641,6 +674,8 @@ paths: $ref: "#/components/schemas/RecentUserDataEntry" "401": $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalServerError" security: @@ -717,6 +752,46 @@ paths: - oauth2: [user:read] x-swagger-router-controller: StoreProfile + /stores/search: + get: + tags: [Store Management] + summary: Search Stores + description: Searches for stores by name. Requires authentication. + operationId: searchStores + parameters: + - name: query + in: query + description: The search term to look for in store names. + required: true + schema: + type: string + minLength: 2 + - name: limit + in: query + description: Maximum number of results to return. + required: false + schema: + type: integer + default: 10 + responses: + "200": + description: Stores matching the query retrieved successfully. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/StoreBasicInfo" + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] + x-swagger-router-controller: StoreProfile + components: schemas: AttributeDistribution: diff --git a/api-service/controllers/StoreProfile.js b/api-service/controllers/StoreProfile.js index ef368da..83c2ad0 100644 --- a/api-service/controllers/StoreProfile.js +++ b/api-service/controllers/StoreProfile.js @@ -39,4 +39,14 @@ module.exports.lookupStores = function lookupStores(req, res, next, ids) { .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.getStoreUsers = function getStoreUsers(req, res, next) { + StoreProfile.getStoreUsers(req) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); }; \ No newline at end of file diff --git a/api-service/controllers/UserProfile.js b/api-service/controllers/UserProfile.js index 0364e2e..fd4dc52 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -32,8 +32,8 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { }; module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page) { - // Pass query parameters to the service function - UserProfile.getRecentUserData(req, limit, page) + const { dataType, storeId, startDate, endDate, searchTerm } = req.query; + UserProfile.getRecentUserData(req, limit, page, dataType, storeId, startDate, endDate, searchTerm) .then((response) => { utils.writeJson(res, response); }) diff --git a/api-service/service/StoreProfileService.js b/api-service/service/StoreProfileService.js index ce5677b..1cbdf3f 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -166,3 +166,40 @@ exports.lookupStores = async function (req, ids) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Search Stores + * Searches for stores by name. + */ +exports.searchStores = async function (req, query, limit = 10) { + try { + if (!query || query.length < 2) { // Basic validation + return respondWithCode(400, { code: 400, message: 'Search query must be at least 2 characters long.' }); + } + + const db = getDB(); + const regex = new RegExp(query, 'i'); // Case-insensitive search + + // Consider adding a text index on the 'name' field in the 'stores' collection for performance + // db.collection('stores').createIndex({ name: "text" }); + // Then use: { $text: { $search: query } } instead of regex for better performance + + const stores = await db.collection('stores') + .find({ name: regex }) // Using regex for simplicity here + .limit(parseInt(limit)) // Ensure limit is an integer + .project({ _id: 1, name: 1 }) // Project only ID and name + .toArray(); + + // Format the response to match StoreBasicInfo schema + const formattedStores = stores.map(store => ({ + storeId: store._id.toString(), // Convert ObjectId back to string + name: store.name + })); + + return respondWithCode(200, formattedStores); + + } catch (error) { + console.error('Search stores failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error during store search' }); + } +}; diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 60189d9..53ba8fa 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -272,7 +272,7 @@ exports.deleteUserProfile = async function (req) { * Get Recent User Data Submissions * Retrieves a list of recent data submissions made about the authenticated user. */ -exports.getRecentUserData = async function (req, limit = 10, page = 1) { +exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, storeId, startDate, endDate, searchTerm) { // Add new params try { const db = getDB(); const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); @@ -285,36 +285,95 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1) { const skip = (page - 1) * limit; - // Query userData collection + // --- Build the MongoDB query dynamically --- + const matchQuery = { userId: user._id }; + + if (dataType) { + matchQuery.dataType = dataType; + } + if (storeId) { + // Validate storeId format if necessary before querying + try { + matchQuery.storeId = new ObjectId(storeId); + } catch (e) { + console.warn(`Invalid storeId format provided: ${storeId}`); + // Decide how to handle: return empty, error, or ignore filter + return respondWithCode(400, { code: 400, message: 'Invalid store ID format provided.' }); + } + } + + // Date range filtering on the main document timestamp + const dateFilter = {}; + if (startDate) { + try { + dateFilter.$gte = new Date(startDate); + } catch (e) { console.warn('Invalid startDate format:', startDate); } + } + if (endDate) { + try { + const end = new Date(endDate); + end.setDate(end.getDate() + 1); // Include the whole end day + dateFilter.$lt = end; + } catch (e) { console.warn('Invalid endDate format:', endDate); } + } + if (Object.keys(dateFilter).length > 0) { + matchQuery.timestamp = dateFilter; + } + + // Search term filtering within entries (simple regex example) + // NOTE: For better performance on large datasets, consider a text index + // on 'entries.items.name', 'entries.items.category', 'entries.query', etc. + if (searchTerm) { + const regex = new RegExp(searchTerm, 'i'); // Case-insensitive regex + matchQuery.$or = [ + { 'entries.items.name': regex }, + { 'entries.items.category': regex }, + { 'entries.query': regex }, + // Add other fields within entries to search if needed + ]; + } + // --- End Query Building --- + + // Query userData collection with the built query const recentData = await db.collection('userData') - .find({ userId: user._id }) // Filter by the user's ObjectId + .find(matchQuery) // Use the dynamic query .sort({ timestamp: -1 }) // Sort by submission time descending .skip(skip) .limit(limit) - .project({ // Project only necessary fields for RecentUserDataEntry schema + .project({ // Expand projection to include details needed for display _id: 1, storeId: 1, dataType: 1, timestamp: 1, // Submission timestamp - entryTimestamp: '$entries.timestamp', // Assuming timestamp is within entries array - // Add simplified details if needed, e.g., item count or query string - // details: { $cond: { if: { $eq: ['$dataType', 'purchase'] }, then: { itemCount: { $size: '$entries.items' } }, else: '$entries.query' } } + entries: 1, // Include the full entries array for now + // Alternatively, project specific fields from entries if known: + // 'entries.timestamp': 1, + // 'entries.query': 1, + // 'entries.items.name': 1, + // 'entries.items.price': 1, + // 'entries.items.quantity': 1, + // 'entries.items.category': 1, }) .toArray(); - // Simple transformation if needed (e.g., flatten entryTimestamp if it's an array) + // Simple transformation (can be enhanced on frontend) const formattedData = recentData.map(entry => ({ - ...entry, - // If entryTimestamp is an array due to projection, take the first element - entryTimestamp: Array.isArray(entry.entryTimestamp) ? entry.entryTimestamp[0] : entry.entryTimestamp, - // Add placeholder for details - details: {} + _id: entry._id.toString(), // Convert ObjectId to string + storeId: entry.storeId.toString(), // Convert ObjectId to string + dataType: entry.dataType, + timestamp: entry.timestamp, + // Process entries for simpler display structure if needed here, + // or handle it on the frontend. Example: + details: entry.entries.map(e => ({ + timestamp: e.timestamp, + ...(entry.dataType === 'purchase' && { items: e.items }), + ...(entry.dataType === 'search' && { query: e.query, results: e.results }), + })), })); - - // Caching could be added here if this data is frequently accessed - // const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}`; - // await setCache(cacheKey, JSON.stringify(formattedData), { EX: CACHE_TTL.SHORT }); // Example TTL + // Caching: Consider if caching is appropriate with dynamic filters. + // If cached, the cache key MUST include all filter parameters. + // Example: const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}:${dataType || 'all'}:${storeId || 'all'}:${startDate || 'all'}:${endDate || 'all'}:${searchTerm || ''}`; return respondWithCode(200, formattedData); diff --git a/web/src/api/hooks/useStoreHooks.ts b/web/src/api/hooks/useStoreHooks.ts index a92f26a..297137b 100644 --- a/web/src/api/hooks/useStoreHooks.ts +++ b/web/src/api/hooks/useStoreHooks.ts @@ -1,12 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; -import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache"; // <-- Import CACHE_TIMES +import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache"; import { ApiKeyCreate, StoreUpdate, - StoreBasicInfo, // <-- Import StoreBasicInfo + StoreBasicInfo, + Error, // <-- Import Error type + SearchStoresParams, // <-- Import SearchStoresParams if generated } from "../types/data-contracts"; -import { useAuth } from "../../hooks/useAuth"; // Import useAuth +import { useAuth } from "../../hooks/useAuth"; +import { useState, useEffect } from "react"; // <-- Import useState and useEffect for debounce export function useStoreProfile() { // Get clientsReady state @@ -90,6 +93,49 @@ export function useApiKeyUsage(keyId: string) { }); } +// --- New Hook for Searching Stores --- +export function useSearchStores(searchTerm: string, debounceMs = 300) { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); + + // Debounce effect + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, debounceMs); + + // Cleanup function to cancel the timeout if searchTerm changes again quickly + return () => { + clearTimeout(handler); + }; + }, [searchTerm, debounceMs]); + + // Define query parameters type if not auto-generated + // type SearchStoresParams = { query: string; limit?: number }; + + return useQuery({ + // Query key includes the debounced term + queryKey: [...cacheKeys.stores.all, "search", debouncedSearchTerm], + queryFn: () => { + // Prepare parameters for the API call + const params: SearchStoresParams = { + query: debouncedSearchTerm, + limit: 15, + }; // Adjust limit as needed + return apiClients.stores.searchStores(params).then((res) => res.data); + }, + // Only run query if client is ready, user is authenticated, + // and the debounced search term is long enough + enabled: + clientsReady && + isAuthenticated && + !authLoading && + debouncedSearchTerm.length >= 2, // Match backend validation + staleTime: CACHE_TIMES.MEDIUM, // Cache results for a bit + }); +} + // --- New Hook --- // Hook to lookup multiple stores by their IDs diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 512f32d..17e81ae 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -6,10 +6,10 @@ import { UserUpdate, User, RecentUserDataEntry, - // SpendingAnalytics, // <-- Remove old type if not used elsewhere StoreConsentList, - MonthlySpendingAnalytics, // <-- Import new type - GetSpendingAnalyticsParams, // <-- Import params type + MonthlySpendingAnalytics, + GetSpendingAnalyticsParams, + GetRecentUserDataParams, // <-- Import params type for recent data } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -159,21 +159,22 @@ export function useDeleteUserProfile() { // --- New Hooks --- -export function useRecentUserData(limit: number = 10, page: number = 1) { +// Update useRecentUserData to accept GetRecentUserDataParams +export function useRecentUserData(params?: GetRecentUserDataParams) { const { apiClients, clientsReady } = useApiClients(); const { isAuthenticated, isLoading: authLoading } = useAuth(); + // Use the params object directly for the queryKey + const queryParams = params || {}; // Ensure params is an object + return useQuery({ - // Expect an array - queryKey: cacheKeys.users.recentData(limit, page), + queryKey: cacheKeys.users.recentData(queryParams), // Pass the params object queryFn: () => + // Pass the params object to the API call apiClients.users - .getRecentUserData({ limit, page }) + .getRecentUserData(queryParams) // Pass the whole object .then((res) => res.data), enabled: isAuthenticated && !authLoading && clientsReady, - // Add specific cache settings if needed, otherwise defaults apply - // ...cacheSettings.recentData, // Example - // Replace keepPreviousData with placeholderData for TanStack Query v5+ placeholderData: (previousData) => previousData, }); } diff --git a/web/src/api/types/Stores.ts b/web/src/api/types/Stores.ts index ce70675..19fc1c2 100644 --- a/web/src/api/types/Stores.ts +++ b/web/src/api/types/Stores.ts @@ -18,6 +18,7 @@ import { Error, GetApiKeyUsagePayload, LookupStoresParams, + SearchStoresParams, Store, StoreBasicInfo, StoreCreate, @@ -229,4 +230,26 @@ export class Stores< format: "json", ...params, }); + /** + * @description Searches for stores by name. Requires authentication. + * + * @tags Store Management + * @name SearchStores + * @summary Search Stores + * @request GET:/stores/search + * @secure + * @response `200` `(StoreBasicInfo)[]` Stores matching the query retrieved successfully. + * @response `400` `Error` + * @response `401` `Error` + * @response `500` `Error` + */ + searchStores = (query: SearchStoresParams, params: RequestParams = {}) => + this.request({ + path: `/stores/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); } diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index df15d2a..5656970 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -291,15 +291,16 @@ export class Users< ...params, }); /** - * @description Retrieves a list of recent data submissions made about the authenticated user. + * @description Retrieves a list of recent data submissions made about the authenticated user, with optional filtering and searching. * * @tags User Management * @name GetRecentUserData * @summary Get Recent User Data Submissions * @request GET:/users/data/recent * @secure - * @response `200` `(RecentUserDataEntry)[]` Recent data submissions retrieved successfully + * @response `200` `(RecentUserDataEntry)[]` Recent user data retrieved successfully. * @response `401` `Error` + * @response `404` `Error` * @response `500` `Error` */ getRecentUserData = ( diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 4e1d9af..5e50b7b 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -474,6 +474,22 @@ export interface GetRecentUserDataParams { * @default 1 */ page?: number; + /** Filter by data type (purchase or search) */ + dataType?: "purchase" | "search"; + /** Filter by store ID */ + storeId?: string; + /** + * Filter by start date (ISO 8601 format YYYY-MM-DD) + * @format date + */ + startDate?: string; + /** + * Filter by end date (ISO 8601 format YYYY-MM-DD) + * @format date + */ + endDate?: string; + /** Search term for entries (e.g., item name, search query) */ + searchTerm?: string; } export interface GetSpendingAnalyticsParams { @@ -493,3 +509,16 @@ export interface LookupStoresParams { /** Comma-separated list of store IDs to lookup. */ ids: string; } + +export interface SearchStoresParams { + /** + * The search term to look for in store names. + * @minLength 2 + */ + query: string; + /** + * Maximum number of results to return. + * @default 10 + */ + limit?: number; +} diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index 7772a20..4e8ee8a 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -1,5 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; -import { User } from "../types/data-contracts"; +// Import GetRecentUserDataParams if not already imported +import { User, GetRecentUserDataParams } from "../types/data-contracts"; // Cache time configurations (in milliseconds) export const CACHE_TIMES = { @@ -26,10 +27,21 @@ export const cacheKeys = { all: ["users"], profile: () => [...cacheKeys.users.all, "profile"], preferences: () => [...cacheKeys.users.all, "preferences"], - recentData: (limit: number, page: number) => [ + // Update recentData to accept GetRecentUserDataParams + recentData: (params: GetRecentUserDataParams = {}) => [ + // Default to empty object ...cacheKeys.users.all, "recentData", - { limit, page }, + // Create a stable object key based on params + { + limit: params.limit ?? 10, // Default limit + page: params.page ?? 1, // Default page + dataType: params.dataType ?? "all", + storeId: params.storeId ?? "all", + startDate: params.startDate ?? "all", + endDate: params.endDate ?? "all", + searchTerm: params.searchTerm ?? "", + }, ], // Update spendingAnalytics to accept optional dates spendingAnalytics: (startDate?: string, endDate?: string) => [ diff --git a/web/src/pages/UserAnalyticsPage.tsx b/web/src/pages/UserAnalyticsPage.tsx index 5d1265e..9775b5b 100644 --- a/web/src/pages/UserAnalyticsPage.tsx +++ b/web/src/pages/UserAnalyticsPage.tsx @@ -1,18 +1,524 @@ -import React from "react"; -import { Card } from "flowbite-react"; +import React, { useState, useMemo } from "react"; // Added useEffect +import { + Card, + Datepicker, + Button, + Spinner, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TableHeadCell, + TextInput, + Select, + Label, +} from "flowbite-react"; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from "recharts"; +import { + HiCalendar, + HiOutlineSearch, + HiChevronLeft, + HiChevronRight, +} from "react-icons/hi"; +import { + useRecentUserData, + useSpendingAnalytics, +} from "../api/hooks/useUserHooks"; +import { useLookupStores } from "../api/hooks/useStoreHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { + RecentUserDataEntry, // Keep this import now + StoreBasicInfo, + MonthlySpendingItem, + GetRecentUserDataParams, + PurchaseItem, // Assuming PurchaseItem is the type for purchase details items + PurchaseEntry, // <-- Import PurchaseEntry + SearchEntry, // Assuming SearchEntry is the type for search details +} from "../api/types/data-contracts"; + +// --- Helper Functions (Keep existing) --- +const formatDate = (dateString: string | Date | undefined) => { + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +}; + +const formatMonth = (monthString: string) => { + try { + const [year, month] = monthString.split("-"); + const date = new Date(parseInt(year), parseInt(month) - 1); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + }); + } catch { + return monthString; + } +}; + +const formatDateToISO = (date: Date | null | undefined): string | undefined => { + if (!date) return undefined; + return date.toISOString().split("T")[0]; +}; + +const LINE_COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FF5733", + "#C70039", + "#900C3F", + "#581845", +]; +// --- End Helper Functions --- const UserAnalyticsPage: React.FC = () => { + // --- State for Filters --- + const [spendingStartDate, setSpendingStartDate] = useState(null); + const [spendingEndDate, setSpendingEndDate] = useState(null); + const [activityStartDate, setActivityStartDate] = useState(null); + const [activityEndDate, setActivityEndDate] = useState(null); + const [activityDataType, setActivityDataType] = + useState(undefined); // Initialize with undefined + const [activitySearchTerm, setActivitySearchTerm] = useState(""); + const [activityPage, setActivityPage] = useState(1); + const activityLimit = 15; // Items per page + + // --- Data Fetching --- + const { + data: spendingData, + isLoading: spendingLoading, + error: spendingError, + } = useSpendingAnalytics({ + startDate: formatDateToISO(spendingStartDate), + endDate: formatDateToISO(spendingEndDate), + }); + + const activityParams: GetRecentUserDataParams = useMemo( + () => ({ + limit: activityLimit, + page: activityPage, + startDate: formatDateToISO(activityStartDate), + endDate: formatDateToISO(activityEndDate), + dataType: activityDataType || undefined, + searchTerm: activitySearchTerm || undefined, + }), + [ + activityPage, + activityStartDate, + activityEndDate, + activityDataType, + activitySearchTerm, + ], + ); + + const { + data: activityData, + isLoading: activityLoading, + error: activityError, + isPlaceholderData, // Check if data is placeholder (useful for disabling next) + } = useRecentUserData(activityParams); + + const activityStoreIds = useMemo(() => { + const ids = new Set(); + activityData?.forEach((entry) => { + if (entry.storeId) ids.add(entry.storeId); + }); + return Array.from(ids); + }, [activityData]); + + const { + data: storeDetails, + isLoading: storesLoading, + error: storesError, + } = useLookupStores(activityStoreIds); + + // --- Memos (Keep existing) --- + const storeNameMap = useMemo(() => { + const map = new Map(); + storeDetails?.forEach((store: StoreBasicInfo) => { + map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); + }); + return map; + }, [storeDetails]); + + const { lineChartData, categories } = useMemo(() => { + if (!spendingData) return { lineChartData: [], categories: [] }; + const allCategories = new Set(); + const dataMap = new Map>(); + spendingData.forEach((monthlyItem: MonthlySpendingItem) => { + const monthData: Record = { + month: monthlyItem.month, + }; + Object.entries(monthlyItem.spending).forEach(([category, amount]) => { + allCategories.add(category); + monthData[category] = amount; + }); + dataMap.set(monthlyItem.month, monthData); + }); + const processedData = spendingData.map((item: MonthlySpendingItem) => { + const monthEntry = dataMap.get(item.month) || { month: item.month }; + allCategories.forEach((cat) => { + if (!(cat in monthEntry)) monthEntry[cat] = 0; + }); + return monthEntry; + }); + return { + lineChartData: processedData, + categories: Array.from(allCategories).sort(), + }; + }, [spendingData]); + + // --- Handlers (Keep existing) --- + const clearSpendingDates = () => { + setSpendingStartDate(null); + setSpendingEndDate(null); + }; + + const clearActivityFilters = () => { + setActivityStartDate(null); + setActivityEndDate(null); + setActivityDataType(undefined); // Reset to undefined + setActivitySearchTerm(""); + setActivityPage(1); + }; + + // --- Pagination Logic --- + // Determine if there might be a next page + // We infer this if the current page loaded the maximum number of items + const hasMoreData = useMemo(() => { + return activityData && activityData.length === activityLimit; + }, [activityData, activityLimit]); + + const handlePreviousPage = () => { + if (activityPage > 1) { + setActivityPage((prevPage) => prevPage - 1); + } + }; + + const handleNextPage = () => { + if (hasMoreData) { + setActivityPage((prevPage) => prevPage + 1); + } + }; + // --- End Pagination Logic --- + + // --- Render Logic --- + const isLoading = spendingLoading || activityLoading || storesLoading; + // Remove combinedError + // const combinedError = spendingError || activityError || storesError; + + if (isLoading && !spendingData && !activityData) { + return ; + } + return ( -
-

+
+

Your Data Insights

+ + {/* --- Spending Overview Section (Keep existing) --- */} -

- View insights derived from the data you've shared, such as spending - habits and recent activity. (Implementation coming soon!) -

- {/* Analytics charts and recent activity list will go here */} +

+ Spending Overview +

+ {/* Date Filters */} +
+ setSpendingStartDate(date)} + maxDate={spendingEndDate || undefined} + placeholder="Start Date" + /> + setSpendingEndDate(date)} + minDate={spendingStartDate || undefined} + placeholder="End Date" + /> + {(spendingStartDate || spendingEndDate) && ( + + )} +
+ + {/* Chart */} + {spendingError ? ( + + ) : spendingLoading ? ( +
+ +
+ ) : !lineChartData || lineChartData.length === 0 ? ( +

+ No spending data available + {spendingStartDate || spendingEndDate ? " for this period" : " yet"} + . +

+ ) : ( +
+ + + + + + formatCurrency(value)} + labelFormatter={formatMonth} + /> + + {categories.map((category, index) => ( + + ))} + + +
+ )} +
+ + {/* --- Recent Activity Section --- */} + +

+ Recent Activity Log +

+ {/* Activity Filters (Keep existing) */} +
+ { + setActivityStartDate(date); + setActivityPage(1); + }} + maxDate={activityEndDate || undefined} + placeholder="Start Date" + /> + { + setActivityEndDate(date); + setActivityPage(1); + }} + minDate={activityStartDate || undefined} + placeholder="End Date" + /> +
+ + +
+
+ + { + setActivitySearchTerm(e.target.value); + setActivityPage(1); + }} + /> +
+
+ +
+
+ + {/* Activity Table */} + {activityError || storesError ? ( // <-- Check both activityError and storesError + + ) : activityLoading && isPlaceholderData ? ( // Show spinner only if loading AND data is placeholder +
+ +
+ ) : !activityData || activityData.length === 0 ? ( +

+ No activity found matching your filters. +

+ ) : ( + <> +
+ + + Date + Type + Store + Details + + + {activityData.map((entry: RecentUserDataEntry) => { + // Safely access details[0] + const firstDetail = + Array.isArray(entry.details) && entry.details.length > 0 + ? entry.details[0] + : undefined; + + // Cast details based on dataType for better type safety (optional but recommended) + const purchaseDetail = + entry.dataType === "purchase" + ? (firstDetail as PurchaseEntry | undefined) + : undefined; + const searchDetail = + entry.dataType === "search" + ? (firstDetail as SearchEntry | undefined) + : undefined; + + return ( + + + {formatDate(entry.timestamp)} + + + {entry.dataType} + + + {/* Check storeId before using map */} + {entry.storeId + ? (storeNameMap.get(entry.storeId) ?? entry.storeId) + : "N/A"} + + + {/* Display relevant details based on type */} + {entry.dataType === "purchase" && + purchaseDetail?.items && // Use casted detail and optional chaining + purchaseDetail.items.length > 0 && ( + + {purchaseDetail.items + .map((item: PurchaseItem) => item.name) // Add type to item + .join(", ")} + + )} + {entry.dataType === "search" && + searchDetail?.query && ( // Use casted detail and optional chaining + Query: "{searchDetail.query}" + )} + {/* Add more detail rendering as needed */} + + + ); + })} + +
+
+ + {/* --- Simple Previous/Next Pagination --- */} +
+ + + Page {activityPage} + + +
+ {/* --- End Simple Pagination --- */} + + )}
); diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index b5e8415..ac5f255 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -13,6 +13,7 @@ import { ListItem, Datepicker, Button, + Spinner, // <-- Import Spinner for inline loading } from "flowbite-react"; import { HiArrowRight, @@ -23,6 +24,10 @@ import { HiOutlineShare, HiOutlineAdjustments, HiCalendar, + HiOutlineGlobeAlt, + HiOutlineCake, + HiOutlineCash, + HiOutlineUserCircle, } from "react-icons/hi"; import { ResponsiveContainer, @@ -33,9 +38,9 @@ import { CartesianGrid, Tooltip, Legend, - BarChart, - Bar, Cell, + PieChart, + Pie, } from "recharts"; import { @@ -46,6 +51,7 @@ import { useUserPreferences, } from "../api/hooks/useUserHooks"; import { useLookupStores } from "../api/hooks/useStoreHooks"; +import { useTaxonomy } from "../api/hooks/useTaxonomyHooks"; import LoadingSpinner from "../components/common/LoadingSpinner"; import ErrorDisplay from "../components/common/ErrorDisplay"; import { @@ -67,7 +73,7 @@ const formatDate = (dateString: string | Date | undefined) => { }); }; -// Define colors for the lines (can reuse or define new ones) +// Define colors for the lines/pie slices (keep existing) const LINE_COLORS = [ "#0088FE", "#00C49F", @@ -91,7 +97,7 @@ const formatCurrency = (value: number) => { }).format(value); }; -// Helper to format YYYY-MM date string for display +// Helper to format YYYY-MM date string for display (keep existing) const formatMonth = (monthString: string) => { try { const [year, month] = monthString.split("-"); @@ -105,12 +111,86 @@ const formatMonth = (monthString: string) => { } }; -// Helper to format Date object to YYYY-MM-DD string +// Helper to format Date object to YYYY-MM-DD string (keep existing) const formatDateToISO = (date: Date | null | undefined): string | undefined => { if (!date) return undefined; return date.toISOString().split("T")[0]; }; +// Helper for Pie Chart Label Rendering (Optional, for better labels) +const RADIAN = Math.PI / 180; + +// Define an interface for the label props +interface CustomizedLabelProps { + cx: number; + cy: number; + midAngle: number; + innerRadius: number; + outerRadius: number; + percent: number; +} + +const renderCustomizedLabel = ({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + percent, +}: CustomizedLabelProps) => { + // Use the defined interface + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + if (percent < 0.05) return null; // Don't render label for small slices + + return ( + cx ? "start" : "end"} + dominantBaseline="central" + fontSize={12} + > + {`${(percent * 100).toFixed(0)}%`} + + ); +}; + +// --- Mini Demographic Card Component --- +interface DemoInfoCardProps { + icon: React.ElementType; + label: string; + value: string | number | null | undefined; + isLoading?: boolean; +} + +const DemoInfoCard: React.FC = ({ + icon: Icon, + label, + value, + isLoading, +}) => ( +
+ +
+

+ {label} +

+ {isLoading ? ( + + ) : ( +

+ {value ?? "--"} {/* Display '--' if value is null/undefined */} +

+ )} +
+
+); +// --- End Mini Demographic Card Component --- + export default function UserDashboard() { // --- State for Date Range --- const [startDate, setStartDate] = useState(null); @@ -126,7 +206,7 @@ export default function UserDashboard() { data: recentActivity, isLoading: activityLoading, error: activityError, - } = useRecentUserData(3); // <-- Changed limit from 5 to 3 + } = useRecentUserData({ limit: 3 }); const { data: spendingData, isLoading: spendingLoading, @@ -145,6 +225,11 @@ export default function UserDashboard() { isLoading: preferencesLoading, error: preferencesError, } = useUserPreferences(); + const { + data: taxonomyData, + isLoading: taxonomyLoading, + error: taxonomyError, + } = useTaxonomy(); // <-- Fetch taxonomy // --- Prepare Derived Data (useMemo hooks called unconditionally) --- const optInStoreIds = useMemo( @@ -200,17 +285,84 @@ export default function UserDashboard() { }; }, [spendingData]); - // Data for Preferences Bar Chart - const topPreferencesChartData = useMemo(() => { - if (!preferencesData?.preferences) return []; - return [...preferencesData.preferences] - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - .slice(0, 5) - .map((pref) => ({ - name: pref.category, - score: pref.score != null ? pref.score * 100 : 0, + // --- Data Transformation for Preferences Pie Chart --- + const preferencesPieChartData = useMemo(() => { + if ( + !preferencesData?.preferences || + !taxonomyData?.categories || + preferencesData.preferences.length === 0 || + taxonomyData.categories.length === 0 + ) { + return []; + } + + // Build helper maps from taxonomy + const categoryNameMap = new Map(); + const parentMap = new Map(); + taxonomyData.categories.forEach((cat) => { + categoryNameMap.set(cat.id, cat.name); + parentMap.set(cat.id, cat.parent_id || null); + }); + + // Function to find the top-level parent + const getTopLevelCategory = ( + categoryId: string, + ): { id: string; name: string } | null => { + let currentId: string | null | undefined = categoryId; // Allow undefined + let topLevelId: string = categoryId; + let safety = 0; // Prevent infinite loops + + while (currentId != null && safety < 10) { + // Check for null or undefined + const parentId = parentMap.get(currentId); + if (parentId == null) { + // Check for null or undefined + topLevelId = currentId; // Found the root + break; + } + currentId = parentId; + safety++; + } + const topLevelName = categoryNameMap.get(topLevelId); + return topLevelName ? { id: topLevelId, name: topLevelName } : null; + }; + + // Aggregate scores by top-level category + const aggregatedScores = new Map(); + preferencesData.preferences.forEach((pref) => { + if (pref.category && pref.score != null) { + // Use pref.category + const topLevelCat = getTopLevelCategory(pref.category); // Use pref.category + if (topLevelCat) { + const current = aggregatedScores.get(topLevelCat.id) || { + name: topLevelCat.name, + score: 0, + }; + current.score += pref.score; + aggregatedScores.set(topLevelCat.id, current); + } + } + }); + + // Convert map to array suitable for PieChart, calculate total score + let totalScore = 0; + const chartData = Array.from(aggregatedScores.values()).map((item) => { + totalScore += item.score; + return { name: item.name, value: item.score }; // Use 'value' for PieChart + }); + + // Normalize scores to percentages (optional, but good for display) + // If totalScore is 0, avoid division by zero + if (totalScore > 0) { + return chartData.map((item) => ({ + ...item, + value: (item.value / totalScore) * 100, // Normalize to percentage })); - }, [preferencesData]); + } else { + // Handle case where all scores are 0 or negative (unlikely but possible) + return chartData.map((item) => ({ ...item, value: 0 })); + } + }, [preferencesData, taxonomyData]); // --- Loading and Error States (Checked AFTER hooks) --- const isLoading = @@ -219,7 +371,8 @@ export default function UserDashboard() { spendingLoading || consentLoading || preferencesLoading || - storesLoading; + storesLoading || + taxonomyLoading; // <-- Add taxonomy loading const combinedError = profileError || @@ -227,7 +380,8 @@ export default function UserDashboard() { spendingError || consentError || preferencesError || - storesError; + storesError || + taxonomyError; // <-- Add taxonomy error const [showInterestForm, setShowInterestForm] = useState(false); @@ -242,11 +396,13 @@ export default function UserDashboard() { } }, [preferencesData, preferencesLoading]); - if (isLoading && !spendingData) { + // Adjust initial loading state check if needed + if (isLoading && !profile && !spendingData && !preferencesData) { return ; } - if (combinedError && !spendingData) { + // Adjust combined error check if needed + if (combinedError && !profile && !spendingData && !preferencesData) { return ( {/* --- Spending Overview Card --- */} - + + {" "} + {/* Make spending full width on large screens */}

@@ -416,125 +574,206 @@ export default function UserDashboard() { {/* --- Data Sharing Card --- */} + {" "} + {/* Ensure flex-col */}
+ {" "} + {/* Content takes available space */}

Data Sharing

- {consentError || storesError ? ( + {/* Specific Loading State for this Card */} + {consentLoading || storesLoading ? ( +
+ {" "} + {/* Center spinner, add min-height */} + +
+ ) : consentError || storesError ? ( Could not load sharing status. ) : (consentLists?.optInStores?.length ?? 0) === 0 ? ( -

- You are not currently sharing data with any stores. -

+
+ {" "} + {/* Center empty state, add min-height */} +

+ You are not currently sharing data with any stores. +

+
) : ( <> -

+

You are sharing data with{" "} - {consentLists?.optInStores?.length} store(s): + + {consentLists?.optInStores?.length} + {" "} + store(s):

- + {/* Use a slightly more styled list */} + + {" "} + {/* Add max-height and scroll */} {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( {storeNameMap.get(storeId) || `Store ID: ${storeId}`} ))} {(consentLists?.optInStores?.length ?? 0) > 5 && ( - - ... and more + + ... and {consentLists!.optInStores!.length - 5} more )} )}
+ {/* Link stays at the bottom */} Manage Sharing Settings
- {/* --- Preferences Summary Card --- */} + {/* --- Preferences & Demographics Card --- */} + {/* Make this card span 2 columns on medium screens and up */}
+ {/* Main Title */}

- Top Preferences + Preference & Profile Overview

- {preferencesError ? ( - - Could not load preferences data. - - ) : preferencesLoading ? ( -
- + + {/* Grid for Pie Chart and Demographics */} +
+ {/* Pie Chart Section */} +
+

+ Top Interests +

+ {preferencesError || taxonomyError ? ( + + Could not load preference data. + + ) : preferencesLoading || taxonomyLoading ? ( +
+ +
+ ) : !preferencesPieChartData || + preferencesPieChartData.length === 0 ? ( +

+ No preference data available yet. Add interests to see + insights. +

+ ) : ( +
+ + + + {preferencesPieChartData.map((_entry, index) => ( + + ))} + + + `${value.toFixed(1)}%` + } + contentStyle={{ + backgroundColor: "rgba(31, 41, 55, 0.9)", + borderColor: "rgba(75, 85, 99, 0.5)", + borderRadius: "0.375rem", + }} + itemStyle={{ color: "#e5e7eb" }} + labelStyle={{ + color: "#f9fafb", + fontWeight: "bold", + }} + /> + + + +
+ )} + + Manage All Preferences{" "} + +
- ) : !topPreferencesChartData || - topPreferencesChartData.length === 0 ? ( -

- No preference data available yet. -

- ) : ( -
- - - - `${value}%`} - fontSize={12} - tick={{ fill: "currentColor" }} + + {/* Demographics Section */} +
+

+ About You +

+ {profileError ? ( + + Could not load profile information. + + ) : ( +
+ - - `${value.toFixed(1)}%`} - cursor={{ fill: "rgba(156, 163, 175, 0.2)" }} - contentStyle={{ - backgroundColor: "rgba(31, 41, 55, 0.9)", - borderColor: "rgba(75, 85, 99, 0.5)", - borderRadius: "0.375rem", - }} - itemStyle={{ color: "#e5e7eb" }} - labelStyle={{ color: "#f9fafb", fontWeight: "bold" }} + - - - {topPreferencesChartData.map((_entry, index) => ( - - ))} - - - + +
+ )}
- )} +
- - Manage All Preferences -
diff --git a/web/src/pages/UserDataSharingPage.tsx b/web/src/pages/UserDataSharingPage.tsx index abab8c4..3053439 100644 --- a/web/src/pages/UserDataSharingPage.tsx +++ b/web/src/pages/UserDataSharingPage.tsx @@ -1,19 +1,289 @@ -import React from "react"; -import { Card } from "flowbite-react"; +import React, { useState, useMemo } from "react"; +import { + Card, + List, + ListItem, + Button, + Spinner, + Alert, + TextInput, // For search +} from "flowbite-react"; +import { HiInformationCircle, HiOutlineSearch } from "react-icons/hi"; +import { + useStoreConsentLists, + useOptInToStore, + useOptOutFromStore, +} from "../api/hooks/useUserHooks"; +// Import the new search hook +import { useLookupStores, useSearchStores } from "../api/hooks/useStoreHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { StoreBasicInfo } from "../api/types/data-contracts"; const UserDataSharingPage: React.FC = () => { + const { + data: consentLists, + isLoading: consentLoading, + error: consentError, + } = useStoreConsentLists(); + const { + mutate: optIn, + isPending: isOptingIn, + variables: optInVariables, // Get variables for optIn + } = useOptInToStore(); + const { + mutate: optOut, + isPending: isOptingOut, + variables: optOutVariables, // Get variables for optOut + } = useOptOutFromStore(); + + // Combine IDs from both lists for lookup + const storeIdsToLookup = useMemo(() => { + const ids = new Set(); + (consentLists?.optInStores || []).forEach((id) => ids.add(id)); + (consentLists?.optOutStores || []).forEach((id) => ids.add(id)); + return Array.from(ids); + }, [consentLists]); + + const { + data: storeDetails, + isLoading: storesLoading, + error: storesError, + } = useLookupStores(storeIdsToLookup); + + // Map store IDs to names for easy display + const storeNameMap = useMemo(() => { + const map = new Map(); + storeDetails?.forEach((store: StoreBasicInfo) => { + map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); + }); + return map; + }, [storeDetails]); + + // --- State and Hook for Search --- + const [searchTerm, setSearchTerm] = useState(""); + const { + data: searchResults, + isLoading: searchLoading, + error: searchError, // Add error handling for search + } = useSearchStores(searchTerm); // Use the search hook + + // --- Move useMemo hook here, before early returns --- + const filteredSearchResults = useMemo(() => { + // Ensure searchResults and consentLists are defined before accessing them + const existingIds = new Set([ + ...(consentLists?.optInStores || []), + ...(consentLists?.optOutStores || []), + ]); + // Only filter if searchResults is available + return searchResults?.filter((store) => !existingIds.has(store.storeId)); + }, [searchResults, consentLists]); // Dependencies remain the same + + const handleOptIn = (storeId: string) => { + optIn(storeId); + }; + + const handleOptOut = (storeId: string) => { + optOut(storeId); + }; + + // --- Loading and Error Checks (Now after the useMemo) --- + const isLoadingInitial = consentLoading || storesLoading; + const initialError = consentError || storesError; + + if (isLoadingInitial) { + return ; + } + + if (initialError) { + return ( + + ); + } + + const isMutating = isOptingIn || isOptingOut; + return (
-

+

Control Data Sharing

- -

- Manage which stores you allow to access your preference data. You can - opt-in or opt-out from individual stores at any time. (Implementation - coming soon!) -

- {/* Opt-in/Opt-out UI will go here */} + +
+ {/* Opt-In List (Existing) */} + +

+ Stores You Share Data With (Opt-In) +

+

+ These stores can access your anonymized preference data based on + your profile settings. +

+ {consentLists?.optInStores && consentLists.optInStores.length > 0 ? ( + + {consentLists.optInStores.map((storeId) => ( + + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + + + ))} + + ) : ( +

+ You are not currently sharing data with any stores. +

+ )} +
+ + {/* Opt-Out List (Existing) */} + +

+ Stores You Don't Share Data With (Opt-Out) +

+

+ These stores cannot access your preference data. +

+ {consentLists?.optOutStores && + consentLists.optOutStores.length > 0 ? ( + + {consentLists.optOutStores.map((storeId) => ( + + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + + + ))} + + ) : ( +

+ You haven't opted out of any specific stores yet. +

+ )} +
+
+ + {/* --- Search Stores Section (Uncommented and Implemented) --- */} + +

+ Find Other Stores +

+
+ setSearchTerm(e.target.value)} // Update search term state + /> +
+ + {/* Search Loading State */} + {searchLoading && ( +
+ +
+ )} + + {/* Search Error State */} + {searchError && ( + + Error searching stores: {searchError.message} + + )} + + {/* Search Results */} + {!searchLoading && + !searchError && + searchTerm.length >= 2 && // Only show results/message if search term is long enough + filteredSearchResults && + filteredSearchResults.length > 0 && ( + + {" "} + {/* Added max-height and scroll */} + {filteredSearchResults.map((store) => ( + + + {store.name} + + {/* Only show Opt-In button for search results */} + + + ))} + + )} + + {/* No Results Message */} + {!searchLoading && + !searchError && + searchTerm.length >= 2 && // Only show message if search term is long enough + (!filteredSearchResults || filteredSearchResults.length === 0) && ( +

+ No new stores found matching "{searchTerm}". +

+ )}
); diff --git a/web/src/pages/UserPreferencesPage.tsx b/web/src/pages/UserPreferencesPage.tsx index 97e80d0..ba44a41 100644 --- a/web/src/pages/UserPreferencesPage.tsx +++ b/web/src/pages/UserPreferencesPage.tsx @@ -1,19 +1,687 @@ -import React from "react"; -import { Card } from "flowbite-react"; +import React, { useState, useEffect, useMemo } from "react"; +import { + Card, + Button, + Spinner, + Alert, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Label, + TextInput, + Select, + RangeSlider, + List, // Import List + ListItem, // Import ListItem +} from "flowbite-react"; +import { + HiInformationCircle, + HiPencil, + HiTrash, + HiPlus, + HiUser, + HiSparkles, + HiOutlineUserCircle, + HiOutlineCake, + HiOutlineGlobeAlt, + HiOutlineCash, + // --- End Add icons --- +} from "react-icons/hi"; +import { useForm, Controller, SubmitHandler } from "react-hook-form"; +import { + useUserProfile, + useUpdateUserProfile, + useUserPreferences, + useUpdateUserPreferences, +} from "../api/hooks/useUserHooks"; +import { useTaxonomy } from "../api/hooks/useTaxonomyHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { + UserUpdate, + PreferenceItem, + TaxonomyCategory, +} from "../api/types/data-contracts"; + +// --- Form Types --- +type DemographicsFormData = Pick< + UserUpdate, + "gender" | "age" | "country" | "incomeBracket" +>; +type PreferenceFormData = { + category: string; + score: number; + attributes?: Record; +}; + +// --- Mini Demographic Card Component (Add this) --- +interface DemoInfoCardProps { + icon: React.ElementType; + label: string; + value: string | number | null | undefined; + isLoading?: boolean; // Optional loading state if needed later +} + +const DemoInfoCard: React.FC = ({ + icon: Icon, + label, + value, + isLoading, +}) => ( +
+ +
+

+ {label} +

+ {isLoading ? ( + + ) : ( +

+ {value || "Not set"} +

+ )} +
+
+); +// --- End Mini Demographic Card Component --- const UserPreferencesPage: React.FC = () => { + // --- Data Fetching --- + const { + data: userProfile, + isLoading: profileLoading, + error: profileError, + } = useUserProfile(); + const { + data: preferencesData, + isLoading: preferencesLoading, + error: preferencesError, + } = useUserPreferences(); + const { + data: taxonomyData, + isLoading: taxonomyLoading, + error: taxonomyError, + } = useTaxonomy(); + + // --- Mutations --- + const { + mutate: updateProfile, + isPending: isUpdatingProfile, + error: updateProfileError, + } = useUpdateUserProfile(); + const { + mutate: updatePreferences, + isPending: isUpdatingPreferences, + error: updatePreferencesError, + } = useUpdateUserPreferences(); + + // --- State --- + const [isEditingDemographics, setIsEditingDemographics] = useState(false); + const [showPreferenceModal, setShowPreferenceModal] = useState(false); + const [editingPreferenceIndex, setEditingPreferenceIndex] = useState< + number | null + >(null); + + // --- Forms --- + const { + register: registerDemo, + handleSubmit: handleDemoSubmit, + reset: resetDemoForm, + formState: { isDirty: isDemoDirty }, + } = useForm(); + + const { + register: registerPref, + handleSubmit: handlePrefSubmit, + reset: resetPrefForm, + control: prefControl, + watch: watchPref, + formState: { errors: prefErrors }, + } = useForm({ + defaultValues: { category: "", attributes: {}, score: 50 }, + }); + + // --- Effects --- + // Reset demographics form when profile loads or editing starts/stops + useEffect(() => { + if (userProfile && !isEditingDemographics) { + resetDemoForm({ + gender: userProfile.gender || "", + age: userProfile.age || undefined, + country: userProfile.country || "", + incomeBracket: userProfile.incomeBracket || "", + }); + } + }, [userProfile, isEditingDemographics, resetDemoForm]); + + // Reset preference form when modal opens/closes or editing target changes + useEffect(() => { + if (showPreferenceModal) { + if ( + editingPreferenceIndex !== null && + preferencesData?.preferences?.[editingPreferenceIndex] + ) { + // Editing existing preference + const pref = preferencesData.preferences[editingPreferenceIndex]; + const formAttributes: Record = {}; + if (pref.attributes) { + Object.entries(pref.attributes).forEach(([key, valueObj]) => { + let displayValue: string | undefined = undefined; + if (typeof valueObj === "object" && valueObj !== null) { + // Attempt to get the first key if it's an object like { "Blue": 1.0 } + const firstKey = Object.keys(valueObj)[0]; + if (firstKey) displayValue = firstKey; + } else if (typeof valueObj === "string") { + // Handle simple string attributes if your API supports them + displayValue = valueObj; + } + formAttributes[key] = displayValue; + }); + } + resetPrefForm({ + category: pref.category || "", + attributes: formAttributes, + score: + pref.score !== null && pref.score !== undefined + ? Math.round(pref.score * 100) // Convert 0-1 score to 0-100 for slider + : 50, // Default slider value + }); + } else { + // Adding new preference + resetPrefForm({ category: "", attributes: {}, score: 50 }); + } + } + }, [ + showPreferenceModal, + editingPreferenceIndex, + preferencesData, + resetPrefForm, + ]); + + // --- Memos --- + const { categoryMap, attributeMap } = useMemo(() => { + const catMap = new Map(); + const attrMap = new Map>(); // categoryId -> Map + if (taxonomyData?.categories) { + taxonomyData.categories.forEach((cat) => { + catMap.set(cat.id, cat); + const catAttrs = new Map(); + cat.attributes?.forEach((attr) => { + catAttrs.set(attr.name, attr.description || attr.name); + }); + // Include parent attributes (simple one-level for now) + if (cat.parent_id) { + const parentCat = taxonomyData.categories.find( + (p) => p.id === cat.parent_id, + ); + parentCat?.attributes?.forEach((attr) => { + if (!catAttrs.has(attr.name)) { + // Avoid overwriting child attributes + catAttrs.set(attr.name, attr.description || attr.name); + } + }); + } + attrMap.set(cat.id, catAttrs); + }); + } + return { categoryMap: catMap, attributeMap: attrMap }; + }, [taxonomyData]); + + const selectedCategoryId = watchPref("category"); + const availableAttributes = useMemo(() => { + return attributeMap.get(selectedCategoryId) || new Map(); + }, [selectedCategoryId, attributeMap]); + + // --- Handlers --- + const onDemoSubmit: SubmitHandler = (data) => { + // Filter out empty strings before sending + const payload: Partial = {}; + if (data.gender) payload.gender = data.gender; + if (data.age) payload.age = data.age; + if (data.country) payload.country = data.country; + if (data.incomeBracket) payload.incomeBracket = data.incomeBracket; + + updateProfile(payload as UserUpdate, { + // Cast as UserUpdate + onSuccess: () => setIsEditingDemographics(false), + }); + }; + + const onPrefSubmit: SubmitHandler = (data) => { + const currentPreferences = preferencesData?.preferences || []; + let updatedPreferences: PreferenceItem[]; + + // Format attributes for the API: { "brand": { "Apple": 1.0 }, "color": { "Blue": 1.0 } } + const apiAttributes: Record = {}; + if (data.attributes) { + Object.entries(data.attributes).forEach(([key, value]) => { + if (value && value.trim() !== "") { + // Only include non-empty attributes + apiAttributes[key] = { [value.trim()]: 1.0 }; // Assign score 1.0 + } + }); + } + + const newPrefItem: PreferenceItem = { + category: data.category, + attributes: apiAttributes, + score: data.score / 100, // Convert 0-100 slider value to 0-1 score + }; + + if (editingPreferenceIndex !== null) { + // Update existing preference + updatedPreferences = currentPreferences.map((pref, index) => + index === editingPreferenceIndex ? newPrefItem : pref, + ); + } else { + // Add new preference + updatedPreferences = [...currentPreferences, newPrefItem]; + } + + updatePreferences( + { preferences: updatedPreferences }, + { + onSuccess: () => { + setShowPreferenceModal(false); + setEditingPreferenceIndex(null); + }, + }, + ); + }; + + const handleRemovePreference = (indexToRemove: number) => { + const currentPreferences = preferencesData?.preferences || []; + const updatedPreferences = currentPreferences.filter( + (_, index) => index !== indexToRemove, + ); + updatePreferences({ preferences: updatedPreferences }); + }; + + const openAddModal = () => { + setEditingPreferenceIndex(null); + setShowPreferenceModal(true); + }; + + const openEditModal = (index: number) => { + setEditingPreferenceIndex(index); + setShowPreferenceModal(true); + }; + + // --- Render Logic --- + const isLoading = profileLoading || preferencesLoading || taxonomyLoading; + const error = profileError || preferencesError || taxonomyError; + const isMutating = isUpdatingProfile || isUpdatingPreferences; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + return (
-

- Manage Your Preferences +

+ Manage Your Profile & Interests

- -

- Here you can view and update your interest preferences. This helps us - show you more relevant content and ads. (Implementation coming soon!) -

- {/* Preference selection UI will go here */} -
+ +
+ {/* --- Demographics Section (Left Column on Large Screens) --- */} + +
+

+ + About You +

+ {!isEditingDemographics && ( + + )} +
+ {updateProfileError && ( + + Failed to update profile: {updateProfileError.message} + + )} + {isEditingDemographics ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : ( +
+ + + + +
+ )} +
+ + {/* --- Preferences Section (Right Column on Large Screens) --- */} + +
+

+ + Your Interests +

+ +
+ {updatePreferencesError && ( + + Failed to update preferences: {updatePreferencesError.message} + + )} + {isUpdatingPreferences && !showPreferenceModal && ( +
+ Saving... +
+ )} +
+ {preferencesData?.preferences && + preferencesData.preferences.length > 0 ? ( + + {preferencesData.preferences.map((pref, index) => { + // Extract attribute display logic + const attributesDisplay = + pref.attributes && + Object.entries(pref.attributes).map(([key, valueObj]) => { + let displayValue = "[Complex Value]"; + if (typeof valueObj === "object" && valueObj !== null) { + const firstKey = Object.keys(valueObj)[0]; + if (firstKey) displayValue = firstKey; + } else if (typeof valueObj === "string") { + displayValue = valueObj; + } + return { key, displayValue }; + }); + + return ( + +
+ + {categoryMap.get(pref.category || "")?.name || + "Unknown Category"} + + + Score:{" "} + {pref.score !== null && pref.score !== undefined + ? Math.round(pref.score * 100) + : "N/A"} + + {attributesDisplay && attributesDisplay.length > 0 && ( +
+ {attributesDisplay.map(({ key, displayValue }) => ( + + + {attributeMap + .get(pref.category || "") + ?.get(key) || key} + : + {" "} + {displayValue} + + ))} +
+ )} +
+
+ + +
+
+ ); + })} +
+ ) : ( +

+ You haven't added any specific interests yet. Click "Add + Interest" to get started. +

+ )} +
+
+
+ + {/* --- Preference Add/Edit Modal --- */} + !isMutating && setShowPreferenceModal(false)} + size="lg" // Slightly larger modal + > + + {editingPreferenceIndex !== null + ? "Edit Interest" + : "Add New Interest"} + +
+ + {/* Category Select */} +
+ + + {prefErrors.category && ( +

+ {prefErrors.category.message} +

+ )} +
+ + {/* Attributes */} + {selectedCategoryId && availableAttributes.size > 0 && ( +
+ + Refine Interest (Optional) + +
+ {Array.from(availableAttributes.entries()).map( + ([attrName, attrDesc]) => ( +
+ + +
+ ), + )} +
+
+ )} + + {/* Score Slider */} +
+ + ( +
+ onChange(parseInt(e.target.value, 10))} + className="flex-grow" + /> + + {value ?? 50} + +
+ )} + /> +

+ How interested are you in this category? (0 = Not at all, 100 = + Very interested) +

+
+
+ + + + +
+
); };