aktuazalizacja profilow,prefernecji,bezpiecznestwa
This commit is contained in:
+561
-80
@@ -3,8 +3,9 @@ import './styles.css';
|
||||
import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { Avatar, initialsFrom } from './Avatar';
|
||||
import { apiFetch, useAuth } from './auth';
|
||||
import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, UserSettings, UserSettingsPatch } from './auth';
|
||||
import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, SearchPreferences, UserSettings, UserSettingsPatch } from './auth';
|
||||
import { useNotifications, toDisplayNotifications } from './notifications';
|
||||
import type { ServerNotification, DisplayNotification } from './notifications';
|
||||
import { getManualTranslation } from './i18nOverrides';
|
||||
@@ -121,6 +122,8 @@ export type ApiListingDetail = {
|
||||
virtualTourUrl: string | null;
|
||||
ownerEmail: string | null;
|
||||
ownerId: number | null;
|
||||
// Zdjecie profilowe wlasciciela - null, gdy go nie ustawil albo ogloszenie nie ma konta w serwisie.
|
||||
ownerAvatar: string | null;
|
||||
status: ApiListingStatus;
|
||||
createdAt: string;
|
||||
viewsCount?: number;
|
||||
@@ -2395,15 +2398,88 @@ function clampCenter(center: LatLng): LatLng {
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// "ł" to osobna litera, a nie "l" ze znakiem diakrytycznym - NFD jej nie rozklada, wiec bez
|
||||
// tej podmiany "Łódź" zostaje jako "łodz" i wpisane "lodz" nigdy w nie nie trafia.
|
||||
.replace(/ł/g, 'l')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Odleglosc edycyjna: ile pojedynczych poprawek dzieli dwa napisy. Liczymy dodanie, usuniecie,
|
||||
* zamiane litery oraz przestawienie dwoch sasiednich liter ("wrocwal" zamiast "wroclaw") - to
|
||||
* ostatnie jest jedna z najczestszych literowek przy szybkim pisaniu, a zwykly Levenshtein
|
||||
* liczylby je jako dwie osobne poprawki i taka nazwa nie zmiescilaby sie w tolerancji.
|
||||
*/
|
||||
function editDistance(a: string, b: string): number {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
const rows: number[][] = [Array.from({ length: b.length + 1 }, (_, index) => index)];
|
||||
for (let i = 1; i <= a.length; i += 1) {
|
||||
const current = [i];
|
||||
for (let j = 1; j <= b.length; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
let best = Math.min(current[j - 1] + 1, rows[i - 1][j] + 1, rows[i - 1][j - 1] + cost);
|
||||
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
||||
best = Math.min(best, rows[i - 2][j - 2] + 1);
|
||||
}
|
||||
current[j] = best;
|
||||
}
|
||||
rows.push(current);
|
||||
}
|
||||
return rows[a.length][b.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ile literowek wybaczamy przy zapytaniu danej dlugosci. Krotkich nie ruszamy: "gda" pasuje
|
||||
* do zbyt wielu nazw, zeby jeszcze zgadywac, co uzytkownik mial na mysli.
|
||||
*
|
||||
* Progi dobrane pomiarem na liscie 55 polskich miast. Dopuszczenie dwoch poprawek juz przy
|
||||
* siedmiu znakach zaczyna mylic prawdziwe nazwy: "krakow" trafia w Katowice, a "kielce"
|
||||
* w Siedlce - a to gorszy blad niz nieznalezienie miasta wpisanego z dwiema literowkami.
|
||||
* Przy obecnych progach jedyne kolizje to pary nierozroznialne takze dla czlowieka
|
||||
* (Lublin i Lubin, Wrocław i Włocławek).
|
||||
*/
|
||||
function cityTypoTolerance(length: number): number {
|
||||
if (length < 4) {
|
||||
return 0;
|
||||
}
|
||||
return length < 8 ? 1 : 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy nazwa miasta pasuje do tego, co wpisano. Najpierw zwykle dopasowanie poczatku nazwy,
|
||||
* a gdy ono zawiedzie - z marginesem na literowki ("warszwa" trafia w "Warszawa").
|
||||
*/
|
||||
function cityStartsWith(cityName: string, query: string): boolean {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
return normalizeSearchText(cityName).startsWith(normalizedQuery);
|
||||
const normalizedName = normalizeSearchText(cityName);
|
||||
if (normalizedName.startsWith(normalizedQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tolerance = cityTypoTolerance(normalizedQuery.length);
|
||||
if (tolerance === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Porownujemy z poczatkiem nazwy, nie z cala - inaczej "gdansk" przegralby z dluzszymi nazwami.
|
||||
// Dlugosc okna zmieniamy w zakresie tolerancji, zeby zlapac tez brakujaca albo nadmiarowa litere.
|
||||
const from = Math.max(1, normalizedQuery.length - tolerance);
|
||||
const to = Math.min(normalizedName.length, normalizedQuery.length + tolerance);
|
||||
for (let length = from; length <= to; length += 1) {
|
||||
if (editDistance(normalizedQuery, normalizedName.slice(0, length)) <= tolerance) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findKnownCityKey(name: string): string | null {
|
||||
@@ -2647,16 +2723,8 @@ function getLastName(fullName: string): string {
|
||||
return parts.slice(1).join(' ');
|
||||
}
|
||||
|
||||
function getInitials(fullName: string): string {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return '??';
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
}
|
||||
// Inicjaly liczy komponent awatara - to ta sama regula w kazdym miejscu aplikacji.
|
||||
const getInitials = initialsFrom;
|
||||
|
||||
function contactPreferenceLabel(preference: ContactPreference | null | undefined): string {
|
||||
if (preference === 'EMAIL') {
|
||||
@@ -2682,7 +2750,7 @@ function preferredLanguageLabel(language: PreferredLanguage | null | undefined):
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, loadSearchPreferences } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
@@ -2715,6 +2783,23 @@ function App() {
|
||||
const notificationsCenter = useNotifications();
|
||||
const [savedSearchesState, setSavedSearchesState] = useState<SavedSearchRecord[]>([]);
|
||||
|
||||
/**
|
||||
* Preferencje wyszukiwania z ustawien konta wypelniaja filtry raz - po zalogowaniu. Pozniej
|
||||
* nic ich nie nadpisuje, wiec zmiana filtrow przez uzytkownika jest ostateczna az do wylogowania.
|
||||
* Gosc nie ma preferencji i widzi puste filtry, tak jak dotad.
|
||||
*/
|
||||
const { applyPreferences } = filters;
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadSearchPreferences()
|
||||
.then((prefs) => { if (!cancelled) { applyPreferences(prefs); } })
|
||||
.catch(() => { /* brak preferencji nie moze psuc wyszukiwarki - zostaja puste filtry */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [user, loadSearchPreferences, applyPreferences]);
|
||||
|
||||
// Po udanym logowaniu wracamy tam, gdzie uzytkownik zmierzal (ProtectedRoute zapisuje cel
|
||||
// w location.state.from). Gdy wszedl na logowanie wprost - admin do panelu, uzytkownik do konta.
|
||||
const handleAuthenticated = (loggedUser: AuthUser) => {
|
||||
@@ -3615,7 +3700,7 @@ function App() {
|
||||
<Route path={ROUTES.comparison} element={<AccountComparisonPage onOpenListing={openListing} />} />
|
||||
<Route path={ROUTES.accountMeetings} element={<AccountMeetingsPage />} />
|
||||
<Route path={ROUTES.accountListings} element={<AccountListingsPage onOpenListing={openListing} />} />
|
||||
<Route path={ROUTES.accountSettings} element={<AccountSettingsPage />} />
|
||||
<Route path={ROUTES.accountSettings} element={<AccountSettingsPage onSearchPreferencesSaved={applyPreferences} />} />
|
||||
{/* Bezpieczenstwo i edycja profilu sa teraz sekcjami /konto/ustawienia - stare adresy
|
||||
zostaja jako przekierowania, zeby zapisane linki i zakladki dalej dzialaly. */}
|
||||
<Route path={ROUTES.accountSecurity} element={<Navigate to={ROUTES.accountSettings} replace />} />
|
||||
@@ -4195,8 +4280,11 @@ function AdminPage() {
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [statsError, setStatsError] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
// silent = odswiezenie w tle: bez chowania tabel za komunikat "Ladowanie danych".
|
||||
const loadData = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (!options?.silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const [loadedUsers, loadedListings, loadedReports, loadedUserReports, loadedForbiddenWords] = await Promise.all([
|
||||
@@ -4222,6 +4310,26 @@ function AdminPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
/**
|
||||
* Panel pobiera dane przy wejsciu, ale administrator zwykle zostawia go otwartego i przelacza
|
||||
* sie na inne okna. Gdy wraca do karty, dociagamy dane od nowa - inaczej ogladalby stan sprzed
|
||||
* zmian, ktore uzytkownicy zrobili w miedzyczasie (np. poprawione imie albo numer telefonu).
|
||||
* Odswiezamy po powrocie, a nie w kolku - odpowiedz z ogloszeniami wazy kilkaset kilobajtow.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const refreshWhenBack = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadData({ silent: true });
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', refreshWhenBack);
|
||||
document.addEventListener('visibilitychange', refreshWhenBack);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshWhenBack);
|
||||
document.removeEventListener('visibilitychange', refreshWhenBack);
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
const runAction = async (actionKey: string, action: () => Promise<unknown>) => {
|
||||
setBusyId(actionKey);
|
||||
setError(null);
|
||||
@@ -4571,7 +4679,7 @@ function AdminPage() {
|
||||
<div className="admin-sidebar-status">
|
||||
<strong><Icon name="shield" /> System działa poprawnie</strong>
|
||||
<p>Wszystkie usługi działają bez zarzutu.</p>
|
||||
<button type="button" onClick={loadData}>Odśwież status <Icon name="arrow" /></button>
|
||||
<button type="button" onClick={() => loadData()}>Odśwież status <Icon name="arrow" /></button>
|
||||
</div>
|
||||
|
||||
<Link className="admin-back" to={ROUTES.home}>
|
||||
@@ -4585,7 +4693,7 @@ function AdminPage() {
|
||||
<h1>{activeMeta.title}</h1>
|
||||
<p>Zalogowano jako <strong>{user?.fullName || user?.email}</strong>. {activeMeta.subtitle}</p>
|
||||
</div>
|
||||
<button type="button" className="admin-refresh" onClick={loadData} disabled={loading}>
|
||||
<button type="button" className="admin-refresh" onClick={() => loadData()} disabled={loading}>
|
||||
<Icon name="shuffle" /> Odśwież
|
||||
</button>
|
||||
</header>
|
||||
@@ -6153,7 +6261,7 @@ function AccountSidebar() {
|
||||
return (
|
||||
<aside className="account-sidebar">
|
||||
<div className="account-profile-card">
|
||||
<div className="account-avatar">{getInitials(getDisplayName(user))}</div>
|
||||
<Avatar className="account-avatar" name={getDisplayName(user)} image={user?.avatarImage} />
|
||||
<div>
|
||||
<strong>{getDisplayName(user)}</strong>
|
||||
<small>{user?.email}</small>
|
||||
@@ -10269,6 +10377,8 @@ type SettingsUploadTarget = 'avatar' | 'cover';
|
||||
|
||||
const SETTINGS_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const SETTINGS_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
// Jak dlugo wisi potwierdzenie zapisu w ustawieniach konta, zanim zniknie samo.
|
||||
const SETTINGS_BANNER_VISIBLE_MS = 5000;
|
||||
const SETTINGS_IMAGE_MIN_DIMENSIONS: Record<SettingsUploadTarget, { width: number; height: number }> = {
|
||||
avatar: { width: 160, height: 160 },
|
||||
cover: { width: 960, height: 260 },
|
||||
@@ -10363,12 +10473,24 @@ const AREA_UNIT_LABELS: Record<AreaUnit, string> = {
|
||||
|
||||
const PROFILE_VISIBILITY_OPTIONS: Array<{ value: ProfileVisibility; label: string; description: string }> = [
|
||||
{ value: 'PUBLIC', label: 'Publiczna', description: 'Twój profil jest widoczny dla wszystkich użytkowników.' },
|
||||
{ value: 'CONTACTS', label: 'Tylko kontakty', description: 'Profil widzą tylko osoby, z którymi masz aktywny kontakt.' },
|
||||
{ value: 'PRIVATE', label: 'Prywatna', description: 'Twój profil jest ukryty i widzisz go tylko Ty.' },
|
||||
{ value: 'PRIVATE', label: 'Prywatna', description: 'Inni zobaczą tylko informację, że Twój profil jest prywatny - bez ogłoszeń, opisu i zdjęcia.' },
|
||||
];
|
||||
|
||||
const SETTINGS_PROPERTY_TYPES = ['Mieszkania', 'Domy', 'Apartamenty', 'Działki', 'Lokale użytkowe'];
|
||||
|
||||
/** Wycina z zapisanych ustawien same preferencje wyszukiwania - w postaci, ktora rozumieja filtry. */
|
||||
function toSearchPreferences(settings: UserSettings): SearchPreferences {
|
||||
return {
|
||||
locations: settings.searchLocations,
|
||||
propertyType: settings.searchPropertyType,
|
||||
budgetMax: settings.searchBudgetMax,
|
||||
areaMin: settings.searchAreaMin,
|
||||
areaMax: settings.searchAreaMax,
|
||||
roomsMin: settings.searchRoomsMin,
|
||||
roomsMax: settings.searchRoomsMax,
|
||||
};
|
||||
}
|
||||
|
||||
// Musi odpowiadac RESEND_COOLDOWN_SECONDS w PhoneVerificationService - inaczej przycisk odblokuje
|
||||
// sie wczesniej, niz backend przyjmie kolejna wysylke, i uzytkownik dostanie blad 429.
|
||||
const OTP_RESEND_COOLDOWN_SECONDS = 60;
|
||||
@@ -10399,8 +10521,16 @@ function SettingsToggleRow({ icon, label, hint, checked, disabled, onChange }: {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSettingsPage() {
|
||||
const { user, updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, verifyPhone, resendPhoneOtp } = useAuth();
|
||||
/**
|
||||
* onSearchPreferencesSaved: zapis preferencji ma od razu przelozyc sie na filtry wyszukiwarki.
|
||||
* Przejscia wewnatrz aplikacji nie tworza korzenia od nowa, wiec bez tego wywolania zmiana
|
||||
* zadzialalaby dopiero po przeladowaniu strony.
|
||||
*/
|
||||
function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferencesSaved: (prefs: SearchPreferences) => void }) {
|
||||
const {
|
||||
user, updateProfile, changePassword, verifyPassword, passwordCooldown,
|
||||
deleteAccount, loadSettings, saveSettings, refreshUser, verifyPhone, resendPhoneOtp,
|
||||
} = useAuth();
|
||||
const { notify } = useNotifications();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -10453,18 +10583,59 @@ function AccountSettingsPage() {
|
||||
roomsMax: '',
|
||||
});
|
||||
const [searchBanner, setSearchBanner] = useState<Banner>(null);
|
||||
// Podpowiedzi lokalizacji pod polem "Lokalizacje" - jak w wyszukiwarce ofert.
|
||||
const [placeSuggestions, setPlaceSuggestions] = useState<PlaceSuggestion[]>([]);
|
||||
const [placesLoading, setPlacesLoading] = useState(false);
|
||||
// Lista jest otwarta tylko podczas pisania - po wybraniu pozycji nie ma juz czego podpowiadac.
|
||||
const [placesOpen, setPlacesOpen] = useState(false);
|
||||
const skipNextPlaceFetch = useRef(false);
|
||||
const [isSavingSearch, setIsSavingSearch] = useState(false);
|
||||
|
||||
// --- Bezpieczenstwo ---
|
||||
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [passwordBanner, setPasswordBanner] = useState<Banner>(null);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
// Stan sprawdzenia dotychczasowego hasla. Pola nowego hasla otwieraja sie dopiero przy 'ok'.
|
||||
const [currentCheck, setCurrentCheck] = useState<'empty' | 'checking' | 'ok' | 'bad'>('empty');
|
||||
// Ile sekund zostalo do kolejnej dozwolonej zmiany hasla; 0 = mozna zmieniac.
|
||||
const [passwordWait, setPasswordWait] = useState(0);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const [prefsBanner, setPrefsBanner] = useState<Banner>(null);
|
||||
const bannerTimers = useRef<Map<string, number>>(new Map());
|
||||
|
||||
/**
|
||||
* Potwierdzenie zapisu, ktore gasnie samo po chwili. Kazda sekcja ustawien ma wlasny komunikat,
|
||||
* wiec licznik trzymamy pod kluczem sekcji - inaczej zapis w jednej karcie gasilby napis w drugiej.
|
||||
* Bledy przez to nie przechodza: te zostaja na ekranie, bo wymagaja reakcji uzytkownika.
|
||||
*/
|
||||
const flashBanner = (key: string, setBanner: Dispatch<SetStateAction<Banner>>, banner: Banner) => {
|
||||
const running = bannerTimers.current.get(key);
|
||||
if (running !== undefined) {
|
||||
window.clearTimeout(running);
|
||||
}
|
||||
setBanner(banner);
|
||||
const timer = window.setTimeout(() => {
|
||||
// Gasimy tylko wlasny komunikat - jesli w miedzyczasie pojawil sie nowszy (np. blad
|
||||
// kolejnej operacji), zostaje na ekranie.
|
||||
setBanner((current) => (current === banner ? null : current));
|
||||
bannerTimers.current.delete(key);
|
||||
}, SETTINGS_BANNER_VISIBLE_MS);
|
||||
bannerTimers.current.set(key, timer);
|
||||
};
|
||||
|
||||
const flashPrefsBanner = (banner: Banner) => flashBanner('prefs', setPrefsBanner, banner);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = bannerTimers.current;
|
||||
return () => {
|
||||
timers.forEach((timer) => window.clearTimeout(timer));
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedCountry: PhoneCountry = PHONE_COUNTRY_OPTIONS.find((option) => option.code === identity.phonePrefix) ?? DEFAULT_PHONE_COUNTRY;
|
||||
const phoneMaskGroups = phoneMaskGroupsFor(selectedCountry.digits);
|
||||
@@ -10599,8 +10770,15 @@ function AccountSettingsPage() {
|
||||
try {
|
||||
const saved = await saveSettings(patch);
|
||||
setSettings(saved);
|
||||
// Zdjecie profilowe wisi przy zalogowanym uzytkowniku, wiec po jego zmianie odswiezamy profil -
|
||||
// awatar w naglowku i panelu konta zmienia sie od razu, bez przeladowania strony.
|
||||
if ('avatarImage' in patch) {
|
||||
void refreshUser();
|
||||
}
|
||||
if (successText) {
|
||||
setPrefsBanner({ text: successText, tone: 'success' });
|
||||
// Kazde potwierdzenie zapisu gasnie samo - to informacja o wykonanej akcji, a nie stan
|
||||
// formularza. Bledy zostaja na ekranie, bo wymagaja reakcji uzytkownika.
|
||||
flashPrefsBanner({ text: successText, tone: 'success' });
|
||||
}
|
||||
return saved;
|
||||
} catch (error) {
|
||||
@@ -10648,7 +10826,7 @@ function AccountSettingsPage() {
|
||||
preferredLanguage: identity.preferredLanguage,
|
||||
});
|
||||
await patchSettings({ bio: identity.bio.trim() });
|
||||
setIdentityBanner({ text: 'Dane zapisane.', tone: 'success' });
|
||||
flashBanner('identity', setIdentityBanner, { text: 'Dane zapisane.', tone: 'success' });
|
||||
} catch (error) {
|
||||
setIdentityBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać danych.', tone: 'error' });
|
||||
} finally {
|
||||
@@ -10727,44 +10905,168 @@ function AccountSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Puste pole liczbowe wysylamy jako -1: backend traktuje to jako wyczyszczenie wartosci.
|
||||
const searchPrefsPatch = (prefs: typeof searchPrefs) => {
|
||||
const asNumber = (value: string) => (value.trim() === '' ? -1 : Number(value));
|
||||
return {
|
||||
searchLocations: prefs.locations.trim(),
|
||||
searchPropertyType: prefs.propertyType.trim(),
|
||||
searchBudgetMax: asNumber(prefs.budgetMax),
|
||||
searchAreaMin: asNumber(prefs.areaMin),
|
||||
searchAreaMax: asNumber(prefs.areaMax),
|
||||
searchRoomsMin: asNumber(prefs.roomsMin),
|
||||
searchRoomsMax: asNumber(prefs.roomsMax),
|
||||
};
|
||||
};
|
||||
|
||||
// Czy jest co czyscic - od tego zalezy, czy pokazujemy przycisk czyszczenia.
|
||||
const hasSearchPrefs = Object.values(searchPrefs).some((value) => value.trim() !== '');
|
||||
|
||||
/**
|
||||
* Podpowiedzi lokalizacji - ten sam mechanizm co w wyszukiwarce ofert: od dwoch znakow pytamy
|
||||
* o prawdziwe miejscowosci (nie tylko miasta wojewodzkie), z opoznieniem, zeby nie wysylac
|
||||
* zapytania po kazdej literze. Puste pole pokazuje najpopularniejsze miasta jako skrot.
|
||||
*/
|
||||
const locationQuery = searchPrefs.locations.trim();
|
||||
useEffect(() => {
|
||||
if (skipNextPlaceFetch.current) {
|
||||
skipNextPlaceFetch.current = false;
|
||||
return;
|
||||
}
|
||||
if (locationQuery.length < 2) {
|
||||
setPlaceSuggestions([]);
|
||||
setPlacesLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setPlacesLoading(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
fetchPlaceSuggestions(locationQuery, controller.signal)
|
||||
.then((places) => setPlaceSuggestions(places))
|
||||
.catch(() => { /* przerwane zapytanie albo brak sieci - zostaje poprzednia lista */ })
|
||||
.finally(() => setPlacesLoading(false));
|
||||
}, 300);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [locationQuery]);
|
||||
|
||||
// Wybor z listy wpisuje nazwe do pola, ale nie ma od razu odpytywac o podpowiedzi do tej nazwy.
|
||||
const pickLocation = (label: string) => {
|
||||
skipNextPlaceFetch.current = true;
|
||||
setPlaceSuggestions([]);
|
||||
setPlacesOpen(false);
|
||||
setSearchPrefs((current) => ({ ...current, locations: label }));
|
||||
};
|
||||
|
||||
const saveSearchPrefs = async () => {
|
||||
setIsSavingSearch(true);
|
||||
setSearchBanner(null);
|
||||
// Puste pole liczbowe wysylamy jako -1: backend traktuje to jako wyczyszczenie wartosci.
|
||||
const asNumber = (value: string) => (value.trim() === '' ? -1 : Number(value));
|
||||
const saved = await patchSettings({
|
||||
searchLocations: searchPrefs.locations.trim(),
|
||||
searchPropertyType: searchPrefs.propertyType.trim(),
|
||||
searchBudgetMax: asNumber(searchPrefs.budgetMax),
|
||||
searchAreaMin: asNumber(searchPrefs.areaMin),
|
||||
searchAreaMax: asNumber(searchPrefs.areaMax),
|
||||
searchRoomsMin: asNumber(searchPrefs.roomsMin),
|
||||
searchRoomsMax: asNumber(searchPrefs.roomsMax),
|
||||
});
|
||||
setSearchBanner(saved
|
||||
? { text: 'Preferencje wyszukiwania zapisane.', tone: 'success' }
|
||||
: { text: 'Nie udało się zapisać preferencji.', tone: 'error' });
|
||||
const saved = await patchSettings(searchPrefsPatch(searchPrefs));
|
||||
if (saved) {
|
||||
onSearchPreferencesSaved(toSearchPreferences(saved));
|
||||
flashBanner('search', setSearchBanner, { text: 'Preferencje wyszukiwania zapisane.', tone: 'success' });
|
||||
} else {
|
||||
setSearchBanner({ text: 'Nie udało się zapisać preferencji.', tone: 'error' });
|
||||
}
|
||||
setIsSavingSearch(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Czysci wszystkie preferencje wyszukiwania - i w formularzu, i na koncie. Zapisujemy od razu,
|
||||
* bo samo oproznienie pol bez zapisu zostawiloby wyszukiwarke z filtrami, ktorych uzytkownik
|
||||
* juz nie widzi w ustawieniach.
|
||||
*/
|
||||
const clearSearchPrefs = async () => {
|
||||
const empty = {
|
||||
locations: '', propertyType: '', budgetMax: '',
|
||||
areaMin: '', areaMax: '', roomsMin: '', roomsMax: '',
|
||||
};
|
||||
setIsSavingSearch(true);
|
||||
setSearchBanner(null);
|
||||
setSearchPrefs(empty);
|
||||
const saved = await patchSettings(searchPrefsPatch(empty));
|
||||
if (saved) {
|
||||
onSearchPreferencesSaved(toSearchPreferences(saved));
|
||||
flashBanner('search', setSearchBanner, { text: 'Preferencje wyczyszczone.', tone: 'success' });
|
||||
} else {
|
||||
setSearchBanner({ text: 'Nie udało się wyczyścić preferencji.', tone: 'error' });
|
||||
}
|
||||
setIsSavingSearch(false);
|
||||
};
|
||||
|
||||
// Po wejsciu na strone pytamy serwer, czy odstep po ostatniej zmianie hasla juz minal -
|
||||
// licznik ma przetrwac odswiezenie strony i zmiane urzadzenia.
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
passwordCooldown()
|
||||
.then((seconds) => { if (!cancelled) { setPasswordWait(seconds); } })
|
||||
.catch(() => { /* brak odpowiedzi nie moze blokowac formularza - decyduje i tak serwer */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [user, passwordCooldown]);
|
||||
|
||||
// Odliczanie w dol do kolejnej dozwolonej zmiany.
|
||||
useEffect(() => {
|
||||
if (passwordWait <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setPasswordWait(passwordWait - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [passwordWait]);
|
||||
|
||||
// Sprawdzenie dotychczasowego hasla w tle, chwile po tym jak uzytkownik przestanie pisac.
|
||||
// Dopiero poprawne haslo otwiera pola nowego - inaczej wypelnia sie caly formularz na darmo.
|
||||
useEffect(() => {
|
||||
const typed = passwordForm.current;
|
||||
if (!typed) {
|
||||
setCurrentCheck('empty');
|
||||
return;
|
||||
}
|
||||
setCurrentCheck('checking');
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
verifyPassword(typed)
|
||||
.then(() => { if (!cancelled) { setCurrentCheck('ok'); } })
|
||||
.catch(() => { if (!cancelled) { setCurrentCheck('bad'); } });
|
||||
}, 600);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [passwordForm.current, verifyPassword]);
|
||||
|
||||
const canEditNewPassword = currentCheck === 'ok' && passwordWait === 0;
|
||||
const passwordsMatch = passwordForm.next === passwordForm.confirm;
|
||||
const canSubmitPassword = canEditNewPassword
|
||||
&& passwordForm.next.length >= 8
|
||||
&& passwordsMatch
|
||||
&& !isSavingPassword;
|
||||
|
||||
// 300 s -> "5:00"
|
||||
const waitLabel = `${Math.floor(passwordWait / 60)}:${String(passwordWait % 60).padStart(2, '0')}`;
|
||||
|
||||
const submitPasswordChange = async (event: ReactFormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPasswordBanner(null);
|
||||
if (passwordForm.next.length < 8) {
|
||||
setPasswordBanner({ text: 'Nowe hasło musi mieć co najmniej 8 znaków.', tone: 'error' });
|
||||
return;
|
||||
}
|
||||
if (passwordForm.next !== passwordForm.confirm) {
|
||||
setPasswordBanner({ text: 'Nowe hasła nie są identyczne.', tone: 'error' });
|
||||
if (!canSubmitPassword) {
|
||||
return;
|
||||
}
|
||||
setIsSavingPassword(true);
|
||||
try {
|
||||
await changePassword(passwordForm.current, passwordForm.next);
|
||||
setPasswordForm({ current: '', next: '', confirm: '' });
|
||||
setPasswordBanner({ text: 'Hasło zostało zmienione.', tone: 'success' });
|
||||
setCurrentCheck('empty');
|
||||
flashBanner('password', setPasswordBanner, { text: 'Hasło zostało zmienione.', tone: 'success' });
|
||||
// Odstep liczy serwer - pytamy go o wartosc zamiast zakladac pelne 5 minut.
|
||||
setPasswordWait(await passwordCooldown());
|
||||
} catch (error) {
|
||||
setPasswordBanner({ text: error instanceof Error ? error.message : 'Nie udało się zmienić hasła.', tone: 'error' });
|
||||
// Blad moze oznaczac wlasnie trwajacy odstep - odswiezamy licznik zgodnie z serwerem.
|
||||
passwordCooldown().then(setPasswordWait).catch(() => { /* zostaje dotychczasowa wartosc */ });
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
@@ -10787,7 +11089,12 @@ function AccountSettingsPage() {
|
||||
const avatarImage = settings?.avatarImage ?? '';
|
||||
const coverImage = settings?.coverImage ?? '';
|
||||
const visibility = settings?.profileVisibility ?? 'PUBLIC';
|
||||
const accountTypeLabel = user?.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto prywatne';
|
||||
// Jedna nazwa stanu profilu na calej stronie: naglowek karty i podpis pod imieniem mowia to samo,
|
||||
// inaczej po ustawieniu prywatnosci sekcja dalej nazywalaby sie "Profil publiczny".
|
||||
const visibilityLabel = visibility === 'PRIVATE' ? 'Profil prywatny' : 'Profil publiczny';
|
||||
// "Konto prywatne" jako nazwa typu konta mylilo sie z prywatnoscia profilu - typ konta mowi
|
||||
// o dzialalnosci (osobista albo firmowa), a nie o widocznosci.
|
||||
const accountTypeLabel = user?.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto osobiste';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -10809,16 +11116,15 @@ function AccountSettingsPage() {
|
||||
<section className="settings-card settings-card-wide" aria-label="Moje dane">
|
||||
<div className="settings-card-head">
|
||||
<h2>Moje dane</h2>
|
||||
<p>Nazwa użytkownika, e-mail i telefon są zapisane z rejestracji. Możesz je tutaj zmienić.</p>
|
||||
{/* Bez e-maila - jest przypisany do konta na stale i pole jest tylko do odczytu. */}
|
||||
<p>Nazwa użytkownika i telefon są zapisane z rejestracji. Możesz je tutaj zmienić.</p>
|
||||
</div>
|
||||
|
||||
<div className="profile-photo-row">
|
||||
<div className="profile-photo-avatar">
|
||||
{avatarImage ? <img src={avatarImage} alt="Zdjęcie profilowe" /> : getInitials(getDisplayName(user))}
|
||||
</div>
|
||||
<Avatar className="profile-photo-avatar" name={getDisplayName(user)} image={avatarImage} />
|
||||
<div>
|
||||
<strong>{getDisplayName(user)}</strong>
|
||||
<small>{accountTypeLabel}{user?.nick ? ` · @${user.nick}` : ''}</small>
|
||||
<small>{accountTypeLabel} · {visibilityLabel}{user?.nick ? ` · @${user.nick}` : ''}</small>
|
||||
<div className="profile-photo-actions">
|
||||
<label>
|
||||
<input
|
||||
@@ -11022,10 +11328,10 @@ function AccountSettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Profil publiczny --- */}
|
||||
<section className="settings-card" aria-label="Profil publiczny">
|
||||
{/* --- Profil publiczny albo prywatny - zaleznie od wybranej widocznosci --- */}
|
||||
<section className="settings-card" aria-label={visibilityLabel}>
|
||||
<div className="settings-card-head">
|
||||
<h2>Profil publiczny</h2>
|
||||
<h2>{visibilityLabel}</h2>
|
||||
<p>Zdjęcie w tle i to, kto może oglądać Twój profil.</p>
|
||||
</div>
|
||||
|
||||
@@ -11090,9 +11396,32 @@ function AccountSettingsPage() {
|
||||
<span>Lokalizacje</span>
|
||||
<input
|
||||
value={searchPrefs.locations}
|
||||
placeholder="Np. Warszawa (Mokotów, Wola)"
|
||||
onChange={(event) => setSearchPrefs((current) => ({ ...current, locations: event.target.value }))}
|
||||
placeholder="Wpisz miasto, dzielnicę lub ulicę"
|
||||
onChange={(event) => {
|
||||
setPlacesOpen(true);
|
||||
setSearchPrefs((current) => ({ ...current, locations: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
{/* Od dwoch znakow podpowiadamy prawdziwe miejscowosci - tak samo jak wyszukiwarka
|
||||
ofert. Puste pole nie pokazuje nic: to preferencja, a nie wyszukiwanie,
|
||||
wiec nie podsuwamy tu miast, ktorych uzytkownik nie szukal. */}
|
||||
{placesOpen && locationQuery.length >= 2 && (
|
||||
<div className="settings-place-suggest">
|
||||
{placesLoading && <p className="settings-place-status">Szukam miejscowości...</p>}
|
||||
{!placesLoading && placeSuggestions.length === 0 && (
|
||||
<p className="settings-place-status">Brak wyników dla „{locationQuery}". Możesz zostawić własny wpis.</p>
|
||||
)}
|
||||
{placeSuggestions.map((place) => (
|
||||
<button type="button" key={place.id} onClick={() => pickLocation(place.label)}>
|
||||
<Icon name="pin" />
|
||||
<span>
|
||||
<strong>{place.label}</strong>
|
||||
{place.sublabel && <small>{place.sublabel}</small>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>Typ nieruchomości</span>
|
||||
@@ -11156,6 +11485,17 @@ function AccountSettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="profile-form-actions">
|
||||
{/* Czyszczenie pokazujemy tylko wtedy, gdy jest co czyscic. */}
|
||||
{hasSearchPrefs && (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-clear-button"
|
||||
onClick={() => { void clearSearchPrefs(); }}
|
||||
disabled={isSavingSearch || isLoading}
|
||||
>
|
||||
Wyczyść preferencje
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { void saveSearchPrefs(); }} disabled={isSavingSearch || isLoading}>
|
||||
{isSavingSearch ? 'Zapisywanie...' : 'Zapisz preferencje'}
|
||||
</button>
|
||||
@@ -11305,24 +11645,41 @@ function AccountSettingsPage() {
|
||||
|
||||
<form className="profile-form-grid settings-password-form" onSubmit={submitPasswordChange}>
|
||||
<h3>Zmiana hasła</h3>
|
||||
|
||||
{/* Trwajacy odstep po ostatniej zmianie - formularz jest wtedy w calosci zamkniety.
|
||||
Tekst siedzi w jednym <span>, bo rodzic jest flexem: kazdy osobny wezel dostalby
|
||||
odstep i przed kropka po liczniku zrobilaby sie luka. */}
|
||||
{passwordWait > 0 && (
|
||||
<p className="password-cooldown" role="status">
|
||||
<Icon name="clock" />
|
||||
<span>Hasło zmieniono niedawno. Kolejna zmiana będzie możliwa za <strong>{waitLabel}</strong>.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>Dotychczasowe hasło</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={passwordForm.current}
|
||||
disabled={passwordWait > 0}
|
||||
onChange={(event) => setPasswordForm((current) => ({ ...current, current: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
{currentCheck === 'checking' && <small className="field-hint">Sprawdzam hasło...</small>}
|
||||
{currentCheck === 'bad' && <small className="field-hint error">Nieprawidłowe hasło - pola nowego hasła pozostają zablokowane.</small>}
|
||||
{currentCheck === 'ok' && passwordWait === 0 && <small className="field-hint ok">Hasło potwierdzone. Możesz ustawić nowe.</small>}
|
||||
</label>
|
||||
|
||||
<div className="settings-range-row">
|
||||
<label>
|
||||
<span>Nowe hasło</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Min. 8 znaków"
|
||||
placeholder={canEditNewPassword ? 'Min. 8 znaków' : 'Najpierw podaj dotychczasowe hasło'}
|
||||
value={passwordForm.next}
|
||||
disabled={!canEditNewPassword}
|
||||
onChange={(event) => setPasswordForm((current) => ({ ...current, next: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
@@ -11333,13 +11690,25 @@ function AccountSettingsPage() {
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passwordForm.confirm}
|
||||
disabled={!canEditNewPassword}
|
||||
onChange={(event) => setPasswordForm((current) => ({ ...current, confirm: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Podpowiedzi pokazujemy dopiero, gdy jest co porownywac - nie karcimy za puste pola. */}
|
||||
{canEditNewPassword && passwordForm.next.length > 0 && passwordForm.next.length < 8 && (
|
||||
<small className="field-hint error">Nowe hasło musi mieć co najmniej 8 znaków.</small>
|
||||
)}
|
||||
{canEditNewPassword && passwordForm.confirm.length > 0 && !passwordsMatch && (
|
||||
<small className="field-hint error">Hasła nie są identyczne - popraw, żeby móc zapisać.</small>
|
||||
)}
|
||||
|
||||
<div className="profile-form-actions">
|
||||
<button type="submit" disabled={isSavingPassword}>{isSavingPassword ? 'Zapisywanie...' : 'Zmień hasło'}</button>
|
||||
<button type="submit" disabled={!canSubmitPassword}>
|
||||
{isSavingPassword ? 'Zapisywanie...' : (passwordWait > 0 ? `Zmiana możliwa za ${waitLabel}` : 'Zmień hasło')}
|
||||
</button>
|
||||
{passwordBanner && <span className={`profile-message ${passwordBanner.tone}`}>{passwordBanner.text}</span>}
|
||||
</div>
|
||||
</form>
|
||||
@@ -11543,11 +11912,7 @@ function AccountPublicProfilePage() {
|
||||
<section className="public-profile-cover-card">
|
||||
<div className="public-profile-cover" style={{ backgroundImage: `url(${publicProfileCover})`, backgroundPosition: 'center center' }} />
|
||||
<div className="public-profile-summary">
|
||||
<div className="public-profile-avatar">
|
||||
{profileSettings?.avatarImage
|
||||
? <img src={profileSettings.avatarImage} alt="Zdjęcie profilowe" />
|
||||
: initialsFromName(getDisplayName(user))}
|
||||
</div>
|
||||
<Avatar className="public-profile-avatar" name={getDisplayName(user)} image={profileSettings?.avatarImage} />
|
||||
<div className="public-profile-user">
|
||||
<h2>{getDisplayName(user)}</h2>
|
||||
<div className="public-profile-meta">
|
||||
@@ -12819,6 +13184,16 @@ function RentPage({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ten sam pasek co przy sprzedazy - filtry sa wspolne dla obu list. */}
|
||||
{filters.fromPreferences && (
|
||||
<p className="preferences-note" role="status">
|
||||
<Icon name="gear" />
|
||||
<span>Filtry ustawione według Twoich preferencji wyszukiwania.</span>
|
||||
<button type="button" onClick={resetAll}>Wyczyść</button>
|
||||
<Link to={ROUTES.accountSettings}>Zmień preferencje</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<RealListingsBand
|
||||
offerType="RENT"
|
||||
onOpenListing={onOpenListing}
|
||||
@@ -17549,6 +17924,29 @@ function thousands(value: number): string {
|
||||
|
||||
const propertyTypeOptions: PropertySearchType[] = ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Pokój'];
|
||||
|
||||
/**
|
||||
* Ustawienia konta uzywaja liczby mnogiej ("Mieszkania"), a filtry pojedynczej ("Mieszkanie").
|
||||
* "Apartamenty" nie maja odpowiednika wsrod filtrow - dla nich zostaje wartosc domyslna.
|
||||
*/
|
||||
const PREFERENCE_PROPERTY_TYPES: Record<string, PropertySearchType> = {
|
||||
Mieszkania: 'Mieszkanie',
|
||||
Domy: 'Dom',
|
||||
Działki: 'Działka',
|
||||
'Lokale użytkowe': 'Lokal użytkowy',
|
||||
};
|
||||
|
||||
/**
|
||||
* Preferencje trzymaja lokalizacje jako swobodny tekst ("Warszawa (Mokotów, Wola)"), a filtr miasta
|
||||
* porownuje sie z nazwa miasta z ogloszenia. Bierzemy pierwszy czlon - to, co przed nawiasem
|
||||
* albo przecinkiem - bo tylko on ma szanse dopasowac sie do miasta.
|
||||
*/
|
||||
function primaryLocation(raw: string | null): string {
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
return raw.split(/[(,]/)[0].trim();
|
||||
}
|
||||
|
||||
function useFilterState() {
|
||||
const [cityInput, setCityInput] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
@@ -17562,6 +17960,15 @@ function useFilterState() {
|
||||
const [pierwotny, setPierwotny] = useState(true);
|
||||
const [noFeeOnly, setNoFeeOnly] = useState(false);
|
||||
const [more, setMore] = useState<Record<string, string>>({});
|
||||
// Czy obecne filtry pochodza z preferencji konta - od tego zalezy pasek nad wynikami.
|
||||
// Kopia w ref, bo applyPreferences jest stabilne (useCallback bez zaleznosci) i inaczej
|
||||
// widzialoby wartosc z chwili utworzenia funkcji, a nie biezaca.
|
||||
const [fromPreferences, setFromPreferences] = useState(false);
|
||||
const fromPreferencesRef = useRef(false);
|
||||
const markFromPreferences = (value: boolean) => {
|
||||
fromPreferencesRef.current = value;
|
||||
setFromPreferences(value);
|
||||
};
|
||||
|
||||
const pMin = parseNumber(priceMin);
|
||||
const pMax = parseNumber(priceMax);
|
||||
@@ -17603,8 +18010,58 @@ function useFilterState() {
|
||||
setPierwotny(true);
|
||||
setNoFeeOnly(false);
|
||||
setMore({});
|
||||
markFromPreferences(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wypelnia filtry preferencjami z ustawien konta. Wolane po wczytaniu preferencji oraz po ich
|
||||
* zapisaniu w ustawieniach - bez tego drugiego zmiana preferencji dzialalaby dopiero po
|
||||
* przeladowaniu strony, bo przejscia wewnatrz aplikacji nie tworza komponentu od nowa.
|
||||
* Gdy cokolwiek zostalo ustawione, podnosimy flage, zeby lista wynikow mogla powiedziec, skad
|
||||
* wziely sie te filtry: zawezenie bez wyjasnienia wyglada jak brak ofert w serwisie.
|
||||
*/
|
||||
const applyPreferences = useCallback((prefs: SearchPreferences) => {
|
||||
let applied = false;
|
||||
|
||||
const location = primaryLocation(prefs.locations);
|
||||
if (location) {
|
||||
setCity(location);
|
||||
setCityInput(location);
|
||||
applied = true;
|
||||
}
|
||||
const mappedType = prefs.propertyType ? PREFERENCE_PROPERTY_TYPES[prefs.propertyType.trim()] : undefined;
|
||||
if (mappedType) {
|
||||
setPropertyType(mappedType);
|
||||
applied = true;
|
||||
}
|
||||
if (prefs.budgetMax != null && prefs.budgetMax > 0) {
|
||||
setPriceMax(String(prefs.budgetMax));
|
||||
applied = true;
|
||||
}
|
||||
if (prefs.areaMin != null && prefs.areaMin > 0) {
|
||||
setAreaMin(String(prefs.areaMin));
|
||||
applied = true;
|
||||
}
|
||||
if (prefs.areaMax != null && prefs.areaMax > 0) {
|
||||
setAreaMax(String(prefs.areaMax));
|
||||
applied = true;
|
||||
}
|
||||
// Filtr pokoi przyjmuje jedna wartosc (5 = "5 i wiecej"), preferencje maja zakres -
|
||||
// bierzemy dolna granice, bo to ona odsiewa za male mieszkania.
|
||||
if (prefs.roomsMin != null && prefs.roomsMin > 0) {
|
||||
setRooms(Math.min(prefs.roomsMin, 5));
|
||||
applied = true;
|
||||
}
|
||||
|
||||
// Puste preferencje po wyczyszczeniu w ustawieniach zdejmuja filtry, ktore same nalozyly.
|
||||
// Filtrow ustawionych recznie przez uzytkownika nie ruszamy.
|
||||
if (!applied && fromPreferencesRef.current) {
|
||||
resetAll();
|
||||
return;
|
||||
}
|
||||
markFromPreferences(applied);
|
||||
}, []);
|
||||
|
||||
const clearCity = () => {
|
||||
setCity('');
|
||||
setCityInput('');
|
||||
@@ -17640,6 +18097,7 @@ function useFilterState() {
|
||||
pMin, pMax, aMin, aMax,
|
||||
priceLabel, areaLabel, roomsLabelValue, roomsShort, marketLabel,
|
||||
resetAll, clearCity, chips,
|
||||
fromPreferences, applyPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19381,7 +19839,7 @@ function BuyPage({
|
||||
savedSearchActive: boolean;
|
||||
onToggleSavedSearch: () => void;
|
||||
}) {
|
||||
const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll } = filters;
|
||||
const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll, fromPreferences } = filters;
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
const applySort = (value: SortKey) => {
|
||||
@@ -19476,6 +19934,17 @@ function BuyPage({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bez tego paska zawezona lista wyglada jak brak ofert w serwisie - uzytkownik musi
|
||||
wiedziec, ze to jego wlasne preferencje z ustawien konta, i moc je zdjac. */}
|
||||
{fromPreferences && (
|
||||
<p className="preferences-note" role="status">
|
||||
<Icon name="gear" />
|
||||
<span>Filtry ustawione według Twoich preferencji wyszukiwania.</span>
|
||||
<button type="button" onClick={resetAll}>Wyczyść</button>
|
||||
<Link to={ROUTES.accountSettings}>Zmień preferencje</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<RealListingsBand
|
||||
offerType="SALE"
|
||||
onOpenListing={onOpenListing}
|
||||
@@ -20832,7 +21301,7 @@ function Header({
|
||||
onClick={() => setIsAccountMenuOpen((current) => !current)}
|
||||
aria-label={getDisplayName(user)}
|
||||
>
|
||||
<span className="account-trigger-avatar">{getInitials(getDisplayName(user))}</span>
|
||||
<Avatar className="account-trigger-avatar" name={getDisplayName(user)} image={user?.avatarImage} />
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
|
||||
@@ -21000,9 +21469,12 @@ type PublicProfile = {
|
||||
nick: string | null;
|
||||
bio: string | null;
|
||||
avatarImage: string | null;
|
||||
coverImage: string | null;
|
||||
accountType: AccountType;
|
||||
verified: boolean;
|
||||
memberSince: string;
|
||||
// Profil prywatny ogladany przez kogos innego niz wlasciciel - reszta pol jest wtedy pusta.
|
||||
privateProfile: boolean;
|
||||
listingsCount: number;
|
||||
listings: {
|
||||
id: number;
|
||||
@@ -21057,13 +21529,6 @@ function PublicProfilePage() {
|
||||
};
|
||||
}, [id, nick]);
|
||||
|
||||
const initials = (profile?.fullName ?? '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((chunk) => chunk[0]?.toUpperCase() ?? '')
|
||||
.join('') || '?';
|
||||
|
||||
const memberSinceLabel = profile
|
||||
? new Date(profile.memberSince).toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
@@ -21081,14 +21546,30 @@ function PublicProfilePage() {
|
||||
{loading && <p className="admin-empty">Wczytywanie profilu...</p>}
|
||||
{!loading && error && <p className="admin-empty">{error}</p>}
|
||||
|
||||
{!loading && !error && profile && (
|
||||
{/* Profil prywatny: serwer nie przysyla ogloszen ani danych profilu, wiec nie ma czego ukrywac
|
||||
w interfejsie - pokazujemy sama informacje, do kogo profil nalezy i ze jest prywatny. */}
|
||||
{!loading && !error && profile?.privateProfile && (
|
||||
<section className="settings-card private-profile-card" aria-label="Profil prywatny">
|
||||
<Avatar className="private-profile-avatar" name={profile.fullName} />
|
||||
<h1>{profile.fullName}</h1>
|
||||
<strong><Icon name="lock" /> Ten profil jest prywatny</strong>
|
||||
<p>
|
||||
Ogłoszenia, opis i pozostałe dane tego konta widzi wyłącznie jego właściciel.
|
||||
Jeśli chcesz się skontaktować, napisz wiadomość w serwisie.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && profile && !profile.privateProfile && (
|
||||
<>
|
||||
<section className="public-profile-cover-card">
|
||||
<div className="public-profile-cover" style={{ backgroundImage: `url(${cityImage})`, backgroundPosition: 'center center' }} />
|
||||
{/* Zdjecie w tle ustawione przez wlasciciela; bez niego zostaje grafika serwisu. */}
|
||||
<div
|
||||
className="public-profile-cover"
|
||||
style={{ backgroundImage: `url(${profile.coverImage || cityImage})`, backgroundPosition: 'center center' }}
|
||||
/>
|
||||
<div className="public-profile-summary">
|
||||
<div className="public-profile-avatar">
|
||||
{profile.avatarImage ? <img src={profile.avatarImage} alt={`Zdjęcie profilowe ${profile.fullName}`} /> : initials}
|
||||
</div>
|
||||
<Avatar className="public-profile-avatar" name={profile.fullName} image={profile.avatarImage} />
|
||||
<div className="public-profile-user">
|
||||
<h1>{profile.fullName}</h1>
|
||||
<div className="public-profile-meta">
|
||||
@@ -24027,7 +24508,7 @@ function ListingDetailPage({
|
||||
</button>
|
||||
|
||||
<div className="listing-detail-seller-card">
|
||||
<div className="listing-detail-seller-avatar">{sellerName.slice(0, 1).toUpperCase()}</div>
|
||||
<Avatar className="listing-detail-seller-avatar" name={sellerName} image={listing.ownerAvatar} singleLetter />
|
||||
<div>
|
||||
<strong>{sellerName}</strong>
|
||||
<small>{sellerRole}</small>
|
||||
|
||||
Reference in New Issue
Block a user