diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java index 5f22035..32f5a66 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java @@ -17,8 +17,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; /** - * Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI), - * a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte. + * Ulubione ogloszenia uzytkownika - trzymane na koncie, wiec sa te same na kazdym urzadzeniu. + * Z tych samych danych liczymy wlascicielowi, ile osob zapisalo jego oferte. */ @RestController @RequestMapping("/api/listings") @@ -49,30 +49,38 @@ public class ListingFavoriteController { return ResponseEntity.noContent().build(); } - /** - * Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie - * i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego, - * z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie. - */ - @PostMapping("/favorites/sync") - @Transactional - public ResponseEntity sync(@RequestBody SyncRequest request, Authentication authentication) { - if (request == null || request.listingIds() == null) { - return ResponseEntity.noContent().build(); - } - String userEmail = authentication.getName(); - List wanted = request.listingIds().stream() - .filter(java.util.Objects::nonNull) - .distinct() - .filter(listingRepository::existsById) + /** Ulubione zalogowanego uzytkownika - te same na kazdym urzadzeniu. */ + @GetMapping("/favorites") + public List myFavorites(Authentication authentication) { + List listingIds = favoriteRepository.findByUserEmailIgnoreCase(authentication.getName()).stream() + .map(ListingFavorite::getListingId) .toList(); + if (listingIds.isEmpty()) { + return List.of(); + } + // Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy. + return listingRepository.findAllById(listingIds).stream() + .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) + .map(listing -> ListingResponse.from(listing, listing.isApproximateLocation())) + .toList(); + } - favoriteRepository.findByUserEmailIgnoreCase(userEmail).stream() - .filter(favorite -> !wanted.contains(favorite.getListingId())) - .forEach(favoriteRepository::delete); - - wanted.forEach(listingId -> save(listingId, userEmail)); - return ResponseEntity.noContent().build(); + /** + * Dopisanie ulubionych zapisanych wczesniej w przegladarce. Tylko dodaje - nigdy nie kasuje, + * zeby wejscie z pustej przegladarki nie wyczyscilo listy zebranej na innym urzadzeniu. + */ + @PostMapping("/favorites/import") + @Transactional + public List importLocal(@RequestBody SyncRequest request, Authentication authentication) { + if (request != null && request.listingIds() != null) { + String userEmail = authentication.getName(); + request.listingIds().stream() + .filter(java.util.Objects::nonNull) + .distinct() + .filter(listingRepository::existsById) + .forEach(listingId -> save(listingId, userEmail)); + } + return myFavorites(authentication); } /** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc70288..346d737 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2782,33 +2782,40 @@ function App() { }, [user]); useEffect(() => { - setFavoriteListingsState(readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, [])); setFavoritesPreferredTab(readUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, 'buy')); setListingPriceAlertSubscriptionsState(readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, [])); }, [user]); - - useEffect(() => { - writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState); - }, [favoriteListingsState, user]); - - // Ulubione zyja lokalnie (szybkie UI), ale wlasciciel ogloszenia musi widziec, ile osob je zapisalo. - // Po kazdej zmianie wysylamy caly zbior - dziala niezaleznie od tego, gdzie uzytkownik kliknal serce. + // Ulubione trzyma konto (/api/listings/favorites), wiec sa te same na kazdym urzadzeniu. + // Wpisy zapisane wczesniej w przegladarce dopisujemy raz przy pierwszym wejsciu. useEffect(() => { if (!user) { + setFavoriteListingsState([]); return; } - const listingIds = favoriteListingsState - .map((favorite) => favorite.listingId) - .filter((listingId): listingId is number => typeof listingId === 'number'); + let cancelled = false; + const local = readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []); + const localIds = local.map((item) => item.listingId).filter((id): id is number => typeof id === 'number'); + const load = localIds.length > 0 + ? apiFetch('/listings/favorites/import', { method: 'POST', body: JSON.stringify({ listingIds: localIds }) }) + .then((items) => { + writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []); + return items; + }) + : apiFetch('/listings/favorites'); + load + .then((items) => { if (!cancelled) { setFavoriteListingsState(items.map(toFavoriteListingFromSummary)); } }) + .catch(() => { if (!cancelled) { setFavoriteListingsState([]); } }); + return () => { cancelled = true; }; + }, [user]); - apiFetch('/listings/favorites/sync', { - method: 'POST', - body: JSON.stringify({ listingIds }), - }).catch(() => { - // Brak polaczenia - licznik zapisan dogoni stan przy nastepnej zmianie ulubionych. - }); - }, [favoriteListingsState, user]); + + // Zapis i usuniecie ulubionego ida na serwer pojedynczo - stan przegladarki nigdy nie + // dyktuje serwerowi calej listy, wiec wejscie z nowego urzadzenia niczego nie kasuje. + const saveFavoriteOnServer = (listingId: number, add: boolean) => { + apiFetch(`/listings/${listingId}/favorite`, { method: add ? 'POST' : 'DELETE' }) + .catch(() => { /* brak sieci - zmiana wroci przy nastepnym wejsciu na konto */ }); + }; useEffect(() => { writeUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, favoritesPreferredTab); @@ -3241,6 +3248,7 @@ function App() { const toggleListingFavorite = (listing: ApiListingDetail) => { const listingKey = favoriteListingKey(listing.id); const alreadyFavorite = favoriteListingsState.some((item) => item.id === listingKey); + saveFavoriteOnServer(listing.id, !alreadyFavorite); if (!alreadyFavorite) { notificationsCenter.notify({ category: 'listing', @@ -3332,6 +3340,7 @@ function App() { const toggleSummaryFavorite = async (listing: ApiListingSummary) => { const listingKey = favoriteListingKey(listing.id); const exists = favoriteListingsState.some((item) => item.id === listingKey); + saveFavoriteOnServer(listing.id, !exists); if (exists) { setUnreadFavoriteIds((prev) => { if (!prev.has(listingKey)) { @@ -6497,9 +6506,19 @@ function AccountSidebar() { .catch(() => { if (!cancelled) { setSavedSearchCount(0); } }); return () => { cancelled = true; }; }, [user, location.pathname]); - const favoritesCount = user - ? readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length - : 0; + // Ulubione sa na koncie - licznik bierzemy z serwera, tak jak liczbe ofert i wyszukiwan. + const [favoritesCount, setFavoritesCount] = useState(0); + useEffect(() => { + if (!user) { + setFavoritesCount(0); + return; + } + let cancelled = false; + apiFetch('/listings/favorites') + .then((items) => { if (!cancelled) { setFavoritesCount(items.length); } }) + .catch(() => { if (!cancelled) { setFavoritesCount(0); } }); + return () => { cancelled = true; }; + }, [user, location.pathname]); // Licznik obejmuje oba rodzaje alertow - lokalizacji i pojedynczych ogloszen - // dokladnie tak, jak lista na /konto/alerty-cenowe. const priceAlertsCount = user @@ -26297,7 +26316,17 @@ function FavoritesPage({ return () => window.clearTimeout(timeoutId); }, [favoritesFeedback]); + // Usuniecie idzie takze na serwer - ulubione sa na koncie, nie w tej jednej przegladarce. + const forgetOnServer = (listingId: number) => { + apiFetch(`/listings/${listingId}/favorite`, { method: 'DELETE' }) + .catch(() => { /* brak sieci - wpis wroci przy nastepnym wejsciu */ }); + }; + const removeFavoriteListing = (id: string) => { + const removed = favoritesState.find((listing) => listing.id === id); + if (removed) { + forgetOnServer(removed.listingId); + } setFavoritesState((current) => current.filter((listing) => listing.id !== id)); setMenuListingId((current) => (current === id ? null : current)); setFavoritesFeedback('Usunięto ogłoszenie z ulubionych.'); @@ -26363,6 +26392,7 @@ function FavoritesPage({ return; } + favoritesState.forEach((listing) => forgetOnServer(listing.listingId)); setFavoritesState([]); setMenuListingId(null); setFavoritesFeedback('Usunięto wszystkie ogłoszenia z ulubionych.'); diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 69acdb9..416301b 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -566,3 +566,34 @@ test.describe('Spotkania', () => { await expect(page.locator('.messages-meeting-form')).toBeVisible(); }); }); + +// Ulubione sa na koncie. Test pilnuje regresji: wejscie z pustej przegladarki NIE moze +// skasowac ulubionych zapisanych wczesniej (kiedys front dyktowal serwerowi cala liste). +test.describe('Ulubione', () => { + test('przezywaja wyczyszczenie pamieci przegladarki', async ({ page }) => { + await login(page); + await goto(page, ROUTES.buy); + await page.locator('.real-result-card').first().waitFor(); + const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); + await goto(page, href!); + const title = await page.locator('.listing-detail-title, h1').first().innerText(); + + const heart = page.locator('button[aria-label*="ulubion"], .listing-detail-fav').first(); + await heart.click(); + await goto(page, ROUTES.favorites); + await expect(page.locator('main')).toContainText(title.trim().slice(0, 20)); + + await page.evaluate(() => { + const token = window.localStorage.getItem('polskalokalnie-auth-token'); + window.localStorage.clear(); + if (token) { + window.localStorage.setItem('polskalokalnie-auth-token', token); + } + }); + await goto(page, ROUTES.favorites); + await expect(page.locator('main')).toContainText(title.trim().slice(0, 20)); + + await page.locator('button[aria-label*="Usuń"], .favorites-card-remove').first().click(); + await expect(page.locator('main')).not.toContainText(title.trim().slice(0, 20)); + }); +});