From c2b54b6a2f4e84fb2cde3ffdc995adc28e6578f1 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 07:22:38 +0530 Subject: [PATCH 1/3] Add User Preferences and Profile Management Pages - Implement UserPreferencesPage for managing user interests and demographics. - Create UserProfilePage for updating user profile information and privacy settings. - Integrate form handling with react-hook-form for both pages. - Add loading and error handling components for better user experience. - Include delete account functionality with confirmation modal in UserProfilePage. - Utilize Flowbite components for UI consistency and responsiveness. --- api-service/api/openapi.yaml | 114 +++++ api-service/controllers/StoreManagement.js | 12 +- api-service/service/StoreManagementService.js | 86 ++++ web/src/api/hooks/useStoreHooks.ts | 63 ++- web/src/api/types/Stores.ts | 37 ++ web/src/api/types/data-contracts.ts | 58 +++ web/src/api/utils/cache.ts | 105 ++-- web/src/main.tsx | 14 +- web/src/pages/StoreDashboard.tsx | 49 -- .../pages/StoreDashboard/ApiKeyManagement.tsx | 448 ++++++++++++++++++ .../StoreDashboard/ApiUsageDashboard.tsx | 365 ++++++++++++++ .../pages/StoreDashboard/StoreDashboard.tsx | 130 +++++ .../{ => StoreDashboard}/StoreProfilePage.tsx | 10 +- .../{ => UserDashboard}/UserAnalyticsPage.tsx | 10 +- .../{ => UserDashboard}/UserDashboard.tsx | 14 +- .../UserDataSharingPage.tsx | 13 +- .../UserPreferencesPage.tsx | 10 +- .../{ => UserDashboard}/UserProfilePage.tsx | 10 +- 18 files changed, 1409 insertions(+), 139 deletions(-) delete mode 100644 web/src/pages/StoreDashboard.tsx create mode 100644 web/src/pages/StoreDashboard/ApiKeyManagement.tsx create mode 100644 web/src/pages/StoreDashboard/ApiUsageDashboard.tsx create mode 100644 web/src/pages/StoreDashboard/StoreDashboard.tsx rename web/src/pages/{ => StoreDashboard}/StoreProfilePage.tsx (97%) rename web/src/pages/{ => UserDashboard}/UserAnalyticsPage.tsx (98%) rename web/src/pages/{ => UserDashboard}/UserDashboard.tsx (98%) rename web/src/pages/{ => UserDashboard}/UserDataSharingPage.tsx (96%) rename web/src/pages/{ => UserDashboard}/UserPreferencesPage.tsx (98%) rename web/src/pages/{ => UserDashboard}/UserProfilePage.tsx (98%) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 4073ba4..c390957 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -533,6 +533,75 @@ paths: - oauth2: [store:read] x-swagger-router-controller: StoreManagement + /stores/api-usage-log: + get: + tags: [Store Management] + summary: Get API usage log + description: Retrieves a paginated list of detailed API usage logs for the authenticated store, with optional filtering. + operationId: getApiUsageLog + security: + - oauth2: [store:read] # Requires store read scope + parameters: + - in: query + name: keyId + schema: + type: string + required: false + description: Filter logs by a specific API key ID. + - in: query + name: startDate + schema: + type: string + format: date + required: false + description: Filter logs from this date (inclusive). + - in: query + name: endDate + schema: + type: string + format: date + required: false + description: Filter logs up to this date (inclusive). + - in: query + name: page + schema: + type: integer + default: 1 + required: false + description: Page number for pagination. + - in: query + name: limit + schema: + type: integer + default: 15 + required: false + description: Number of logs per page. + responses: + "200": + description: A paginated list of API usage logs. + content: + application/json: + schema: + type: object + properties: + logs: + type: array + items: + $ref: "#/components/schemas/ApiUsageLogEntry" # Define this schema below + pagination: + $ref: "#/components/schemas/PaginationInfo" # Define this schema below + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" # If store not found + "500": + $ref: "#/components/responses/InternalServerError" + x-swagger-router-controller: StoreManagement # Route to StoreManagement controller + /health: get: tags: [Health] @@ -1581,6 +1650,51 @@ components: items: $ref: "#/components/schemas/MonthlySpendingItem" + ApiUsageLogEntry: + type: object + properties: + _id: + type: string + description: The unique ID of the log entry. + storeId: + type: string + description: The ID of the store associated with the API key. + apiKeyId: + type: string + description: The ID of the API key used. + apiKeyPrefix: + type: string + description: The prefix of the API key used. + endpoint: + type: string + description: The API endpoint accessed. + method: + type: string + description: The HTTP method used. + timestamp: + type: string + format: date-time + description: The timestamp when the request occurred. + userAgent: + type: string + description: The user agent of the client making the request. + + PaginationInfo: + type: object + properties: + currentPage: + type: integer + description: The current page number. + totalPages: + type: integer + description: The total number of pages available. + totalItems: + type: integer + description: The total number of items matching the query. + limit: + type: integer + description: The number of items per page. + responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/controllers/StoreManagement.js b/api-service/controllers/StoreManagement.js index bad6b1a..1f21bc8 100644 --- a/api-service/controllers/StoreManagement.js +++ b/api-service/controllers/StoreManagement.js @@ -39,4 +39,14 @@ module.exports.getApiKeyUsage = function getApiKeyUsage(req, res, next, keyId) { .catch((response) => { utils.writeJson(res, response); }); -} \ No newline at end of file +}; + +module.exports.getApiUsageLog = function getApiUsageLog(req, res, next) { + StoreManagement.getApiUsageLog(req) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); +}; diff --git a/api-service/service/StoreManagementService.js b/api-service/service/StoreManagementService.js index 5d6edd6..a31b702 100644 --- a/api-service/service/StoreManagementService.js +++ b/api-service/service/StoreManagementService.js @@ -279,4 +279,90 @@ exports.getApiKeyUsage = async function (req, keyId) { console.error('Get API key usage failed:', error); return respondWithCode(500, { code: 500, message: 'Internal server error' }); } +}; + +/** + * Get API Usage Log + * Get paginated detailed usage logs for the store's API keys + */ +exports.getApiUsageLog = async function (req) { + try { + const db = getDB(); + + // Get user data (store owner) + const userData = req.user || await getUserData(req.headers.authorization?.split(' ')[1]); + + // Find the store to ensure it exists and get its ID + const store = await db.collection('stores').findOne( + { auth0Id: userData.sub }, + { projection: { _id: 1 } } // Only need the store's _id + ); + if (!store) { + return respondWithCode(404, { code: 404, message: 'Store not found' }); + } + const storeId = store._id.toString(); + + // Get query parameters + const { keyId, startDate, endDate } = req.query; + const page = parseInt(req.query.page || '1', 10); + const limit = parseInt(req.query.limit || '15', 10); + const skip = (page - 1) * limit; + + // Build the query filter + const filter = { storeId: storeId }; // Filter by the authenticated store + + if (keyId) { + // Optional: Validate if keyId belongs to this store? + // For now, just filter by it if provided. + filter.apiKeyId = keyId; + } + + const dateFilter = {}; + if (startDate) { + try { + dateFilter.$gte = new Date(startDate); + } catch (e) { + return respondWithCode(400, { code: 400, message: 'Invalid startDate format' }); + } + } + if (endDate) { + try { + // Add 1 day to endDate to make it inclusive of the whole day + const end = new Date(endDate); + end.setDate(end.getDate() + 1); + dateFilter.$lt = end; // Use $lt with the next day + } catch (e) { + return respondWithCode(400, { code: 400, message: 'Invalid endDate format' }); + } + } + if (Object.keys(dateFilter).length > 0) { + filter.timestamp = dateFilter; + } + + // Get total count for pagination + const totalItems = await db.collection('apiUsage').countDocuments(filter); + const totalPages = Math.ceil(totalItems / limit); + + // Get paginated logs, sorted by timestamp descending + const logs = await db.collection('apiUsage') + .find(filter) + .sort({ timestamp: -1 }) // Show most recent first + .skip(skip) + .limit(limit) + .toArray(); + + return respondWithCode(200, { + logs, + pagination: { + currentPage: page, + totalPages, + totalItems, + limit, + }, + }); + + } catch (error) { + console.error('Get API usage log failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error' }); + } }; \ No newline at end of file diff --git a/web/src/api/hooks/useStoreHooks.ts b/web/src/api/hooks/useStoreHooks.ts index 297137b..4ef8232 100644 --- a/web/src/api/hooks/useStoreHooks.ts +++ b/web/src/api/hooks/useStoreHooks.ts @@ -5,11 +5,21 @@ import { ApiKeyCreate, StoreUpdate, StoreBasicInfo, - Error, // <-- Import Error type - SearchStoresParams, // <-- Import SearchStoresParams if generated + Error, + SearchStoresParams, + GetApiKeyUsagePayload, // <-- Import payload type for usage stats + GetApiUsageLogParams, // <-- Import params type for usage log + ApiUsageLogEntry, // <-- Import log entry type + PaginationInfo, // <-- Import pagination info type } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; -import { useState, useEffect } from "react"; // <-- Import useState and useEffect for debounce +import { useState, useEffect } from "react"; + +// Define the expected response structure for getApiUsageLog +interface ApiUsageLogResponse { + logs?: ApiUsageLogEntry[]; + pagination?: PaginationInfo; +} export function useStoreProfile() { // Get clientsReady state @@ -78,22 +88,53 @@ export function useRevokeApiKey() { }); } -export function useApiKeyUsage(keyId: string) { - // Get clientsReady state +// Update useApiKeyUsage to accept payload +export function useApiKeyUsage( + keyId: string, + payload?: GetApiKeyUsagePayload, // Accept payload +) { const { apiClients, clientsReady } = useApiClients(); - const { isAuthenticated, isLoading: authLoading } = useAuth(); // Get auth state + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + // Include payload in the query key if present + // This object now matches the expected parameter type for the updated cache key + const queryKeyParams = { keyId, ...payload }; return useQuery({ - queryKey: cacheKeys.stores.apiKeyUsage(keyId), + // This call should now be valid + queryKey: cacheKeys.stores.apiKeyUsage(queryKeyParams), queryFn: () => - apiClients.stores.getApiKeyUsage(keyId).then((res) => res.data), - // Update enabled check, keeping !!keyId + // Pass payload to the API call + apiClients.stores.getApiKeyUsage(keyId, payload).then((res) => res.data), enabled: !!keyId && isAuthenticated && !authLoading && clientsReady, - ...cacheSettings.apiKeys, + ...cacheSettings.apiKeys, // Consider specific cache settings for usage + }); +} + +// --- New Hook for API Usage Log --- +export function useApiUsageLog(params?: GetApiUsageLogParams) { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + // Ensure params is always an object for the query key + const queryParams = params || {}; + + return useQuery({ + // Use the defined response type + queryKey: cacheKeys.stores.apiUsageLog(queryParams), // Use the new cache key + queryFn: () => + // Pass the queryParams object to the API client method + apiClients.stores.getApiUsageLog(queryParams).then((res) => res.data), + // Enable only when authenticated and client is ready + enabled: isAuthenticated && !authLoading && clientsReady, + // Keep previous data while fetching new page/filters + placeholderData: (previousData) => previousData, + // Consider specific cache settings for logs if needed + // staleTime: CACHE_TIMES.SHORT, }); } +// --- End New Hook --- -// --- New Hook for Searching Stores --- export function useSearchStores(searchTerm: string, debounceMs = 300) { const { apiClients, clientsReady } = useApiClients(); const { isAuthenticated, isLoading: authLoading } = useAuth(); diff --git a/web/src/api/types/Stores.ts b/web/src/api/types/Stores.ts index 19fc1c2..d30129e 100644 --- a/web/src/api/types/Stores.ts +++ b/web/src/api/types/Stores.ts @@ -15,9 +15,12 @@ import { ApiKeyCreate, ApiKeyList, ApiKeyUsage, + ApiUsageLogEntry, Error, GetApiKeyUsagePayload, + GetApiUsageLogParams, LookupStoresParams, + PaginationInfo, SearchStoresParams, Store, StoreBasicInfo, @@ -209,6 +212,40 @@ export class Stores< ...params, }); /** + * @description Retrieves a paginated list of detailed API usage logs for the authenticated store, with optional filtering. + * + * @tags Store Management + * @name GetApiUsageLog + * @summary Get API usage log + * @request GET:/stores/api-usage-log + * @secure + * @response `200` `{ + logs?: (ApiUsageLogEntry)[], + pagination?: PaginationInfo, + +}` A paginated list of API usage logs. + * @response `400` `Error` + * @response `401` `Error` + * @response `403` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + getApiUsageLog = (query: GetApiUsageLogParams, params: RequestParams = {}) => + this.request< + { + logs?: ApiUsageLogEntry[]; + pagination?: PaginationInfo; + }, + Error + >({ + path: `/stores/api-usage-log`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); + /** * @description Retrieves basic details (like name) for a list of store IDs. * * @tags Store Management diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 5e50b7b..3f263ea 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -450,6 +450,39 @@ export interface MonthlySpendingItem { /** An array of monthly spending breakdowns. */ export type MonthlySpendingAnalytics = MonthlySpendingItem[]; +export interface ApiUsageLogEntry { + /** The unique ID of the log entry. */ + _id?: string; + /** The ID of the store associated with the API key. */ + storeId?: string; + /** The ID of the API key used. */ + apiKeyId?: string; + /** The prefix of the API key used. */ + apiKeyPrefix?: string; + /** The API endpoint accessed. */ + endpoint?: string; + /** The HTTP method used. */ + method?: string; + /** + * The timestamp when the request occurred. + * @format date-time + */ + timestamp?: string; + /** The user agent of the client making the request. */ + userAgent?: string; +} + +export interface PaginationInfo { + /** The current page number. */ + currentPage?: number; + /** The total number of pages available. */ + totalPages?: number; + /** The total number of items matching the query. */ + totalItems?: number; + /** The number of items per page. */ + limit?: number; +} + export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data @@ -463,6 +496,31 @@ export interface GetApiKeyUsagePayload { endDate?: string; } +export interface GetApiUsageLogParams { + /** Filter logs by a specific API key ID. */ + keyId?: string; + /** + * Filter logs from this date (inclusive). + * @format date + */ + startDate?: string; + /** + * Filter logs up to this date (inclusive). + * @format date + */ + endDate?: string; + /** + * Page number for pagination. + * @default 1 + */ + page?: number; + /** + * Number of logs per page. + * @default 15 + */ + limit?: number; +} + export interface GetRecentUserDataParams { /** * Maximum number of records to return diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index 4e8ee8a..dabf951 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -1,6 +1,11 @@ import { QueryClient } from "@tanstack/react-query"; // Import GetRecentUserDataParams if not already imported -import { User, GetRecentUserDataParams } from "../types/data-contracts"; +import { + User, + GetRecentUserDataParams, + GetApiUsageLogParams, // <-- Add this import + GetApiKeyUsagePayload, // <-- Import payload type +} from "../types/data-contracts"; // Cache time configurations (in milliseconds) export const CACHE_TIMES = { @@ -24,48 +29,70 @@ export const queryClient = new QueryClient({ // Cache keys for consistent query identification export const cacheKeys = { users: { - all: ["users"], - profile: () => [...cacheKeys.users.all, "profile"], - preferences: () => [...cacheKeys.users.all, "preferences"], - // Update recentData to accept GetRecentUserDataParams - recentData: (params: GetRecentUserDataParams = {}) => [ - // Default to empty object - ...cacheKeys.users.all, - "recentData", - // 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) => [ - ...cacheKeys.users.all, - "spendingAnalytics", - { startDate: startDate ?? "all", endDate: endDate ?? "all" }, // Use 'all' if undefined - ], - storeConsent: () => [...cacheKeys.users.all, "storeConsent"], + all: ["users"] as const, + profile: () => [...cacheKeys.users.all, "profile"] as const, + preferences: () => [...cacheKeys.users.all, "preferences"] as const, + storeConsent: () => [...cacheKeys.users.all, "storeConsent"] as const, + // Pass params object for recent data + recentData: (params: GetRecentUserDataParams = {}) => + [ + ...cacheKeys.users.all, + "recentData", + // 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 ?? "", + }, + ] as const, + // Pass dates for spending analytics + spendingAnalytics: (startDate?: string, endDate?: string) => + [ + ...cacheKeys.users.all, + "spendingAnalytics", + { startDate: startDate ?? "all", endDate: endDate ?? "all" }, // Use 'all' if undefined + ] as const, }, stores: { - all: ["stores"], - profile: () => [...cacheKeys.stores.all, "profile"], - apiKeys: () => [...cacheKeys.stores.all, "apiKeys"], - apiKeyUsage: (keyId: string) => [ - ...cacheKeys.stores.apiKeys(), - keyId, - "usage", - ], - lookup: (ids: string[]) => [...cacheKeys.stores.all, "lookup", ids], + all: ["stores"] as const, + profile: () => [...cacheKeys.stores.all, "profile"] as const, + apiKeys: () => [...cacheKeys.stores.all, "apiKeys"] as const, + // Update apiKeyUsage to accept an object with keyId and optional dates + apiKeyUsage: ( + params: { keyId: string } & GetApiKeyUsagePayload, // <-- Accept object + ) => + [ + ...cacheKeys.stores.all, + "apiKeyUsage", + // Create a stable object key + { + keyId: params.keyId, + startDate: params.startDate ?? "all", + endDate: params.endDate ?? "all", + }, + ] as const, + // Add key for API usage log, accepting filter params + apiUsageLog: (params: GetApiUsageLogParams) => + [ + // <-- New Key + ...cacheKeys.stores.all, + "apiUsageLog", + params, + ] as const, + lookup: (ids: string[]) => + [...cacheKeys.stores.all, "lookup", ids] as const, + search: (query: string) => + [...cacheKeys.stores.all, "search", query] as const, }, system: { - health: () => ["system", "health"], - ping: () => ["system", "ping"], - taxonomy: () => ["system", "taxonomy"], + all: ["system"] as const, + health: () => [...cacheKeys.system.all, "health"] as const, + ping: () => [...cacheKeys.system.all, "ping"] as const, + taxonomy: () => [...cacheKeys.system.all, "taxonomy"] as const, }, }; diff --git a/web/src/main.tsx b/web/src/main.tsx index aea723f..db9dc65 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,16 +9,16 @@ import { Layout } from "./layout/Layout"; import HomePage from "./pages/static/HomePage"; import AboutPage from "./pages/static/AboutPage"; import ApiDocsPage from "./pages/static/ApiDocsPage"; -import UserDashboard from "./pages/UserDashboard"; -import StoreDashboard from "./pages/StoreDashboard"; +import UserDashboard from "./pages/UserDashboard/UserDashboard"; +import StoreDashboard from "./pages/StoreDashboard/StoreDashboard"; import { AuthProviderWrapper } from "./context/AuthContext"; import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; -import UserProfilePage from "./pages/UserProfilePage"; -import StoreProfilePage from "./pages/StoreProfilePage"; -import UserPreferencesPage from "./pages/UserPreferencesPage"; -import UserDataSharingPage from "./pages/UserDataSharingPage"; -import UserAnalyticsPage from "./pages/UserAnalyticsPage"; +import UserProfilePage from "./pages/UserDashboard/UserProfilePage"; +import StoreProfilePage from "./pages/StoreDashboard/StoreProfilePage"; +import UserPreferencesPage from "./pages/UserDashboard/UserPreferencesPage"; +import UserDataSharingPage from "./pages/UserDashboard/UserDataSharingPage"; +import UserAnalyticsPage from "./pages/UserDashboard/UserAnalyticsPage"; const router = createBrowserRouter([ { diff --git a/web/src/pages/StoreDashboard.tsx b/web/src/pages/StoreDashboard.tsx deleted file mode 100644 index 55eb5cd..0000000 --- a/web/src/pages/StoreDashboard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Card } from "flowbite-react"; -import { useStoreProfile } from "../api/hooks/useStoreHooks"; // Import the hook -import LoadingSpinner from "../components/common/LoadingSpinner"; // Import spinner -import ErrorDisplay from "../components/common/ErrorDisplay"; // Import error display - -export default function StoreDashboard() { - // Fetch store profile data - const { data: storeProfile, isLoading, error } = useStoreProfile(); - - // Handle Loading State - if (isLoading) { - return ; - } - - // Handle Error State - if (error) { - return ( - - ); - } - - // Render content when data is available - return ( -
- {/* Add dark mode text color */} -

- Store Dashboard - API Management -

- - {/* Add dark mode text color */} -

- Welcome, {storeProfile?.name || "Store Owner"}! -

- {/* Add dark mode text color */} -

- Manage your API keys, view usage statistics, and access billing - information here. (Content coming soon!) -

- {/* Add API Key Management, Analytics, and Billing sections later */} - {/* Example: Displaying fetched data */} - {/*
{JSON.stringify(storeProfile, null, 2)}
*/} -
-
- ); -} diff --git a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx new file mode 100644 index 0000000..cf7847a --- /dev/null +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -0,0 +1,448 @@ +import { useState, useEffect } from "react"; +// Removed unused react-hook-form imports +import { + Button, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TableHeadCell, + Spinner, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + TextInput, + Label, + Badge, + Toast, + ToastToggle, + Alert, + Tooltip, +} from "flowbite-react"; // Consolidated imports +import { + HiPlus, + HiTrash, + HiClipboardCopy, + HiCheck, + HiX, + HiExclamation, + HiInformationCircle, + HiOutlineCheck, +} from "react-icons/hi"; +import { + useApiKeys, + useCreateApiKey, + useRevokeApiKey, +} from "../../api/hooks/useStoreHooks"; +import { ApiKey } from "../../api/types/data-contracts"; // Removed unused ApiKeyCreate + +// Define a type for the response when creating a key, which includes the raw key +interface GeneratedApiKeyResponse extends ApiKey { + apiKey: string; // The raw API key string, only returned on creation +} + +// Helper function to format date (can be moved to a utils file later) +const formatDate = (dateString: string | Date | undefined) => { + // Add null check for dateString + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export function ApiKeyManagement() { + // --- Data Fetching --- + const { + data: apiKeysData, + isLoading: keysLoading, + error: keysError, + } = useApiKeys(); + + // --- Mutations --- + const { + mutate: createApiKey, + isPending: isCreatingKey, + error: createKeyError, + reset: resetCreateKeyMutation, + } = useCreateApiKey(); + const { + mutate: revokeApiKey, + isPending: isRevokingKey, + error: revokeKeyError, + reset: resetRevokeKeyMutation, + } = useRevokeApiKey(); + + // --- State --- + const [showGenerateModal, setShowGenerateModal] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + // Use the specific type for the generated key response + const [generatedApiKey, setGeneratedApiKey] = + useState(null); + const [copied, setCopied] = useState(false); + + const [showRevokeModal, setShowRevokeModal] = useState(false); + const [keyToRevoke, setKeyToRevoke] = useState(null); + + // --- Local Toast State (Consider moving to a global context later) --- + const [showSuccessToast, setShowSuccessToast] = useState(false); + const [showErrorToast, setShowErrorToast] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + + // --- Effects for Toasts --- + useEffect(() => { + if (createKeyError) { + // Error is shown inside the modal + } + }, [createKeyError]); + + useEffect(() => { + if (revokeKeyError) { + // Error is shown inside the modal + } + }, [revokeKeyError]); + + // --- Handlers --- + const handleGenerateSubmit = () => { + createApiKey( + { name: newKeyName || undefined }, + { + onSuccess: (data) => { + // Cast the received data to the expected response type + const generatedData = data as GeneratedApiKeyResponse; + setGeneratedApiKey(generatedData); // Store the full response including the raw key + setNewKeyName(""); // Clear input field + // Don't show toast immediately, modal shows the key + }, + // onError handled by modal display + }, + ); + }; + + const handleRevokeConfirm = () => { + if (keyToRevoke?.keyId) { + revokeApiKey(keyToRevoke.keyId, { + onSuccess: () => { + setShowRevokeModal(false); + setKeyToRevoke(null); // Reset key to revoke + setShowSuccessToast(true); // Show success toast after modal closes + setToastMessage("API Key revoked successfully!"); + resetRevokeKeyMutation(); // Reset mutation state + const timer = setTimeout(() => setShowSuccessToast(false), 5000); + return () => clearTimeout(timer); + }, + // onError handled by modal display + }); + } + }; + + const openRevokeModal = (key: ApiKey) => { + setKeyToRevoke(key); + setShowRevokeModal(true); + resetRevokeKeyMutation(); // Reset error when opening modal + }; + + const closeGenerateModal = () => { + setShowGenerateModal(false); + setGeneratedApiKey(null); // Clear generated key state + setCopied(false); // Reset copied state + setNewKeyName(""); // Clear name input + resetCreateKeyMutation(); // Reset mutation state including error + }; + + const copyToClipboard = () => { + // The actual API key is only available in the `generatedApiKey.apiKey` field + // right after creation. + if (generatedApiKey?.apiKey) { + navigator.clipboard.writeText(generatedApiKey.apiKey).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); // Show "Copied" for 2 seconds + }); + } + }; + + return ( +
+ {/* Success Toast */} + {showSuccessToast && ( + +
+ +
+
{toastMessage}
+ setShowSuccessToast(false)} /> +
+ )} + {/* Error Toast (Only for general fetch errors now) */} + {showErrorToast && ( + +
+ +
+
{toastMessage}
+ setShowErrorToast(false)} /> +
+ )} +
+ +
+ {keysLoading ? ( +
+ +
+ ) : keysError ? ( + + Failed to load API keys: {keysError.message} + + ) : !apiKeysData || apiKeysData.length === 0 ? ( +

+ No API keys generated yet. +

+ ) : ( +
+ + + Name + Prefix + Status + Created At + Actions + + + {apiKeysData.map((key) => ( + + + {key.name || Unnamed Key} + + + {key.prefix}... + + + + {key.status} + + + {formatDate(key.createdAt)} + + {key.status === "active" ? ( + + ) : ( + + Revoked + + )} + + + ))} + +
+
+ )} + {/* Generate Key Modal */} + + + {generatedApiKey ? "API Key Generated" : "Generate New API Key"} + + + {generatedApiKey ? ( + // Display generated key info +
+ + Your new API key has been generated. Please copy it now. You + won't be able to see it again! + +
+

+ Key Name:{" "} + + {generatedApiKey.name || ( + Unnamed Key + )} + +

+

+ Key Prefix:{" "} + + {generatedApiKey.prefix}... + +

+
+
+ + + + +
+
+ ) : ( +
{ + e.preventDefault(); + handleGenerateSubmit(); + }} + className="space-y-4" + > +
+ + setNewKeyName(e.target.value)} + disabled={isCreatingKey} + /> +
+ {createKeyError && ( // Show error inside modal before key is generated + + {createKeyError.message || "Failed to generate key."} + + )} +
+ )} +
+ + {generatedApiKey ? ( + // Only show Close button after generation + + ) : ( + <> + + + + )} + +
+ {/* Revoke Key Confirmation Modal */} + !isRevokingKey && setShowRevokeModal(false)} // Prevent closing while revoking + popup + > + + +
+ +

+ Are you sure you want to revoke this API key? +

+ {keyToRevoke && ( +

+ Name:{" "} + + {keyToRevoke.name || ( + Unnamed Key + )} + +
+ Prefix:{" "} + {keyToRevoke.prefix}... +

+ )} +

+ This key will immediately stop working and cannot be reactivated. +

+
+ + +
+ {revokeKeyError && ( // Show error inside modal during revoke attempt + + {revokeKeyError.message || "Failed to revoke key."} + + )} +
+
+
+
+ ); +} diff --git a/web/src/pages/StoreDashboard/ApiUsageDashboard.tsx b/web/src/pages/StoreDashboard/ApiUsageDashboard.tsx new file mode 100644 index 0000000..c512178 --- /dev/null +++ b/web/src/pages/StoreDashboard/ApiUsageDashboard.tsx @@ -0,0 +1,365 @@ +import { useState, useMemo } from "react"; +import { + Card, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TableHeadCell, + Spinner, + Alert, + Select, + Datepicker, + Pagination, + Label, + Badge, +} from "flowbite-react"; +import { HiInformationCircle, HiFilter, HiCalendar } from "react-icons/hi"; +import { + useApiKeys, // Still needed for the filter dropdown + useApiKeyUsage, + useApiUsageLog, +} from "../../api/hooks/useStoreHooks"; +import { GetApiUsageLogParams } from "../../api/types/data-contracts"; +// Import Recharts components +import { + ResponsiveContainer, + LineChart, + Line, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from "recharts"; + +// Helper function to format date (can be moved to a utils file later) +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", + }); +}; + +// Helper function to format date for API (YYYY-MM-DD) +const formatDateForApi = ( + date: Date | null | undefined, +): string | undefined => { + if (!date) return undefined; + return date.toISOString().split("T")[0]; +}; + +// Colors for charts +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82ca9d", +]; + +export function ApiUsageDashboard() { + // --- State for API Usage Tab --- + const [usageFilters, setUsageFilters] = useState({ + keyId: undefined, + startDate: undefined, + endDate: undefined, + page: 1, + limit: 10, + }); + + // --- Data Fetching --- + // Fetch API keys for the filter dropdown + const { data: apiKeysData, isLoading: keysLoading } = useApiKeys(); + + // Fetch usage stats + const { + data: usageStats, + isLoading: usageStatsLoading, + error: usageStatsError, + } = useApiKeyUsage(usageFilters.keyId || "all", { + startDate: usageFilters.startDate, + endDate: usageFilters.endDate, + }); + + // Fetch detailed logs + const { + data: usageLogData, + isLoading: usageLogLoading, + error: usageLogError, + isPlaceholderData: isLogPlaceholder, + } = useApiUsageLog(usageFilters); + + // --- Memos --- + const apiKeyOptions = useMemo(() => { + const options = [{ value: "", label: "All Keys" }]; + if (apiKeysData) { + apiKeysData.forEach((key) => { + options.push({ + value: key.keyId || "", + label: `${key.name} (${key.prefix}...${key.status === "revoked" ? " [Revoked]" : ""})`, + }); + }); + } + return options; + }, [apiKeysData]); + + // --- Handlers --- + const handleFilterChange = ( + field: keyof GetApiUsageLogParams, + value: string | number | Date | null | undefined, + ) => { + let apiValue: string | number | undefined; + + if (field === "startDate" || field === "endDate") { + apiValue = formatDateForApi(value as Date | null); + } else if (field === "keyId" && value === "") { + apiValue = undefined; + } else { + apiValue = value as string | number | undefined; + } + + setUsageFilters((prev) => ({ + ...prev, + [field]: apiValue, + page: field !== "page" ? 1 : (apiValue as number), + })); + }; + + const onPageChange = (page: number) => { + handleFilterChange("page", page); + }; + + return ( +
+ {/* Filter Controls */} + +

+ Filters +

+
+
+ + +
+
+ + + handleFilterChange("startDate", date) + } + maxDate={ + usageFilters.endDate + ? new Date(usageFilters.endDate) + : undefined + } + /> +
+
+ + + handleFilterChange("endDate", date) + } + minDate={ + usageFilters.startDate + ? new Date(usageFilters.startDate) + : undefined + } + /> +
+
+
+ + {/* Usage Statistics Section */} + +

+ Usage Statistics +

+ {usageStatsLoading ? ( +
+ +
+ ) : usageStatsError ? ( + + Failed to load usage statistics: {usageStatsError.message} + + ) : !usageStats || usageStats.totalRequests === 0 ? ( +

+ No usage data found for the selected filters. +

+ ) : ( +
+

+ Total Requests:{" "} + + {usageStats.totalRequests} + +

+ {/* Charts */} +
+ {/* Daily Usage Chart */} + {usageStats.dailyUsage && usageStats.dailyUsage.length > 0 && ( +
+
+ Requests per Day +
+ + + + + + + + + + +
+ )} + {/* Method Breakdown Chart */} + {usageStats.methodBreakdown && + Object.keys(usageStats.methodBreakdown).length > 0 && ( +
+
+ Requests by Method +
+ + + ({ name, value }), + )} + cx="50%" + cy="50%" + labelLine={false} + outerRadius={80} + fill="#8884d8" + dataKey="value" + label={({ name, percent }) => + `${name} ${(percent * 100).toFixed(0)}%` + } + > + {Object.entries(usageStats.methodBreakdown).map( + (entry, index) => ( + + ), + )} + + + + +
+ )} +
+
+ )} +
+ + {/* Detailed Log Section */} + +

+ Detailed Request Log +

+ {usageLogLoading && !isLogPlaceholder ? ( +
+ +
+ ) : usageLogError ? ( + + Failed to load detailed logs: {usageLogError.message} + + ) : !usageLogData?.logs || usageLogData.logs.length === 0 ? ( +

+ No detailed logs found for the selected filters. +

+ ) : ( + <> +
+ + + Timestamp + Method + Endpoint + Key Prefix + User Agent + + + {usageLogData.logs.map((log) => ( + + {formatDate(log.timestamp)} + + {log.method} + + + {log.endpoint} + + + {log.apiKeyPrefix}... + + + {log.userAgent} + + + ))} + +
+
+ {/* Pagination Controls */} + {usageLogData.pagination && + usageLogData.pagination.totalPages && + usageLogData.pagination.totalPages > 1 && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/web/src/pages/StoreDashboard/StoreDashboard.tsx b/web/src/pages/StoreDashboard/StoreDashboard.tsx new file mode 100644 index 0000000..c524a61 --- /dev/null +++ b/web/src/pages/StoreDashboard/StoreDashboard.tsx @@ -0,0 +1,130 @@ +import { useMemo } from "react"; // Removed useState, useEffect +import { + Card, + Tabs, + TabItem, + Button, + Spinner, // Keep Spinner for initial load +} from "flowbite-react"; +import { + HiOutlineKey, + HiOutlineInformationCircle, + HiDocumentText, + HiChartPie, +} from "react-icons/hi"; +import { Link } from "react-router"; +import { + useStoreProfile, + useApiKeys, // Still needed for Overview count +} from "../../api/hooks/useStoreHooks"; +import LoadingSpinner from "../../components/common/LoadingSpinner"; +import ErrorDisplay from "../../components/common/ErrorDisplay"; +// Import the new components +import { ApiKeyManagement } from "./ApiKeyManagement"; +import { ApiUsageDashboard } from "./ApiUsageDashboard"; + +export default function StoreDashboard() { + // --- Data Fetching (Only for Overview) --- + const { + data: storeProfile, + isLoading: profileLoading, + error: profileError, + } = useStoreProfile(); + // Fetch keys here ONLY if needed for the overview count. + // If ApiKeyManagement fetches its own keys, this can be removed + // if the count isn't strictly needed on the overview tab initially. + const { + data: apiKeysData, + isLoading: keysLoading, // Used for combined loading state + error: keysError, // Used for combined error state + } = useApiKeys(); + + // --- Memos (Only for Overview) --- + const activeKeyCount = useMemo(() => { + return apiKeysData?.filter((key) => key.status === "active").length || 0; + }, [apiKeysData]); + + // --- Removed State and Handlers for Keys/Usage --- + + // --- Loading and Error States (Combined for initial load) --- + const isLoading = profileLoading || keysLoading; + const error = profileError || keysError; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( + // Removed relative positioning, toasts are now inside child components +
+

+ Store Dashboard +

+ + + {/* Overview Tab */} + +
+ +

+ Welcome, {storeProfile?.name || "Store Owner"}! +

+

+ Manage your API keys and view usage statistics here. +

+
+ +

+ API Key Summary +

+ {keysLoading ? ( // Show spinner if keys are still loading for count + + ) : keysError ? ( + Error loading count + ) : ( +

+ You currently have{" "} + + {activeKeyCount} + {" "} + active API key(s). +

+ )} + + + +
+ {/* Add more overview widgets later */} +
+
+ + {/* API Keys Tab - Render the new component */} + + + + + {/* API Usage Tab - Render the new component */} + + + + + {/* Add more tabs later (Analytics, etc.) */} +
+ + {/* Removed Modals - they are now inside ApiKeyManagement */} +
+ ); +} diff --git a/web/src/pages/StoreProfilePage.tsx b/web/src/pages/StoreDashboard/StoreProfilePage.tsx similarity index 97% rename from web/src/pages/StoreProfilePage.tsx rename to web/src/pages/StoreDashboard/StoreProfilePage.tsx index f67a0fc..0ca0142 100644 --- a/web/src/pages/StoreProfilePage.tsx +++ b/web/src/pages/StoreDashboard/StoreProfilePage.tsx @@ -28,11 +28,11 @@ import { useStoreProfile, useUpdateStoreProfile, useDeleteStoreProfile, // Import delete hook -} from "../api/hooks/useStoreHooks"; -import { StoreUpdate } from "../api/types/data-contracts"; -import LoadingSpinner from "../components/common/LoadingSpinner"; -import ErrorDisplay from "../components/common/ErrorDisplay"; -import { useAuth } from "../hooks/useAuth"; // Import useAuth for logout +} from "../../api/hooks/useStoreHooks"; +import { StoreUpdate } from "../../api/types/data-contracts"; +import LoadingSpinner from "../../components/common/LoadingSpinner"; +import ErrorDisplay from "../../components/common/ErrorDisplay"; +import { useAuth } from "../../hooks/useAuth"; // Import useAuth for logout // Define the form data structure based on StoreUpdate schema type StoreProfileFormData = { diff --git a/web/src/pages/UserAnalyticsPage.tsx b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx similarity index 98% rename from web/src/pages/UserAnalyticsPage.tsx rename to web/src/pages/UserDashboard/UserAnalyticsPage.tsx index 9775b5b..5e3234a 100644 --- a/web/src/pages/UserAnalyticsPage.tsx +++ b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx @@ -33,10 +33,10 @@ import { 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"; +} 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, @@ -45,7 +45,7 @@ import { 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"; +} from "../../api/types/data-contracts"; // --- Helper Functions (Keep existing) --- const formatDate = (dateString: string | Date | undefined) => { diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx similarity index 98% rename from web/src/pages/UserDashboard.tsx rename to web/src/pages/UserDashboard/UserDashboard.tsx index ac5f255..a92411c 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -49,17 +49,17 @@ import { useSpendingAnalytics, useStoreConsentLists, 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"; +} 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 { RecentUserDataEntry, StoreBasicInfo, MonthlySpendingItem, -} from "../api/types/data-contracts"; -import { InterestFormModal } from "../components/auth/InterestFormModal"; +} from "../../api/types/data-contracts"; +import { InterestFormModal } from "../../components/auth/InterestFormModal"; // Helper function to format date (keep existing) const formatDate = (dateString: string | Date | undefined) => { diff --git a/web/src/pages/UserDataSharingPage.tsx b/web/src/pages/UserDashboard/UserDataSharingPage.tsx similarity index 96% rename from web/src/pages/UserDataSharingPage.tsx rename to web/src/pages/UserDashboard/UserDataSharingPage.tsx index 3053439..3d26345 100644 --- a/web/src/pages/UserDataSharingPage.tsx +++ b/web/src/pages/UserDashboard/UserDataSharingPage.tsx @@ -13,12 +13,15 @@ import { useStoreConsentLists, useOptInToStore, useOptOutFromStore, -} from "../api/hooks/useUserHooks"; +} 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"; +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 { diff --git a/web/src/pages/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx similarity index 98% rename from web/src/pages/UserPreferencesPage.tsx rename to web/src/pages/UserDashboard/UserPreferencesPage.tsx index ba44a41..000251b 100644 --- a/web/src/pages/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -34,15 +34,15 @@ import { 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"; +} 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"; +} from "../../api/types/data-contracts"; // --- Form Types --- type DemographicsFormData = Pick< diff --git a/web/src/pages/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx similarity index 98% rename from web/src/pages/UserProfilePage.tsx rename to web/src/pages/UserDashboard/UserProfilePage.tsx index f8cf574..c3a9be3 100644 --- a/web/src/pages/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -29,11 +29,11 @@ import { useUserProfile, useUpdateUserProfile, useDeleteUserProfile, // Import delete hook -} from "../api/hooks/useUserHooks"; -import { UserUpdate } from "../api/types/data-contracts"; -import LoadingSpinner from "../components/common/LoadingSpinner"; -import ErrorDisplay from "../components/common/ErrorDisplay"; -import { useAuth } from "../hooks/useAuth"; // Import useAuth for logout +} from "../../api/hooks/useUserHooks"; +import { UserUpdate } from "../../api/types/data-contracts"; +import LoadingSpinner from "../../components/common/LoadingSpinner"; +import ErrorDisplay from "../../components/common/ErrorDisplay"; +import { useAuth } from "../../hooks/useAuth"; // Import useAuth for logout // Define the form data structure based on UserUpdate schema type UserProfileFormData = { From c2cb0a0e6555ab6ad0ea48cce4e5eb01f027775c Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 07:30:22 +0530 Subject: [PATCH 2/3] feat: Integrate LoadingSpinner and ErrorDisplay components in ApiKeyManagement for improved loading and error handling --- .../pages/StoreDashboard/ApiKeyManagement.tsx | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx index cf7847a..80240b2 100644 --- a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -37,6 +37,8 @@ import { useRevokeApiKey, } from "../../api/hooks/useStoreHooks"; import { ApiKey } from "../../api/types/data-contracts"; // Removed unused ApiKeyCreate +import LoadingSpinner from "../../components/common/LoadingSpinner"; // Import LoadingSpinner +import ErrorDisplay from "../../components/common/ErrorDisplay"; // Import ErrorDisplay // Define a type for the response when creating a key, which includes the raw key interface GeneratedApiKeyResponse extends ApiKey { @@ -195,13 +197,16 @@ export function ApiKeyManagement() { {keysLoading ? ( -
- -
+ // Use LoadingSpinner component + ) : keysError ? ( - - Failed to load API keys: {keysError.message} - + // Use ErrorDisplay component + ) : !apiKeysData || apiKeysData.length === 0 ? (

No API keys generated yet. @@ -222,12 +227,15 @@ export function ApiKeyManagement() { key={key.keyId} className="bg-white dark:border-gray-700 dark:bg-gray-800" > + {/* Key Name */} {key.name || Unnamed Key} + {/* Key Prefix */} {key.prefix}... + {/* Key Status */} + {/* Created At */} {formatDate(key.createdAt)} + {/* Actions */} {key.status === "active" ? ( ) : ( @@ -316,6 +331,7 @@ export function ApiKeyManagement() { ) : ( + // Form to generate key

{ @@ -348,12 +364,12 @@ export function ApiKeyManagement() { // Only show Close button after generation ) : ( + // Show Generate/Cancel buttons before generation <> - {revokeKeyError && ( // Show error inside modal during revoke attempt + {/* Show error inside modal during revoke attempt */} + {revokeKeyError && ( Date: Thu, 24 Apr 2025 16:24:08 +0530 Subject: [PATCH 3/3] feat: Refactor UserDashboard to implement tab navigation and streamline user profile management --- web/src/main.tsx | 22 - web/src/pages/UserDashboard/UserDashboard.tsx | 839 ++++++++++-------- 2 files changed, 445 insertions(+), 416 deletions(-) diff --git a/web/src/main.tsx b/web/src/main.tsx index db9dc65..ebfc7f7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -14,11 +14,7 @@ import StoreDashboard from "./pages/StoreDashboard/StoreDashboard"; import { AuthProviderWrapper } from "./context/AuthContext"; import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; -import UserProfilePage from "./pages/UserDashboard/UserProfilePage"; import StoreProfilePage from "./pages/StoreDashboard/StoreProfilePage"; -import UserPreferencesPage from "./pages/UserDashboard/UserPreferencesPage"; -import UserDataSharingPage from "./pages/UserDashboard/UserDataSharingPage"; -import UserAnalyticsPage from "./pages/UserDashboard/UserAnalyticsPage"; const router = createBrowserRouter([ { @@ -49,24 +45,6 @@ const router = createBrowserRouter([ path: "dashboard/user", element: , }, - // Add User Profile Route - { - path: "profile/user", - element: , - }, - // --- Add New User Dashboard Sub-routes --- - { - path: "profile/user/preferences", // Route for preferences - element: , - }, - { - path: "profile/user/sharing", // Route for data sharing - element: , - }, - { - path: "profile/user/analytics", // Route for analytics - element: , - }, ], }, // --- Protected Store Routes --- diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index a92411c..2a3c7ce 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState, useEffect } from "react"; -import { Link } from "react-router"; // <-- Corrected import +import React, { useState, useMemo, useEffect } from "react"; // <-- Import useState, useEffect import { Card, Alert, @@ -13,7 +12,9 @@ import { ListItem, Datepicker, Button, - Spinner, // <-- Import Spinner for inline loading + Spinner, + Tabs, + TabItem, } from "flowbite-react"; import { HiArrowRight, @@ -28,21 +29,23 @@ import { HiOutlineCake, HiOutlineCash, HiOutlineUserCircle, + HiOutlineViewGrid, + HiOutlineSparkles, + HiOutlineChartPie, } from "react-icons/hi"; import { ResponsiveContainer, + PieChart, + Pie, + Cell, + Tooltip as RechartsTooltip, // Alias Tooltip to avoid conflict + Legend as RechartsLegend, // Alias Legend LineChart, Line, XAxis, YAxis, CartesianGrid, - Tooltip, - Legend, - Cell, - PieChart, - Pie, } from "recharts"; - import { useUserProfile, useRecentUserData, @@ -55,12 +58,17 @@ import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; import LoadingSpinner from "../../components/common/LoadingSpinner"; import ErrorDisplay from "../../components/common/ErrorDisplay"; import { - RecentUserDataEntry, StoreBasicInfo, MonthlySpendingItem, } from "../../api/types/data-contracts"; import { InterestFormModal } from "../../components/auth/InterestFormModal"; +// --- Import Page Components --- +import UserPreferencesPage from "./UserPreferencesPage"; +import UserDataSharingPage from "./UserDataSharingPage"; +import UserAnalyticsPage from "./UserAnalyticsPage"; +// --- End Import Page Components --- + // Helper function to format date (keep existing) const formatDate = (dateString: string | Date | undefined) => { if (!dateString) return "N/A"; @@ -183,7 +191,7 @@ const DemoInfoCard: React.FC = ({ ) : (

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

)} @@ -192,6 +200,9 @@ const DemoInfoCard: React.FC = ({ // --- End Mini Demographic Card Component --- export default function UserDashboard() { + // --- State for Active Tab --- + const [activeTab, setActiveTab] = useState(0); // 0 = Overview, 1 = Profile, etc. + // --- State for Date Range --- const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); @@ -272,9 +283,7 @@ export default function UserDashboard() { 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; - } + if (!(cat in monthEntry)) monthEntry[cat] = 0; // Ensure all categories exist for each month }); return monthEntry; }); @@ -311,7 +320,6 @@ export default function UserDashboard() { 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); @@ -328,7 +336,7 @@ export default function UserDashboard() { }; // Aggregate scores by top-level category - const aggregatedScores = new Map(); + const aggregatedScores = new Map(); // Use 'value' for PieChart preferencesData.preferences.forEach((pref) => { if (pref.category && pref.score != null) { // Use pref.category @@ -336,43 +344,33 @@ export default function UserDashboard() { if (topLevelCat) { const current = aggregatedScores.get(topLevelCat.id) || { name: topLevelCat.name, - score: 0, + value: 0, // Use 'value' }; - current.score += pref.score; + current.value += pref.score; // Add score to value 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 - }); + // Convert map to array suitable for PieChart + const chartData = Array.from(aggregatedScores.values()); - // 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 - })); - } else { - // Handle case where all scores are 0 or negative (unlikely but possible) - return chartData.map((item) => ({ ...item, value: 0 })); - } + // Optional: Normalize scores to percentages if needed, or just use raw scores + // For PieChart, raw values usually work fine as it calculates percentages internally. + + // Filter out items with zero or negative score if necessary + return chartData.filter((item) => item.value > 0); }, [preferencesData, taxonomyData]); // --- Loading and Error States (Checked AFTER hooks) --- - const isLoading = + const isLoadingInitial = profileLoading || activityLoading || spendingLoading || consentLoading || preferencesLoading || storesLoading || - taxonomyLoading; // <-- Add taxonomy loading + taxonomyLoading; // Combined loading state for initial dashboard view const combinedError = profileError || @@ -381,7 +379,7 @@ export default function UserDashboard() { consentError || preferencesError || storesError || - taxonomyError; // <-- Add taxonomy error + taxonomyError; // Combined error state const [showInterestForm, setShowInterestForm] = useState(false); @@ -396,16 +394,16 @@ export default function UserDashboard() { } }, [preferencesData, preferencesLoading]); - // Adjust initial loading state check if needed - if (isLoading && !profile && !spendingData && !preferencesData) { + // Show main spinner only if loading initial data for the overview + if (isLoadingInitial && activeTab === 0) { return ; } - // Adjust combined error check if needed - if (combinedError && !profile && !spendingData && !preferencesData) { + // Show main error only if error occurred and trying to view overview + if (combinedError && activeTab === 0) { return ( @@ -415,369 +413,422 @@ export default function UserDashboard() { // --- Render Dashboard --- return ( <> -
-

- Welcome back, {profile?.username || "User"}! -

- -
- {/* --- Recent Activity Card --- */} - -
-

- - Recent Activity -

- {activityError ? ( - - Could not load recent activity. - - ) : !recentActivity || recentActivity.length === 0 ? ( -

- No recent activity found. -

- ) : ( -
- - {recentActivity.map( - ( - activity: RecentUserDataEntry, // No change needed here, map will iterate over 3 items max - ) => ( - - - - - {formatDate(activity.timestamp)} - - - {activity.dataType === "purchase" - ? "Purchase" - : "Search"}{" "} - from{" "} - {activity.storeId - ? storeNameMap.get(activity.storeId) || - `Store ID: ${activity.storeId}` - : "Unknown Store"} - - {/* Add more details if needed */} - {/* Details about the activity... */} - - - ), - )} - -
- )} -
- - View All Activity - -
- - {/* --- Spending Overview Card --- */} - - {" "} - {/* Make spending full width on large screens */} -
-
-

- - Spending Overview -

-
- setStartDate(date)} - maxDate={endDate || undefined} - className="w-full" - placeholder="Start Date" - /> - setEndDate(date)} - minDate={startDate || undefined} - className="w-full" - placeholder="End Date" - /> - {(startDate || endDate) && ( - - )} -
+
+ {/* Control Tabs with state */} + setActiveTab(tab)} + > + {/* Overview Tab (Existing Content) */} + + {/* Show loading/error specific to overview if needed, or rely on main checks */} + {profileLoading || + activityLoading || + spendingLoading || + consentLoading || + preferencesLoading || + storesLoading || + taxonomyLoading ? ( +
+
- - {spendingError ? ( - - Could not load spending data for the selected range. - - ) : spendingLoading ? ( -
- -
- ) : !lineChartData || lineChartData.length === 0 ? ( -

- No spending data available - {startDate || endDate ? " for this period" : " yet"}. -

- ) : ( -
- - - - - - formatCurrency(value)} - labelFormatter={formatMonth} - /> - - {categories.map((category, index) => ( - - ))} - - -
- )} -
- - View Detailed Analytics - - - - {/* --- Data Sharing Card --- */} - - {" "} - {/* Ensure flex-col */} -
- {" "} - {/* Content takes available space */} -

- - Data Sharing -

- {/* 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 ? ( -
- {" "} - {/* 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): -

- {/* 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 {consentLists!.optInStores!.length - 5} more - + ) : profileError || + activityError || + spendingError || + consentError || + preferencesError || + storesError || + taxonomyError ? ( +
+ +
+ ) : ( +
+ {/* --- Recent Activity Card --- */} + +
+

+ + Recent Activity +

+ {activityLoading ? ( +
+ +
+ ) : activityError ? ( + + Could not load recent activity. + + ) : !recentActivity || recentActivity.length === 0 ? ( +

+ No recent activity recorded. +

+ ) : ( +
+ + {recentActivity.map((entry) => ( + + + + + {formatDate(entry.timestamp)} + + + {entry.dataType} + {entry.storeId && + ` at ${storeNameMap.get(entry.storeId) || "a store"}`} + + {/* Add more details if needed */} + + + ))} + +
)} - - - )} -
- {/* Link stays at the bottom */} - - Manage Sharing Settings - -
- - {/* --- Preferences & Demographics Card --- */} - {/* Make this card span 2 columns on medium screens and up */} - -
- {/* Main Title */} -

- - Preference & Profile Overview -

- - {/* Grid for Pie Chart and Demographics */} -
- {/* Pie Chart Section */} -
-

- Top Interests -

- {preferencesError || taxonomyError ? ( - - Could not load preference data. - - ) : preferencesLoading || taxonomyLoading ? ( -
- +
+ {/* Update Link to Button to change tab */} + + + + {/* --- Spending Overview Card --- */} + +
+

+ + Spending Overview +

+ {/* Date Filters */} +
+ setStartDate(date)} + maxDate={endDate || undefined} + placeholder="Start Date" + /> + setEndDate(date)} + minDate={startDate || undefined} + placeholder="End Date" + /> + {(startDate || endDate) && ( + + )}
- ) : !preferencesPieChartData || - preferencesPieChartData.length === 0 ? ( -

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

- ) : ( -
- - - + Could not load spending data. + + ) : spendingLoading ? ( +
+ +
+ ) : !lineChartData || lineChartData.length === 0 ? ( +

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

+ ) : ( +
+ + - {preferencesPieChartData.map((_entry, index) => ( - + + + + formatCurrency(value) + } + labelFormatter={formatMonth} + /> + + {categories.map((category, 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", - }} - /> - - - -
- )} - +
+
+ )} +
+ {/* Update Link to Button to change tab */} +
- - {/* Demographics Section */} -
-

- About You -

- {profileError ? ( - - Could not load profile information. - - ) : ( -
- - - - + + + + {/* --- Data Sharing Card --- */} + +
+

+ + Data Sharing +

+ {consentLoading || storesLoading ? ( +
+ +
+ ) : consentError || storesError ? ( + + Could not load sharing settings. + + ) : (consentLists?.optInStores?.length ?? 0) === 0 ? ( +

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

+ ) : ( + <> +

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

+ + {(consentLists?.optInStores || []) + .slice(0, 3) // Show first 3 + .map((storeId) => ( + + {storeNameMap.get(storeId) || + `Store ID: ${storeId}`} + + ))} + {(consentLists?.optInStores?.length ?? 0) > 3 && ( + + ...and more + + )} + + + )} +
+ {/* Update Link to Button to change tab */} + +
+ + {/* --- Preferences & Demographics Card --- */} + +
+

+ + Preference & Profile Overview +

+
+ {/* Pie Chart Section */} +
+

+ Top Interests +

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

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

+ ) : ( +
+ + + + {preferencesPieChartData.map( + (_entry, index) => ( + + ), + )} + + + `${Math.round(value * 100)}% Interest` + } + /> + + + +
+ )} + {/* Update Link to Button to change tab */} + +
+ + {/* Demographics Section */} +
+

+ About You +

+ {profileError ? ( + + Could not load profile information. + + ) : profileLoading ? ( +
+ Loading profile... +
+ ) : ( +
+ + + + +
+ )} +
- )} -
+
+
-
- -
+ )} + + + {/* Preferences Tab */} + + {/* Render UserPreferencesPage directly */} + + + + {/* Sharing Tab */} + + {/* Render UserDataSharingPage directly */} + + + + {/* Analytics Tab */} + + {/* Render UserAnalyticsPage directly */} + + +
+ {/* Interest Form Modal (Keep outside tabs) */} setShowInterestForm(false)}