Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
d8aa19ac40 ci: Bump version to 0.9.84 [skip ci] 2026-01-10 23:45:42 +05:00
dcd9452b8c Adopt TanStack Query
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m46s
2026-01-10 10:45:10 -08:00
33 changed files with 587 additions and 160 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.83",
"version": "0.9.84",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.83",
"version": "0.9.84",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.9.83",
"version": "0.9.84",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

84
src/config/queryKeys.ts Normal file
View File

@@ -0,0 +1,84 @@
// src/config/queryKeys.ts
/**
* Centralized query keys for TanStack Query.
*
* This file provides a single source of truth for all query keys used
* throughout the application. Using these factory functions ensures
* consistent key naming and proper cache invalidation.
*
* @example
* ```tsx
* // In a query hook
* useQuery({
* queryKey: queryKeys.flyers(10, 0),
* queryFn: fetchFlyers,
* });
*
* // For cache invalidation
* queryClient.invalidateQueries({ queryKey: queryKeys.watchedItems() });
* ```
*/
export const queryKeys = {
// User Features
flyers: (limit: number, offset: number) => ['flyers', { limit, offset }] as const,
flyerItems: (flyerId: number) => ['flyer-items', flyerId] as const,
flyerItemsBatch: (flyerIds: number[]) =>
['flyer-items-batch', flyerIds.sort().join(',')] as const,
flyerItemsCount: (flyerIds: number[]) =>
['flyer-items-count', flyerIds.sort().join(',')] as const,
masterItems: () => ['master-items'] as const,
watchedItems: () => ['watched-items'] as const,
shoppingLists: () => ['shopping-lists'] as const,
// Auth & Profile
authProfile: () => ['auth-profile'] as const,
userAddress: (addressId: number | null) => ['user-address', addressId] as const,
userProfileData: () => ['user-profile-data'] as const,
// Admin Features
activityLog: (limit: number, offset: number) => ['activity-log', { limit, offset }] as const,
applicationStats: () => ['application-stats'] as const,
suggestedCorrections: () => ['suggested-corrections'] as const,
categories: () => ['categories'] as const,
// Analytics
bestSalePrices: () => ['best-sale-prices'] as const,
priceHistory: (masterItemIds: number[]) =>
['price-history', [...masterItemIds].sort((a, b) => a - b).join(',')] as const,
leaderboard: (limit: number) => ['leaderboard', limit] as const,
} as const;
/**
* Base keys for partial matching in cache invalidation.
*
* Use these when you need to invalidate all queries of a certain type
* regardless of their parameters.
*
* @example
* ```tsx
* // Invalidate all flyer-related queries
* queryClient.invalidateQueries({ queryKey: queryKeyBases.flyers });
* ```
*/
export const queryKeyBases = {
flyers: ['flyers'] as const,
flyerItems: ['flyer-items'] as const,
flyerItemsBatch: ['flyer-items-batch'] as const,
flyerItemsCount: ['flyer-items-count'] as const,
masterItems: ['master-items'] as const,
watchedItems: ['watched-items'] as const,
shoppingLists: ['shopping-lists'] as const,
authProfile: ['auth-profile'] as const,
userAddress: ['user-address'] as const,
userProfileData: ['user-profile-data'] as const,
activityLog: ['activity-log'] as const,
applicationStats: ['application-stats'] as const,
suggestedCorrections: ['suggested-corrections'] as const,
categories: ['categories'] as const,
bestSalePrices: ['best-sale-prices'] as const,
priceHistory: ['price-history'] as const,
leaderboard: ['leaderboard'] as const,
} as const;
export type QueryKeys = typeof queryKeys;
export type QueryKeyBases = typeof queryKeyBases;

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
interface AddShoppingListItemParams {
listId: number;
@@ -61,7 +62,7 @@ export const useAddShoppingListItemMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch shopping lists to get the updated list
queryClient.invalidateQueries({ queryKey: ['shopping-lists'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.shoppingLists });
notifySuccess('Item added to shopping list');
},
onError: (error: Error) => {

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
interface AddWatchedItemParams {
itemName: string;
@@ -50,7 +51,7 @@ export const useAddWatchedItemMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch watched items to get the updated list
queryClient.invalidateQueries({ queryKey: ['watched-items'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.watchedItems });
notifySuccess('Item added to watched list');
},
onError: (error: Error) => {

View File

@@ -0,0 +1,113 @@
// src/hooks/mutations/useAuthMutations.ts
import { useMutation } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifyError } from '../../services/notificationService';
import type { UserProfile } from '../../types';
interface AuthResponse {
userprofile: UserProfile;
token: string;
}
/**
* Mutation hook for user login.
*
* @example
* ```tsx
* const loginMutation = useLoginMutation();
* loginMutation.mutate({ email, password, rememberMe });
* ```
*/
export const useLoginMutation = () => {
return useMutation({
mutationFn: async ({
email,
password,
rememberMe,
}: {
email: string;
password: string;
rememberMe: boolean;
}): Promise<AuthResponse> => {
const response = await apiClient.loginUser(email, password, rememberMe);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to login');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to login');
},
});
};
/**
* Mutation hook for user registration.
*
* @example
* ```tsx
* const registerMutation = useRegisterMutation();
* registerMutation.mutate({ email, password, fullName });
* ```
*/
export const useRegisterMutation = () => {
return useMutation({
mutationFn: async ({
email,
password,
fullName,
}: {
email: string;
password: string;
fullName: string;
}): Promise<AuthResponse> => {
const response = await apiClient.registerUser(email, password, fullName, '');
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to register');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to register');
},
});
};
/**
* Mutation hook for requesting a password reset.
*
* @example
* ```tsx
* const passwordResetMutation = usePasswordResetRequestMutation();
* passwordResetMutation.mutate({ email });
* ```
*/
export const usePasswordResetRequestMutation = () => {
return useMutation({
mutationFn: async ({ email }: { email: string }): Promise<{ message: string }> => {
const response = await apiClient.requestPasswordReset(email);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to request password reset');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to request password reset');
},
});
};

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
interface CreateShoppingListParams {
name: string;
@@ -48,7 +49,7 @@ export const useCreateShoppingListMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch shopping lists to get the updated list
queryClient.invalidateQueries({ queryKey: ['shopping-lists'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.shoppingLists });
notifySuccess('Shopping list created');
},
onError: (error: Error) => {

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
interface DeleteShoppingListParams {
listId: number;
@@ -48,7 +49,7 @@ export const useDeleteShoppingListMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch shopping lists to get the updated list
queryClient.invalidateQueries({ queryKey: ['shopping-lists'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.shoppingLists });
notifySuccess('Shopping list deleted');
},
onError: (error: Error) => {

View File

@@ -0,0 +1,179 @@
// src/hooks/mutations/useProfileMutations.ts
import { useMutation } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifyError } from '../../services/notificationService';
import type { Profile, Address } from '../../types';
/**
* Mutation hook for updating user profile.
*
* @example
* ```tsx
* const updateProfile = useUpdateProfileMutation();
* updateProfile.mutate({ full_name: 'New Name', avatar_url: 'https://...' });
* ```
*/
export const useUpdateProfileMutation = () => {
return useMutation({
mutationFn: async (data: Partial<Profile>): Promise<Profile> => {
const response = await apiClient.updateUserProfile(data);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to update profile');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to update profile');
},
});
};
/**
* Mutation hook for updating user address.
*
* @example
* ```tsx
* const updateAddress = useUpdateAddressMutation();
* updateAddress.mutate({ street_address: '123 Main St', city: 'Toronto' });
* ```
*/
export const useUpdateAddressMutation = () => {
return useMutation({
mutationFn: async (data: Partial<Address>): Promise<Address> => {
const response = await apiClient.updateUserAddress(data);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to update address');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to update address');
},
});
};
/**
* Mutation hook for updating user password.
*
* @example
* ```tsx
* const updatePassword = useUpdatePasswordMutation();
* updatePassword.mutate({ password: 'newPassword123' });
* ```
*/
export const useUpdatePasswordMutation = () => {
return useMutation({
mutationFn: async ({ password }: { password: string }): Promise<void> => {
const response = await apiClient.updateUserPassword(password);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to update password');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to update password');
},
});
};
/**
* Mutation hook for updating user preferences.
*
* @example
* ```tsx
* const updatePreferences = useUpdatePreferencesMutation();
* updatePreferences.mutate({ darkMode: true });
* ```
*/
export const useUpdatePreferencesMutation = () => {
return useMutation({
mutationFn: async (prefs: Partial<Profile['preferences']>): Promise<Profile> => {
const response = await apiClient.updateUserPreferences(prefs);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to update preferences');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to update preferences');
},
});
};
/**
* Mutation hook for exporting user data.
*
* @example
* ```tsx
* const exportData = useExportDataMutation();
* exportData.mutate();
* ```
*/
export const useExportDataMutation = () => {
return useMutation({
mutationFn: async (): Promise<unknown> => {
const response = await apiClient.exportUserData();
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to export data');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to export data');
},
});
};
/**
* Mutation hook for deleting user account.
*
* @example
* ```tsx
* const deleteAccount = useDeleteAccountMutation();
* deleteAccount.mutate({ password: 'currentPassword' });
* ```
*/
export const useDeleteAccountMutation = () => {
return useMutation({
mutationFn: async ({ password }: { password: string }): Promise<void> => {
const response = await apiClient.deleteUserAccount(password);
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to delete account');
}
return response.json();
},
onError: (error: Error) => {
notifyError(error.message || 'Failed to delete account');
},
});
};

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
interface RemoveShoppingListItemParams {
itemId: number;
@@ -48,7 +49,7 @@ export const useRemoveShoppingListItemMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch shopping lists to get the updated list
queryClient.invalidateQueries({ queryKey: ['shopping-lists'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.shoppingLists });
notifySuccess('Item removed from shopping list');
},
onError: (error: Error) => {

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
interface RemoveWatchedItemParams {
masterItemId: number;
@@ -48,7 +49,7 @@ export const useRemoveWatchedItemMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch watched items to get the updated list
queryClient.invalidateQueries({ queryKey: ['watched-items'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.watchedItems });
notifySuccess('Item removed from watched list');
},
onError: (error: Error) => {

View File

@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { notifySuccess, notifyError } from '../../services/notificationService';
import { queryKeyBases } from '../../config/queryKeys';
import type { ShoppingListItem } from '../../types';
interface UpdateShoppingListItemParams {
@@ -60,7 +61,7 @@ export const useUpdateShoppingListItemMutation = () => {
},
onSuccess: () => {
// Invalidate and refetch shopping lists to get the updated list
queryClient.invalidateQueries({ queryKey: ['shopping-lists'] });
queryClient.invalidateQueries({ queryKey: queryKeyBases.shoppingLists });
notifySuccess('Shopping list item updated');
},
onError: (error: Error) => {

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useActivityLogQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchActivityLog } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { ActivityLogItem } from '../../types';
/**
@@ -21,7 +22,7 @@ import type { ActivityLogItem } from '../../types';
*/
export const useActivityLogQuery = (limit: number = 20, offset: number = 0) => {
return useQuery({
queryKey: ['activity-log', { limit, offset }],
queryKey: queryKeys.activityLog(limit, offset),
queryFn: async (): Promise<ActivityLogItem[]> => {
const response = await fetchActivityLog(limit, offset);

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useApplicationStatsQuery.ts
import { useQuery } from '@tanstack/react-query';
import { getApplicationStats, AppStats } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
/**
* Query hook for fetching application-wide statistics (admin feature).
@@ -19,7 +20,7 @@ import { getApplicationStats, AppStats } from '../../services/apiClient';
*/
export const useApplicationStatsQuery = () => {
return useQuery({
queryKey: ['application-stats'],
queryKey: queryKeys.applicationStats(),
queryFn: async (): Promise<AppStats> => {
const response = await getApplicationStats();

View File

@@ -2,13 +2,15 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAuthenticatedUserProfile } from '../../services/apiClient';
import { getToken } from '../../services/tokenStorage';
import { queryKeys, queryKeyBases } from '../../config/queryKeys';
import type { UserProfile } from '../../types';
/**
* Query key for the authenticated user's profile.
* Exported for cache invalidation purposes.
* @deprecated Use queryKeys.authProfile() from '../../config/queryKeys' instead
*/
export const AUTH_PROFILE_QUERY_KEY = ['auth-profile'] as const;
export const AUTH_PROFILE_QUERY_KEY = queryKeys.authProfile();
/**
* Query hook for fetching the authenticated user's profile.
@@ -28,7 +30,7 @@ export const useAuthProfileQuery = (enabled: boolean = true) => {
const hasToken = !!getToken();
return useQuery({
queryKey: AUTH_PROFILE_QUERY_KEY,
queryKey: queryKeys.authProfile(),
queryFn: async (): Promise<UserProfile> => {
const response = await getAuthenticatedUserProfile();
@@ -55,6 +57,6 @@ export const useInvalidateAuthProfile = () => {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: AUTH_PROFILE_QUERY_KEY });
queryClient.invalidateQueries({ queryKey: queryKeyBases.authProfile });
};
};

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useBestSalePricesQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchBestSalePrices } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { WatchedItemDeal } from '../../types';
/**
@@ -19,7 +20,7 @@ import type { WatchedItemDeal } from '../../types';
*/
export const useBestSalePricesQuery = (enabled: boolean = true) => {
return useQuery({
queryKey: ['best-sale-prices'],
queryKey: queryKeys.bestSalePrices(),
queryFn: async (): Promise<WatchedItemDeal[]> => {
const response = await fetchBestSalePrices();

View File

@@ -0,0 +1,35 @@
// src/hooks/queries/useBrandsQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchAllBrands } from '../../services/apiClient';
import type { Brand } from '../../types';
/**
* Query hook for fetching all brands (admin feature).
*
* @param enabled - Whether the query should run (default: true)
* @returns TanStack Query result with Brand[] data
*
* @example
* ```tsx
* const { data: brands = [], isLoading, error } = useBrandsQuery();
* ```
*/
export const useBrandsQuery = (enabled: boolean = true) => {
return useQuery({
queryKey: ['brands'],
queryFn: async (): Promise<Brand[]> => {
const response = await fetchAllBrands();
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `Request failed with status ${response.status}`,
}));
throw new Error(error.message || 'Failed to fetch brands');
}
return response.json();
},
enabled,
staleTime: 1000 * 60 * 5, // 5 minutes - brands don't change frequently
});
};

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useCategoriesQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchCategories } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { Category } from '../../types';
/**
@@ -14,7 +15,7 @@ import type { Category } from '../../types';
*/
export const useCategoriesQuery = () => {
return useQuery({
queryKey: ['categories'],
queryKey: queryKeys.categories(),
queryFn: async (): Promise<Category[]> => {
const response = await fetchCategories();

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useFlyerItemCountQuery.ts
import { useQuery } from '@tanstack/react-query';
import { countFlyerItemsForFlyers } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
interface FlyerItemCount {
count: number;
@@ -24,7 +25,7 @@ interface FlyerItemCount {
export const useFlyerItemCountQuery = (flyerIds: number[], enabled: boolean = true) => {
return useQuery({
// Include flyerIds in the key so cache is per-set of flyers
queryKey: ['flyer-items-count', flyerIds.sort().join(',')],
queryKey: queryKeys.flyerItemsCount(flyerIds),
queryFn: async (): Promise<FlyerItemCount> => {
if (flyerIds.length === 0) {
return { count: 0 };

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useFlyerItemsForFlyersQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchFlyerItemsForFlyers } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { FlyerItem } from '../../types';
/**
@@ -21,7 +22,7 @@ import type { FlyerItem } from '../../types';
export const useFlyerItemsForFlyersQuery = (flyerIds: number[], enabled: boolean = true) => {
return useQuery({
// Include flyerIds in the key so cache is per-set of flyers
queryKey: ['flyer-items-batch', flyerIds.sort().join(',')],
queryKey: queryKeys.flyerItemsBatch(flyerIds),
queryFn: async (): Promise<FlyerItem[]> => {
if (flyerIds.length === 0) {
return [];

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useFlyerItemsQuery.ts
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { FlyerItem } from '../../types';
/**
@@ -19,7 +20,7 @@ import type { FlyerItem } from '../../types';
*/
export const useFlyerItemsQuery = (flyerId: number | undefined) => {
return useQuery({
queryKey: ['flyer-items', flyerId],
queryKey: queryKeys.flyerItems(flyerId as number),
queryFn: async (): Promise<FlyerItem[]> => {
if (!flyerId) {
throw new Error('Flyer ID is required');

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useFlyersQuery.ts
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { Flyer } from '../../types';
/**
@@ -20,7 +21,7 @@ import type { Flyer } from '../../types';
*/
export const useFlyersQuery = (limit: number = 20, offset: number = 0) => {
return useQuery({
queryKey: ['flyers', { limit, offset }],
queryKey: queryKeys.flyers(limit, offset),
queryFn: async (): Promise<Flyer[]> => {
const response = await apiClient.fetchFlyers(limit, offset);

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useLeaderboardQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchLeaderboard } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { LeaderboardUser } from '../../types';
/**
@@ -17,7 +18,7 @@ import type { LeaderboardUser } from '../../types';
*/
export const useLeaderboardQuery = (limit: number = 10, enabled: boolean = true) => {
return useQuery({
queryKey: ['leaderboard', limit],
queryKey: queryKeys.leaderboard(limit),
queryFn: async (): Promise<LeaderboardUser[]> => {
const response = await fetchLeaderboard(limit);

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useMasterItemsQuery.ts
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { MasterGroceryItem } from '../../types';
/**
@@ -19,7 +20,7 @@ import type { MasterGroceryItem } from '../../types';
*/
export const useMasterItemsQuery = () => {
return useQuery({
queryKey: ['master-items'],
queryKey: queryKeys.masterItems(),
queryFn: async (): Promise<MasterGroceryItem[]> => {
const response = await apiClient.fetchMasterItems();

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/usePriceHistoryQuery.ts
import { useQuery } from '@tanstack/react-query';
import { fetchHistoricalPriceData } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { HistoricalPriceDataPoint } from '../../types';
/**
@@ -17,11 +18,8 @@ import type { HistoricalPriceDataPoint } from '../../types';
* ```
*/
export const usePriceHistoryQuery = (masterItemIds: number[], enabled: boolean = true) => {
// Sort IDs for stable query key
const sortedIds = [...masterItemIds].sort((a, b) => a - b);
return useQuery({
queryKey: ['price-history', sortedIds.join(',')],
queryKey: queryKeys.priceHistory(masterItemIds),
queryFn: async (): Promise<HistoricalPriceDataPoint[]> => {
if (masterItemIds.length === 0) {
return [];

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useShoppingListsQuery.ts
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { ShoppingList } from '../../types';
/**
@@ -19,7 +20,7 @@ import type { ShoppingList } from '../../types';
*/
export const useShoppingListsQuery = (enabled: boolean) => {
return useQuery({
queryKey: ['shopping-lists'],
queryKey: queryKeys.shoppingLists(),
queryFn: async (): Promise<ShoppingList[]> => {
const response = await apiClient.fetchShoppingLists();

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useSuggestedCorrectionsQuery.ts
import { useQuery } from '@tanstack/react-query';
import { getSuggestedCorrections } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { SuggestedCorrection } from '../../types';
/**
@@ -14,7 +15,7 @@ import type { SuggestedCorrection } from '../../types';
*/
export const useSuggestedCorrectionsQuery = () => {
return useQuery({
queryKey: ['suggested-corrections'],
queryKey: queryKeys.suggestedCorrections(),
queryFn: async (): Promise<SuggestedCorrection[]> => {
const response = await getSuggestedCorrections();

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useUserAddressQuery.ts
import { useQuery } from '@tanstack/react-query';
import { getUserAddress } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { Address } from '../../types';
/**
@@ -20,7 +21,7 @@ export const useUserAddressQuery = (
enabled: boolean = true,
) => {
return useQuery({
queryKey: ['user-address', addressId],
queryKey: queryKeys.userAddress(addressId ?? null),
queryFn: async (): Promise<Address> => {
if (!addressId) {
throw new Error('Address ID is required');

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useUserProfileDataQuery.ts
import { useQuery } from '@tanstack/react-query';
import { getAuthenticatedUserProfile, getUserAchievements } from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { UserProfile, Achievement, UserAchievement } from '../../types';
interface UserProfileData {
@@ -26,7 +27,7 @@ interface UserProfileData {
*/
export const useUserProfileDataQuery = (enabled: boolean = true) => {
return useQuery({
queryKey: ['user-profile-data'],
queryKey: queryKeys.userProfileData(),
queryFn: async (): Promise<UserProfileData> => {
const [profileRes, achievementsRes] = await Promise.all([
getAuthenticatedUserProfile(),

View File

@@ -1,6 +1,7 @@
// src/hooks/queries/useWatchedItemsQuery.ts
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import { queryKeys } from '../../config/queryKeys';
import type { MasterGroceryItem } from '../../types';
/**
@@ -19,7 +20,7 @@ import type { MasterGroceryItem } from '../../types';
*/
export const useWatchedItemsQuery = (enabled: boolean) => {
return useQuery({
queryKey: ['watched-items'],
queryKey: queryKeys.watchedItems(),
queryFn: async (): Promise<MasterGroceryItem[]> => {
const response = await apiClient.fetchWatchedItems();

View File

@@ -1,36 +1,22 @@
// src/pages/admin/components/AdminBrandManager.tsx
import React, { useState, useCallback } from 'react';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient';
import { uploadBrandLogo } from '../../../services/apiClient';
import { Brand } from '../../../types';
import { ErrorDisplay } from '../../../components/ErrorDisplay';
import { useApiOnMount } from '../../../hooks/useApiOnMount';
import { useBrandsQuery } from '../../../hooks/queries/useBrandsQuery';
import { logger } from '../../../services/logger.client';
export const AdminBrandManager: React.FC = () => {
// Wrap the fetcher function in useCallback to prevent it from being recreated on every render.
// The hook expects a function that returns a Promise<Response>, and it will handle
// the JSON parsing and error checking internally.
const fetchBrandsWrapper = useCallback(() => {
logger.debug(
'[AdminBrandManager] The memoized fetchBrandsWrapper is being passed to useApiOnMount',
);
// This wrapper simply calls the API client function. The hook will manage the promise.
return fetchAllBrands();
}, []); // An empty dependency array ensures this function is created only once.
const { data: initialBrands, isLoading: loading, error } = useBrandsQuery();
const {
data: initialBrands,
loading,
error,
} = useApiOnMount<Brand[], []>(fetchBrandsWrapper, []);
// This state will hold a modified list of brands only after an optimistic update (e.g., logo upload).
// It starts as null, indicating that we should use the original data from the API.
const [updatedBrands, setUpdatedBrands] = useState<Brand[] | null>(null);
// At render time, decide which data to display. If updatedBrands exists, it takes precedence.
// Otherwise, fall back to the initial data from the hook. Default to an empty array.
const brandsToRender = updatedBrands || initialBrands || [];
const brandsToRender: Brand[] = updatedBrands || initialBrands || [];
logger.debug(
{
loading,

View File

@@ -1,18 +1,16 @@
// src/pages/admin/components/AuthView.tsx
import React, { useState } from 'react';
import type { UserProfile } from '../../../types';
import { useApi } from '../../../hooks/useApi';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess } from '../../../services/notificationService';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { PasswordInput } from '../../../components/PasswordInput';
interface AuthResponse {
userprofile: UserProfile;
token: string;
}
import {
useLoginMutation,
useRegisterMutation,
usePasswordResetRequestMutation,
} from '../../../hooks/mutations/useAuthMutations';
interface AuthViewProps {
onLoginSuccess: (user: UserProfile, token: string, rememberMe: boolean) => void;
@@ -27,37 +25,50 @@ export const AuthView: React.FC<AuthViewProps> = ({ onLoginSuccess, onClose }) =
const [isForgotPassword, setIsForgotPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const { execute: executeLogin, loading: loginLoading } = useApi<
AuthResponse,
[string, string, boolean]
>(apiClient.loginUser);
const { execute: executeRegister, loading: registerLoading } = useApi<
AuthResponse,
[string, string, string, string]
>(apiClient.registerUser);
const { execute: executePasswordReset, loading: passwordResetLoading } = useApi<
{ message: string },
[string]
>(apiClient.requestPasswordReset);
const loginMutation = useLoginMutation();
const registerMutation = useRegisterMutation();
const passwordResetMutation = usePasswordResetRequestMutation();
const loginLoading = loginMutation.isPending;
const registerLoading = registerMutation.isPending;
const passwordResetLoading = passwordResetMutation.isPending;
const handleAuthSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const authResult = isRegistering
? await executeRegister(authEmail, authPassword, authFullName, '')
: await executeLogin(authEmail, authPassword, rememberMe);
if (authResult) {
onLoginSuccess(authResult.userprofile, authResult.token, rememberMe);
onClose();
if (isRegistering) {
registerMutation.mutate(
{ email: authEmail, password: authPassword, fullName: authFullName },
{
onSuccess: (authResult) => {
onLoginSuccess(authResult.userprofile, authResult.token, rememberMe);
onClose();
},
},
);
} else {
loginMutation.mutate(
{ email: authEmail, password: authPassword, rememberMe },
{
onSuccess: (authResult) => {
onLoginSuccess(authResult.userprofile, authResult.token, rememberMe);
onClose();
},
},
);
}
};
const handlePasswordResetRequest = async (e: React.FormEvent) => {
e.preventDefault();
const result = await executePasswordReset(authEmail);
if (result) {
notifySuccess(result.message);
}
passwordResetMutation.mutate(
{ email: authEmail },
{
onSuccess: (result) => {
notifySuccess(result.message);
},
},
);
};
const handleOAuthSignIn = (provider: 'google' | 'github') => {

View File

@@ -1,21 +1,27 @@
// src/pages/admin/components/ProfileManager.tsx
import React, { useState, useEffect } from 'react';
import type { Profile, Address, UserProfile } from '../../../types';
import { useApi } from '../../../hooks/useApi';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
import { logger } from '../../../services/logger.client';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { ConfirmationModal } from '../../../components/ConfirmationModal'; // This path is correct
import { ConfirmationModal } from '../../../components/ConfirmationModal';
import { PasswordInput } from '../../../components/PasswordInput';
import { MapView } from '../../../components/MapView';
import type { AuthStatus } from '../../../hooks/useAuth';
import { AuthView } from './AuthView';
import { AddressForm } from './AddressForm';
import { useProfileAddress } from '../../../hooks/useProfileAddress';
import {
useUpdateProfileMutation,
useUpdateAddressMutation,
useUpdatePasswordMutation,
useUpdatePreferencesMutation,
useExportDataMutation,
useDeleteAccountMutation,
} from '../../../hooks/mutations/useProfileMutations';
export interface ProfileManagerProps {
isOpen: boolean;
@@ -27,23 +33,6 @@ export interface ProfileManagerProps {
onLoginSuccess: (user: UserProfile, token: string, rememberMe: boolean) => void; // Add login handler
}
// --- API Hook Wrappers ---
// These wrappers adapt the apiClient functions (which expect an ApiOptions object)
// to the signature expected by the useApi hook (which passes a raw AbortSignal).
// They are defined outside the component to ensure they have a stable identity
// across re-renders, preventing infinite loops in useEffect hooks.
const updateAddressWrapper = (data: Partial<Address>, signal?: AbortSignal) =>
apiClient.updateUserAddress(data, { signal });
const updatePasswordWrapper = (password: string, signal?: AbortSignal) =>
apiClient.updateUserPassword(password, { signal });
const exportDataWrapper = (signal?: AbortSignal) => apiClient.exportUserData({ signal });
const deleteAccountWrapper = (password: string, signal?: AbortSignal) =>
apiClient.deleteUserAccount(password, { signal });
const updatePreferencesWrapper = (prefs: Partial<Profile['preferences']>, signal?: AbortSignal) =>
apiClient.updateUserPreferences(prefs, { signal });
const updateProfileWrapper = (data: Partial<Profile>, signal?: AbortSignal) =>
apiClient.updateUserProfile(data, { signal });
export const ProfileManager: React.FC<ProfileManagerProps> = ({
isOpen,
onClose,
@@ -63,32 +52,25 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({
const { address, initialAddress, isGeocoding, handleAddressChange, handleManualGeocode } =
useProfileAddress(userProfile, isOpen);
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(
updateProfileWrapper,
);
const { execute: updateAddress, loading: addressLoading } = useApi<Address, [Partial<Address>]>(
updateAddressWrapper,
);
// TanStack Query mutations
const updateProfileMutation = useUpdateProfileMutation();
const updateAddressMutation = useUpdateAddressMutation();
const updatePasswordMutation = useUpdatePasswordMutation();
const updatePreferencesMutation = useUpdatePreferencesMutation();
const exportDataMutation = useExportDataMutation();
const deleteAccountMutation = useDeleteAccountMutation();
const profileLoading = updateProfileMutation.isPending;
const addressLoading = updateAddressMutation.isPending;
const passwordLoading = updatePasswordMutation.isPending;
const exportLoading = exportDataMutation.isPending;
const deleteLoading = deleteAccountMutation.isPending;
// Password state
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const { execute: updatePassword, loading: passwordLoading } = useApi<unknown, [string]>(
updatePasswordWrapper,
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Data & Privacy state
const { execute: exportData, loading: exportLoading } = useApi<unknown, []>(exportDataWrapper);
const { execute: deleteAccount, loading: deleteLoading } = useApi<unknown, [string]>(
deleteAccountWrapper,
);
// Preferences state
const { execute: updatePreferences } = useApi<Profile, [Partial<Profile['preferences']>]>(
updatePreferencesWrapper,
);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const [passwordForDelete, setPasswordForDelete] = useState('');
@@ -146,15 +128,16 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({
}
// Create an array of promises for the API calls that need to be made.
// Because useApi() catches errors and returns null, we can safely use Promise.all.
const promisesToRun = [];
const promisesToRun: Promise<Profile | Address>[] = [];
if (profileDataChanged) {
logger.debug('[handleProfileSave] Queuing profile update promise.');
promisesToRun.push(updateProfile({ full_name: fullName, avatar_url: avatarUrl }));
promisesToRun.push(
updateProfileMutation.mutateAsync({ full_name: fullName, avatar_url: avatarUrl }),
);
}
if (addressDataChanged) {
logger.debug('[handleProfileSave] Queuing address update promise.');
promisesToRun.push(updateAddress(address));
promisesToRun.push(updateAddressMutation.mutateAsync(address));
}
try {
@@ -169,7 +152,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({
// Determine which promises succeeded or failed.
results.forEach((result, index) => {
const isProfilePromise = profileDataChanged && index === 0;
if (result.status === 'rejected' || (result.status === 'fulfilled' && !result.value)) {
if (result.status === 'rejected') {
anyFailures = true;
} else if (result.status === 'fulfilled' && isProfilePromise) {
successfulProfileUpdate = result.value as Profile;
@@ -187,12 +170,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({
onClose();
} else {
logger.warn(
'[handleProfileSave] One or more operations failed. The useApi hook should have shown an error. The modal will remain open.',
'[handleProfileSave] One or more operations failed. The mutation hook should have shown an error. The modal will remain open.',
);
}
} catch (error) {
// This catch block is a safeguard. In normal operation, the useApi hook
// should prevent any promises from rejecting.
// This catch block is a safeguard for unexpected errors.
logger.error(
{ err: error },
"[CRITICAL] An unexpected error was caught directly in handleProfileSave's catch block.",
@@ -229,51 +211,66 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({
return;
}
const result = await updatePassword(password);
if (result) {
notifySuccess('Password updated successfully!');
setPassword('');
setConfirmPassword('');
}
updatePasswordMutation.mutate(
{ password },
{
onSuccess: () => {
notifySuccess('Password updated successfully!');
setPassword('');
setConfirmPassword('');
},
},
);
};
const handleExportData = async () => {
const userData = await exportData();
if (userData) {
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
const link = document.createElement('a');
link.href = jsonString;
link.download = `flyer-crawler-data-export-${new Date().toISOString().split('T')[0]}.json`;
link.click();
}
exportDataMutation.mutate(undefined, {
onSuccess: (userData) => {
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
const link = document.createElement('a');
link.href = jsonString;
link.download = `flyer-crawler-data-export-${new Date().toISOString().split('T')[0]}.json`;
link.click();
},
});
};
const handleDeleteAccount = async () => {
setIsDeleteModalOpen(false); // Close the confirmation modal
const result = await deleteAccount(passwordForDelete);
if (result) {
// useApi returns null on failure, so this check is sufficient.
notifySuccess('Account deleted successfully. You will be logged out shortly.');
setTimeout(() => {
onClose();
onSignOut();
}, 3000);
}
deleteAccountMutation.mutate(
{ password: passwordForDelete },
{
onSuccess: () => {
notifySuccess('Account deleted successfully. You will be logged out shortly.');
setTimeout(() => {
onClose();
onSignOut();
}, 3000);
},
},
);
};
const handleToggleDarkMode = async (newMode: boolean) => {
const updatedProfile = await updatePreferences({ darkMode: newMode });
if (updatedProfile) {
onProfileUpdate(updatedProfile);
}
updatePreferencesMutation.mutate(
{ darkMode: newMode },
{
onSuccess: (updatedProfile) => {
onProfileUpdate(updatedProfile);
},
},
);
};
const handleToggleUnitSystem = async (newSystem: 'metric' | 'imperial') => {
const updatedProfile = await updatePreferences({ unitSystem: newSystem });
if (updatedProfile) {
onProfileUpdate(updatedProfile);
}
updatePreferencesMutation.mutate(
{ unitSystem: newSystem },
{
onSuccess: (updatedProfile) => {
onProfileUpdate(updatedProfile);
},
},
);
};
if (!isOpen) return null;