panel uzytkownika

This commit is contained in:
2026-08-24 14:14:54 +02:00
parent 6c35589e70
commit 1a067cce8c
6 changed files with 476 additions and 263 deletions
+254 -249
View File
@@ -6518,7 +6518,7 @@ function AccountDashboardPage({
}) {
const navigate = useNavigate();
const { user } = useAuth();
const { formatPrice, formatPricePerArea, formatArea, formatAreaNumber } = useDisplay();
const { formatPrice, formatMonthlyPrice, formatPricePerArea, formatArea } = useDisplay();
type DashboardWidgetId = 'recent' | 'alerts' | 'searches' | 'tools';
type DashboardWidgetConfig = {
id: DashboardWidgetId;
@@ -6536,13 +6536,30 @@ function AccountDashboardPage({
{ icon: 'message', label: 'Nieprzeczytane wiadomości', count: 4, action: 'Zobacz wiadomości' },
];
// Wartosci trzymamy liczbowo (PLN, m²), zeby szly za ustawieniem waluty i jednostki z /konto/ustawienia.
const alerts = [
{ place: 'Mokotów, Stegny', rooms: 'Mieszkania 3-pokojowe', areaFrom: 50, areaTo: 70, budget: 700000 },
{ place: 'Praga-Południe, Grochów', rooms: 'Mieszkania 2-pokojowe', areaFrom: 35, areaTo: 55, budget: 600000 },
{ place: 'Wola, Odolany', rooms: 'Mieszkania 3-4 pokojowe', areaFrom: 60, areaTo: 80, budget: 900000 },
];
const [activeAlerts, setActiveAlerts] = useState<boolean[]>(() => alerts.map(() => true));
// Realne dane panelu: ostatnio oglądane oferty (localStorage) i alerty cenowe użytkownika
// (to samo źródło co strona /konto/alerty-cenowe).
const [recentlyViewed, setRecentlyViewed] = useState<RecentlyViewedItem[]>(() => readRecentlyViewed());
const [cityAlerts, setCityAlerts] = useState<CityPriceAlertSubscription[]>(
() => readUserJsonStorage<CityPriceAlertSubscription[]>(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []),
);
// Po zmianie konta wczytujemy dane właściwego użytkownika.
useEffect(() => {
setRecentlyViewed(readRecentlyViewed());
setCityAlerts(readUserJsonStorage<CityPriceAlertSubscription[]>(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []));
}, [user]);
// Przełącznik powiadomień alertu - zapis trafia do tego samego miejsca co na stronie alertów,
// więc zmiana jest spójna w całej aplikacji. Brak wartości traktujemy jako "włączone".
const toggleCityAlert = (key: string) => {
setCityAlerts((current) => {
const next = current.map((alert) => (alert.key === key
? { ...alert, notificationsEnabled: alert.notificationsEnabled === false }
: alert));
writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next);
return next;
});
};
const tools = [
{ icon: 'money', title: 'Zdolność kredytowa', text: 'Sprawdź, na jaki kredyt Cię stać i jaka będzie rata', path: ROUTES.creditCalculator },
@@ -6553,40 +6570,6 @@ function AccountDashboardPage({
{ icon: 'shuffle', title: 'Porównanie mieszkań', text: 'Porównuj oferty i wybieraj najlepsze', path: ROUTES.comparison },
];
// Cena i powierzchnia liczbowo - etykiety powstaja przy renderowaniu, wiec reaguja na ustawienia.
const recentListings = [
{
title: 'Mieszkanie 3-pokojowe',
district: 'Warszawa, Mokotów, Stegny',
price: 749000,
area: 62,
roomsLabel: '3 pokoje',
floorLabel: '4/8 piętro',
image: heroImage,
position: '47% 58%',
},
{
title: 'Mieszkanie 2-pokojowe',
district: 'Warszawa, Praga-Południe',
price: 699000,
area: 45,
roomsLabel: '2 pokoje',
floorLabel: '3/6 piętro',
image: cityImage,
position: '52% 55%',
},
{
title: 'Mieszkanie 4-pokojowe',
district: 'Warszawa, Wola, Odolany',
price: 999000,
area: 45,
roomsLabel: '2 pokoje',
floorLabel: '5/7 piętro',
image: heroImage,
position: '72% 52%',
},
];
const defaultDashboardWidgets: DashboardWidgetConfig[] = [
{ id: 'recent', label: 'Ostatnio oglądane', visible: false, layout: 'normal', order: 0 },
{ id: 'alerts', label: 'Alerty cenowe', visible: false, layout: 'normal', order: 1 },
@@ -6688,63 +6671,88 @@ function AccountDashboardPage({
});
};
// Wspolny naglowek widzetu: kolorowa ikona (wg tonu), tytul, podpis i akcja po prawej -
// ten sam jezyk wizualny co karty w katalogu "Dostepne widzety".
const renderWidgetHead = (id: DashboardWidgetId, action?: ReactNode) => {
const meta = widgetCardMeta[id];
const label = defaultDashboardWidgets.find((widget) => widget.id === id)?.label ?? '';
return (
<div className="account-panel-head">
<div className={`account-panel-title ${meta.tone}`}>
<span className="account-panel-icon"><Icon name={meta.icon} /></span>
<div>
<h2>{label}</h2>
<small>{meta.description}</small>
</div>
</div>
{action}
</div>
);
};
const widgetSections: Record<DashboardWidgetId, ReactNode> = {
recent: (
<section className="account-panel" key="recent">
<div className="account-panel-head">
<h2>Ostatnio oglądane</h2>
<button type="button">Zobacz wszystkie <Icon name="arrow" /></button>
</div>
<div className="account-listings">
{recentListings.map((listing) => (
<article key={`${listing.title}-${listing.district}`}>
<div className="photo" style={{ backgroundImage: `url(${listing.image})`, backgroundPosition: listing.position }} />
<strong>{formatPrice(listing.price)}</strong>
<h3>{listing.title}</h3>
<p>{listing.district}</p>
<small>{formatArea(listing.area)} • {listing.roomsLabel} • {listing.floorLabel}</small>
<em>{formatPricePerArea(listing.price / listing.area)}</em>
</article>
))}
</div>
{renderWidgetHead('recent', recentlyViewed.length > 0
? <Link to={ROUTES.buy}>Zobacz wszystkie <Icon name="arrow" /></Link>
: undefined)}
{recentlyViewed.length === 0 ? (
<p className="account-searches-empty">Nie masz jeszcze przeglądanych ofert. Otwórz dowolne ogłoszenie, a pojawi się tutaj.</p>
) : (
<div className="account-listings">
{recentlyViewed.slice(0, 3).map((listing) => (
<article
key={listing.id}
role="button"
tabIndex={0}
onClick={() => navigate(listingPath(listing.id))}
onKeyDown={(event) => { if (event.key === 'Enter') { navigate(listingPath(listing.id)); } }}
>
<div className="photo" style={{ backgroundImage: `url(${listing.photo ?? heroImage})` }} />
<strong>{listing.offerType === 'RENT' ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}</strong>
<h3>{listing.title}</h3>
<p>{[listing.city, listing.district].filter(Boolean).join(', ')}</p>
<small>{formatArea(listing.area)}{listing.rooms ? ` • ${listing.rooms} ${listing.rooms === 1 ? 'pokój' : 'pokoje'}` : ''}</small>
{listing.area > 0 && <em>{formatPricePerArea(listing.price / listing.area)}</em>}
</article>
))}
</div>
)}
</section>
),
alerts: (
<section className="account-panel" key="alerts">
<div className="account-panel-head">
<h2>Alerty cenowe</h2>
<button type="button">Zarządzaj alertami <Icon name="arrow" /></button>
</div>
{renderWidgetHead('alerts', <Link to={ROUTES.accountPriceAlerts}>Zarządzaj alertami <Icon name="arrow" /></Link>)}
<div className="account-alerts">
{alerts.map((alert, index) => (
<article key={alert.place}>
<span><Icon name="chart" /></span>
<div>
<strong>{alert.place}</strong>
<small>{`${alert.rooms}, ${formatAreaNumber(alert.areaFrom)}-${formatArea(alert.areaTo)}`}</small>
<em>{`Do ${formatPrice(alert.budget)}`}</em>
</div>
<button
type="button"
className={`account-toggle ${activeAlerts[index] ? 'on' : ''}`}
aria-label={`Przełącz alert: ${alert.place}`}
aria-pressed={activeAlerts[index]}
onClick={() => {
setActiveAlerts((current) => current.map((value, idx) => (idx === index ? !value : value)));
}}
/>
</article>
))}
<button type="button" className="account-add-alert">Utwórz nowy alert <Icon name="plus" /></button>
{cityAlerts.slice(0, 3).map((alert) => {
const enabled = alert.notificationsEnabled !== false;
return (
<article key={alert.key}>
<span><Icon name="chart" /></span>
<div>
<strong>{alert.label}</strong>
<small>{alert.propertyType} • {alert.marketType}</small>
</div>
<button
type="button"
className={`account-toggle ${enabled ? 'on' : ''}`}
aria-label={`Przełącz powiadomienia alertu: ${alert.label}`}
aria-pressed={enabled}
onClick={() => toggleCityAlert(alert.key)}
/>
</article>
);
})}
{cityAlerts.length === 0 && (
<p className="account-searches-empty">Nie masz jeszcze alertów cenowych. Utwórz alert dla wybranej lokalizacji, a damy znać o zmianach cen.</p>
)}
<Link className="account-add-alert" to={ROUTES.accountPriceAlerts}>Utwórz nowy alert <Icon name="plus" /></Link>
</div>
</section>
),
searches: (
<section className="account-panel" key="searches">
<div className="account-panel-head">
<h2>Moje wyszukiwania</h2>
<Link to={ROUTES.accountSearches}>Zobacz wszystkie <Icon name="arrow" /></Link>
</div>
{renderWidgetHead('searches', <Link to={ROUTES.accountSearches}>Zobacz wszystkie <Icon name="arrow" /></Link>)}
<div className="account-searches">
{savedSearches.slice(0, 4).map((row) => (
<article
@@ -6776,9 +6784,7 @@ function AccountDashboardPage({
),
tools: (
<section className="account-panel" key="tools">
<div className="account-panel-head">
<h2>Narzędzia Polska Lokalnie</h2>
</div>
{renderWidgetHead('tools')}
<div className="account-tools">
{tools.map((tool) => (
// Kafelek prowadzi na inna strone, wiec jest linkiem - bez protezy role="button".
@@ -7207,6 +7213,7 @@ function AccountHelpContactPage({
title: 'Poradniki',
description: 'Praktyczne artykuły i wskazówki dotyczące kupna, sprzedaży i wynajmu nieruchomości.',
action: 'Zobacz poradniki',
to: ROUTES.guides,
},
{
id: 'security',
@@ -7215,6 +7222,7 @@ function AccountHelpContactPage({
title: 'Bezpieczeństwo',
description: 'Dowiedz się jak bezpiecznie korzystać z serwisu i unikać oszustw.',
action: 'Zasady bezpieczeństwa',
to: ROUTES.accountSecurity,
},
{
id: 'rules',
@@ -7223,14 +7231,17 @@ function AccountHelpContactPage({
title: 'Regulamin i zasady',
description: 'Zapoznaj się z regulaminem serwisu oraz zasadami korzystania.',
action: 'Przeczytaj regulamin',
to: ROUTES.terms,
},
{
// Bez trasy - "Zgłoś problem" przewija do formularza kontaktowego na tej samej stronie.
id: 'report',
icon: 'warning',
iconTone: 'red',
title: 'Zgłoś problem',
description: 'Zgłoś nieodpowiednie treści lub problemy techniczne.',
action: 'Zgłoś',
to: null,
},
] as const;
@@ -7244,6 +7255,7 @@ function AccountHelpContactPage({
const [isSubmittingContactForm, setIsSubmittingContactForm] = useState(false);
const [isContactFormHighlighted, setIsContactFormHighlighted] = useState(false);
const contactFormHighlightTimeoutRef = useRef<number | null>(null);
const contactFormCardRef = useRef<HTMLElement | null>(null);
const contactTopicLabels: Record<string, string> = {
ogloszenie: 'Pytanie o ogłoszenie',
konto: 'Problemy z kontem',
@@ -7286,6 +7298,13 @@ function AccountHelpContactPage({
}, 3000);
}, []);
// "Zgłoś problem": przewijamy do formularza "Napisz do nas" i podświetlamy go, zamiast
// otwierać osobną stronę - zgłoszenia trafiają właśnie przez ten formularz.
const focusContactForm = useCallback(() => {
contactFormCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
highlightContactFormForThreeSeconds();
}, [highlightContactFormForThreeSeconds]);
const handleContactSubmit = useCallback(async (event: ReactFormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -7418,7 +7437,7 @@ function AccountHelpContactPage({
</div>
</article>
<article className={`help-contact-form-card ${isContactFormHighlighted ? 'is-highlighted' : ''}`}>
<article ref={contactFormCardRef} className={`help-contact-form-card ${isContactFormHighlighted ? 'is-highlighted' : ''}`}>
<h2>Napisz do nas</h2>
<form onSubmit={handleContactSubmit}>
<div className="help-contact-form-row">
@@ -7469,13 +7488,13 @@ function AccountHelpContactPage({
{moreHelpCards.map((card) => (
<article key={card.id} className="help-contact-more-card">
<span className={`help-contact-more-icon ${card.iconTone}`}><Icon name={card.icon} /></span>
<div>
<div className="help-contact-more-body">
<h3>{card.title}</h3>
<p>{card.description}</p>
{card.id === 'rules' ? (
<Link to={ROUTES.terms}>{card.action} <Icon name="arrow" /></Link>
{card.to ? (
<Link className="help-contact-more-link" to={card.to}>{card.action} <Icon name="arrow" /></Link>
) : (
<button type="button">{card.action} <Icon name="arrow" /></button>
<button type="button" className="help-contact-more-link" onClick={focusContactForm}>{card.action} <Icon name="arrow" /></button>
)}
</div>
</article>
@@ -10967,6 +10986,31 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
}
};
// Jezyk komunikacji jest czescia profilu (PUT /auth/me), a nie ustawien wyswietlania. Zapisujemy
// go od razu przy zmianie - jak reszta "Ustawien aplikacji". Backend nadpisuje caly profil, wiec
// wysylamy aktualne, zapisane dane uzytkownika i zmieniamy tylko jezyk (nie ruszamy niezapisanych
// edycji z sekcji "Moje dane").
const saveLanguage = async (next: PreferredLanguage) => {
if (!user || next === user.preferredLanguage) {
return;
}
try {
await updateProfile({
fullName: user.fullName,
nick: user.nick ?? undefined,
phone: user.phone ?? undefined,
birthDate: user.birthDate ?? undefined,
address: user.address ?? undefined,
contactPreference: user.contactPreference,
preferredLanguage: next,
});
setIdentityField('preferredLanguage', next);
flashPrefsBanner({ text: 'Język komunikacji zapisany.', tone: 'success' });
} catch (error) {
setPrefsBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać języka.', tone: 'error' });
}
};
const updateConsent = async (next: { emailConsent: boolean; smsConsent: boolean }) => {
const previous = consent;
setConsent(next);
@@ -11470,19 +11514,6 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
</select>
</label>
<label>
<span>Język komunikacji</span>
<select
value={identity.preferredLanguage}
onChange={(event) => setIdentityField('preferredLanguage', event.target.value as PreferredLanguage)}
>
<option value="PL">Polski</option>
<option value="EN">Angielski</option>
<option value="UK">Ukraiński</option>
<option value="DE">Niemiecki</option>
</select>
</label>
<label>
<span>Biografia (opcjonalnie)</span>
<textarea
@@ -11716,6 +11747,21 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
</select>
</article>
<article className="settings-select-row">
<span><Icon name="message" /></span>
<em>Język komunikacji</em>
<select
value={user?.preferredLanguage ?? 'PL'}
disabled={isLoading}
onChange={(event) => { void saveLanguage(event.target.value as PreferredLanguage); }}
>
<option value="PL">Polski</option>
<option value="EN">Angielski</option>
<option value="UK">Ukraiński</option>
<option value="DE">Niemiecki</option>
</select>
</article>
<SettingsToggleRow
icon="shield"
label="Pokaż tylko oferty bezpośrednie"
@@ -12000,76 +12046,31 @@ function AccountPublicProfilePage() {
};
}, [user, loadSettings]);
const publicProfileCover = profileSettings?.coverImage || heroImage;
const publicReviews = [
{
name: 'Anna Nowak',
text: 'Bardzo profesjonalne podejście i świetny kontakt. Wszystko przebiegło sprawnie.',
avatar: '',
},
{
name: 'Michał Wiśniewski',
text: 'Pan Jan pomógł mi znaleźć idealne mieszkanie i przeprowadził przez cały proces.',
avatar: '',
},
{
name: 'Katarzyna Zielińska',
text: 'Polecam współpracę. Pełne zaangażowanie i bardzo dobra komunikacja.',
avatar: '',
},
];
const initialsFromName = (fullName: string) => fullName
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((chunk) => chunk[0]?.toUpperCase() ?? '')
.join('');
// Prawdziwe ogloszenia uzytkownika (te same, ktore widzi kazdy odwiedzajacy profil) z /listings/mine.
const [myListings, setMyListings] = useState<ApiListingSummary[]>([]);
useEffect(() => {
if (!user) {
setMyListings([]);
return;
}
let active = true;
apiFetch<ApiListingSummary[]>('/listings/mine')
.then((data) => { if (active) setMyListings(Array.isArray(data) ? data : []); })
.catch(() => { if (active) setMyListings([]); });
return () => { active = false; };
}, [user]);
// Cena i metraz liczbowo (PLN, m²) - etykiety powstaja przy renderowaniu, wiec ida za ustawieniami.
const publicListings = [
{
image: heroImage,
imagePosition: 'left center',
badge: 'Na sprzedaż',
badgeClass: 'sale',
price: 748000,
isRent: false,
title: 'Mieszkanie 3-pokojowe',
location: 'Warszawa, Mokotów, Stegny',
area: 62,
rooms: '3 pokoje',
floor: '4/8 piętro',
date: 'Dodane 2 dni temu',
},
{
image: cityImage,
imagePosition: 'center center',
badge: 'Na sprzedaż',
badgeClass: 'sale',
price: 699000,
isRent: false,
title: 'Mieszkanie 2-pokojowe',
location: 'Warszawa, Praga-Południe',
area: 45,
rooms: '2 pokoje',
floor: '3/6 piętro',
date: 'Dodane 5 dni temu',
},
{
image: heroImage,
imagePosition: '70% center',
badge: 'Na wynajem',
badgeClass: 'rent',
price: 3200,
isRent: true,
title: 'Mieszkanie 2-pokojowe',
location: 'Warszawa, Wola, Odolany',
area: 48,
rooms: '2 pokoje',
floor: '2/7 piętro',
date: 'Dodane 1 tydzień temu',
},
];
const saleCount = myListings.filter((item) => item.offerType !== 'RENT').length;
const rentCount = myListings.filter((item) => item.offerType === 'RENT').length;
// Data dolaczenia z konta - zamiast zaszytej na sztywno.
const memberSince = (() => {
const created = new Date(user?.createdAt ?? '');
return Number.isNaN(created.getTime())
? null
: created.toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' });
})();
return (
<section className="account-settings-page public-profile-page" aria-label="Jak inni widzą Twój profil">
@@ -12093,13 +12094,8 @@ function AccountPublicProfilePage() {
<div className="public-profile-user">
<h2>{getDisplayName(user)}</h2>
<div className="public-profile-meta">
<span><Icon name="calendar" /> Dołączył w marcu 2024</span>
<span><Icon name="clock" /> Ostatnio aktywny dziś</span>
</div>
<div className="public-profile-rating">
<strong>4,9</strong>
<span><Icon name="star" /><Icon name="star" /><Icon name="star" /><Icon name="star" /><Icon name="star" /></span>
<small>(27 opinii)</small>
{memberSince && <span><Icon name="calendar" /> Z nami od: {memberSince}</span>}
{user?.verified && <span><Icon name="shield" /> Konto zweryfikowane</span>}
</div>
</div>
{/* Biografia z ustawien konta - dokladnie to, co w tym miejscu zobacza inni. */}
@@ -12109,74 +12105,60 @@ function AccountPublicProfilePage() {
: <span className="public-profile-bio-empty">Nie dodałeś jeszcze opisu. Możesz go uzupełnić w polu „Biografia" w ustawieniach konta.</span>}
</blockquote>
<div className="public-profile-stats">
<div><strong>23</strong><small>Ogłoszenia</small></div>
<div><strong>18</strong><small>Sprzedane</small></div>
<div><strong>11</strong><small>Wynajęte</small></div>
<div><strong>{myListings.length}</strong><small>Ogłoszenia</small></div>
<div><strong>{saleCount}</strong><small>Na sprzedaż</small></div>
<div><strong>{rentCount}</strong><small>Na wynajem</small></div>
</div>
</div>
</section>
<div className="public-profile-grid">
<section className="settings-card public-profile-listings">
<div className="settings-card-head">
<h2>Moje ogłoszenia (23)</h2>
<button type="button" className="ghost">Zobacz wszystkie</button>
</div>
<div className="public-listing-grid">
{publicListings.map((listing) => (
<article className="public-listing-card" key={`${listing.title}-${listing.location}`}>
<div className="public-listing-photo" style={{ backgroundImage: `url(${listing.image})`, backgroundPosition: listing.imagePosition }}>
<span className={`public-listing-badge ${listing.badgeClass}`}>{listing.badge}</span>
</div>
<div className="public-listing-body">
<strong>{listing.isRent ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}</strong>
<h3>{listing.title}</h3>
<small><Icon name="pin" /> {listing.location}</small>
<div className="public-listing-meta">
<span><Icon name="scale" /> {formatArea(listing.area)}</span>
<span><Icon name="user" /> {listing.rooms}</span>
<span><Icon name="building" /> {listing.floor}</span>
</div>
<div className="public-listing-foot">
<em>{listing.date}</em>
<button type="button" aria-label="Dodaj do ulubionych"><Icon name="heart" /></button>
</div>
</div>
</article>
))}
</div>
</section>
<section className="settings-card public-profile-reviews">
<div className="settings-card-head">
<h2>Opinie o mnie</h2>
<button type="button" className="ghost">Zobacz wszystkie</button>
</div>
<div className="public-review-list">
{publicReviews.map((review) => (
<article className="public-review-item" key={review.name}>
<div className="public-review-avatar" aria-hidden="true">
{review.avatar ? <img src={review.avatar} alt="" /> : <span>{initialsFromName(review.name)}</span>}
</div>
<div className="public-review-content">
<strong>{review.name}</strong>
<div className="public-review-stars"><Icon name="star" /><Icon name="star" /><Icon name="star" /><Icon name="star" /><Icon name="star" /></div>
<p>{review.text}</p>
</div>
</article>
))}
</div>
</section>
</div>
<section className="settings-card public-profile-about">
<h2>Kilka słów o mnie</h2>
<p>Od 5 lat pomagam klientom w sprzedaży, zakupie i wynajmie nieruchomości na terenie Warszawy. Stawiam na uczciwość, transparentność i indywidualne podejście do każdej transakcji.</p>
<div className="public-profile-badges">
<span><Icon name="message" /> Szybki kontakt</span>
<span><Icon name="shield" /> Zweryfikowany użytkownik</span>
<span><Icon name="check" /> Bezpieczne transakcje</span>
<section className="settings-card public-profile-listings">
<div className="settings-card-head">
<h2>Moje ogłoszenia ({myListings.length})</h2>
{myListings.length > 0 && <Link className="ghost" to={ROUTES.accountListings}>Zobacz wszystkie</Link>}
</div>
{myListings.length === 0 ? (
<p className="public-profile-bio-empty">
Nie masz jeszcze żadnych ogłoszeń. <Link to={ROUTES.add}>Dodaj pierwsze ogłoszenie</Link>, aby pojawiło się w Twoim profilu.
</p>
) : (
<div className="public-listing-grid">
{myListings.map((listing) => {
const isRent = listing.offerType === 'RENT';
const location = [listing.district, listing.city].filter(Boolean).join(', ') || listing.city;
const floorLabel = listing.floor
? (listing.buildingFloors ? `${listing.floor}/${listing.buildingFloors} piętro` : `${listing.floor} piętro`)
: null;
return (
<article
className="public-listing-card"
key={listing.id}
role="button"
tabIndex={0}
onClick={() => navigate(listingPath(listing.id))}
onKeyDown={(event) => { if (event.key === 'Enter') { navigate(listingPath(listing.id)); } }}
>
<div className="public-listing-photo" style={{ backgroundImage: `url(${listing.coverPhoto || heroImage})`, backgroundPosition: 'center center' }}>
<span className={`public-listing-badge ${isRent ? 'rent' : 'sale'}`}>{isRent ? 'Na wynajem' : 'Na sprzedaż'}</span>
</div>
<div className="public-listing-body">
<strong>{isRent ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}</strong>
<h3>{listing.title}</h3>
<small><Icon name="pin" /> {location}</small>
<div className="public-listing-meta">
<span><Icon name="scale" /> {formatArea(listing.area)}</span>
<span><Icon name="user" /> {listing.rooms} {listing.rooms === 1 ? 'pokój' : 'pokoje'}</span>
{floorLabel && <span><Icon name="building" /> {floorLabel}</span>}
</div>
<div className="public-listing-foot">
<em>{listingRelativeDate(listing.createdAt) || 'Dodane niedawno'}</em>
</div>
</div>
</article>
);
})}
</div>
)}
</section>
<footer className="settings-footer-note">
@@ -23381,7 +23363,7 @@ function Header({
function NotificationsPage({ onOpenNotification }: { onOpenNotification: (notification: DisplayNotification) => void }) {
const [activeTab, setActiveTab] = useState<'all' | NotificationCategory>('all');
const { notifications: serverNotifications } = useNotifications();
const { notifications: serverNotifications, unreadCount, markAllRead, clearAll } = useNotifications();
// Cała historia z backendu (przeczytane pozostają na liście); mapowanie do kształtu widoku bez zmiany designu.
const allNotifications = toDisplayNotifications(serverNotifications);
@@ -23397,9 +23379,32 @@ function NotificationsPage({ onOpenNotification }: { onOpenNotification: (notifi
<div className="notifications-panel">
<div className="notifications-head">
<h1>Powiadomienia</h1>
<button type="button" aria-label="Ustawienia powiadomień">
<Icon name="gear" />
</button>
<div className="notifications-actions">
<button
type="button"
className="notifications-mark-all"
onClick={markAllRead}
disabled={unreadCount === 0}
aria-label="Oznacz wszystkie powiadomienia jako przeczytane"
>
<Icon name="check" />
Odczytaj wszystkie
</button>
<button
type="button"
className="notifications-clear-all"
onClick={() => {
if (window.confirm('Usunąć wszystkie powiadomienia? Tej operacji nie można cofnąć.')) {
clearAll();
}
}}
disabled={allNotifications.length === 0}
aria-label="Usuń wszystkie powiadomienia"
>
<Icon name="trash" />
Usuń wszystkie
</button>
</div>
</div>
<div className="notifications-tabs" role="tablist" aria-label="Filtr powiadomień">