Ulubione na koncie - koniec kasowania zapisow przez pusta przegladarke
Deploy Polska Lokalnie / deploy (push) Successful in 12s

Dotad front po kazdej zmianie dyktowal serwerowi cala liste ulubionych.
Zalogowanie sie na innym urzadzeniu wysylalo "mam zero ulubionych"
i kasowalo zapisy, przez co wlasciciel ogloszenia tracil licznik zapisan.

Teraz: GET /api/listings/favorites zwraca ulubione konta, dodanie
i usuniecie ida pojedynczo (POST/DELETE /{id}/favorite), a wpisy z
przegladarki dopisuje jednorazowy /favorites/import. Endpoint
/favorites/sync usuniety razem z jego jedynym wywolaniem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:26:00 +02:00
parent b9fecdea85
commit 557c76257b
3 changed files with 114 additions and 45 deletions
@@ -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<Void> sync(@RequestBody SyncRequest request, Authentication authentication) {
if (request == null || request.listingIds() == null) {
return ResponseEntity.noContent().build();
}
String userEmail = authentication.getName();
List<Long> 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<ListingResponse> myFavorites(Authentication authentication) {
List<Long> 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<ListingResponse> 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}. */
+51 -21
View File
@@ -2782,33 +2782,40 @@ function App() {
}, [user]);
useEffect(() => {
setFavoriteListingsState(readUserJsonStorage<FavoriteListing[]>(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []));
setFavoritesPreferredTab(readUserJsonStorage<FavoriteTab>(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, 'buy'));
setListingPriceAlertSubscriptionsState(readUserJsonStorage<ListingPriceAlertSubscription[]>(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<FavoriteListing[]>(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<ApiListingSummary[]>('/listings/favorites/import', { method: 'POST', body: JSON.stringify({ listingIds: localIds }) })
.then((items) => {
writeUserJsonStorage<FavoriteListing[]>(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []);
return items;
})
: apiFetch<ApiListingSummary[]>('/listings/favorites');
load
.then((items) => { if (!cancelled) { setFavoriteListingsState(items.map(toFavoriteListingFromSummary)); } })
.catch(() => { if (!cancelled) { setFavoriteListingsState([]); } });
return () => { cancelled = true; };
}, [user]);
apiFetch<void>('/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<void>(`/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<FavoriteListing[]>(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<ApiListingSummary[]>('/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<void>(`/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.');
+31
View File
@@ -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));
});
});