Zapisane wyszukiwania na koncie zamiast w przegladarce
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Nowa tabela saved_searches i /api/saved-searches (lista, dodanie, usuniecie, jednorazowy import wpisow z localStorage). Front czyta liste z serwera, wiec wyszukiwania sa te same na kazdym urzadzeniu i przezywaja wyczyszczenie danych strony. Bez powiadomien. Skrypt czyszczacy usuwa wpisy skasowanych kont. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Zapisane wyszukiwanie uzytkownika ("Zapisz wyszukiwanie" na liscie ofert). Wczesniej
|
||||
* zylo tylko w localStorage przegladarki, wiec ginelo po zmianie urzadzenia albo wyczyszczeniu
|
||||
* danych. Nie wysylamy z niego zadnych powiadomien - to lista do wygodnego wracania do filtrow.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "saved_searches")
|
||||
public class SavedSearch {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
/** "buy" albo "rent" - te same wartosci co w adresach /kupno i /wynajem. */
|
||||
@Column(nullable = false, length = 10)
|
||||
private String mode;
|
||||
|
||||
@Column(length = 120)
|
||||
private String city;
|
||||
|
||||
@Column(name = "property_type", length = 60)
|
||||
private String propertyType;
|
||||
|
||||
@Column(name = "price_min")
|
||||
private Long priceMin;
|
||||
|
||||
@Column(name = "price_max")
|
||||
private Long priceMax;
|
||||
|
||||
@Column(name = "area_min")
|
||||
private Integer areaMin;
|
||||
|
||||
@Column(name = "area_max")
|
||||
private Integer areaMax;
|
||||
|
||||
private Integer rooms;
|
||||
|
||||
@Column(name = "secondary_market", nullable = false)
|
||||
private boolean secondaryMarket;
|
||||
|
||||
@Column(name = "primary_market", nullable = false)
|
||||
private boolean primaryMarket;
|
||||
|
||||
@Column(name = "no_fee_only", nullable = false)
|
||||
private boolean noFeeOnly;
|
||||
|
||||
/** Pozostale filtry ("wiecej filtrow") jako JSON - jest ich wiele i zmieniaja sie z widokiem. */
|
||||
@Column(name = "more_filters", columnDefinition = "text")
|
||||
private String moreFilters;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getPropertyType() {
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
public void setPropertyType(String propertyType) {
|
||||
this.propertyType = propertyType;
|
||||
}
|
||||
|
||||
public Long getPriceMin() {
|
||||
return priceMin;
|
||||
}
|
||||
|
||||
public void setPriceMin(Long priceMin) {
|
||||
this.priceMin = priceMin;
|
||||
}
|
||||
|
||||
public Long getPriceMax() {
|
||||
return priceMax;
|
||||
}
|
||||
|
||||
public void setPriceMax(Long priceMax) {
|
||||
this.priceMax = priceMax;
|
||||
}
|
||||
|
||||
public Integer getAreaMin() {
|
||||
return areaMin;
|
||||
}
|
||||
|
||||
public void setAreaMin(Integer areaMin) {
|
||||
this.areaMin = areaMin;
|
||||
}
|
||||
|
||||
public Integer getAreaMax() {
|
||||
return areaMax;
|
||||
}
|
||||
|
||||
public void setAreaMax(Integer areaMax) {
|
||||
this.areaMax = areaMax;
|
||||
}
|
||||
|
||||
public Integer getRooms() {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
public void setRooms(Integer rooms) {
|
||||
this.rooms = rooms;
|
||||
}
|
||||
|
||||
public boolean isSecondaryMarket() {
|
||||
return secondaryMarket;
|
||||
}
|
||||
|
||||
public void setSecondaryMarket(boolean secondaryMarket) {
|
||||
this.secondaryMarket = secondaryMarket;
|
||||
}
|
||||
|
||||
public boolean isPrimaryMarket() {
|
||||
return primaryMarket;
|
||||
}
|
||||
|
||||
public void setPrimaryMarket(boolean primaryMarket) {
|
||||
this.primaryMarket = primaryMarket;
|
||||
}
|
||||
|
||||
public boolean isNoFeeOnly() {
|
||||
return noFeeOnly;
|
||||
}
|
||||
|
||||
public void setNoFeeOnly(boolean noFeeOnly) {
|
||||
this.noFeeOnly = noFeeOnly;
|
||||
}
|
||||
|
||||
public String getMoreFilters() {
|
||||
return moreFilters;
|
||||
}
|
||||
|
||||
public void setMoreFilters(String moreFilters) {
|
||||
this.moreFilters = moreFilters;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Zapisane wyszukiwania zalogowanego uzytkownika. Dostep wymaga logowania (regula
|
||||
* anyRequest().authenticated() w SecurityConfig), a kazda operacja dotyczy wylacznie
|
||||
* wpisow wlasciciela konta. Zadnych powiadomien - to tylko lista zapisanych filtrow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/saved-searches")
|
||||
public class SavedSearchController {
|
||||
|
||||
/** Tyle samo, ile dotad miescil zapis w przegladarce - chroni przed zasypaniem listy. */
|
||||
static final int LIMIT = 60;
|
||||
|
||||
private final SavedSearchRepository repository;
|
||||
|
||||
public SavedSearchController(SavedSearchRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** Kryteria wyszukiwania - dokladnie to, co pokazuje pasek filtrow na liscie ofert. */
|
||||
public record SavedSearchRequest(String mode, String city, String propertyType, Long priceMin, Long priceMax,
|
||||
Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny, boolean pierwotny,
|
||||
boolean noFeeOnly, String more) {
|
||||
}
|
||||
|
||||
public record SavedSearchResponse(Long id, String mode, String city, String propertyType, Long priceMin,
|
||||
Long priceMax, Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny,
|
||||
boolean pierwotny, boolean noFeeOnly, String more, Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<SavedSearchResponse> list(Authentication authentication) {
|
||||
return repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(authentication.getName()).stream()
|
||||
.map(SavedSearchController::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public SavedSearchResponse add(@RequestBody SavedSearchRequest request, Authentication authentication) {
|
||||
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak trybu wyszukiwania");
|
||||
}
|
||||
String userEmail = authentication.getName();
|
||||
List<SavedSearch> existing = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||
SavedSearch duplicate = existing.stream().filter(saved -> sameCriteria(saved, request)).findFirst().orElse(null);
|
||||
if (duplicate != null) {
|
||||
return toResponse(duplicate);
|
||||
}
|
||||
// Najstarsze wpisy ponad limit usuwamy, zeby zapis zawsze sie udal.
|
||||
existing.stream().skip(LIMIT - 1L).forEach(repository::delete);
|
||||
return toResponse(repository.save(fromRequest(request, userEmail)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Przeniesienie wyszukiwan zapisanych wczesniej w przegladarce. Dodajemy tylko te,
|
||||
* ktorych uzytkownik nie ma jeszcze na koncie - import mozna powtorzyc bez duplikatow.
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
@Transactional
|
||||
public List<SavedSearchResponse> importLocal(@RequestBody List<SavedSearchRequest> requests, Authentication authentication) {
|
||||
String userEmail = authentication.getName();
|
||||
if (requests != null) {
|
||||
for (SavedSearchRequest request : requests) {
|
||||
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
List<SavedSearch> current = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||
if (current.size() >= LIMIT || current.stream().anyMatch(saved -> sameCriteria(saved, request))) {
|
||||
continue;
|
||||
}
|
||||
repository.save(fromRequest(request, userEmail));
|
||||
}
|
||||
}
|
||||
return list(authentication);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> remove(@PathVariable Long id, Authentication authentication) {
|
||||
repository.findById(id)
|
||||
.filter(saved -> saved.getUserEmail().equalsIgnoreCase(authentication.getName()))
|
||||
.ifPresent(repository::delete);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static SavedSearch fromRequest(SavedSearchRequest request, String userEmail) {
|
||||
SavedSearch saved = new SavedSearch();
|
||||
Instant now = Instant.now();
|
||||
saved.setUserEmail(userEmail);
|
||||
saved.setMode(request.mode());
|
||||
saved.setCity(request.city());
|
||||
saved.setPropertyType(request.propertyType());
|
||||
saved.setPriceMin(request.priceMin());
|
||||
saved.setPriceMax(request.priceMax());
|
||||
saved.setAreaMin(request.areaMin());
|
||||
saved.setAreaMax(request.areaMax());
|
||||
saved.setRooms(request.rooms());
|
||||
saved.setSecondaryMarket(request.wtorny());
|
||||
saved.setPrimaryMarket(request.pierwotny());
|
||||
saved.setNoFeeOnly(request.noFeeOnly());
|
||||
saved.setMoreFilters(request.more());
|
||||
saved.setCreatedAt(now);
|
||||
saved.setUpdatedAt(now);
|
||||
return saved;
|
||||
}
|
||||
|
||||
static boolean sameCriteria(SavedSearch saved, SavedSearchRequest request) {
|
||||
return Objects.equals(saved.getMode(), request.mode())
|
||||
&& Objects.equals(trim(saved.getCity()), trim(request.city()))
|
||||
&& Objects.equals(trim(saved.getPropertyType()), trim(request.propertyType()))
|
||||
&& Objects.equals(saved.getPriceMin(), request.priceMin())
|
||||
&& Objects.equals(saved.getPriceMax(), request.priceMax())
|
||||
&& Objects.equals(saved.getAreaMin(), request.areaMin())
|
||||
&& Objects.equals(saved.getAreaMax(), request.areaMax())
|
||||
&& Objects.equals(saved.getRooms(), request.rooms())
|
||||
&& saved.isSecondaryMarket() == request.wtorny()
|
||||
&& saved.isPrimaryMarket() == request.pierwotny()
|
||||
&& saved.isNoFeeOnly() == request.noFeeOnly()
|
||||
&& Objects.equals(trim(saved.getMoreFilters()), trim(request.more()));
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static SavedSearchResponse toResponse(SavedSearch saved) {
|
||||
return new SavedSearchResponse(saved.getId(), saved.getMode(), saved.getCity(), saved.getPropertyType(),
|
||||
saved.getPriceMin(), saved.getPriceMax(), saved.getAreaMin(), saved.getAreaMax(), saved.getRooms(),
|
||||
saved.isSecondaryMarket(), saved.isPrimaryMarket(), saved.isNoFeeOnly(), saved.getMoreFilters(),
|
||||
saved.getCreatedAt(), saved.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SavedSearchRepository extends JpaRepository<SavedSearch, Long> {
|
||||
|
||||
List<SavedSearch> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||
|
||||
void deleteByUserEmailIgnoreCase(String userEmail);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import pl.polskalokalnie.search.SavedSearchController.SavedSearchRequest;
|
||||
|
||||
class SavedSearchControllerTest {
|
||||
|
||||
private SavedSearch saved(String city, Long priceMax, String more) {
|
||||
SavedSearch saved = new SavedSearch();
|
||||
saved.setMode("buy");
|
||||
saved.setCity(city);
|
||||
saved.setPropertyType("Mieszkanie");
|
||||
saved.setPriceMax(priceMax);
|
||||
saved.setSecondaryMarket(true);
|
||||
saved.setMoreFilters(more);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private SavedSearchRequest request(String city, Long priceMax, String more) {
|
||||
return new SavedSearchRequest("buy", city, "Mieszkanie", null, priceMax, null, null, null, true, false, false, more);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsSameCriteriaAsDuplicate() {
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{}"))).isTrue();
|
||||
// Spacje i brak wartosci nie robia z wyszukiwania nowego wpisu.
|
||||
assertThat(SavedSearchController.sameCriteria(saved(" Kraków ", 800000L, null), request("Kraków", 800000L, ""))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentFiltersAreSeparateSearches() {
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 900000L, "{}"))).isFalse();
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Gdańsk", 800000L, "{}"))).isFalse();
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{\"balkon\":\"tak\"}"))).isFalse();
|
||||
}
|
||||
}
|
||||
+107
-24
@@ -384,7 +384,71 @@ const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions';
|
||||
const LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions';
|
||||
const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots';
|
||||
const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events';
|
||||
// Klucz zostaje tylko po to, zeby raz przeniesc na konto wyszukiwania zapisane w przegladarce
|
||||
// przed przeniesieniem tej funkcji na serwer.
|
||||
const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches';
|
||||
|
||||
// Zapisane wyszukiwanie w postaci z API - id nadaje serwer.
|
||||
type ApiSavedSearch = {
|
||||
id: number;
|
||||
mode: SavedSearchMode;
|
||||
city: string | null;
|
||||
propertyType: string | null;
|
||||
priceMin: number | null;
|
||||
priceMax: number | null;
|
||||
areaMin: number | null;
|
||||
areaMax: number | null;
|
||||
rooms: number | null;
|
||||
wtorny: boolean;
|
||||
pierwotny: boolean;
|
||||
noFeeOnly: boolean;
|
||||
more: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function fromApiSavedSearch(item: ApiSavedSearch): SavedSearchRecord {
|
||||
let more: Record<string, string> = {};
|
||||
try {
|
||||
more = item.more ? JSON.parse(item.more) as Record<string, string> : {};
|
||||
} catch {
|
||||
more = {};
|
||||
}
|
||||
return {
|
||||
id: String(item.id),
|
||||
mode: item.mode,
|
||||
city: item.city ?? '',
|
||||
propertyType: item.propertyType ?? '',
|
||||
priceMin: item.priceMin,
|
||||
priceMax: item.priceMax,
|
||||
areaMin: item.areaMin,
|
||||
areaMax: item.areaMax,
|
||||
rooms: item.rooms,
|
||||
wtorny: item.wtorny,
|
||||
pierwotny: item.pierwotny,
|
||||
noFeeOnly: item.noFeeOnly,
|
||||
more,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toSavedSearchPayload(search: SavedSearchRecord | SavedSearchDraft) {
|
||||
return {
|
||||
mode: search.mode,
|
||||
city: search.city,
|
||||
propertyType: search.propertyType,
|
||||
priceMin: search.priceMin,
|
||||
priceMax: search.priceMax,
|
||||
areaMin: search.areaMin,
|
||||
areaMax: search.areaMax,
|
||||
rooms: search.rooms,
|
||||
wtorny: search.wtorny,
|
||||
pierwotny: search.pierwotny,
|
||||
noFeeOnly: search.noFeeOnly,
|
||||
more: JSON.stringify(search.more ?? {}),
|
||||
};
|
||||
}
|
||||
// Lokalizacje, ktorych cene za m2 sprawdzil uzytkownik - najnowsza pierwsza, przechowywane per konto.
|
||||
const PRICE_HISTORY_MARKETS_SUFFIX = 'price-history-markets';
|
||||
const PRICE_HISTORY_MARKETS_LIMIT = 5;
|
||||
@@ -2703,12 +2767,26 @@ function App() {
|
||||
navigate(ROUTES.buy);
|
||||
};
|
||||
|
||||
// Zapisane wyszukiwania trzyma serwer (/api/saved-searches), wiec sa te same na kazdym
|
||||
// urzadzeniu. Wpisy zapisane wczesniej w przegladarce przenosimy raz przy pierwszym wejsciu.
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setSavedSearchesState([]);
|
||||
return;
|
||||
}
|
||||
setSavedSearchesState(readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []));
|
||||
let cancelled = false;
|
||||
const local = readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []);
|
||||
const load = local.length > 0
|
||||
? apiFetch<ApiSavedSearch[]>('/saved-searches/import', { method: 'POST', body: JSON.stringify(local.map(toSavedSearchPayload)) })
|
||||
.then((items) => {
|
||||
writeUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []);
|
||||
return items;
|
||||
})
|
||||
: apiFetch<ApiSavedSearch[]>('/saved-searches');
|
||||
load
|
||||
.then((items) => { if (!cancelled) { setSavedSearchesState(items.map(fromApiSavedSearch)); } })
|
||||
.catch(() => { if (!cancelled) { setSavedSearchesState([]); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2717,12 +2795,6 @@ function App() {
|
||||
setListingPriceAlertSubscriptionsState(readUserJsonStorage<ListingPriceAlertSubscription[]>(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []));
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
writeUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, savedSearchesState);
|
||||
}, [savedSearchesState, user]);
|
||||
|
||||
useEffect(() => {
|
||||
writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState);
|
||||
@@ -3141,20 +3213,19 @@ function App() {
|
||||
}
|
||||
|
||||
const draft = buildSavedSearchDraft(mode, filters);
|
||||
setSavedSearchesState((current) => {
|
||||
const existing = current.find((item) => sameSavedSearchCriteria(item, draft));
|
||||
if (existing) {
|
||||
return current.filter((item) => item.id !== existing.id);
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
const next: SavedSearchRecord = {
|
||||
id: `saved-search-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
...draft,
|
||||
};
|
||||
return [next, ...current].slice(0, SAVED_SEARCHES_LIMIT);
|
||||
});
|
||||
const existing = savedSearchesState.find((item) => sameSavedSearchCriteria(item, draft));
|
||||
if (existing) {
|
||||
removeSavedSearch(existing.id);
|
||||
return;
|
||||
}
|
||||
apiFetch<ApiSavedSearch>('/saved-searches', { method: 'POST', body: JSON.stringify(toSavedSearchPayload(draft)) })
|
||||
.then((saved) => {
|
||||
const record = fromApiSavedSearch(saved);
|
||||
setSavedSearchesState((current) => (
|
||||
current.some((item) => item.id === record.id) ? current : [record, ...current].slice(0, SAVED_SEARCHES_LIMIT)
|
||||
));
|
||||
})
|
||||
.catch(() => { /* brak sieci - lista zostaje bez zmian, uzytkownik moze sprobowac ponownie */ });
|
||||
};
|
||||
|
||||
const openSavedSearch = (savedSearch: SavedSearchRecord) => {
|
||||
@@ -3164,6 +3235,8 @@ function App() {
|
||||
|
||||
const removeSavedSearch = (savedSearchId: string) => {
|
||||
setSavedSearchesState((current) => current.filter((item) => item.id !== savedSearchId));
|
||||
apiFetch<void>(`/saved-searches/${encodeURIComponent(savedSearchId)}`, { method: 'DELETE' })
|
||||
.catch(() => { /* usuniecie na serwerze nie wyszlo - wroci przy nastepnym wejsciu */ });
|
||||
};
|
||||
|
||||
const buySearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('buy', filters)));
|
||||
@@ -6417,9 +6490,19 @@ function AccountSidebar() {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
const savedSearchCount = user
|
||||
? readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []).length
|
||||
: 0;
|
||||
// Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert.
|
||||
const [savedSearchCount, setSavedSearchCount] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setSavedSearchCount(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
apiFetch<ApiSavedSearch[]>('/saved-searches')
|
||||
.then((items) => { if (!cancelled) { setSavedSearchCount(items.length); } })
|
||||
.catch(() => { if (!cancelled) { setSavedSearchCount(0); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [user, location.pathname]);
|
||||
const favoritesCount = user
|
||||
? readUserJsonStorage<FavoriteListing[]>(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length
|
||||
: 0;
|
||||
|
||||
@@ -500,3 +500,34 @@ test.describe('Standard: przyciski robia to, co obiecuja', () => {
|
||||
await expect(page.locator('a.help-contact-channel-action[href^="tel:"]')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Zapisane wyszukiwania trzyma serwer, wiec sa te same w kazdej przegladarce i przezywaja
|
||||
// wyczyszczenie danych strony. Bez powiadomien - to tylko lista filtrow do wygodnego powrotu.
|
||||
test.describe('Zapisane wyszukiwania', () => {
|
||||
test('zapis przezywa wyczyszczenie pamieci przegladarki', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, `${ROUTES.buy}?miasto=Gdynia`);
|
||||
const saveButton = page.getByRole('button', { name: /wyszukiwanie/ }).first();
|
||||
if ((await saveButton.innerText()).includes('Zapisano')) {
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toHaveText(/Zapisz wyszukiwanie/);
|
||||
}
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toHaveText(/Zapisano wyszukiwanie/);
|
||||
|
||||
// Symulacja innego urzadzenia: zostaje sam token logowania, reszta pamieci znika.
|
||||
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.accountSearches);
|
||||
await expect(page.locator('.account-searches-list, main').first()).toContainText('Gdynia');
|
||||
|
||||
// Sprzatanie, zeby kolejne uruchomienie testu zaczynalo od czystej listy.
|
||||
await page.locator('.account-search-remove').first().click();
|
||||
await expect(page.locator('main')).not.toContainText('Gdynia');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ DELETE FROM notifications WHERE lower(user_email) NOT IN (SELECT email FROM k
|
||||
DELETE FROM auth_tokens WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
|
||||
DELETE FROM phone_otps WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
|
||||
DELETE FROM support_messages WHERE lower(account_email) NOT IN (SELECT email FROM keep_emails);
|
||||
DELETE FROM saved_searches WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
|
||||
DELETE FROM user_settings WHERE user_id NOT IN (SELECT id FROM keep_users);
|
||||
|
||||
DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);
|
||||
|
||||
Reference in New Issue
Block a user