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>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Zdjecie profilowe uzytkownika - jedno miejsce dla calej aplikacji.
|
||||
*
|
||||
* Uzytkownik ustawia zdjecie raz w /konto/ustawienia, a backend dokleja je do profilu
|
||||
* (/api/auth/me, profil publiczny, karta sprzedajacego przy ogloszeniu). Gdy zdjecia nie ma,
|
||||
* rysujemy inicjaly na zielonym tle - ten sam wyglad co awatar w prawym gornym rogu naglowka.
|
||||
*
|
||||
* Rozmiar i pozycje nadaje klasa z miejsca uzycia (np. .account-trigger-avatar), a wspolna
|
||||
* klasa .avatar odpowiada za ksztalt, kolor tla i wpasowanie zdjecia w kolo.
|
||||
*/
|
||||
|
||||
type AvatarProps = {
|
||||
/** Nazwa uzytkownika - zrodlo inicjalow i tekstu alternatywnego zdjecia. */
|
||||
name: string;
|
||||
/** Zdjecie jako data URL. Puste albo null oznacza, ze uzytkownik zadnego nie ustawil. */
|
||||
image?: string | null;
|
||||
/** Klasa miejsca uzycia, ktora nadaje rozmiar. */
|
||||
className?: string;
|
||||
/** Sama pierwsza litera imienia zamiast dwoch inicjalow - tak wyglada karta sprzedajacego. */
|
||||
singleLetter?: boolean;
|
||||
};
|
||||
|
||||
/** Inicjaly z nazwy: "Jan Kowalski" -> "JK", "joanna" -> "JO", pusta nazwa -> "??". */
|
||||
export function initialsFrom(name: string): string {
|
||||
const parts = name.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();
|
||||
}
|
||||
|
||||
export function Avatar({ name, image, className, singleLetter }: AvatarProps) {
|
||||
const trimmed = image?.trim();
|
||||
const letters = singleLetter ? (name.trim()[0] ?? '?').toUpperCase() : initialsFrom(name);
|
||||
return (
|
||||
<span className={className ? `avatar ${className}` : 'avatar'}>
|
||||
{trimmed
|
||||
? <img src={trimmed} alt={`Zdjęcie profilowe: ${name}`} />
|
||||
: letters}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+39
-3
@@ -9,7 +9,8 @@ export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
|
||||
|
||||
export type Currency = 'PLN' | 'EUR' | 'USD';
|
||||
export type AreaUnit = 'M2' | 'FT2';
|
||||
export type ProfileVisibility = 'PUBLIC' | 'CONTACTS' | 'PRIVATE';
|
||||
// Profil jest publiczny, dopoki wlasciciel sam nie ustawi go jako prywatny.
|
||||
export type ProfileVisibility = 'PUBLIC' | 'PRIVATE';
|
||||
|
||||
/**
|
||||
* Ustawienia konta trzymane na serwerze (/api/me/settings). Zgody marketingowe celowo tu nie leza -
|
||||
@@ -41,6 +42,20 @@ export type UserSettings = {
|
||||
// Zapis czesciowy: pominiete pole zostaje bez zmian, pusty tekst je czysci, liczba ujemna zeruje.
|
||||
export type UserSettingsPatch = Partial<Record<keyof UserSettings, unknown>>;
|
||||
|
||||
/**
|
||||
* Preferencje wyszukiwania w lekkiej postaci (/api/me/settings/search) - wyszukiwarka pobiera je
|
||||
* przy wejsciu do aplikacji, wiec nie ciagniemy przy okazji zdjec z pelnych ustawien.
|
||||
*/
|
||||
export type SearchPreferences = {
|
||||
locations: string | null;
|
||||
propertyType: string | null;
|
||||
budgetMax: number | null;
|
||||
areaMin: number | null;
|
||||
areaMax: number | null;
|
||||
roomsMin: number | null;
|
||||
roomsMax: number | null;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
@@ -60,6 +75,8 @@ export type AuthUser = {
|
||||
blocked: boolean;
|
||||
promotionCredits: number;
|
||||
createdAt: string;
|
||||
// Zdjecie profilowe z ustawien konta - null, gdy uzytkownik zadnego nie ustawil.
|
||||
avatarImage: string | null;
|
||||
};
|
||||
|
||||
type AuthResponse = { token: string; user: AuthUser };
|
||||
@@ -89,8 +106,11 @@ type AuthContextValue = {
|
||||
resendPhoneOtp: (email: string) => Promise<void>;
|
||||
updateProfile: (profile: ProfileUpdate) => Promise<AuthUser>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||
verifyPassword: (password: string) => Promise<void>;
|
||||
passwordCooldown: () => Promise<number>;
|
||||
deleteAccount: (password: string) => Promise<void>;
|
||||
loadSettings: () => Promise<UserSettings>;
|
||||
loadSearchPreferences: () => Promise<SearchPreferences>;
|
||||
saveSettings: (patch: UserSettingsPatch) => Promise<UserSettings>;
|
||||
refreshUser: () => Promise<AuthUser | null>;
|
||||
logout: () => void;
|
||||
@@ -286,6 +306,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
|
||||
const verifyPassword = useCallback(async (password: string) => {
|
||||
await apiFetch<void>('/auth/verify-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Ile sekund zostalo do momentu, w ktorym serwer przyjmie kolejna zmiane hasla.
|
||||
const passwordCooldown = useCallback(async () => {
|
||||
const data = await apiFetch<{ secondsLeft: number }>('/auth/password-cooldown');
|
||||
return data.secondsLeft;
|
||||
}, []);
|
||||
|
||||
// Po usunieciu konta token jest bezuzyteczny - czyscimy sesje od razu, bez czekania na 401.
|
||||
const deleteAccount = useCallback(async (password: string) => {
|
||||
await apiFetch<void>('/auth/me', {
|
||||
@@ -299,6 +333,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const loadSettings = useCallback(async () => apiFetch<UserSettings>('/me/settings'), []);
|
||||
|
||||
const loadSearchPreferences = useCallback(async () => apiFetch<SearchPreferences>('/me/settings/search'), []);
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (patch: UserSettingsPatch) =>
|
||||
apiFetch<UserSettings>('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }),
|
||||
@@ -329,11 +365,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
user, token, loading, login, register, socialLogin,
|
||||
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
||||
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout,
|
||||
updateProfile, changePassword, verifyPassword, passwordCooldown, deleteAccount, loadSettings, loadSearchPreferences, saveSettings, refreshUser, logout,
|
||||
}),
|
||||
[user, token, loading, login, register, socialLogin,
|
||||
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
||||
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout],
|
||||
updateProfile, changePassword, verifyPassword, passwordCooldown, deleteAccount, loadSettings, loadSearchPreferences, saveSettings, refreshUser, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
+354
-29
@@ -14275,9 +14275,7 @@ svg {
|
||||
|
||||
.account-avatar {
|
||||
align-items: center;
|
||||
background: #e8f4ff;
|
||||
border-radius: 999px;
|
||||
color: #20344f;
|
||||
display: grid;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
@@ -19557,10 +19555,11 @@ svg {
|
||||
color: #2b4e74;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-list .cover-upload-tag input {
|
||||
@@ -19568,7 +19567,38 @@ svg {
|
||||
}
|
||||
|
||||
.settings-list .cover-photo-row strong {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Podpowiedz przy zdjeciu w tle to pelne zdanie, a nie krotka etykieta. W waskiej trzeciej
|
||||
kolumnie konczyla sie wielokropkiem ("Dodaj zdjecie w tle swo..."), wiec schodzi do drugiej
|
||||
linii pod nazwe i zawija sie normalnie. Nazwa i przycisk zostaja w jednym wierszu. */
|
||||
.settings-list article.cover-photo-row {
|
||||
grid-template-columns: 22px minmax(0, 1fr) auto;
|
||||
row-gap: 3px;
|
||||
}
|
||||
|
||||
.settings-list article.cover-photo-row > span {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.settings-list article.cover-photo-row > em {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.settings-list article.cover-photo-row > .cover-upload-tag {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.settings-list article.cover-photo-row > strong {
|
||||
grid-column: 2 / span 2;
|
||||
grid-row: 2;
|
||||
overflow: visible;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cover-photo-actions-row {
|
||||
@@ -19712,6 +19742,8 @@ svg {
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
grid-template-columns: 22px 1fr;
|
||||
/* Wyrazny odstep od wiersza ze zdjeciem w tle - to osobna decyzja, nie kolejna pozycja listy. */
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.settings-list article.profile-visibility-card > span {
|
||||
@@ -19725,10 +19757,19 @@ svg {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* Opis wybranej widocznosci to pelne zdanie pod przyciskami, a nie krotka etykieta w wierszu.
|
||||
Domyslne .settings-list strong tnie go w jednej linii wielokropkiem - tutaj ma sie zawijac. */
|
||||
.settings-list article.profile-visibility-card > strong {
|
||||
overflow: visible;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Dwie opcje widocznosci: publiczna i prywatna - kolumny dziela szerokosc po rowno. */
|
||||
.profile-visibility-card .profile-visibility-options {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -19738,9 +19779,10 @@ svg {
|
||||
border: 1px solid #d8e1ec;
|
||||
border-radius: 8px;
|
||||
color: #41566f;
|
||||
font-size: 9px;
|
||||
/* Etykiety byly scisniete do 9 px, zeby zmiescic trzy kolumny - przy dwoch jest miejsce. */
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
min-height: 28px;
|
||||
min-height: 30px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
text-overflow: ellipsis;
|
||||
@@ -20738,10 +20780,7 @@ svg {
|
||||
}
|
||||
|
||||
.profile-photo-avatar {
|
||||
background: #eaf2ff;
|
||||
border: 2px solid #d7e2f2;
|
||||
border-radius: 999px;
|
||||
color: #2f6cd3;
|
||||
display: grid;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
@@ -20766,21 +20805,41 @@ svg {
|
||||
.profile-photo-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
/* Przycisk stal tuz pod nazwa konta i wisial wysoko nad linia zamykajaca sekcje - odsuwamy go
|
||||
od tekstu, zeby siedzial mniej wiecej w polowie miedzy nazwa a ta linia. */
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-photo-actions label,
|
||||
.profile-photo-actions button {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 6px;
|
||||
color: #344860;
|
||||
/* Wybor pliku to <label>, a element inline ignoruje wysokosc i pionowe wysrodkowanie -
|
||||
stad inline-flex: bez tego napis siedzi na krawedzi ramki. */
|
||||
.profile-photo-actions label {
|
||||
align-items: center;
|
||||
background: #12a764;
|
||||
border: 1px solid #12a764;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0 16px;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-photo-actions label:hover {
|
||||
background: #0f8f56;
|
||||
border-color: #0f8f56;
|
||||
}
|
||||
|
||||
.profile-photo-actions label:focus-within {
|
||||
outline: 2px solid #0b7a49;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.profile-photo-actions label input {
|
||||
@@ -20788,11 +20847,20 @@ svg {
|
||||
}
|
||||
|
||||
.profile-photo-actions button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #d14646;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-photo-actions button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settings-separator {
|
||||
border-top: 1px solid #edf1f5;
|
||||
margin: 14px 0;
|
||||
@@ -21771,11 +21839,9 @@ svg {
|
||||
|
||||
.public-profile-avatar {
|
||||
align-items: center;
|
||||
background: #eaf2ff;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 6px 14px rgba(29, 52, 82, 0.16);
|
||||
color: #2f6cd3;
|
||||
display: grid;
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
@@ -30014,6 +30080,14 @@ a.listing-detail-back {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Wiersz z telefonem jest ostatni, wiec nie ma wlasnej kreski na dole (border-bottom zdejmuje
|
||||
:last-child). Domyslny odstep separatora dokladal pod nim 14 px pustki, przez co tresc wiersza
|
||||
wygladala, jakby wisiala w powietrzu: 15 px nad tekstem i 29 px pod nim. Separator idzie tuz
|
||||
pod wiersz i zamyka go tak samo, jak kreska zamyka wiersz z e-mailem. */
|
||||
.security-status-list + .settings-separator {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
@@ -30045,11 +30119,12 @@ a.listing-detail-back {
|
||||
}
|
||||
|
||||
/* Link podgladu profilu jest teraz czwartym dzieckiem karty widocznosci - bez tego
|
||||
wpadalby do waskiej kolumny z ikona i wychodzil poza karte. */
|
||||
wpadalby do waskiej kolumny z ikona i wychodzil poza karte. Stoi na dole po prawej,
|
||||
pod opisem wybranej widocznosci - to domkniecie sekcji, a nie element wiersza. */
|
||||
.settings-list article.profile-visibility-card > .profile-preview-cta {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
margin-top: 4px;
|
||||
justify-self: end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.settings-danger-zone {
|
||||
@@ -30168,3 +30243,253 @@ a.listing-detail-back {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --- Awatar uzytkownika: jeden wyglad w calej aplikacji ---------------------------------
|
||||
Klasa z miejsca uzycia (.account-trigger-avatar, .account-avatar, .public-profile-avatar,
|
||||
.profile-photo-avatar, .listing-detail-seller-avatar) nadaje rozmiar i pozycje, a ta -
|
||||
ksztalt, kolor i wpasowanie zdjecia. Bez ustawionego zdjecia zostaja inicjaly: biale
|
||||
litery na zielonym tle, tak jak awatar w prawym gornym rogu naglowka.
|
||||
Reguly stoja na koncu arkusza, zeby kolor tla wygral z wczesniejszymi definicjami. */
|
||||
.avatar {
|
||||
align-items: center;
|
||||
background: #12a764;
|
||||
/* Ksztalt trzymamy tutaj, zeby kazde nowe uzycie bylo okragle bez dopisywania wlasnej reguly. */
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
font-weight: 800;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.3px;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
border-radius: inherit;
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Profil prywatny -------------------------------------------------------------------
|
||||
Zamiast pustego szkieletu profilu pokazujemy jedna karte: czyj to profil i ze jest
|
||||
prywatny. Ogloszen i danych nie ma tu do ukrycia - serwer ich nie przysyla. */
|
||||
.private-profile-card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.private-profile-avatar {
|
||||
font-size: 24px;
|
||||
height: 76px;
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.private-profile-card h1 {
|
||||
color: #13243d;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.private-profile-card strong {
|
||||
align-items: center;
|
||||
color: #4a5c73;
|
||||
display: inline-flex;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.private-profile-card p {
|
||||
color: #77869a;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
/* --- Zmiana hasla: podpowiedzi pod polami i licznik odstepu -----------------------------
|
||||
Pola nowego hasla otwieraja sie dopiero po potwierdzeniu dotychczasowego, wiec formularz
|
||||
musi na biezaco mowic, na czym stoi. */
|
||||
.settings-password-form .field-hint {
|
||||
color: #77869a;
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-password-form .field-hint.ok {
|
||||
color: #12784f;
|
||||
}
|
||||
|
||||
.settings-password-form .field-hint.error {
|
||||
color: #c0392f;
|
||||
}
|
||||
|
||||
.settings-password-form input:disabled {
|
||||
background: #f4f6f9;
|
||||
color: #9aa7b6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-password-form button[type="submit"]:disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.55);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.password-cooldown {
|
||||
align-items: center;
|
||||
background: #fdf6e8;
|
||||
border: 1px solid #f0dcae;
|
||||
border-radius: 8px;
|
||||
color: #7a5a12;
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
margin: 0 0 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.password-cooldown svg {
|
||||
flex: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.password-cooldown strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* --- Pasek "filtry z Twoich preferencji" nad wynikami ----------------------------------
|
||||
Wyszukiwarka startuje z preferencjami z /konto/ustawienia. Bez tego paska zawezona lista
|
||||
wygladalaby jak brak ofert w serwisie, wiec musi byc widoczny i dawac wyjscie jednym kliknieciem. */
|
||||
.preferences-note {
|
||||
align-items: center;
|
||||
background: #eef7f2;
|
||||
border: 1px solid #cfe8dc;
|
||||
border-radius: 8px;
|
||||
color: #1c5c42;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.preferences-note svg {
|
||||
flex: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.preferences-note > span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.preferences-note button,
|
||||
.preferences-note a {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #12784f;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preferences-note button:hover,
|
||||
.preferences-note a:hover {
|
||||
color: #0b5c3a;
|
||||
}
|
||||
|
||||
/* Czyszczenie preferencji stoi obok zapisu, ale jest akcja drugorzedna - nie moze wygladac
|
||||
tak samo jak zielony przycisk zapisu, bo to dwie rozne decyzje. */
|
||||
.profile-form-actions button.settings-clear-button {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9e2ec;
|
||||
color: #5c6b7f;
|
||||
}
|
||||
|
||||
.profile-form-actions button.settings-clear-button:hover:not(:disabled) {
|
||||
border-color: #c3d0de;
|
||||
color: #33445c;
|
||||
}
|
||||
|
||||
|
||||
/* Lista podpowiedzi miejscowosci w preferencjach - uklad jak w wyszukiwarce ofert,
|
||||
ale bez odnosnika do mapy: w ustawieniach nie prowadzimy wyszukiwania. */
|
||||
.settings-place-suggest {
|
||||
border: 1px solid #e3eaf3;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-place-suggest button {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eff3f8;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-place-suggest button:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-place-suggest button:hover {
|
||||
background: #f4f8fc;
|
||||
}
|
||||
|
||||
.settings-place-suggest svg {
|
||||
color: #12a764;
|
||||
flex: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.settings-place-suggest strong {
|
||||
color: #1c2c42;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-place-suggest small {
|
||||
color: #7d8b9d;
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-place-status {
|
||||
color: #7d8b9d;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user