aktuazalizacja profilow,prefernecji,bezpiecznestwa

This commit is contained in:
2026-08-13 16:18:46 +02:00
parent fbb7ab2d90
commit 571ec05d76
22 changed files with 1339 additions and 151 deletions
+561 -80
View File
@@ -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>