Zapisane wyszukiwania na koncie zamiast w przegladarce
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:
2026-09-23 09:10:20 +02:00
parent 8cd2db38cc
commit e85cc70982
7 changed files with 532 additions and 24 deletions
+107 -24
View File
@@ -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;
+31
View File
@@ -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');
});
});