From 2a776d9678e6b4076f20eac062f36f35e2fb8c23 Mon Sep 17 00:00:00 2001 From: DiogoMachado04 Date: Wed, 28 May 2025 23:30:30 +0100 Subject: [PATCH 1/3] feat: favorites --- backend/vizzy-backend/src/app.module.ts | 5 +- .../constants/cache/favorites.cache-keys.ts | 11 + .../src/favorites/favorites.controller.ts | 195 +++++++++++++++++ .../src/favorites/favorites.service.ts | 207 ++++++++++++++++++ .../helpers/favorites-database.helper.ts | 140 ++++++++++++ .../app/dashboard/dashboard-page-client.tsx | 8 +- .../app/dashboard/layout/favorites-page.tsx | 133 +++++++++++ frontend/vizzy/app/listing/[id]/page.tsx | 79 ++++++- .../vizzy/lib/api/favorites/add-favorite.ts | 20 ++ .../api/favorites/check-favorite-status.ts | 26 +++ .../vizzy/lib/api/favorites/get-favorites.ts | 33 +++ .../lib/api/favorites/remove-favorite.ts | 20 ++ 12 files changed, 872 insertions(+), 5 deletions(-) create mode 100644 backend/vizzy-backend/src/constants/cache/favorites.cache-keys.ts create mode 100644 backend/vizzy-backend/src/favorites/favorites.controller.ts create mode 100644 backend/vizzy-backend/src/favorites/favorites.service.ts create mode 100644 backend/vizzy-backend/src/favorites/helpers/favorites-database.helper.ts create mode 100644 frontend/vizzy/app/dashboard/layout/favorites-page.tsx create mode 100644 frontend/vizzy/lib/api/favorites/add-favorite.ts create mode 100644 frontend/vizzy/lib/api/favorites/check-favorite-status.ts create mode 100644 frontend/vizzy/lib/api/favorites/get-favorites.ts create mode 100644 frontend/vizzy/lib/api/favorites/remove-favorite.ts diff --git a/backend/vizzy-backend/src/app.module.ts b/backend/vizzy-backend/src/app.module.ts index 76387ef2..b40f0a02 100644 --- a/backend/vizzy-backend/src/app.module.ts +++ b/backend/vizzy-backend/src/app.module.ts @@ -23,7 +23,8 @@ import { ProposalService } from './proposal/proposal.service'; import { GeocodingService } from './geocoding/geocoding.service'; import { GeocodingController } from './geocoding/geocoding.controller'; import { CustomThrottlerGuard } from './common/guards/throttler.guard'; - +import { FavoritesController } from './favorites/favorites.controller'; +import { FavoritesService } from './favorites/favorites.service'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), @@ -47,6 +48,7 @@ import { CustomThrottlerGuard } from './common/guards/throttler.guard'; ContactController, ProposalController, GeocodingController, + FavoritesController, ], providers: [ SupabaseService, @@ -57,6 +59,7 @@ import { CustomThrottlerGuard } from './common/guards/throttler.guard'; ContactService, ProposalService, GeocodingService, + FavoritesService, CustomThrottlerGuard, ], }) diff --git a/backend/vizzy-backend/src/constants/cache/favorites.cache-keys.ts b/backend/vizzy-backend/src/constants/cache/favorites.cache-keys.ts new file mode 100644 index 00000000..2407557e --- /dev/null +++ b/backend/vizzy-backend/src/constants/cache/favorites.cache-keys.ts @@ -0,0 +1,11 @@ +export const FAVORITES_CACHE_KEYS = { + /** + * Get the cache key for a user's favorites list + * @param userId - The ID of the user + * @param limit - Number of items to return + * @param offset - Number of items to skip + * @returns string + */ + USER_LIST: (userId: string, limit?: number, offset?: number): string => + `favorites:user:${userId}:list:${limit || 10}:${offset || 0}`, +} as const; diff --git a/backend/vizzy-backend/src/favorites/favorites.controller.ts b/backend/vizzy-backend/src/favorites/favorites.controller.ts new file mode 100644 index 00000000..ffaa8b42 --- /dev/null +++ b/backend/vizzy-backend/src/favorites/favorites.controller.ts @@ -0,0 +1,195 @@ +import { + Controller, + Post, + Get, + UseGuards, + Req, + Delete, + Param, + ParseIntPipe, + HttpException, + HttpStatus, + HttpCode, + Version, + Inject, + Query, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { API_VERSIONS } from '@/constants/api-versions'; +import { FavoritesService } from './favorites.service'; +import { JwtAuthGuard } from '@/auth/guards/jwt.auth.guard'; +import { RequestWithUser } from '@/auth/types/jwt-payload.type'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger } from 'winston'; +import { ListingBasic } from '@/dtos/listing/listing-basic.dto'; + +@ApiTags('Favorites') +@Controller('favorites') +export class FavoritesController { + constructor( + private readonly favoritesService: FavoritesService, + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + ) {} + + /** + * Get all favorites for the logged-in user + * @param req - The request object with user information + * @param limit - Number of items to return + * @param offset - Number of items to skip + * @returns Promise + */ + @Get() + @Version(API_VERSIONS.V1) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Find all favorites for the logged-in user' }) + @ApiQuery({ name: 'limit', type: Number, required: false, example: 10 }) + @ApiQuery({ name: 'offset', type: Number, required: false, example: 0 }) + @ApiResponse({ + status: 200, + description: 'List of favorites', + type: [ListingBasic], + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async findAll( + @Req() req: RequestWithUser, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + ): Promise { + const userId = req.user?.sub; + if (!userId) { + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + + this.logger.info( + `Controller: Fetching favorites for user ${userId} with limit ${limit} and offset ${offset}`, + ); + return this.favoritesService.findAll(userId, limit, offset); + } + + /** + * Add a new favorite + * @param req - The request object with user information + * @param listingId - ID of the listing to favorite + * @returns Promise + */ + @Post(':listingId') + @Version(API_VERSIONS.V1) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Add a new favorite' }) + @ApiParam({ + name: 'listingId', + type: Number, + description: 'ID of the listing to favorite', + }) + @ApiResponse({ status: 201, description: 'Favorite added successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async addFavorite( + @Req() req: RequestWithUser, + @Param('listingId', ParseIntPipe) listingId: number, + ): Promise { + const userId = req.user?.sub; + if (!userId) { + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + + this.logger.info( + `Controller: Adding favorite for user ${userId} and listing ${listingId}`, + ); + await this.favoritesService.addFavorite(userId, listingId); + } + + /** + * Remove a favorite + * @param req - The request object with user information + * @param listingId - ID of the listing favorite to remove + * @returns Promise + */ + @Delete(':listingId') + @Version(API_VERSIONS.V1) + @UseGuards(JwtAuthGuard) + @HttpCode(204) + @ApiBearerAuth() + @ApiOperation({ summary: 'Remove a favorite' }) + @ApiParam({ + name: 'listingId', + type: Number, + description: 'ID of the listing favorite to remove', + }) + @ApiResponse({ status: 204, description: 'Favorite removed successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Favorite not found' }) + async removeFavorite( + @Req() req: RequestWithUser, + @Param('listingId', ParseIntPipe) listingId: number, + ): Promise { + const userId = req.user?.sub; + if (!userId) { + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + + this.logger.info( + `Controller: Removing favorite ${listingId} for user ${userId}`, + ); + await this.favoritesService.removeFavorite(userId, listingId); + } + + /** + * Check if a listing is favorited by the logged-in user + * @param req - The request object with user information + * @param listingId - ID of the listing to check + * @returns Promise + */ + @Get(':listingId/status') + @Version(API_VERSIONS.V1) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Check if a listing is favorited by the logged-in user', + }) + @ApiParam({ + name: 'listingId', + type: Number, + description: 'ID of the listing to check', + }) + @ApiResponse({ + status: 200, + description: 'Favorite status', + schema: { + type: 'object', + properties: { + isFavorited: { + type: 'boolean', + description: 'Whether the listing is favorited by the user', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async checkFavoriteStatus( + @Req() req: RequestWithUser, + @Param('listingId', ParseIntPipe) listingId: number, + ): Promise<{ isFavorited: boolean }> { + const userId = req.user?.sub; + if (!userId) { + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + + this.logger.info( + `Controller: Checking favorite status for listing ${listingId} and user ${userId}`, + ); + const isFavorited = await this.favoritesService.isListingFavorited( + userId, + listingId, + ); + return { isFavorited }; + } +} diff --git a/backend/vizzy-backend/src/favorites/favorites.service.ts b/backend/vizzy-backend/src/favorites/favorites.service.ts new file mode 100644 index 00000000..54ec8eb4 --- /dev/null +++ b/backend/vizzy-backend/src/favorites/favorites.service.ts @@ -0,0 +1,207 @@ +import { Injectable, Inject, HttpException, HttpStatus } from '@nestjs/common'; +import { SupabaseClient } from '@supabase/supabase-js'; +import { SupabaseService } from '@/supabase/supabase.service'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger } from 'winston'; +import { FavoritesDatabaseHelper } from './helpers/favorites-database.helper'; +import { RedisService } from '@/redis/redis.service'; +import { GlobalCacheHelper } from '@/common/helpers/global-cache.helper'; +import { FAVORITES_CACHE_KEYS } from '@/constants/cache/favorites.cache-keys'; +import { ListingBasic } from '@/dtos/listing/listing-basic.dto'; + +/** + * Service for handling favorites-related operations + */ +@Injectable() +export class FavoritesService { + private supabase: SupabaseClient; + private readonly CACHE_EXPIRATION = 300; // 5 minutes + + constructor( + private readonly supabaseService: SupabaseService, + private readonly redisService: RedisService, + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + ) { + this.supabase = this.supabaseService.getAdminClient(); + } + + /** + * Retrieves all favorites for a user + * @param userId - The ID of the user + * @param limit - Number of items to return + * @param offset - Number of items to skip + * @returns Promise + */ + async findAll( + userId: string, + limit?: number, + offset?: number, + ): Promise { + this.logger.info( + `Service: Finding favorites for user ${userId} with limit ${limit} and offset ${offset}`, + ); + + const cacheKey = FAVORITES_CACHE_KEYS.USER_LIST(userId, limit, offset); + const redisClient = this.redisService.getRedisClient(); + + // Try to get from cache first + const cachedFavorites = await GlobalCacheHelper.getFromCache< + ListingBasic[] + >(redisClient, cacheKey); + + if (cachedFavorites) { + this.logger.info( + `Service: Retrieved favorites for user ${userId} from cache`, + ); + return cachedFavorites; + } + + try { + // If not in cache, fetch from database + const favorites = await FavoritesDatabaseHelper.fetchUserFavorites( + this.supabase, + userId, + limit, + offset, + ); + + // Store in cache for future requests + await GlobalCacheHelper.setCache( + redisClient, + cacheKey, + favorites, + this.CACHE_EXPIRATION, + ); + + this.logger.info( + `Service: Found ${favorites.length} favorites for user ${userId}`, + ); + return favorites; + } catch (error) { + this.logger.error( + `Service: Error fetching favorites for user ${userId}:`, + error, + ); + return []; + } + } + + /** + * Adds a new favorite for a user + * @param userId - The ID of the user + * @param listingId - The ID of the listing to favorite + * @returns Promise + */ + async addFavorite(userId: string, listingId: number): Promise { + this.logger.info( + `Service: Adding favorite for user ${userId} and listing ${listingId}`, + ); + + try { + const insertedFavorite = await FavoritesDatabaseHelper.insertFavorite( + this.supabase, + userId, + listingId, + ); + + if (!insertedFavorite) { + throw new Error('Failed to insert favorite'); + } + + await this.invalidateUserFavoritesCache(userId); + } catch (error) { + this.logger.error( + `Service: Error adding favorite for user ${userId}:`, + error, + ); + throw new HttpException( + 'Failed to add favorite', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + /** + * Removes a favorite for a user + * @param userId - The ID of the user + * @param listingId - The ID of the listing to remove from favorites + * @returns Promise + */ + async removeFavorite(userId: string, listingId: number): Promise { + this.logger.info( + `Service: Removing favorite ${listingId} for user ${userId}`, + ); + + try { + await FavoritesDatabaseHelper.deleteFavorite( + this.supabase, + listingId, + userId, + ); + + // Invalidate cache + await this.invalidateUserFavoritesCache(userId); + + this.logger.info( + `Service: Successfully removed favorite ${listingId} for user ${userId}`, + ); + } catch (error) { + this.logger.error( + `Service: Error removing favorite ${listingId} for user ${userId}:`, + error, + ); + throw new HttpException( + 'Failed to remove favorite', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + /** + * Invalidates the favorites cache for a user + * @param userId - The ID of the user + */ + private async invalidateUserFavoritesCache(userId: string): Promise { + const redisClient = this.redisService.getRedisClient(); + const cacheKey = FAVORITES_CACHE_KEYS.USER_LIST(userId); + await GlobalCacheHelper.invalidateCache(redisClient, cacheKey); + this.logger.info(`Service: Invalidated favorites cache for user ${userId}`); + } + + /** + * Checks if a listing is favorited by a user + * @param userId - The ID of the user + * @param listingId - The ID of the listing to check + * @returns Promise + */ + async isListingFavorited( + userId: string, + listingId: number, + ): Promise { + this.logger.info( + `Service: Checking if listing ${listingId} is favorited by user ${userId}`, + ); + + try { + const isFavorited = await FavoritesDatabaseHelper.isListingFavorited( + this.supabase, + userId, + listingId, + ); + + this.logger.info( + `Service: Listing ${listingId} is ${isFavorited ? '' : 'not '}favorited by user ${userId}`, + ); + return isFavorited; + } catch (error) { + this.logger.error( + `Service: Error checking if listing ${listingId} is favorited by user ${userId}:`, + error, + ); + throw new HttpException( + 'Failed to check favorite status', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/backend/vizzy-backend/src/favorites/helpers/favorites-database.helper.ts b/backend/vizzy-backend/src/favorites/helpers/favorites-database.helper.ts new file mode 100644 index 00000000..08b7639d --- /dev/null +++ b/backend/vizzy-backend/src/favorites/helpers/favorites-database.helper.ts @@ -0,0 +1,140 @@ +import { SupabaseClient } from '@supabase/supabase-js'; +import { ListingBasic } from '@/dtos/listing/listing-basic.dto'; + +export class FavoritesDatabaseHelper { + /** + * Fetches all favorites for a user + * @param supabase - Supabase client instance + * @param userId - The ID of the user + * @param limit - Number of items to return + * @param offset - Number of items to skip + * @returns Promise + */ + static async fetchUserFavorites( + supabase: SupabaseClient, + userId: string, + limit?: number, + offset?: number, + ): Promise { + const { data, error } = await supabase.rpc('get_user_favorites', { + p_user_id: userId, + fetch_limit: limit || 10, + fetch_offset: offset || 0, + }); + + if (error) { + throw error; + } + + if (!data) { + return []; + } + + return data as ListingBasic[]; + } + + /** + * Gets a favorite by its ID + * @param supabase - Supabase client instance + * @param favoriteId - The ID of the favorite + * @returns Promise + */ + static async getFavoriteById( + supabase: SupabaseClient, + favoriteId: number, + ): Promise { + const { data: favorite, error } = await supabase + .from('favorites') + .select('*') + .eq('id', favoriteId) + .single(); + + if (error) { + throw error; + } + + return favorite; + } + + /** + * Inserts a new favorite + * @param supabase - Supabase client instance + * @param userId - The ID of the user + * @param listingId - The ID of the listing to favorite + * @returns Promise + */ + static async insertFavorite( + supabase: SupabaseClient, + userId: string, + listingId: number, + ): Promise { + console.log('Params in databaseHelper:', userId, listingId); + const { data: favorite, error } = await supabase + .from('favorites') + .insert({ + user_id: userId, + listing_id: listingId, + }) + .select() + .single(); + + if (error) { + console.log(error); + throw error; + } + + return favorite; + } + + /** + * Deletes a favorite + * @param supabase - Supabase client instance + * @param listingId - The ID of the listing to remove from favorites + * @param userId - The ID of the user + * @returns Promise + */ + static async deleteFavorite( + supabase: SupabaseClient, + listingId: number, + userId: string, + ): Promise { + const { error } = await supabase + .from('favorites') + .delete() + .eq('listing_id', listingId) + .eq('user_id', userId); + + if (error) { + throw error; + } + } + + /** + * Checks if a listing is favorited by a user + * @param supabase - Supabase client instance + * @param userId - The ID of the user + * @param listingId - The ID of the listing to check + * @returns Promise + */ + static async isListingFavorited( + supabase: SupabaseClient, + userId: string, + listingId: number, + ): Promise { + const { error } = await supabase + .from('favorites') + .select() + .eq('user_id', userId) + .eq('listing_id', listingId) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + return false; + } + throw error; + } + + return true; + } +} diff --git a/frontend/vizzy/app/dashboard/dashboard-page-client.tsx b/frontend/vizzy/app/dashboard/dashboard-page-client.tsx index f1f041a5..103d95ee 100644 --- a/frontend/vizzy/app/dashboard/dashboard-page-client.tsx +++ b/frontend/vizzy/app/dashboard/dashboard-page-client.tsx @@ -17,7 +17,7 @@ import { FilterDropdown, type FilterOption, } from '@/components/ui/data-display/filter-dropdown'; - +import { FavoritesPage } from './layout/favorites-page'; export default function DashboardPageClient() { const searchParams = useSearchParams(); const tabParam = searchParams.get('activeTab'); @@ -83,6 +83,9 @@ export default function DashboardPageClient() { Propostas + + Favoritos + {activeTab === 'listings' && ( @@ -118,6 +121,9 @@ export default function DashboardPageClient() { hasActiveFilters={hasActiveFilters} > + + + diff --git a/frontend/vizzy/app/dashboard/layout/favorites-page.tsx b/frontend/vizzy/app/dashboard/layout/favorites-page.tsx new file mode 100644 index 00000000..aefbc8be --- /dev/null +++ b/frontend/vizzy/app/dashboard/layout/favorites-page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/common/button'; +import ListingCard from '@/components/listings/listing-card'; +import type { ListingBasic } from '@/types/listing'; +import { getFavorites } from '@/lib/api/favorites/get-favorites'; +import { Skeleton } from '@/components/ui/data-display/skeleton'; +import { PaginationControls } from '@/components/marketplace/pagination-controls'; + +export function FavoritesPage() { + const [favorites, setFavorites] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const itemsPerPage = 12; + + useEffect(() => { + async function loadFavorites() { + try { + setIsLoading(true); + setError(null); + + const offset = (currentPage - 1) * itemsPerPage; + const result = await getFavorites({ limit: itemsPerPage, offset }); + + if (result.data) { + setFavorites(result.data); + // Simple heuristic for total pages: if we get back fewer than itemsPerPage, + // assume this is the last page. This is not perfect without total count from backend. + if (result.data.length < itemsPerPage) { + setTotalPages(currentPage); + } else { + // Assume there might be more pages if we got a full set, or if it's the first page + setTotalPages((prevTotalPages) => + result.data.length > 0 + ? Math.max(prevTotalPages, currentPage + 1) + : 1, + ); + } + } else { + setError('Failed to load favorites.'); + setFavorites([]); + setTotalPages(1); + } + } catch (err) { + console.error('Failed to load favorites:', err); + setError('Failed to load favorites. Please try again later.'); + setFavorites([]); + setTotalPages(1); + } finally { + setIsLoading(false); + } + } + + loadFavorites(); + }, [currentPage]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // Loading state + if (isLoading) { + return ( +
+
+ {[...Array(6)].map((_, index) => ( +
+ +
+ + + +
+
+ ))} +
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + // Empty state + if (favorites.length === 0) { + return ( +
+
+

Você ainda não tem favoritos

+

+ Marque anúncios como favoritos para vê-los aqui. +

+
+
+ ); + } + + // Loaded state with data + return ( +
+
+ {favorites.map((listing) => ( + + ))} +
+ {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/frontend/vizzy/app/listing/[id]/page.tsx b/frontend/vizzy/app/listing/[id]/page.tsx index 6533055b..2ece6d09 100644 --- a/frontend/vizzy/app/listing/[id]/page.tsx +++ b/frontend/vizzy/app/listing/[id]/page.tsx @@ -2,11 +2,14 @@ import { useRouter } from 'next/navigation'; import { use, useEffect, useState } from 'react'; -import { Calendar, /* Heart*/ Info, MapPin, Tag, Pencil } from 'lucide-react'; +import { Calendar, Heart, Info, MapPin, Tag, Pencil } from 'lucide-react'; import Image from 'next/image'; import type { Listing } from '@/types/listing'; import { fetchListing } from '@/lib/api/listings/listings'; import { fetchListingImages } from '@/lib/api/listings/fetch-listing-images'; +import { checkFavoriteStatus } from '@/lib/api/favorites/check-favorite-status'; +import { addFavorite } from '@/lib/api/favorites/add-favorite'; +import { removeFavorite } from '@/lib/api/favorites/remove-favorite'; import { Card, CardContent } from '@/components/ui/data-display/card'; import { Skeleton } from '@/components/ui/data-display/skeleton'; import { Button } from '@/components/ui/common/button'; @@ -43,7 +46,8 @@ export default function ProductListing({ const { id } = use(params); const [listing, setListing] = useState(null); const [listingImages, setListingImages] = useState([]); - //const [isFavorite, setIsFavorite] = useState(false); + const [isFavorite, setIsFavorite] = useState(false); + const [isFavoriteLoading, setIsFavoriteLoading] = useState(true); const [isLoading, setIsLoading] = useState(true); const [ownerProfile, setOwnerProfile] = useState(null); @@ -82,7 +86,6 @@ export default function ProductListing({ ? [mainImage, ...imageUrls.filter((url) => url !== mainImage)] : imageUrls; - console.log('Fetched images:', allImages); setListingImages(allImages); if (data.data.owner_username) { @@ -109,6 +112,55 @@ export default function ProductListing({ getListingData(); }, [id]); + useEffect(() => { + const checkFavorite = async () => { + if (!currentUser) { + setIsFavorite(false); + setIsFavoriteLoading(false); + return; + } + + try { + const result = await checkFavoriteStatus(parseInt(id)); + if (result && 'data' in result && result.data) { + setIsFavorite(result.data.isFavorited); + } else { + setIsFavorite(false); + } + } catch (error) { + console.error('Error checking favorite status:', error); + setIsFavorite(false); + } finally { + setIsFavoriteLoading(false); + } + }; + + checkFavorite(); + }, [id, currentUser]); + + const handleFavoriteClick = async () => { + if (!currentUser) { + // Optionally redirect to login or show a message + return; + } + + try { + if (isFavorite) { + const result = await removeFavorite(parseInt(id)); + if ('data' in result) { + setIsFavorite(false); + } + } else { + const result = await addFavorite(parseInt(id)); + if ('data' in result) { + setIsFavorite(true); + } + } + } catch (error) { + console.error('Error toggling favorite:', error); + } + }; + if (isLoading) { return ( @@ -488,6 +540,27 @@ export default function ProductListing({ {ownerProfile.location} )} + {currentUser && currentUser.id !== listing.owner_id && ( + + )}
diff --git a/frontend/vizzy/lib/api/favorites/add-favorite.ts b/frontend/vizzy/lib/api/favorites/add-favorite.ts new file mode 100644 index 00000000..324646a8 --- /dev/null +++ b/frontend/vizzy/lib/api/favorites/add-favorite.ts @@ -0,0 +1,20 @@ +import { apiRequest } from '@/lib/api/core/client'; +import { tryCatch, type Result } from '@/lib/utils/try-catch'; +import { getAuthTokensAction } from '@/lib/actions/auth/token-action'; + +export async function addFavorite(listingId: number): Promise> { + return tryCatch( + (async () => { + const { accessToken } = await getAuthTokensAction(); + if (!accessToken) { + throw new Error('Authentication required'); + } + + return apiRequest({ + method: 'POST', + endpoint: `favorites/${listingId}`, + token: accessToken, + }); + })(), + ); +} diff --git a/frontend/vizzy/lib/api/favorites/check-favorite-status.ts b/frontend/vizzy/lib/api/favorites/check-favorite-status.ts new file mode 100644 index 00000000..0ecd2ab0 --- /dev/null +++ b/frontend/vizzy/lib/api/favorites/check-favorite-status.ts @@ -0,0 +1,26 @@ +import { apiRequest } from '@/lib/api/core/client'; +import { tryCatch, type Result } from '@/lib/utils/try-catch'; +import { getAuthTokensAction } from '@/lib/actions/auth/token-action'; + +export interface FavoriteStatusResponse { + isFavorited: boolean; +} + +export async function checkFavoriteStatus( + listingId: number, +): Promise> { + return tryCatch( + (async () => { + const { accessToken } = await getAuthTokensAction(); + if (!accessToken) { + throw new Error('Authentication required'); + } + + return apiRequest({ + method: 'GET', + endpoint: `favorites/${listingId}/status`, + token: accessToken, + }); + })(), + ); +} diff --git a/frontend/vizzy/lib/api/favorites/get-favorites.ts b/frontend/vizzy/lib/api/favorites/get-favorites.ts new file mode 100644 index 00000000..8b6b3917 --- /dev/null +++ b/frontend/vizzy/lib/api/favorites/get-favorites.ts @@ -0,0 +1,33 @@ +import { apiRequest } from '@/lib/api/core/client'; +import { tryCatch, type Result } from '@/lib/utils/try-catch'; +import type { ListingBasic } from '@/types/listing'; +import { getAuthTokensAction } from '@/lib/actions/auth/token-action'; + +interface GetFavoritesParams { + limit?: number; + offset?: number; +} + +export async function getFavorites( + params?: GetFavoritesParams, +): Promise> { + return tryCatch( + (async () => { + const { accessToken } = await getAuthTokensAction(); + if (!accessToken) { + throw new Error('Authentication required'); + } + + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.offset) + queryParams.append('offset', params.offset.toString()); + + return apiRequest({ + method: 'GET', + endpoint: `favorites?${queryParams.toString()}`, + token: accessToken, + }); + })(), + ); +} diff --git a/frontend/vizzy/lib/api/favorites/remove-favorite.ts b/frontend/vizzy/lib/api/favorites/remove-favorite.ts new file mode 100644 index 00000000..6c12395b --- /dev/null +++ b/frontend/vizzy/lib/api/favorites/remove-favorite.ts @@ -0,0 +1,20 @@ +import { apiRequest } from '@/lib/api/core/client'; +import { tryCatch, type Result } from '@/lib/utils/try-catch'; +import { getAuthTokensAction } from '@/lib/actions/auth/token-action'; + +export async function removeFavorite(listingId: number): Promise> { + return tryCatch( + (async () => { + const { accessToken } = await getAuthTokensAction(); + if (!accessToken) { + throw new Error('Authentication required'); + } + + return apiRequest({ + method: 'DELETE', + endpoint: `favorites/${listingId}`, + token: accessToken, + }); + })(), + ); +} From fe7e6e1615e8adaf4f2f18349ffc4c0865efd4f3 Mon Sep 17 00:00:00 2001 From: DiogoMachado04 Date: Thu, 29 May 2025 16:00:22 +0100 Subject: [PATCH 2/3] fix: invalidation of cache --- .../vizzy-backend/src/favorites/favorites.service.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/vizzy-backend/src/favorites/favorites.service.ts b/backend/vizzy-backend/src/favorites/favorites.service.ts index 54ec8eb4..c7e7bb69 100644 --- a/backend/vizzy-backend/src/favorites/favorites.service.ts +++ b/backend/vizzy-backend/src/favorites/favorites.service.ts @@ -163,8 +163,15 @@ export class FavoritesService { */ private async invalidateUserFavoritesCache(userId: string): Promise { const redisClient = this.redisService.getRedisClient(); - const cacheKey = FAVORITES_CACHE_KEYS.USER_LIST(userId); - await GlobalCacheHelper.invalidateCache(redisClient, cacheKey); + + // Get all keys matching the user's favorites pattern + const pattern = `favorites:user:${userId}:list:*`; + const keys = await redisClient.keys(pattern); + + if (keys.length > 0) { + await redisClient.del(...keys); + } + this.logger.info(`Service: Invalidated favorites cache for user ${userId}`); } From 9c7d92391723d11228151d1cd667fb29347d87db Mon Sep 17 00:00:00 2001 From: DiogoMachado04 Date: Thu, 29 May 2025 16:07:33 +0100 Subject: [PATCH 3/3] feat: adding translations --- .../app/dashboard/layout/favorites-page.tsx | 16 ++++++++-------- frontend/vizzy/messages/en.json | 11 +++++++++++ frontend/vizzy/messages/pt.json | 11 +++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/vizzy/app/dashboard/layout/favorites-page.tsx b/frontend/vizzy/app/dashboard/layout/favorites-page.tsx index aefbc8be..a9d0ae96 100644 --- a/frontend/vizzy/app/dashboard/layout/favorites-page.tsx +++ b/frontend/vizzy/app/dashboard/layout/favorites-page.tsx @@ -7,8 +7,10 @@ import type { ListingBasic } from '@/types/listing'; import { getFavorites } from '@/lib/api/favorites/get-favorites'; import { Skeleton } from '@/components/ui/data-display/skeleton'; import { PaginationControls } from '@/components/marketplace/pagination-controls'; +import { useTranslations } from 'next-intl'; export function FavoritesPage() { + const t = useTranslations('favoritesPage'); const [favorites, setFavorites] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -40,13 +42,13 @@ export function FavoritesPage() { ); } } else { - setError('Failed to load favorites.'); + setError(t('error.loadFailed')); setFavorites([]); setTotalPages(1); } } catch (err) { console.error('Failed to load favorites:', err); - setError('Failed to load favorites. Please try again later.'); + setError(t('error.loadFailedWithRetry')); setFavorites([]); setTotalPages(1); } finally { @@ -55,7 +57,7 @@ export function FavoritesPage() { } loadFavorites(); - }, [currentPage]); + }, [currentPage, t]); const handlePageChange = (page: number) => { setCurrentPage(page); @@ -92,7 +94,7 @@ export function FavoritesPage() { className="mt-2" onClick={() => window.location.reload()} > - Tentar novamente + {t('error.tryAgain')}
@@ -104,10 +106,8 @@ export function FavoritesPage() { return (
-

Você ainda não tem favoritos

-

- Marque anúncios como favoritos para vê-los aqui. -

+

{t('empty.title')}

+

{t('empty.description')}

); diff --git a/frontend/vizzy/messages/en.json b/frontend/vizzy/messages/en.json index 8a381baf..6b203702 100644 --- a/frontend/vizzy/messages/en.json +++ b/frontend/vizzy/messages/en.json @@ -338,5 +338,16 @@ "goBack": "Go Back" } } + }, + "favoritesPage": { + "error": { + "loadFailed": "Failed to load favorites.", + "tryAgain": "Try again", + "loadFailedWithRetry": "Failed to load favorites. Please try again later." + }, + "empty": { + "title": "You don't have any favorites yet", + "description": "Mark listings as favorites to see them here." + } } } diff --git a/frontend/vizzy/messages/pt.json b/frontend/vizzy/messages/pt.json index a78d84e8..af4a58c2 100644 --- a/frontend/vizzy/messages/pt.json +++ b/frontend/vizzy/messages/pt.json @@ -338,5 +338,16 @@ "goBack": "Voltar" } } + }, + "favoritesPage": { + "error": { + "loadFailed": "Falha ao carregar favoritos.", + "tryAgain": "Tentar novamente", + "loadFailedWithRetry": "Falha ao carregar favoritos. Por favor, tente novamente mais tarde." + }, + "empty": { + "title": "Você ainda não tem favoritos", + "description": "Marque anúncios como favoritos para vê-los aqui." + } } }