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ń">
+11 -1
View File
@@ -41,6 +41,7 @@ type NotificationsContextValue = {
unreadCount: number;
markRead: (id: number) => void;
markAllRead: () => void;
clearAll: () => void;
notify: (input: NotifyInput) => void;
// Kasuje powiadomienia o kluczu zaczynajacym sie od prefiksu - uzywane przy usuwaniu
// alertu cenowego, zeby nie zostal po nim slad.
@@ -147,6 +148,15 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
apiFetch('/notifications/read-all', { method: 'POST' }).catch(() => {});
}, []);
// Kasuje wszystkie powiadomienia uzytkownika. Znikaja od razu z widoku, reszte sprzata backend.
// Czyscimy tez blokade deduplikacji, inaczej te same zdarzenia nie wygenerowalyby juz
// powiadomienia w tej sesji.
const clearAll = useCallback(() => {
setNotifications([]);
sentDedupeKeys.current = new Set();
apiFetch('/notifications/all', { method: 'DELETE' }).catch(() => {});
}, []);
const notify = useCallback((input: NotifyInput) => {
if (!getToken() || !input.title) {
return;
@@ -184,7 +194,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
return (
<NotificationsContext.Provider
value={{ notifications, unreadCount, markRead, markAllRead, notify, dismissByDedupePrefix, refresh }}
value={{ notifications, unreadCount, markRead, markAllRead, clearAll, notify, dismissByDedupePrefix, refresh }}
>
{children}
</NotificationsContext.Provider>
+190 -13
View File
@@ -13585,6 +13585,45 @@ svg {
color: #1d2a45;
}
.notifications-actions {
display: flex;
gap: 8px;
}
/* Akcje "Odczytaj wszystkie" / "Usun wszystkie" - przyciski tekstowe, nie okragle ikony. */
.notifications-head button.notifications-mark-all,
.notifications-head button.notifications-clear-all {
gap: 8px;
grid-auto-flow: column;
height: 34px;
padding: 0 16px;
width: auto;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.notifications-head button.notifications-clear-all {
color: #b3261e;
}
.notifications-head button.notifications-clear-all:hover {
background: #fdeceb;
color: #8f1d17;
}
.notifications-head button.notifications-mark-all:disabled,
.notifications-head button.notifications-clear-all:disabled {
color: #9aa6b8;
cursor: default;
}
.notifications-head button.notifications-mark-all:disabled:hover,
.notifications-head button.notifications-clear-all:disabled:hover {
background: transparent;
color: #9aa6b8;
}
.notifications-tabs {
background: #f2f5f8;
border: 1px solid #ebf0f6;
@@ -14878,7 +14917,7 @@ svg {
}
.help-contact-faq-card header button svg,
.help-contact-more-card button svg {
.help-contact-more-card .help-contact-more-link svg {
font-size: 14px;
}
@@ -15012,6 +15051,15 @@ svg {
padding: 14px;
}
/* Kolumna tresci wypelnia cala wysokosc karty, a jej zawartosc (tytul, opis, akcja)
jest wysrodkowana w pionie - bez duzej bialej przerwy przed linkiem. */
.help-contact-more-body {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
}
.help-contact-more-card h3 {
color: #1f3049;
font-size: 15px;
@@ -15026,16 +15074,25 @@ svg {
margin: 8px 0 14px;
}
.help-contact-more-card button {
/* Akcja karty (Link lub button) - jednolity zielony przycisk tekstowy ze strzalka w linii. */
.help-contact-more-card .help-contact-more-link {
align-items: center;
background: transparent;
border: 0;
color: #109253;
cursor: pointer;
display: inline-flex;
font-size: 12px;
font-weight: 900;
gap: 6px;
padding: 0;
text-decoration: none;
width: fit-content;
}
.help-contact-more-card .help-contact-more-link:hover {
color: #0b6e3e;
text-decoration: underline;
}
@media (max-width: 1280px) {
@@ -17704,7 +17761,7 @@ svg {
.account-stats article > span svg,
.account-alerts article > span svg,
.account-searches article > span svg,
.account-tools article > span svg,
.account-tools a > span svg,
.account-menu button svg, .account-menu a svg {
height: 14px;
width: 14px;
@@ -17923,16 +17980,60 @@ svg {
.account-panel-head {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 10px;
margin-bottom: 12px;
}
/* Naglowek widzetu: kolorowa ikona + tytul z podpisem (jak karty w katalogu widzetow). */
.account-panel-title {
align-items: center;
display: flex;
gap: 10px;
min-width: 0;
}
.account-panel-icon {
align-items: center;
border-radius: 12px;
display: grid;
flex: none;
height: 40px;
place-items: center;
width: 40px;
}
.account-panel-icon svg {
height: 17px;
width: 17px;
}
.account-panel-title.green .account-panel-icon { background: #ebf8f0; color: #17a35f; }
.account-panel-title.blue .account-panel-icon { background: #edf5ff; color: #3478ec; }
.account-panel-title.amber .account-panel-icon { background: #fff5e8; color: #d38a15; }
.account-panel-title.pink .account-panel-icon { background: #fff0f4; color: #d64c78; }
.account-panel-head h2 {
color: #15253d;
font-size: 18px;
line-height: 1.15;
margin: 0;
}
.account-panel-title small {
color: #6a7f94;
display: block;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
margin-top: 2px;
}
.account-panel-head > button,
.account-panel-head > a {
flex: none;
}
.account-panel-head button {
background: transparent;
border: 0;
@@ -17953,12 +18054,29 @@ svg {
.account-listings article {
border: 1px solid #e3e9f0;
border-radius: 8px;
cursor: pointer;
overflow: hidden;
/* Karta ostatnio ogladanej oferty reaguje na najechanie - unosi sie i podswietla. */
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.account-listings article:hover,
.account-listings article:focus-visible {
border-color: #109253;
box-shadow: 0 12px 22px rgba(16, 146, 83, 0.16);
outline: none;
transform: translateY(-3px);
}
.account-listings .photo {
background-size: cover;
height: 118px;
transition: transform 0.25s ease;
}
.account-listings article:hover .photo,
.account-listings article:focus-visible .photo {
transform: scale(1.05);
}
.account-listings article strong,
@@ -18475,7 +18593,7 @@ svg {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.account-tools article {
.account-tools a {
align-items: start;
border: 1px solid #e5ebf2;
border-radius: 8px;
@@ -18484,9 +18602,19 @@ svg {
grid-template-columns: 28px minmax(0, 1fr);
min-height: 74px;
padding: 10px;
/* Kafelek narzedzia unosi sie i podswietla po najechaniu. */
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.account-tools article > span {
.account-tools a:hover,
.account-tools a:focus-visible {
border-color: #109253;
box-shadow: 0 10px 20px rgba(16, 146, 83, 0.16);
outline: none;
transform: translateY(-2px);
}
.account-tools a > span {
align-items: center;
background: #eef4ff;
border-radius: 8px;
@@ -18499,22 +18627,22 @@ svg {
width: 28px;
}
.account-tools article:nth-child(2) > span {
.account-tools a:nth-child(2) > span {
background: #eef8ff;
color: #4a79f0;
}
.account-tools article:nth-child(3) > span {
.account-tools a:nth-child(3) > span {
background: #eef3ff;
color: #6b7ef3;
}
.account-tools article:nth-child(4) > span {
.account-tools a:nth-child(4) > span {
background: #edf8f0;
color: #3ba75f;
}
.account-tools article:nth-child(5) > span {
.account-tools a:nth-child(5) > span {
background: #f3efff;
color: #7e61e9;
}
@@ -22035,11 +22163,13 @@ svg {
}
.public-profile-summary {
/* Wszystkie kolumny (awatar, dane, biografia, statystyki) wyrownane do wspolnej osi w pionie -
jeden rzad na tej samej wysokosci. */
align-items: center;
display: grid;
gap: 14px;
gap: 14px 16px;
grid-template-columns: 76px 1.2fr 1fr auto;
padding: 12px 16px 16px;
padding: 16px;
}
.public-profile-avatar {
@@ -22051,10 +22181,16 @@ svg {
font-size: 26px;
font-weight: 900;
height: 76px;
margin-top: -30px;
/* Delikatna nakladka na okladke - awatar zrownany w pionie z tekstem obok. */
margin-top: -12px;
width: 76px;
}
/* Nazwa podniesiona w gore - zeby zrownala sie ze srodkiem awatara zachodzacego na okladke. */
.public-profile-user {
margin-top: -20px;
}
.public-profile-user h2 {
font-size: 31px;
line-height: 1.05;
@@ -22165,7 +22301,47 @@ svg {
.public-listing-card {
border: 1px solid #e4ebf3;
border-radius: 10px;
cursor: pointer;
overflow: hidden;
/* Karta reaguje na najechanie: unosi sie, zyskuje cien i zielona obwodke. */
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
will-change: transform;
}
.public-listing-card:hover,
.public-listing-card:focus-visible {
border-color: #109253;
box-shadow: 0 14px 26px rgba(16, 146, 83, 0.18);
outline: none;
transform: translateY(-4px);
}
/* Zdjecie lekko przybliza sie na hover - kadr "ozywa". overflow:hidden na karcie kadruje nadmiar. */
.public-listing-card:hover .public-listing-photo,
.public-listing-card:focus-visible .public-listing-photo {
transform: scale(1.06);
}
/* Cena mocniej wybija sie na zielono, tytul ciemnieje - subtelne podkreslenie aktywnej karty. */
.public-listing-card:hover .public-listing-body strong,
.public-listing-card:focus-visible .public-listing-body strong {
color: #109253;
}
/* Szanujemy ustawienie systemowe "ogranicz ruch" - wtedy bez unoszenia i przyblizania. */
@media (prefers-reduced-motion: reduce) {
.public-listing-card,
.public-listing-photo {
transition: none;
}
.public-listing-card:hover,
.public-listing-card:focus-visible {
transform: none;
}
.public-listing-card:hover .public-listing-photo,
.public-listing-card:focus-visible .public-listing-photo {
transform: none;
}
}
.public-listing-photo {
@@ -22173,6 +22349,7 @@ svg {
background-size: cover;
border-bottom: 1px solid #e4ebf3;
height: 88px;
transition: transform 0.25s ease;
}
.public-listing-badge {