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..ebfc7f7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,16 +9,12 @@ 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 StoreProfilePage from "./pages/StoreDashboard/StoreProfilePage"; 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/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..80240b2 --- /dev/null +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -0,0 +1,465 @@ +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 +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 { + 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 ? ( + // Use LoadingSpinner component + + ) : keysError ? ( + // Use ErrorDisplay component + + ) : !apiKeysData || apiKeysData.length === 0 ? ( +

+ No API keys generated yet. +

+ ) : ( +
+ + + Name + Prefix + Status + Created At + Actions + + + {apiKeysData.map((key) => ( + + {/* Key Name */} + + {key.name || Unnamed Key} + + {/* Key Prefix */} + + {key.prefix}... + + {/* Key Status */} + + + {key.status} + + + {/* Created At */} + {formatDate(key.createdAt)} + {/* Actions */} + + {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}... + +

+
+
+ + + + +
+
+ ) : ( + // Form to generate key +
{ + 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 + + ) : ( + // Show Generate/Cancel buttons before generation + <> + + + + )} + +
+ {/* Revoke Key Confirmation Modal */} + !isRevokingKey && setShowRevokeModal(false)} // Prevent closing while revoking + popup + > + + +
+ +

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

+ {/* Display key details */} + {keyToRevoke && ( +

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

+ )} +

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

+
+ + +
+ {/* Show error inside modal during revoke attempt */} + {revokeKeyError && ( + + {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/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx deleted file mode 100644 index ac5f255..0000000 --- a/web/src/pages/UserDashboard.tsx +++ /dev/null @@ -1,787 +0,0 @@ -import { useMemo, useState, useEffect } from "react"; -import { Link } from "react-router"; // <-- Corrected import -import { - Card, - Alert, - List, - Timeline, - TimelineItem, - TimelinePoint, - TimelineContent, - TimelineTime, - TimelineTitle, - ListItem, - Datepicker, - Button, - Spinner, // <-- Import Spinner for inline loading -} from "flowbite-react"; -import { - HiArrowRight, - HiClock, - HiInformationCircle, - HiOutlineNewspaper, - HiOutlineCurrencyDollar, - HiOutlineShare, - HiOutlineAdjustments, - HiCalendar, - HiOutlineGlobeAlt, - HiOutlineCake, - HiOutlineCash, - HiOutlineUserCircle, -} from "react-icons/hi"; -import { - ResponsiveContainer, - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - Cell, - PieChart, - Pie, -} from "recharts"; - -import { - useUserProfile, - useRecentUserData, - 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"; -import { - RecentUserDataEntry, - StoreBasicInfo, - MonthlySpendingItem, -} from "../api/types/data-contracts"; -import { InterestFormModal } from "../components/auth/InterestFormModal"; - -// Helper function to format date (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", - }); -}; - -// Define colors for the lines/pie slices (keep existing) -const LINE_COLORS = [ - "#0088FE", - "#00C49F", - "#FFBB28", - "#FF8042", - "#8884D8", - "#82CA9D", - "#FF5733", - "#C70039", - "#900C3F", - "#581845", -]; - -// Helper to format currency (keep existing) -const formatCurrency = (value: number) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", // Adjust currency as needed - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); -}; - -// Helper to format YYYY-MM date string for display (keep existing) -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; // Fallback - } -}; - -// 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); - const [endDate, setEndDate] = useState(null); - - // --- Fetch Data (Hooks called unconditionally at the top) --- - const { - data: profile, - isLoading: profileLoading, - error: profileError, - } = useUserProfile(); - const { - data: recentActivity, - isLoading: activityLoading, - error: activityError, - } = useRecentUserData({ limit: 3 }); - const { - data: spendingData, - isLoading: spendingLoading, - error: spendingError, - } = useSpendingAnalytics({ - startDate: formatDateToISO(startDate), - endDate: formatDateToISO(endDate), - }); - const { - data: consentLists, - isLoading: consentLoading, - error: consentError, - } = useStoreConsentLists(); - const { - data: preferencesData, - 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( - () => consentLists?.optInStores || [], - [consentLists], - ); - - const { - data: storeDetails, - isLoading: storesLoading, - error: storesError, - } = useLookupStores(optInStoreIds); - - const storeNameMap = useMemo(() => { - const map = new Map(); - storeDetails?.forEach((store: StoreBasicInfo) => { - map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); - }); - return map; - }, [storeDetails]); - - // --- Data Transformation for Spending Line Chart --- - 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]); - - // --- 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 - })); - } 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 = - profileLoading || - activityLoading || - spendingLoading || - consentLoading || - preferencesLoading || - storesLoading || - taxonomyLoading; // <-- Add taxonomy loading - - const combinedError = - profileError || - activityError || - spendingError || - consentError || - preferencesError || - storesError || - taxonomyError; // <-- Add taxonomy error - - const [showInterestForm, setShowInterestForm] = useState(false); - - useEffect(() => { - // If preferences loaded and are empty, show the form - if ( - !preferencesLoading && - preferencesData && - (!preferencesData.preferences || preferencesData.preferences.length === 0) - ) { - setShowInterestForm(true); - } - }, [preferencesData, preferencesLoading]); - - // Adjust initial loading state check if needed - if (isLoading && !profile && !spendingData && !preferencesData) { - return ; - } - - // Adjust combined error check if needed - if (combinedError && !profile && !spendingData && !preferencesData) { - return ( - - ); - } - - // --- 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) && ( - - )} -
-
- - {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 - - )} - - - )} -
- {/* 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 ? ( -
- -
- ) : !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{" "} - - -
- - {/* Demographics Section */} -
-

- About You -

- {profileError ? ( - - Could not load profile information. - - ) : ( -
- - - - -
- )} -
-
-
-
-
-
- - setShowInterestForm(false)} - /> - - ); -} 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/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx new file mode 100644 index 0000000..2a3c7ce --- /dev/null +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -0,0 +1,838 @@ +import React, { useState, useMemo, useEffect } from "react"; // <-- Import useState, useEffect +import { + Card, + Alert, + List, + Timeline, + TimelineItem, + TimelinePoint, + TimelineContent, + TimelineTime, + TimelineTitle, + ListItem, + Datepicker, + Button, + Spinner, + Tabs, + TabItem, +} from "flowbite-react"; +import { + HiArrowRight, + HiClock, + HiInformationCircle, + HiOutlineNewspaper, + HiOutlineCurrencyDollar, + HiOutlineShare, + HiOutlineAdjustments, + HiCalendar, + HiOutlineGlobeAlt, + 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, +} from "recharts"; +import { + useUserProfile, + useRecentUserData, + 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"; +import { + 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"; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +// Define colors for the lines/pie slices (keep existing) +const LINE_COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FF5733", + "#C70039", + "#900C3F", + "#581845", +]; + +// Helper to format currency (keep existing) +const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", // Adjust currency as needed + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); +}; + +// Helper to format YYYY-MM date string for display (keep existing) +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; // Fallback + } +}; + +// 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 || "Not set"} +

+ )} +
+
+); +// --- 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); + + // --- Fetch Data (Hooks called unconditionally at the top) --- + const { + data: profile, + isLoading: profileLoading, + error: profileError, + } = useUserProfile(); + const { + data: recentActivity, + isLoading: activityLoading, + error: activityError, + } = useRecentUserData({ limit: 3 }); + const { + data: spendingData, + isLoading: spendingLoading, + error: spendingError, + } = useSpendingAnalytics({ + startDate: formatDateToISO(startDate), + endDate: formatDateToISO(endDate), + }); + const { + data: consentLists, + isLoading: consentLoading, + error: consentError, + } = useStoreConsentLists(); + const { + data: preferencesData, + 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( + () => consentLists?.optInStores || [], + [consentLists], + ); + + const { + data: storeDetails, + isLoading: storesLoading, + error: storesError, + } = useLookupStores(optInStoreIds); + + const storeNameMap = useMemo(() => { + const map = new Map(); + storeDetails?.forEach((store: StoreBasicInfo) => { + map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); + }); + return map; + }, [storeDetails]); + + // --- Data Transformation for Spending Line Chart --- + 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; // Ensure all categories exist for each month + }); + return monthEntry; + }); + + return { + lineChartData: processedData, + categories: Array.from(allCategories).sort(), + }; + }, [spendingData]); + + // --- 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(); // Use 'value' for PieChart + 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, + value: 0, // Use 'value' + }; + current.value += pref.score; // Add score to value + aggregatedScores.set(topLevelCat.id, current); + } + } + }); + + // Convert map to array suitable for PieChart + const chartData = Array.from(aggregatedScores.values()); + + // 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 isLoadingInitial = + profileLoading || + activityLoading || + spendingLoading || + consentLoading || + preferencesLoading || + storesLoading || + taxonomyLoading; // Combined loading state for initial dashboard view + + const combinedError = + profileError || + activityError || + spendingError || + consentError || + preferencesError || + storesError || + taxonomyError; // Combined error state + + const [showInterestForm, setShowInterestForm] = useState(false); + + useEffect(() => { + // If preferences loaded and are empty, show the form + if ( + !preferencesLoading && + preferencesData && + (!preferencesData.preferences || preferencesData.preferences.length === 0) + ) { + setShowInterestForm(true); + } + }, [preferencesData, preferencesLoading]); + + // Show main spinner only if loading initial data for the overview + if (isLoadingInitial && activeTab === 0) { + return ; + } + + // Show main error only if error occurred and trying to view overview + if (combinedError && activeTab === 0) { + return ( + + ); + } + + // --- Render Dashboard --- + return ( + <> +
+ {/* 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 ? ( +
+ +
+ ) : 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 */} + + + ))} + +
+ )} +
+ {/* 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) && ( + + )} +
+ {/* Chart Area */} + {spendingError ? ( + + Could not load spending data. + + ) : spendingLoading ? ( +
+ +
+ ) : !lineChartData || lineChartData.length === 0 ? ( +

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

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