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 LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions';
|
||||||
const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots';
|
const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots';
|
||||||
const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events';
|
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';
|
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.
|
// Lokalizacje, ktorych cene za m2 sprawdzil uzytkownik - najnowsza pierwsza, przechowywane per konto.
|
||||||
const PRICE_HISTORY_MARKETS_SUFFIX = 'price-history-markets';
|
const PRICE_HISTORY_MARKETS_SUFFIX = 'price-history-markets';
|
||||||
const PRICE_HISTORY_MARKETS_LIMIT = 5;
|
const PRICE_HISTORY_MARKETS_LIMIT = 5;
|
||||||
@@ -2703,12 +2767,26 @@ function App() {
|
|||||||
navigate(ROUTES.buy);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setSavedSearchesState([]);
|
setSavedSearchesState([]);
|
||||||
return;
|
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]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2717,12 +2795,6 @@ function App() {
|
|||||||
setListingPriceAlertSubscriptionsState(readUserJsonStorage<ListingPriceAlertSubscription[]>(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []));
|
setListingPriceAlertSubscriptionsState(readUserJsonStorage<ListingPriceAlertSubscription[]>(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, savedSearchesState);
|
|
||||||
}, [savedSearchesState, user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState);
|
writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState);
|
||||||
@@ -3141,20 +3213,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const draft = buildSavedSearchDraft(mode, filters);
|
const draft = buildSavedSearchDraft(mode, filters);
|
||||||
setSavedSearchesState((current) => {
|
const existing = savedSearchesState.find((item) => sameSavedSearchCriteria(item, draft));
|
||||||
const existing = current.find((item) => sameSavedSearchCriteria(item, draft));
|
if (existing) {
|
||||||
if (existing) {
|
removeSavedSearch(existing.id);
|
||||||
return current.filter((item) => item.id !== existing.id);
|
return;
|
||||||
}
|
}
|
||||||
const nowIso = new Date().toISOString();
|
apiFetch<ApiSavedSearch>('/saved-searches', { method: 'POST', body: JSON.stringify(toSavedSearchPayload(draft)) })
|
||||||
const next: SavedSearchRecord = {
|
.then((saved) => {
|
||||||
id: `saved-search-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
const record = fromApiSavedSearch(saved);
|
||||||
createdAt: nowIso,
|
setSavedSearchesState((current) => (
|
||||||
updatedAt: nowIso,
|
current.some((item) => item.id === record.id) ? current : [record, ...current].slice(0, SAVED_SEARCHES_LIMIT)
|
||||||
...draft,
|
));
|
||||||
};
|
})
|
||||||
return [next, ...current].slice(0, SAVED_SEARCHES_LIMIT);
|
.catch(() => { /* brak sieci - lista zostaje bez zmian, uzytkownik moze sprobowac ponownie */ });
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSavedSearch = (savedSearch: SavedSearchRecord) => {
|
const openSavedSearch = (savedSearch: SavedSearchRecord) => {
|
||||||
@@ -3164,6 +3235,8 @@ function App() {
|
|||||||
|
|
||||||
const removeSavedSearch = (savedSearchId: string) => {
|
const removeSavedSearch = (savedSearchId: string) => {
|
||||||
setSavedSearchesState((current) => current.filter((item) => item.id !== savedSearchId));
|
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)));
|
const buySearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('buy', filters)));
|
||||||
@@ -6417,9 +6490,19 @@ function AccountSidebar() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const savedSearchCount = user
|
// Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert.
|
||||||
? readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []).length
|
const [savedSearchCount, setSavedSearchCount] = useState(0);
|
||||||
: 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
|
const favoritesCount = user
|
||||||
? readUserJsonStorage<FavoriteListing[]>(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length
|
? readUserJsonStorage<FavoriteListing[]>(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length
|
||||||
: 0;
|
: 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);
|
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 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 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 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 user_settings WHERE user_id NOT IN (SELECT id FROM keep_users);
|
||||||
|
|
||||||
DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);
|
DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);
|
||||||
|
|||||||
Reference in New Issue
Block a user