diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java index 9c4aafb..83d5efe 100644 --- a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java @@ -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 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 diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java index 21eb12e..fef5dbc 100644 --- a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java @@ -24,4 +24,9 @@ public interface NotificationRepository extends JpaRepository(() => 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(() => readRecentlyViewed()); + const [cityAlerts, setCityAlerts] = useState( + () => readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []), + ); + + // Po zmianie konta wczytujemy dane właściwego użytkownika. + useEffect(() => { + setRecentlyViewed(readRecentlyViewed()); + setCityAlerts(readUserJsonStorage(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 ( +
+
+ +
+

{label}

+ {meta.description} +
+
+ {action} +
+ ); + }; + const widgetSections: Record = { recent: (
-
-

Ostatnio oglądane

- -
-
- {recentListings.map((listing) => ( -
-
- {formatPrice(listing.price)} -

{listing.title}

-

{listing.district}

- {formatArea(listing.area)} • {listing.roomsLabel} • {listing.floorLabel} - {formatPricePerArea(listing.price / listing.area)} -
- ))} -
+ {renderWidgetHead('recent', recentlyViewed.length > 0 + ? Zobacz wszystkie + : undefined)} + {recentlyViewed.length === 0 ? ( +

Nie masz jeszcze przeglądanych ofert. Otwórz dowolne ogłoszenie, a pojawi się tutaj.

+ ) : ( +
+ {recentlyViewed.slice(0, 3).map((listing) => ( +
navigate(listingPath(listing.id))} + onKeyDown={(event) => { if (event.key === 'Enter') { navigate(listingPath(listing.id)); } }} + > +
+ {listing.offerType === 'RENT' ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)} +

{listing.title}

+

{[listing.city, listing.district].filter(Boolean).join(', ')}

+ {formatArea(listing.area)}{listing.rooms ? ` • ${listing.rooms} ${listing.rooms === 1 ? 'pokój' : 'pokoje'}` : ''} + {listing.area > 0 && {formatPricePerArea(listing.price / listing.area)}} +
+ ))} +
+ )}
), alerts: (
-
-

Alerty cenowe

- -
+ {renderWidgetHead('alerts', Zarządzaj alertami )}
- {alerts.map((alert, index) => ( -
- -
- {alert.place} - {`${alert.rooms}, ${formatAreaNumber(alert.areaFrom)}-${formatArea(alert.areaTo)}`} - {`Do ${formatPrice(alert.budget)}`} -
-
- ))} - + {cityAlerts.slice(0, 3).map((alert) => { + const enabled = alert.notificationsEnabled !== false; + return ( +
+ +
+ {alert.label} + {alert.propertyType} • {alert.marketType} +
+
+ ); + })} + {cityAlerts.length === 0 && ( +

Nie masz jeszcze alertów cenowych. Utwórz alert dla wybranej lokalizacji, a damy znać o zmianach cen.

+ )} + Utwórz nowy alert
), searches: (
-
-

Moje wyszukiwania

- Zobacz wszystkie -
+ {renderWidgetHead('searches', Zobacz wszystkie )}
{savedSearches.slice(0, 4).map((row) => (
-
-

Narzędzia Polska Lokalnie

-
+ {renderWidgetHead('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(null); + const contactFormCardRef = useRef(null); const contactTopicLabels: Record = { 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) => { event.preventDefault(); @@ -7418,7 +7437,7 @@ function AccountHelpContactPage({
-
+

Napisz do nas

@@ -7469,13 +7488,13 @@ function AccountHelpContactPage({ {moreHelpCards.map((card) => (
-
+

{card.title}

{card.description}

- {card.id === 'rules' ? ( - {card.action} + {card.to ? ( + {card.action} ) : ( - + )}
@@ -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 - -