panel uzytkownika
This commit is contained in:
@@ -63,6 +63,13 @@ public class NotificationController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// Wyczyszczenie calego centrum powiadomien ("Usun wszystkie"). Zakres bierzemy z tokenu.
|
||||
@DeleteMapping("/all")
|
||||
public ResponseEntity<Void> deleteAll(Authentication authentication) {
|
||||
notificationService.deleteAll(authentication.getName());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// Zdarzenia liczone po stronie klienta (alerty cenowe, spotkania, ulubione, weryfikacja telefonu,
|
||||
// dopasowane oferty). Powiadomienie zawsze trafia do zalogowanego uzytkownika (serwer ustawia odbiorce).
|
||||
@PostMapping
|
||||
|
||||
@@ -24,4 +24,9 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
|
||||
@Query("delete from Notification n where lower(n.userEmail) = lower(:email) "
|
||||
+ "and n.dedupeKey is not null and n.dedupeKey like :pattern escape '\\'")
|
||||
int deleteByDedupeKeyPattern(@Param("email") String email, @Param("pattern") String pattern);
|
||||
|
||||
// Wyczyszczenie calego centrum powiadomien uzytkownika ("Usun wszystkie").
|
||||
@Modifying
|
||||
@Query("delete from Notification n where lower(n.userEmail) = lower(:email)")
|
||||
int deleteAllForUser(@Param("email") String email);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,15 @@ public class NotificationService {
|
||||
repository.markAllRead(email);
|
||||
}
|
||||
|
||||
/** Usuwa wszystkie powiadomienia uzytkownika ("Usun wszystkie" w centrum powiadomien). */
|
||||
@Transactional
|
||||
public int deleteAll(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
return 0;
|
||||
}
|
||||
return repository.deleteAllForUser(email);
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
|
||||
public SseEmitter subscribe(String email) {
|
||||
|
||||
+254
-249
@@ -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ń">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user